Skip to content

FormHelper (@bpmlib/utils-form-helper)

Vue 3 composable untuk manage state modal-based forms dengan built-in CRUD mode management, validation error handling, dan FormData serialization

Versi: 0.1.0
Kategori: Framework Utils (Vue 3)

npm versionTypeScriptVue


TL;DR

Vue 3 composable untuk manage state modal-based forms dengan built-in CRUD mode management, validation error handling, dan FormData serialization. Dibuat untuk pattern umum di family project: form dalam modal dengan mode create/edit/delete, validation dari Laravel backend, dan submission menggunakan multipart/form-data.

Composables:

ts
import { useFormHelper } from '@bpmlib/utils-form-helper';

Types:

ts
import type {
    Mode,
    ModeShort,
    FormErrors,
    ControlState,
    FormHelperObject,
} from '@bpmlib/utils-form-helper';

Installation & Setup

Requirements

Peer Dependencies

Library ini memerlukan peer dependencies berikut:

Wajib:

bash
npm install vue@^3.3.0
DependencyVersiStatusDeskripsi
vue^3.3.0RequiredVue 3 framework

Package Installation

bash
npm install @bpmlib/utils-form-helper
bash
yarn add @bpmlib/utils-form-helper
bash
pnpm add @bpmlib/utils-form-helper
bash
bun install @bpmlib/utils-form-helper

Import

Basic Import:

ts
import { useFormHelper } from '@bpmlib/utils-form-helper';
import type { FormHelperObject, Mode, ModeShort } from '@bpmlib/utils-form-helper';

Quick Start

Basic Usage

Contoh paling sederhana - create form:

vue
<script setup lang="ts">
import { useFormHelper } from '@bpmlib/utils-form-helper';

interface UserForm {
  name: string;
  email: string;
}

const formHelper = useFormHelper<UserForm>({
  name: '',
  email: '',
});

const handleSubmit = async () => {
  const formData = formHelper.getAsFormData();
  await api.post('/users', formData);
  formHelper.setDisplay(false);
};
</script>

<template>
  <button @click="formHelper.openForm('c')">Add User</button>
  
  <Modal :show="formHelper.onDisplay()" @close="formHelper.setDisplay(false)">
    <form @submit.prevent="handleSubmit">
      <input v-model="formHelper.form.name" placeholder="Name" />
      <input v-model="formHelper.form.email" placeholder="Email" />
      <button type="submit">Save</button>
    </form>
  </Modal>
</template>

Key Points:

  • openForm('c') - Open create modal
  • form - Reactive form data
  • onDisplay() / setDisplay() - Control visibility
  • getAsFormData() - Get submission data

Comprehensive Example

Full CRUD pattern dengan helper data:

vue
<script setup lang="ts">
import { useFormHelper } from '@bpmlib/utils-form-helper';

interface UserForm {
  user_id?: number;
  name: string;
  email: string;
  role_id: number;
}

interface UserHelper {
  user?: {
    id: number;
    name: string;
    email: string;
    role: { id: number; name: string };
  };
  roles: Array<{ id: number; name: string }>;
}

const formHelper = useFormHelper<UserForm, UserHelper>({
  name: '',
  email: '',
  role_id: 0,
});

// Create
const openCreate = () => {
  formHelper.openForm('c', undefined, {
    roles: [
      { id: 1, name: 'Admin' },
      { id: 2, name: 'User' },
    ],
  });
};

// Edit
const openEdit = (user: UserHelper['user']) => {
  formHelper.openForm('u', {
    user_id: user!.id,
    name: user!.name,
    email: user!.email,
    role_id: user!.role.id,
  }, {
    user: user,
    roles: [
      { id: 1, name: 'Admin' },
      { id: 2, name: 'User' },
    ],
  });
};

// Delete
const openDelete = (user: UserHelper['user']) => {
  formHelper.openForm('d', { user_id: user!.id }, { user });
};

const handleSubmit = async () => {
  if (formHelper.isModeDelete()) {
    await api.delete(`/users/${formHelper.form.user_id}`);
  } else if (formHelper.isModeCreate()) {
    await api.post('/users', formHelper.getAsFormData());
  } else {
    await api.put(`/users/${formHelper.form.user_id}`, formHelper.getAsFormData());
  }
  
  formHelper.setDisplay(false);
};
</script>

<template>
  <button @click="openCreate">Add User</button>
  
  <Modal :show="formHelper.onDisplay()" @close="formHelper.setDisplay(false)">
    <h2>{{ formHelper.getMode() }} User</h2>
    
    <!-- Delete confirmation -->
    <div v-if="formHelper.isModeDelete()">
      <p>Delete user: {{ formHelper.helper?.user?.name }}?</p>
      <button @click="handleSubmit">Delete</button>
    </div>
    
    <!-- Create/Edit form -->
    <form v-else @submit.prevent="handleSubmit">
      <input v-model="formHelper.form.name" placeholder="Name" />
      <input v-model="formHelper.form.email" placeholder="Email" />
      
      <select v-model="formHelper.form.role_id">
        <option v-for="role in formHelper.helper?.roles" :key="role.id" :value="role.id">
          {{ role.name }}
        </option>
      </select>
      
      <button type="submit">
        {{ formHelper.isModeCreate() ? 'Create' : 'Update' }}
      </button>
    </form>
  </Modal>
</template>

Key Points:

  • openForm(mode, formData, helperData) - One method untuk setup semua mode
  • helper property - Data tambahan untuk display (roles, full user object)
  • isModeCreate() / isModeDelete() - Mode checking
  • getMode() - Returns 'Tambah' / 'Kelola' / 'Delete' untuk display

Core Concepts

FormHelper didesain dengan tiga konsep fundamental yang mempengaruhi cara kamu menggunakan library.

Library ini dioptimalkan untuk pattern form dalam modal/dialog dengan CRUD operations - bukan standalone page forms.

Design Implications:

1. Control State (display/disabled)

Modal visibility dan form disabled state di-manage built-in:

  • onDisplay() / setDisplay() - Modal show/hide
  • onDisable() / setDisable() - Form disabled during submission
  • Pattern: Open modal → Submit → Disable form → Close modal

2. CRUD Mode System

Dual-mode untuk track operation type:

  • Short codes: 'c', 'u', 'd' (internal logic)
  • Display names: 'Tambah', 'Kelola', 'Delete' (UI labels)
  • Mode affects form behavior (reset on create, preserve on edit)

3. Convenience Method: openForm()

One method to open modal with proper setup:

typescript
// Create: Reset form, show modal
formHelper.openForm('c');

// Edit: Load data, show modal
formHelper.openForm('u', userData, helperData);

// Delete: Load data for confirmation
formHelper.openForm('d', { id: 123 }, { name: 'Item to delete' });

Why This Pattern:

Modal forms have different lifecycle than page forms:

  • Transient state - Form resets between opens
  • Mode switching - Same modal for create/edit/delete
  • Controlled visibility - Show/hide explicit
  • Submission flow - Disable → Submit → Close

Example:

vue
<script setup lang="ts">
const formHelper = useFormHelper<UserForm>({ name: '', email: '' });

// Open create modal
const openCreate = () => {
  formHelper.openForm('c');  // Resets form, shows modal
};

// Open edit modal
const openEdit = (user) => {
  formHelper.openForm('u', { name: user.name, email: user.email });
};

const handleSubmit = async () => {
  formHelper.setDisable(true);  // Disable during submission
  
  try {
    if (formHelper.isModeCreate()) {
      await api.post('/users', formHelper.getAsFormData());
    } else {
      await api.put(`/users/${id}`, formHelper.getAsFormData());
    }
    
    formHelper.setDisplay(false);  // Close modal on success
  } finally {
    formHelper.setDisable(false);
  }
};
</script>

<template>
  <button @click="openCreate">Add User</button>
  
  <Modal :show="formHelper.onDisplay()" @close="formHelper.setDisplay(false)">
    <h2>{{ formHelper.getMode() }} User</h2>
    
    <form @submit.prevent="handleSubmit">
      <input 
        v-model="formHelper.form.name" 
        :disabled="formHelper.onDisable()"
      />
      
      <button type="submit" :disabled="formHelper.onDisable()">
        {{ formHelper.isModeCreate() ? 'Create' : 'Update' }}
      </button>
    </form>
  </Modal>
</template>

See: openForm(), Example 1


Form vs Helper Data Separation

FormHelper memisahkan data menjadi dua channel independen dengan purpose yang berbeda.

TForm (Submission Data):

  • Data yang akan di-submit ke backend
  • Hanya fields yang perlu disimpan (IDs, input values)
  • Di-serialize ke FormData via getAsFormData()

THelper (Presentation Data):

  • Data untuk display dan UI logic
  • Full objects, dropdown options, computed values
  • Tidak ikut submission

Why Separated:

Pemisahan ini prevent form pollution - kamu tidak perlu kirim entire user object (dengan timestamps, relations, etc) hanya untuk update name. Form tetap clean, helper provide context.

Example:

typescript
interface OrderForm {
  product_id: number;  // Submit: ID only
  quantity: number;
}

interface OrderHelper {
  product: {           // Helper: Full object for display
    id: number;
    name: string;
    price: number;
    stock: number;
  };
}

// Open edit modal with both form data (for submission) and helper (for display)
formHelper.openForm('u', 
  { product_id: 5, quantity: 2 },              // Form: IDs to submit
  { product: { id: 5, name: 'Laptop', ... } }  // Helper: Full object to show
);

// Template shows: {{ formHelper.helper.product.name }} (from helper)
// Submit sends: { product_id: 5, quantity: 2 } (from form only)

See: Example 2, helper property


Deep Merge Behavior

modifyForm() dan modifyDefault() menggunakan deep merge algorithm yang preserves unchanged fields.

Behavior:

  • Update hanya field yang kamu specify
  • Nested objects di-merge recursively
  • Fields lain tetap unchanged

Contrast with Object.assign:

Object.assign replaces entire nested object, causing data loss.

Example:

typescript
interface ProfileForm {
  user: {
    name: string;
    email: string;
    bio: string;
  };
}

const formHelper = useFormHelper<ProfileForm>({
  user: {
    name: 'John',
    email: 'john@example.com',
    bio: 'Developer',
  }
});

// ✅ Deep Merge - Preserves other fields
formHelper.modifyForm({
  user: {
    name: 'Jane'  // Only update name
  }
});
// Result: { user: { name: 'Jane', email: 'john@...', bio: 'Developer' } }

// ❌ Object.assign - Loses data
Object.assign(formHelper.form.user, {
  name: 'Jane'
});
// Result: { user: { name: 'Jane' } }  // email and bio LOST!

Use Cases:

  • Partial form updates (wizard steps)
  • Conditional field updates
  • Preserving unchanged nested data

See: modifyForm(), Example 3


API Reference

useFormHelper

Composable untuk handling modal forms dengan validation, CRUD modes, dan FormData serialization.

Signature:

typescript
function useFormHelper<
  TForm extends Record<string, unknown> = Record<string, unknown>,
  THelper = any
>(
  initData?: TForm,
  defaultData?: TForm
): FormHelperObject<TForm, THelper>

Parameters

NameTypeDefaultDescription
initDataTForm{}Initial form data
defaultDataTForminitDataDefault values (jika berbeda dari initData)

Returns: FormHelperObject<TForm, THelper>

Example:

typescript
interface UserForm {
  name: string;
  email: string;
}

const formHelper = useFormHelper<UserForm>({
  name: '',
  email: ''
});

// With custom defaults
const formHelper2 = useFormHelper<UserForm>(
  { name: 'Initial', email: '' },  // init
  { name: '', email: '' }          // defaults for reset
);

FormHelperObject

Return object dari useFormHelper composable.

typescript
interface FormHelperObject<TForm, THelper> {
  // State Properties
  form: TForm;
  control: ControlState;
  default: TForm;
  errors: FormErrors;
  helper: THelper | undefined;
  
  // Display & Control Methods
  onDisplay(): boolean;
  onDisable(): boolean;
  setDisplay(val: boolean): void;
  setDisable(val: boolean): void;
  allFalse(): void;
  
  // Form Data Methods
  setDefault(): void;
  modifyDefault(newObj: TForm): void;
  modifyForm(newObj: TForm, defaultToo?: boolean): void;
  getFormRaw(): TForm;
  getFormField(keyOrPath: any): any;
  
  // Error Handling Methods
  setErrors(err: FormErrors): void;
  getErrors(key?: string): string[];
  hasError(fieldName: string): boolean;
  clearFormError(): void;
  
  // Mode Management Methods
  getMode(): Mode;
  getModeShort(): ModeShort;
  setMode(crud?: ModeShort): void;
  isModeCreate(): boolean;
  isModeUpdate(): boolean;
  isModeDelete(): boolean;
  isFormMode(): boolean;
  isModeIn(mode: Mode | ModeShort): boolean;
  
  // Serialization Methods
  getAsFormData(...): FormData;
  
  // Convenience Methods
  openForm(mode: ModeShort, formData?: TForm, helperData?: THelper): void;
  
  // Deprecated Methods
  get<K>(targetKey?: K): ...;
  toggleDisplay(val?: boolean): void;
  toggleDisable(val?: boolean): void;
}

Contains:


State Properties

form
typescript
form: TForm

Reactive form data object.

⚠️ Penting - Penggunaan .value:

  • Di <script>: Tidak perlu .value (bukan Ref) → formHelper.form.name
  • Di <template>: Langsung access → formHelper.form.name

control
typescript
control: ControlState

UI control state untuk modal display dan form disabled state.

Structure:

typescript
interface ControlState {
  display: boolean;  // Modal visibility
  disabled: boolean; // Form disabled state
}

default
typescript
default: TForm

Default form values (getter/setter). Values di-deep clone untuk prevent reference issues.

Example:

typescript
// Get defaults
console.log(formHelper.default);

// Set new defaults
formHelper.default = { name: 'New Default', email: '' };

// Reset form to defaults
formHelper.setDefault();

errors
typescript
errors: FormErrors

Validation errors dalam flat dot notation (Laravel-compatible).

Structure:

typescript
interface FormErrors {
  [key: string]: string[];
}

Example:

typescript
{
  "name": ["The name field is required"],
  "email": ["Invalid email format"],
  "address.street": ["Street is required"]
}

helper
typescript
helper: THelper | undefined

Optional helper/presentation data (getter/setter). Uses deep merge saat di-set.

Example:

typescript
// Set helper
formHelper.helper = {
  roles: [
    { id: 1, name: 'Admin' },
    { id: 2, name: 'User' },
  ]
};

// Get helper
const roles = formHelper.helper?.roles;

// Clear helper
formHelper.helper = undefined;

Display & Control Methods

onDisplay()
typescript
onDisplay(): boolean

Check if modal is currently displayed.

Returns: boolean - true if displayed


onDisable()
typescript
onDisable(): boolean

Check if form is currently disabled.

Returns: boolean - true if disabled


setDisplay()
typescript
setDisplay(val: boolean): void

Set modal visibility.

Parameters:

  • val - true to show, false to hide

setDisable()
typescript
setDisable(val: boolean): void

Set form disabled state.

Parameters:

  • val - true to disable, false to enable

allFalse()
typescript
allFalse(): void

Reset all control states to false (hide modal and enable form).


Form Data Methods

setDefault()
typescript
setDefault(): void

Reset form ke default values (deep cloned).


modifyDefault()
typescript
modifyDefault(newObj: TForm): void

Update stored default values menggunakan deep merge Lihat selengkapnya.

Parameters:

  • newObj - New default values to merge

modifyDefault()

Method untuk update default values dengan deep merge, preserving unchanged fields.

Signature:

typescript
modifyDefault(newObj: TForm): void

Parameters:

  • newObj - Partial form data to merge into defaults

Contoh:

typescript
const formHelper = useFormHelper({
  user: {
    name: 'John',
    email: 'john@example.com',
    role: 'user'
  }
});

// Update only name in defaults
formHelper.modifyDefault({
  user: {
    name: 'Jane'
  }
});

// Defaults now: { user: { name: 'Jane', email: 'john@...', role: 'user' } }
// Email and role preserved

Use Case:

Gunakan saat kamu ingin change defaults tanpa lose existing default values untuk fields lain. Berguna untuk dynamic default values based on user settings atau application state.


modifyForm()
typescript
modifyForm(newObj: TForm, defaultToo?: boolean): void

Update form values menggunakan deep merge Lihat selengkapnya.

Parameters:

  • newObj - New form values to merge
  • defaultToo - Also update default values if true

modifyForm()

Method untuk update form data dengan deep merge, preserving unchanged fields.

Signature:

typescript
modifyForm(newObj: TForm, defaultToo?: boolean): void

Parameters:

  • newObj - Partial form data to merge
  • defaultToo - If true, also update defaults with same values (default: false)

Contoh:

typescript
interface ProfileForm {
  user: {
    name: string;
    email: string;
    bio: string;
  };
  settings: {
    theme: string;
    notifications: boolean;
  };
}

const formHelper = useFormHelper<ProfileForm>({
  user: {
    name: 'John',
    email: 'john@example.com',
    bio: 'Developer'
  },
  settings: {
    theme: 'dark',
    notifications: true
  }
});

// Update only name - other fields preserved
formHelper.modifyForm({
  user: {
    name: 'Jane'
  }
});
// Result: user.name = 'Jane', email and bio unchanged

// Update multiple nested fields
formHelper.modifyForm({
  user: { bio: 'Senior Developer' },
  settings: { theme: 'light' }
});

// Update and save as new defaults
formHelper.modifyForm({
  settings: { theme: 'system' }
}, true);
// Now 'system' becomes new default theme

Use Case:

Gunakan untuk:

  • Partial form updates (wizard steps)
  • Conditional field updates
  • Preserving unchanged nested data

Important: Ini berbeda dengan Object.assign() yang akan replace entire nested object dan lose data.

See: Deep Merge Behavior, Example 3


getFormRaw()
typescript
getFormRaw(): TForm

Get non-reactive (raw) copy of entire form data.

Returns: Raw copy of form object

Example:

typescript
const formData = formHelper.getFormRaw();
// Modify without affecting reactive form
formData.extraField = 'value';

getFormField()
typescript
getFormField(keyOrPath: any): any

Get field value dengan dot notation support.

Parameters:

  • keyOrPath - Field key atau path (e.g., 'name', 'address.street', 'tags.0')

Returns: Field value, atau undefined jika path tidak ditemukan

Example:

typescript
formHelper.getFormField('name');           // Direct field
formHelper.getFormField('address.street'); // Nested field
formHelper.getFormField('tags.0');         // Array element (dot)
formHelper.getFormField('tags[0]');        // Array element (bracket)

Error Handling Methods

setErrors()
typescript
setErrors(err: FormErrors): void

Set validation errors (replaces all existing errors).

Parameters:

  • err - Error object in flat dot notation

Example:

typescript
// Laravel error format
formHelper.setErrors({
  "name": ["The name field is required"],
  "email": ["Invalid email format"],
  "address.street": ["Street is required"]
});

getErrors()
typescript
getErrors(key?: string): string[]

Get error messages untuk specific field.

Parameters:

  • key - Field name in dot notation (optional)

Returns: Array of error messages, empty array if no errors

Example:

typescript
const nameErrors = formHelper.getErrors('name');
// ['The name field is required']

const allErrors = formHelper.getErrors();
// []

hasError()
typescript
hasError(fieldName: string): boolean

Check if field has validation errors.

Parameters:

  • fieldName - Field name in dot notation

Returns: boolean - true if field has errors


clearFormError()
typescript
clearFormError(): void

Clear all validation errors.


Mode Management Methods

Methods untuk manage CRUD operation mode. Library menggunakan dual-mode system:

  • Short codes ('c', 'u', 'd') untuk internal operations
  • Bahasa Indonesia ('Tambah', 'Kelola', 'Delete') untuk user display
getMode()
typescript
getMode(): Mode

Get current mode (long form).

Returns: 'Tambah' | 'Kelola' | 'Delete'


getModeShort()
typescript
getModeShort(): ModeShort

Get current mode (short code).

Returns: 'c' | 'u' | 'd'


setMode()
typescript
setMode(crud?: ModeShort): void

Set CRUD mode.

Parameters:

  • crud - Mode short code: 'c' (create), 'u' (update), 'd' (delete). Default: 'c'

isModeCreate()
typescript
isModeCreate(): boolean

Check if current mode is create.

Returns: boolean - true if mode is 'Tambah'


isModeUpdate()
typescript
isModeUpdate(): boolean

Check if current mode is update.

Returns: boolean - true if mode is 'Kelola'


isModeDelete()
typescript
isModeDelete(): boolean

Check if current mode is delete.

Returns: boolean - true if mode is 'Delete'


isFormMode()
typescript
isFormMode(): boolean

Check if current mode uses form (create or update, not delete).

Returns: boolean - true if create or update


isModeIn()
typescript
isModeIn(mode: Mode | ModeShort): boolean

Check if current mode matches provided mode Lihat selengkapnya.

Parameters:

  • mode - Mode to check (accepts both short and long format)

Returns: boolean - true if matches


isModeIn()

Method untuk check if current mode matches provided mode. Accepts both short code dan long form.

Signature:

typescript
isModeIn(mode: Mode | ModeShort): boolean

Parameters:

  • mode - Mode to check, dapat berupa short code ('c', 'u', 'd') atau long form ('Tambah', 'Kelola', 'Delete')

Returns: boolean - true jika current mode matches

Contoh:

typescript
// Check using short code
formHelper.isModeIn('c');        // Check if create
formHelper.isModeIn('u');        // Check if update

// Check using long form
formHelper.isModeIn('Tambah');   // Same as isModeIn('c')
formHelper.isModeIn('Kelola');   // Same as isModeIn('u')

// Use in conditions
if (formHelper.isModeIn('c') || formHelper.isModeIn('u')) {
  // Show form for both create and update
}

Use Case:

Berguna saat kamu perlu check mode dengan flexible format, terutama saat accept mode dari external source atau user input yang bisa dalam format apapun.


Serialization Methods

getAsFormData()
typescript
getAsFormData(
  arrayAsJson?: boolean,
  objectAsJson?: boolean,
  prefix?: string,
  skipKeys?: string[]
): FormData

Convert form to FormData untuk multipart/form-data submission Lihat selengkapnya.

Parameters:

  • arrayAsJson - Serialize arrays as JSON string (default: false)
  • objectAsJson - Serialize objects as JSON string (default: false)
  • prefix - Add prefix to all keys
  • skipKeys - Array of keys to exclude

Returns: FormData - Ready untuk submission


getAsFormData()

Method untuk serialize form data ke FormData object dengan berbagai serialization options.

Signature:

typescript
getAsFormData(
  arrayAsJson?: boolean,
  objectAsJson?: boolean,
  prefix?: string,
  skipKeys?: Array<string>
): FormData

Parameters:

NameTypeDefaultDescription
arrayAsJsonbooleanfalseSerialize arrays as JSON string instead of bracket notation
objectAsJsonbooleanfalseSerialize objects as JSON string instead of nested keys
prefixstring''Add prefix to all keys
skipKeysstring[][]Array of keys to exclude from serialization

Behavior:

TypeDefault SerializationWith JSON Option
Arrayfield[0]=a&field[1]=bfield=["a","b"]
Objectfield[key1]=val1&field[key2]=val2field={"key1":"val1",...}
Boolean1 or 0Same
File/BlobAs-is (binary)Same
null/undefinedEmpty stringSame

Contoh:

typescript
interface ProductForm {
  name: string;
  tags: string[];
  price: { amount: number; currency: string };
  image: File | null;
  _internal: string;
}

const formHelper = useFormHelper<ProductForm>({
  name: 'Laptop',
  tags: ['electronics', 'computers'],
  price: { amount: 1200, currency: 'USD' },
  image: null,
  _internal: 'not-submitted'
});

// Basic usage - bracket notation for arrays/objects
const formData1 = formHelper.getAsFormData();
// name=Laptop
// tags[0]=electronics
// tags[1]=computers
// price[amount]=1200
// price[currency]=USD
// image=

// Arrays as JSON
const formData2 = formHelper.getAsFormData(true);
// name=Laptop
// tags=["electronics","computers"]
// price[amount]=1200
// price[currency]=USD

// Objects as JSON
const formData3 = formHelper.getAsFormData(false, true);
// name=Laptop
// tags[0]=electronics
// tags[1]=computers
// price={"amount":1200,"currency":"USD"}

// Both as JSON
const formData4 = formHelper.getAsFormData(true, true);
// name=Laptop
// tags=["electronics","computers"]
// price={"amount":1200,"currency":"USD"}

// With prefix
const formData5 = formHelper.getAsFormData(false, false, 'product_');
// product_name=Laptop
// product_tags[0]=electronics
// product_tags[1]=computers

// Skip internal fields
const formData6 = formHelper.getAsFormData(false, false, '', ['_internal']);
// name=Laptop
// tags[0]=electronics
// tags[1]=computers
// price[amount]=1200
// price[currency]=USD
// (_internal not included)

// Real-world: Submit to Laravel with file upload
const handleSubmit = async () => {
  const formData = formHelper.getAsFormData(true, true);
  
  await axios.post('/api/products', formData, {
    headers: {
      'Content-Type': 'multipart/form-data'
    }
  });
};

Use Case:

Laravel expects multipart/form-data untuk file uploads. Method ini automatically handle:

  • File/Blob objects (sent as binary)
  • Boolean values (converted to '1' or '0')
  • Null/undefined (converted to empty string)
  • Arrays/Objects (flexible serialization)

Laravel Backend Example:

php
// With bracket notation (default)
$request->input('tags.0');        // 'electronics'
$request->input('price.amount');  // '1200'

// With JSON option
$tags = json_decode($request->input('tags'));
$price = json_decode($request->input('price'));

Catatan: Untuk complex nested structures atau large arrays, gunakan JSON serialization (arrayAsJson: true, objectAsJson: true) untuk reduce query string length.


Convenience Methods

openForm()
typescript
openForm(mode: ModeShort, formData?: TForm, helperData?: THelper): void

Open modal form with proper setup (all-in-one method) Lihat selengkapnya.

Parameters:

  • mode - CRUD mode: 'c' (create), 'u' (update), 'd' (delete)
  • formData - Optional data to prefill form
  • helperData - Optional presentation/lookup data

openForm()

All-in-one method untuk open modal dengan proper setup: set mode, handle form data, manage helper, dan show modal.

Signature:

typescript
openForm(mode: ModeShort, formData?: TForm, helperData?: THelper): void

Parameters:

NameTypeRequiredDescription
modeModeShortYesCRUD mode: 'c' (create), 'u' (update), 'd' (delete)
formDataTFormNoData untuk prefill form (for edit/delete)
helperDataTHelperNoPresentation/lookup data

Behavior by Mode:

ModeForm DataHelper DataDisplay
'c' (Create)Reset to defaultsSet if provided, clear if notShow modal
'u' (Update)Merge provided dataSet if provided, keep existing if notShow modal
'd' (Delete)Merge provided dataSet if provided, keep existing if notShow modal

Contoh:

typescript
interface UserForm {
  user_id?: number;
  name: string;
  email: string;
  role_id: number;
}

interface UserHelper {
  user?: {
    id: number;
    name: string;
    email: string;
    role: { id: number; name: string };
  };
  roles: Array<{ id: number; name: string }>;
}

const formHelper = useFormHelper<UserForm, UserHelper>({
  name: '',
  email: '',
  role_id: 0,
});

// Create mode - empty form with helper data
const openCreate = () => {
  formHelper.openForm('c', undefined, {
    roles: [
      { id: 1, name: 'Admin' },
      { id: 2, name: 'User' },
    ]
  });
  // Form: { name: '', email: '', role_id: 0 } (reset to defaults)
  // Helper: { roles: [...] }
  // Modal: shown
};

// Update mode - prefill form with data and helper
const openEdit = (user: UserHelper['user']) => {
  formHelper.openForm('u', {
    user_id: user!.id,
    name: user!.name,
    email: user!.email,
    role_id: user!.role.id,
  }, {
    user: user,
    roles: [
      { id: 1, name: 'Admin' },
      { id: 2, name: 'User' },
    ]
  });
  // Form: { user_id: 123, name: 'John', ... } (merged with existing)
  // Helper: { user: {...}, roles: [...] }
  // Modal: shown
};

// Delete mode - just ID for deletion with helper for display
const openDelete = (user: UserHelper['user']) => {
  formHelper.openForm('d', { user_id: user!.id }, { user });
  // Form: { user_id: 123 }
  // Helper: { user: {...} } (untuk show confirmation: "Delete John Doe?")
  // Modal: shown
};

What It Does:

  1. Sets mode: Internal mode state updated
  2. Resets control: allFalse() - disable=false, display will be set to true at end
  3. Handles form data:
    • Create ('c'): Reset to defaults (ignore formData)
    • Update/Delete: Merge formData into form (deep merge)
  4. Handles helper data:
    • If provided: Set helper
    • If not provided and mode is create: Clear helper
    • If not provided and mode is update/delete: Keep existing helper
  5. Shows modal: setDisplay(true)

Use Case:

Simplify modal opening logic - instead of manually calling setMode(), modifyForm(), helper = ..., setDisplay(true), just call one method. Especially useful untuk CRUD operations dengan consistent pattern.

See: Modal/Dialog-Optimized Form Pattern


Deprecated Methods

Methods berikut masih available untuk backward compatibility tapi not recommended untuk new code.

get()
typescript
get<K extends keyof TForm>(targetKey?: K): K extends keyof TForm ? TForm[K] : TForm

⚠️ Deprecated: Use form.fieldName directly, getFormField(key) for single field, or getFormRaw() for entire form.


toggleDisplay()
typescript
toggleDisplay(val?: boolean): void

⚠️ Deprecated: Use setDisplay(boolean) instead.


toggleDisable()
typescript
toggleDisable(val?: boolean): void

⚠️ Deprecated: Use setDisable(boolean) instead.


Types

Mode

typescript
type Mode = 'Tambah' | 'Kelola' | 'Delete'

CRUD operation modes dalam Bahasa Indonesia.

  • 'Tambah' - Create/Add
  • 'Kelola' - Update/Manage
  • 'Delete' - Delete

ModeShort

typescript
type ModeShort = 'c' | 'u' | 'd'

Short code representation of CRUD modes.

  • 'c' - Create (Tambah)
  • 'u' - Update (Kelola)
  • 'd' - Delete

FormErrors

typescript
interface FormErrors {
  [key: string]: string[];
}

Validation errors structure menggunakan flat dot notation (Laravel compatible).

Example:

typescript
{
  "name": ["The name field is required"],
  "address.street": ["The street field is required"],
  "tags.0": ["Invalid tag format"]
}

ControlState

typescript
interface ControlState {
  display: boolean;
  disabled: boolean;
}

UI control state untuk modal display dan form disabled state.

Properties:

  • display - Whether modal/form is visible
  • disabled - Whether form inputs are disabled

Examples

Practical patterns untuk advanced usage.

Contains:

1. Validation Error Handling

Handling Laravel validation errors dengan flat dot notation.

vue
<script setup lang="ts">
import { useFormHelper } from '@bpmlib/utils-form-helper';

interface UserForm {
  name: string;
  email: string;
  address: {
    street: string;
    city: string;
  };
}

const formHelper = useFormHelper<UserForm>({
  name: '',
  email: '',
  address: {
    street: '',
    city: '',
  },
});

const handleSubmit = async () => {
  formHelper.setDisable(true);
  formHelper.clearFormError();
  
  try {
    await api.post('/users', formHelper.getAsFormData());
    formHelper.setDisplay(false);
  } catch (error: any) {
    // Laravel returns: { errors: { "name": ["Required"], "address.street": [...] } }
    if (error.response?.data?.errors) {
      formHelper.setErrors(error.response.data.errors);
    }
  } finally {
    formHelper.setDisable(false);
  }
};
</script>

<template>
  <Modal :show="formHelper.onDisplay()" @close="formHelper.setDisplay(false)">
    <form @submit.prevent="handleSubmit">
      <!-- Basic field error -->
      <div class="field">
        <input 
          v-model="formHelper.form.name" 
          :disabled="formHelper.onDisable()"
          placeholder="Name"
        />
        <span v-if="formHelper.hasError('name')" class="error">
          {{ formHelper.getErrors('name')[0] }}
        </span>
      </div>
      
      <!-- Email field -->
      <div class="field">
        <input 
          v-model="formHelper.form.email"
          :disabled="formHelper.onDisable()"
          placeholder="Email"
        />
        <span v-if="formHelper.hasError('email')" class="error">
          {{ formHelper.getErrors('email')[0] }}
        </span>
      </div>
      
      <!-- Nested field error (dot notation) -->
      <div class="field">
        <input 
          v-model="formHelper.form.address.street"
          :disabled="formHelper.onDisable()"
          placeholder="Street"
        />
        <span v-if="formHelper.hasError('address.street')" class="error">
          {{ formHelper.getErrors('address.street')[0] }}
        </span>
      </div>
      
      <div class="field">
        <input 
          v-model="formHelper.form.address.city"
          :disabled="formHelper.onDisable()"
          placeholder="City"
        />
        <span v-if="formHelper.hasError('address.city')" class="error">
          {{ formHelper.getErrors('address.city')[0] }}
        </span>
      </div>
      
      <button type="submit" :disabled="formHelper.onDisable()">
        Save
      </button>
    </form>
  </Modal>
</template>

Key Takeaways:

  • setErrors() - Accept Laravel error format directly
  • hasError(field) - Check if field has errors before showing
  • getErrors(field) - Get error messages array (show first message)
  • clearFormError() - Clear all errors before resubmit
  • Supports dot notation untuk nested fields ('address.street')
  • setDisable() / onDisable() - Prevent double-submit during processing

2. Form vs Helper Data Separation

Demonstrates TForm (submission) vs THelper (presentation) pattern dengan order form example.

vue
<script setup lang="ts">
import { computed } from 'vue';
import { useFormHelper } from '@bpmlib/utils-form-helper';

interface OrderForm {
  product_id: number;
  quantity: number;
  notes: string;
}

interface OrderHelper {
  products: Array<{
    id: number;
    name: string;
    price: number;
    stock: number;
  }>;
}

const formHelper = useFormHelper<OrderForm, OrderHelper>({
  product_id: 0,
  quantity: 1,
  notes: '',
});

const openCreate = () => {
  formHelper.openForm('c', undefined, {
    products: [
      { id: 1, name: 'Laptop', price: 1200, stock: 10 },
      { id: 2, name: 'Mouse', price: 25, stock: 50 },
      { id: 3, name: 'Keyboard', price: 75, stock: 30 },
    ],
  });
};

// Computed from helper data
const selectedProduct = computed(() => 
  formHelper.helper?.products.find(p => p.id === formHelper.form.product_id)
);

const total = computed(() => 
  (selectedProduct.value?.price || 0) * formHelper.form.quantity
);

const canSubmit = computed(() => 
  formHelper.form.product_id > 0 && 
  formHelper.form.quantity > 0 &&
  (formHelper.form.quantity <= (selectedProduct.value?.stock || 0))
);

const handleSubmit = async () => {
  await api.post('/orders', formHelper.getAsFormData());
  formHelper.setDisplay(false);
};
</script>

<template>
  <button @click="openCreate">Create Order</button>
  
  <Modal :show="formHelper.onDisplay()" @close="formHelper.setDisplay(false)">
    <form @submit.prevent="handleSubmit">
      <!-- Product dropdown - from helper.products -->
      <div class="field">
        <label>Product</label>
        <select v-model="formHelper.form.product_id">
          <option value="0">Select Product</option>
          <option 
            v-for="product in formHelper.helper?.products" 
            :key="product.id" 
            :value="product.id"
          >
            {{ product.name }} - ${{ product.price }} (Stock: {{ product.stock }})
          </option>
        </select>
      </div>
      
      <!-- Show selected product info from helper (computed) -->
      <div v-if="selectedProduct" class="product-info">
        <p><strong>Price:</strong> ${{ selectedProduct.price }}</p>
        <p><strong>Available:</strong> {{ selectedProduct.stock }} units</p>
      </div>
      
      <!-- Quantity - editable in form -->
      <div class="field">
        <label>Quantity</label>
        <input 
          v-model.number="formHelper.form.quantity" 
          type="number"
          min="1"
          :max="selectedProduct?.stock || 999"
        />
      </div>
      
      <!-- Computed total from helper data -->
      <div class="total-section">
        <p><strong>Total:</strong> ${{ total }}</p>
      </div>
      
      <!-- Notes - editable in form -->
      <div class="field">
        <label>Notes</label>
        <textarea v-model="formHelper.form.notes"></textarea>
      </div>
      
      <button type="submit" :disabled="!canSubmit">
        Create Order
      </button>
    </form>
  </Modal>
</template>

Key Takeaways:

  • Form: Hanya field yang di-submit (product_id, quantity, notes)
  • Helper: Full objects untuk display + dropdowns (products array)
  • Helper provides context tanpa pollute form data
  • Computed values derived dari helper data (selectedProduct, total)
  • Submit hanya kirim form, tidak include helper
  • Clean separation: IDs in form, full objects in helper

3. Partial Form Updates (Deep Merge)

Deep merge behavior untuk update nested fields tanpa losing data.

vue
<script setup lang="ts">
import { useFormHelper } from '@bpmlib/utils-form-helper';

interface ProfileForm {
  user: {
    name: string;
    email: string;
    bio: string;
  };
  preferences: {
    theme: string;
    notifications: {
      email: boolean;
      sms: boolean;
    };
  };
}

const formHelper = useFormHelper<ProfileForm>({
  user: {
    name: 'John Doe',
    email: 'john@example.com',
    bio: 'Software developer',
  },
  preferences: {
    theme: 'dark',
    notifications: {
      email: true,
      sms: false,
    },
  },
});

// Scenario 1: Update single nested field
const updateName = () => {
  formHelper.modifyForm({
    user: {
      name: 'Jane Doe',
      // email and bio unchanged
    }
  });
  
  // Result:
  // user: {
  //   name: 'Jane Doe',      ← Updated
  //   email: 'john@...',     ← Unchanged
  //   bio: 'Software...',    ← Unchanged
  // }
};

// Scenario 2: Update deeply nested field
const updateEmailNotification = () => {
  formHelper.modifyForm({
    preferences: {
      notifications: {
        email: false,
        // sms unchanged
      }
    }
  });
  
  // Result:
  // preferences: {
  //   theme: 'dark',          ← Unchanged
  //   notifications: {
  //     email: false,         ← Updated
  //     sms: false,           ← Unchanged
  //   }
  // }
};

// Scenario 3: Compare with Object.assign (wrong way)
const wrongWayWithAssign = () => {
  // ❌ This replaces entire nested object
  Object.assign(formHelper.form.preferences, {
    notifications: {
      email: false,
    }
  });
  
  // Result: LOST sms field!
  // preferences: {
  //   theme: 'dark',
  //   notifications: {
  //     email: false,     ← Only this remains
  //     // sms LOST!
  //   }
  // }
};

// Scenario 4: Update and save as new defaults
const updateAndSaveDefaults = () => {
  formHelper.modifyForm(
    {
      preferences: {
        theme: 'light',
      }
    },
    true  // Also update defaults
  );
  
  // Now theme: 'light' becomes new default
  // Subsequent setDefault() will reset to 'light', not 'dark'
};

// Reset to defaults after modifications
const resetAfterModify = () => {
  // Modify some fields
  formHelper.modifyForm({
    user: { name: 'Changed' },
    preferences: { theme: 'light' },
  });
  
  console.log(formHelper.form.user.name);  // 'Changed'
  
  // Reset to original defaults
  formHelper.setDefault();
  
  console.log(formHelper.form.user.name);  // 'John Doe'
};
</script>

<template>
  <div>
    <h2>Deep Merge Examples</h2>
    
    <button @click="updateName">Update Name Only</button>
    <button @click="updateEmailNotification">Update Email Notification</button>
    <button @click="updateAndSaveDefaults">Update & Save Defaults</button>
    <button @click="resetAfterModify">Reset After Modify</button>
    
    <div class="current-state">
      <h3>Current Form State:</h3>
      <pre>{{ JSON.stringify(formHelper.form, null, 2) }}</pre>
    </div>
    
    <div class="defaults">
      <h3>Default Values:</h3>
      <pre>{{ JSON.stringify(formHelper.default, null, 2) }}</pre>
    </div>
  </div>
</template>

Key Takeaways:

  • modifyForm() uses deep merge, preserves unchanged fields
  • Update single field: only that field changes
  • Update nested: only specified nested fields change
  • Object.assign() would replace entire nested object (data loss!)
  • defaultToo: true updates both form and defaults
  • Useful for wizard forms, partial updates, incremental data collection
  • setDefault() always resets to stored defaults (unless modified with defaultToo)