Skip to content

@bpmlib/utils-form-helper

Vue 3 composable untuk handling modal form state, validation, dan CRUD operations

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 - form create:

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 () => {
        formHelper.setDisable(true);

        try {
            const formData = formHelper.getAsFormData();
            await api.post('/users', formData);

            formHelper.setDisplay(false);
            formHelper.clearFormError();
        } catch (error: any) {
            if (error.response?.data?.errors) {
                formHelper.setErrors(error.response.data.errors);
            }
        } finally {
            formHelper.setDisable(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"
                :disabled="formHelper.onDisable()"
                placeholder="Name"
            />
            <span v-if="formHelper.hasError('name')" class="error">
        {{ formHelper.getErrors('name')[0] }}
      </span>

            <input
                v-model="formHelper.form.email"
                :disabled="formHelper.onDisable()"
                placeholder="Email"
            />
            <span v-if="formHelper.hasError('email')" class="error">
        {{ formHelper.getErrors('email')[0] }}
      </span>

            <button type="submit" :disabled="formHelper.onDisable()">
                Save
            </button>
        </form>
    </Modal>
</template>

Key Points:

  • openForm('c') - Set mode create, reset form, show modal
  • form property - Reactive form data
  • onDisplay() / setDisplay() - Modal visibility control
  • onDisable() / setDisable() - Form disabled state (during submission)

Comprehensive Example

Full CRUD dengan validation dan 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 - dengan form data dan full user object
    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 handleDelete();
            return;
        }

        formHelper.setDisable(true);
        formHelper.clearFormError();

        try {
            const formData = formHelper.getAsFormData();

            if (formHelper.isModeCreate()) {
                await api.post('/users', formData);
            } else {
                await api.post(`/users/${formHelper.form.user_id}`, formData);
            }

            formHelper.setDisplay(false);
            // Reload data...
        } catch (error: any) {
            if (error.response?.data?.errors) {
                formHelper.setErrors(error.response.data.errors);
            }
        } finally {
            formHelper.setDisable(false);
        }
    };

    const handleDelete = async () => {
        formHelper.setDisable(true);

        try {
            await api.delete(`/users/${formHelper.form.user_id}`);
            formHelper.setDisplay(false);
            // Reload data...
        } 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>

        <!-- Delete confirmation -->
        <div v-if="formHelper.isModeDelete()">
            <p>Delete user: {{ formHelper.helper?.user?.name }}?</p>
            <button @click="handleDelete" :disabled="formHelper.onDisable()">
                Delete
            </button>
        </div>

        <!-- Create/Edit form -->
        <form v-else @submit.prevent="handleSubmit">
            <input
                v-model="formHelper.form.name"
                :disabled="formHelper.onDisable()"
            />
            <span v-if="formHelper.hasError('name')" class="error">
        {{ formHelper.getErrors('name')[0] }}
      </span>

            <input
                v-model="formHelper.form.email"
                :disabled="formHelper.onDisable()"
            />
            <span v-if="formHelper.hasError('email')" class="error">
        {{ formHelper.getErrors('email')[0] }}
      </span>

            <select
                v-model="formHelper.form.role_id"
                :disabled="formHelper.onDisable()"
            >
                <option
                    v-for="role in formHelper.helper?.roles"
                    :key="role.id"
                    :value="role.id"
                >
                    {{ role.name }}
                </option>
            </select>

            <button type="submit" :disabled="formHelper.onDisable()">
                {{ 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 lookup, full user object)
  • isModeCreate() / isModeUpdate() / isModeDelete() - Mode checking
  • getMode() - Returns 'Tambah' / 'Kelola' / 'Delete' untuk display

Core Concepts

Library ini didesain untuk pattern umum dalam family project: form dalam modal dengan CRUD operations.

Family project menggunakan modal untuk hampir semua form operations. Pattern ini memerlukan state management untuk:

Control State:

  • display - Modal visibility (show/hide)
  • disabled - Form input state (enabled/disabled saat submit)

Why This Matters:

  • Modal perlu di-hide setelah success submit
  • Form inputs perlu di-disable saat processing untuk prevent double-submit
  • allFalse() method reset semua control state sekaligus

Contoh:

ts
const formHelper = useFormHelper({ name: '' });

// Open modal
formHelper.openForm('c');  // Sets display=true, disabled=false

// During submit
formHelper.setDisable(true);  // Disable inputs

// After success
formHelper.setDisplay(false);  // Hide modal
formHelper.allFalse();  // Or reset everything

CRUD Mode System

Composable menggunakan mode system dengan dua format:

Mode Types:

  • Short code (ModeShort): 'c' | 'u' | 'd'
  • Long form (Mode): 'Tambah' | 'Kelola' | 'Delete'

Mapping:

ts
'c''Tambah'  (Create/Add)
'u''Kelola'  (Update/Manage)
'd''Delete'

Why Both Formats:

  • Short code untuk programmer convenience: openForm('c') lebih cepat
  • Long form untuk UI display: getMode() returns "Tambah" bukan "c"

Contoh:

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

// Set mode using short code
formHelper.openForm('u');

// Get long form for display
const currentMode = formHelper.getMode();  // 'Kelola'

// Check mode
if (formHelper.isModeCreate()) {
  // Create logic
}

// Accept both formats
formHelper.isModeIn('c');        // true if create
formHelper.isModeIn('Tambah');   // same as above
</script>

<template>
  <h2>{{ formHelper.getMode() }} User</h2>
  <!-- Shows: "Kelola User" -->
</template>

Reactive Form State Management

Composable memisahkan form state menjadi dua layer:

1. form - Reactive current state:

ts
const formHelper = useFormHelper({ name: '', email: '' });
formHelper.form.name = 'John';  // Reactive

2. default - Deep cloned defaults:

ts
// Default values di-clone untuk prevent reference issues
formHelper.setDefault();  // Reset form to defaults

Why Deep Cloning:

Tanpa deep clone, modifying form bisa affect defaults:

ts
// ❌ Without deep clone (problematic)
const defaults = { user: { name: 'John' } };
const form = defaults;  // Same reference!
form.user.name = 'Jane';  // Modifies defaults too!

// ✅ With deep clone (safe)
const defaults = { user: { name: 'John' } };
const form = structuredClone(defaults);  // Different reference
form.user.name = 'Jane';  // Defaults unchanged

Contoh:

ts
const formHelper = useFormHelper({
  user: {
    name: '',
    address: { street: '', city: '' }
  }
});

// Modify form
formHelper.form.user.name = 'John';
formHelper.form.user.address.street = 'Main St';

// Reset to defaults (deep cloned)
formHelper.setDefault();
// form is now { user: { name: '', address: { street: '', city: '' } } }

Validation Error Format (Flat Dot Notation)

Composable menggunakan Laravel-compatible error format dengan flat dot notation.

Error Structure:

ts
{
  "name": ["The name field is required"],
  "email": ["Invalid email format", "Email already exists"],
  "address.street": ["Street is required"],
  "tags.0": ["Invalid tag format"],
  "items[1].quantity": ["Must be greater than 0"]
}

Supported Notations:

  • Direct fields: name
  • Nested objects: address.street
  • Array elements (dot): tags.0
  • Array elements (bracket): items[1].quantity

Laravel Integration:

ts
try {
  await api.post('/users', formData);
} catch (error: any) {
  // Laravel returns errors in this format automatically
  if (error.response?.data?.errors) {
    formHelper.setErrors(error.response.data.errors);
  }
}

Contoh:

vue
<template>
  <!-- Direct field -->
  <input v-model="formHelper.form.name" />
  <span v-if="formHelper.hasError('name')">
    {{ formHelper.getErrors('name')[0] }}
  </span>

  <!-- Nested field -->
  <input v-model="formHelper.form.address.street" />
  <span v-if="formHelper.hasError('address.street')">
    {{ formHelper.getErrors('address.street')[0] }}
  </span>

  <!-- Array element -->
  <input v-model="formHelper.form.tags[0]" />
  <span v-if="formHelper.hasError('tags.0')">
    {{ formHelper.getErrors('tags.0')[0] }}
  </span>
</template>

Helper/Presentation Data Pattern

Composable memisahkan form data dan presentation data menggunakan helper property.

Separation:

  • form - Data yang user edit (akan di-submit)
  • helper - Additional context untuk display (tidak di-submit)

Use Cases:

  1. Lookup data - Dropdown options, reference data
  2. Full object - Form hanya field IDs, helper menyimpan full object
  3. Computed display - Derived data untuk UI

Contoh:

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

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

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

// Edit mode
const openEdit = (user: UserHelper['user']) => {
  formHelper.openForm(
    'u',
    // Form: Only editable fields
    {
      user_id: user!.id,
      name: user!.name,
      role_id: user!.role.id,
    },
    // Helper: Full object + lookups
    {
      user: user,
      roles: [
        { id: 1, name: 'Admin' },
        { id: 2, name: 'User' },
      ],
    }
  );
};
vue
<template>
  <Modal>
    <!-- Display from helper (read-only info) -->
    <p class="text-sm text-gray-500">
      Created: {{ formHelper.helper?.user?.created_at }}
    </p>
    <p class="text-sm text-gray-500">
      Email: {{ formHelper.helper?.user?.email }}
    </p>

    <!-- Edit from form -->
    <input v-model="formHelper.form.name" />

    <!-- Lookup from helper -->
    <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>
  </Modal>
</template>

Why Separation:

  • Form tetap clean (hanya editable fields)
  • Helper provides context tanpa pollute form
  • Submit hanya kirim form, tidak include helper data

Deep Merge Behavior

Methods modifyForm() dan modifyDefault() menggunakan deep merge, bukan replace.

Deep Merge vs Object.assign:

ts
const formHelper = useFormHelper({
  user: {
    name: 'John',
    address: {
      street: 'Main St',
      city: 'NYC',
      zip: '10001',
    },
  },
});

// ❌ Object.assign - replaces entire nested object
Object.assign(formHelper.form.user, {
  address: { street: 'New Street' }
});
// Result: { street: 'New Street' } - city and zip LOST!

// ✅ Deep merge - updates only specified fields
formHelper.modifyForm({
  user: {
    address: { street: 'New Street' }
  }
});
// Result: { street: 'New Street', city: 'NYC', zip: '10001' }

When to Use:

ts
// Update partial fields without losing data
formHelper.modifyForm({
  user: {
    name: 'Jane',  // Only update name
    // address unchanged
  }
});

// Update defaults too (for subsequent resets)
formHelper.modifyForm(
  { user: { name: 'Default Name' } },
  true  // Also update defaults
);

Practical Example:

ts
// Initial form
const formHelper = useFormHelper({
  filters: {
    search: '',
    status: 'all',
    date_from: '',
    date_to: '',
  }
});

// User updates only search
formHelper.modifyForm({
  filters: { search: 'keyword' }
});
// Other filters (status, dates) unchanged

// Reset to defaults
formHelper.setDefault();
// Back to { search: '', status: 'all', date_from: '', date_to: '' }

API Reference

Composables

useFormHelper

Vue 3 composable untuk manage modal form state dengan CRUD operations.

Generic Types:

  • TForm extends Record<string, unknown> - Form data structure
  • THelper - Optional helper/presentation data structure

Parameters

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

Returns: FormHelperObject<TForm, THelper>

Contains:

Reactive State
form
ts
form: TForm

Reactive form data. Dapat di-modify langsung atau via modifyForm().

⚠️ Penting - Penggunaan .value:

  • Di <script>: Tidak perlu .valueformHelper.form.name
  • Di <template>: Tidak perlu .value (auto-unwrap) → formHelper.form.name

Contoh:

ts
const formHelper = useFormHelper({ name: '', email: '' });

// Direct access (reactive)
formHelper.form.name = 'John';
console.log(formHelper.form.email);  // ''
control
ts
control: ControlState

UI control state untuk modal visibility dan form disabled state.

Structure:

ts
{
  display: boolean;  // Modal visibility
  disabled: boolean; // Form inputs disabled
}

Contoh:

vue
<template>
  <Modal :show="formHelper.control.display">
    <input :disabled="formHelper.control.disabled" />
  </Modal>
</template>
default
ts
default: TForm  // Getter/setter

Default form values (deep cloned). Used by setDefault() untuk reset form.

Contoh:

ts
const formHelper = useFormHelper({ name: 'Default' });

// Modify form
formHelper.form.name = 'Changed';

// Read defaults
console.log(formHelper.default.name);  // 'Default'

// Reset to defaults
formHelper.setDefault();
console.log(formHelper.form.name);  // 'Default'

// Update defaults
formHelper.default = { name: 'New Default' };
errors
ts
errors: FormErrors

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

Structure:

ts
{
  [fieldPath: string]: string[];
}

Contoh:

ts
formHelper.errors = {
  "name": ["Required"],
  "address.street": ["Too short"],
  "tags.0": ["Invalid format"]
};
helper
ts
helper: THelper | undefined  // Getter/setter

Optional helper/presentation data. Tidak di-submit, hanya untuk display context.

Contoh:

ts
// Set helper
formHelper.helper = {
  user: fullUserObject,
  roles: rolesArray,
};

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

// Clear helper
formHelper.helper = undefined;

Display & Control Methods
onDisplay()
ts
onDisplay(): boolean

Check apakah modal/form sedang displayed.

Returns: true jika displayed, false jika hidden

Contoh:

vue
<template>
  <Modal :show="formHelper.onDisplay()">
    ...
  </Modal>
</template>
onDisable()
ts
onDisable(): boolean

Check apakah form sedang disabled.

Returns: true jika disabled, false jika enabled

Contoh:

vue
<template>
  <button :disabled="formHelper.onDisable()">
    Submit
  </button>
</template>
setDisplay()
ts
setDisplay(val: boolean): void

Set modal/form visibility.

Parameters:

  • val - true untuk show, false untuk hide

Contoh:

ts
// Show modal
formHelper.setDisplay(true);

// Hide modal (after success submit)
formHelper.setDisplay(false);
setDisable()
ts
setDisable(val: boolean): void

Set form disabled state.

Parameters:

  • val - true untuk disable, false untuk enable

Contoh:

ts
const handleSubmit = async () => {
  formHelper.setDisable(true);  // Disable during submit
  
  try {
    await api.post('/data', formHelper.getAsFormData());
  } finally {
    formHelper.setDisable(false);  // Re-enable
  }
};
allFalse()
ts
allFalse(): void

Reset semua control states ke false (hide modal, enable form).

Contoh:

ts
// Reset everything
formHelper.allFalse();
// Same as:
// formHelper.setDisplay(false);
// formHelper.setDisable(false);

Form Data Methods
setDefault()
ts
setDefault(): void

Reset current form ke default values (deep cloned).

Contoh:

ts
const formHelper = useFormHelper({ name: 'Default' });

formHelper.form.name = 'Changed';
formHelper.setDefault();
console.log(formHelper.form.name);  // 'Default'
modifyDefault()
ts
modifyDefault(newObj: TForm): void

Update stored default values menggunakan deep merge.

Parameters:

  • newObj - New default values (akan di-merge dengan existing defaults)

Contoh:

ts
const formHelper = useFormHelper({
  user: { name: '', email: '' }
});

// Update defaults
formHelper.modifyDefault({
  user: { name: 'New Default' }
  // email tetap ''
});
modifyForm()
ts
modifyForm(newObj: TForm, defaultToo?: boolean): void

Update current form values menggunakan deep merge.

Parameters:

  • newObj - New form values (akan di-merge dengan existing form)
  • defaultToo - Jika true, update defaults juga

Contoh:

ts
// Update form only
formHelper.modifyForm({
  user: { name: 'John' }
});

// Update form AND defaults
formHelper.modifyForm(
  { user: { email: 'new@example.com' } },
  true
);
getFormRaw()
ts
getFormRaw(): TForm

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

Returns: Raw copy of form object

Contoh:

ts
const formData = formHelper.getFormRaw();

// Modify tanpa affect reactive form
formData.extra = 'value';
formData.name = 'Changed';

console.log(formHelper.form.name);  // Unchanged
getFormField()
ts
getFormField(keyOrPath: string | keyof TForm): any

Get field value dari form dengan support untuk dot notation dan array access.

Parameters:

  • keyOrPath - Field key atau path ('name', 'address.street', 'tags.0', 'tags[1]')

Returns: Field value, atau undefined jika path tidak ditemukan

Contoh:

ts
const formHelper = useFormHelper({
  name: 'John',
  address: {
    street: 'Main St',
    city: 'NYC',
  },
  tags: ['vue', 'typescript'],
});

formHelper.getFormField('name');              // 'John'
formHelper.getFormField('address.street');    // 'Main St'
formHelper.getFormField('tags.0');            // 'vue'
formHelper.getFormField('tags[1]');           // 'typescript'
formHelper.getFormField('not.exist');         // undefined

Error Handling Methods
setErrors()
ts
setErrors(err: FormErrors): void

Set validation errors (replaces all existing errors).

Parameters:

  • err - Error object dalam flat dot notation

Contoh:

ts
// Laravel response
catch (error: any) {
  if (error.response?.data?.errors) {
    formHelper.setErrors(error.response.data.errors);
    // {
    //   "name": ["Required"],
    //   "email": ["Invalid format"]
    // }
  }
}
getErrors()
ts
getErrors(key?: string): string[]

Get error messages untuk specific field.

Parameters:

  • key - Field name dalam dot notation (optional)

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

Contoh:

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

const allErrors = formHelper.getErrors();
// []
hasError()
ts
hasError(fieldName: string): boolean

Check apakah specific field memiliki validation errors.

Parameters:

  • fieldName - Field name dalam dot notation

Returns: true jika field has errors, false otherwise

Contoh:

vue
<template>
  <input v-model="formHelper.form.name" />
  <span v-if="formHelper.hasError('name')" class="error">
    {{ formHelper.getErrors('name')[0] }}
  </span>
</template>
clearFormError()
ts
clearFormError(): void

Clear all validation errors.

Contoh:

ts
const handleSubmit = async () => {
  formHelper.clearFormError();  // Clear previous errors
  
  try {
    await api.post('/data', formData);
  } catch (error: any) {
    formHelper.setErrors(error.response?.data?.errors);
  }
};

Mode Management Methods
getMode()
ts
getMode(): Mode

Get current CRUD mode (long form).

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

Contoh:

vue
<template>
  <h2>{{ formHelper.getMode() }} User</h2>
  <!-- Shows: "Tambah User" or "Kelola User" -->
</template>
getModeShort()
ts
getModeShort(): ModeShort

Get current CRUD mode (short code).

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

Contoh:

ts
const mode = formHelper.getModeShort();  // 'c'

if (mode === 'c') {
  // Create logic
}
setMode()
ts
setMode(crud?: ModeShort): void

Set CRUD mode.

Parameters:

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

Contoh:

ts
formHelper.setMode('u');  // Set to update mode
formHelper.getMode();     // 'Kelola'
isModeCreate()
ts
isModeCreate(): boolean

Check apakah current mode adalah create/add.

Returns: true jika mode is 'Tambah' (create)

Contoh:

ts
if (formHelper.isModeCreate()) {
  await api.post('/users', formData);
} else {
  await api.put(`/users/${id}`, formData);
}
isModeUpdate()
ts
isModeUpdate(): boolean

Check apakah current mode adalah update/edit.

Returns: true jika mode is 'Kelola' (update)

Contoh:

vue
<template>
  <button v-if="formHelper.isModeUpdate()">
    Update
  </button>
</template>
isModeDelete()
ts
isModeDelete(): boolean

Check apakah current mode adalah delete.

Returns: true jika mode is 'Delete'

Contoh:

vue
<template>
  <div v-if="formHelper.isModeDelete()">
    <p>Confirm deletion?</p>
  </div>
  <form v-else>
    <!-- Form fields -->
  </form>
</template>
isFormMode()
ts
isFormMode(): boolean

Check apakah current mode menggunakan form (create atau update, bukan delete).

Returns: true jika mode is create atau update, false jika delete

Contoh:

vue
<template>
  <form v-if="formHelper.isFormMode()">
    <!-- Show form for create/update -->
  </form>
  <div v-else>
    <!-- Show delete confirmation -->
  </div>
</template>
isModeIn()
ts
isModeIn(mode: Mode | ModeShort): boolean

Check apakah current mode matches provided mode.

Parameters:

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

Returns: true jika current mode matches

Contoh:

ts
formHelper.isModeIn('c');        // true if create
formHelper.isModeIn('Tambah');   // same as above
formHelper.isModeIn('u');        // true if update
formHelper.isModeIn('Kelola');   // same as above

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

Convert form data ke FormData object untuk multipart/form-data submission.

Parameters:

NameTypeDefaultDescription
arrayAsJsonbooleanfalseSerialize arrays as JSON string Lihat selengkapnya
objectAsJsonbooleanfalseSerialize objects as JSON string Lihat selengkapnya
prefixstring''Add prefix to all keys Lihat selengkapnya
skipKeysstring[][]Keys to exclude dari serialization Lihat selengkapnya

Returns: FormData object ready untuk submission

arrayAsJson

Serialize arrays sebagai JSON string instead of bracket notation.

Default Behavior (false):

ts
// form.tags = ['vue', 'typescript']
// FormData:
tags[0] = 'vue'
tags[1] = 'typescript'

With arrayAsJson=true:

ts
// form.tags = ['vue', 'typescript']
// FormData:
tags = '["vue","typescript"]'

Use Case: Gunakan true ketika backend expect JSON string untuk arrays (common di Laravel).

objectAsJson

Serialize objects sebagai JSON string instead of nested keys.

Default Behavior (false):

ts
// form.meta = { key: 'value', count: 5 }
// FormData:
meta[key] = 'value'
meta[count] = '5'

With objectAsJson=true:

ts
// form.meta = { key: 'value', count: 5 }
// FormData:
meta = '{"key":"value","count":5}'

Use Case: Gunakan true ketika backend expect JSON string untuk objects.

prefix

Add prefix ke semua keys dalam FormData.

Example:

ts
// form.name = 'John'
// With prefix='user_'
// FormData:
user_name = 'John'

Use Case: Berguna ketika submitting nested form structure dengan prefix convention.

skipKeys

Array of keys to exclude dari serialization.

Example:

ts
const formData = formHelper.getAsFormData(
  false,
  false,
  '',
  ['internal_id', 'temp_data']
);
// internal_id dan temp_data tidak included dalam FormData

Use Case: Skip internal/computed fields yang tidak perlu di-submit.

Contoh:

ts
// Basic usage
const formData = formHelper.getAsFormData();
await axios.post('/api/users', formData);

// With JSON serialization
const formData = formHelper.getAsFormData(true, true);

// With prefix and skip
const formData = formHelper.getAsFormData(
  false,
  false,
  'user_',
  ['internal_field', 'computed_value']
);

Special Handling:

  • null / undefined → Empty string ''
  • File / Blob → Appended as-is
  • boolean'1' (true) atau '0' (false)
  • Other primitives → String(value)

Convenience Method
openForm()
ts
openForm(mode: ModeShort, formData?: TForm, helperData?: THelper): void

Open modal form dengan proper setup (all-in-one method).

Parameters:

NameTypeDefaultDescription
modeModeShort-CRUD mode: 'c', 'u', 'd'
formDataTForm-Optional data untuk prefill form Lihat selengkapnya
helperDataTHelper-Optional presentation/lookup data Lihat selengkapnya
formData

Data untuk prefill form (typically untuk edit/delete modes).

Behavior:

  • Mode 'c' (create): formData ignored, form reset to defaults
  • Mode 'u' (update): formData merged into form
  • Mode 'd' (delete): formData merged into form

Example:

ts
// Edit mode - prefill with existing data
openForm('u', {
  user_id: 123,
  name: 'John Doe',
  email: 'john@example.com',
});
helperData

Additional presentation/lookup data yang tidak di-submit.

Example:

ts
openForm('u', formData, {
  user: fullUserObject,
  roles: rolesDropdownOptions,
  permissions: permissionsLookup,
});

Behavior:

  • Jika provided, helper di-set ke helperData
  • Jika not provided dan mode 'c', helper di-clear
  • Jika not provided dan mode bukan 'c', helper unchanged (keep existing)

Use Case: Lihat Helper Data Pattern untuk details.

What This Method Does:

  1. Set CRUD mode
  2. Reset control states (display=false, disabled=false)
  3. Handle form data:
    • Create mode → reset to defaults
    • Edit/Delete mode → merge provided data
  4. Handle helper data
  5. Show modal (display=true)

Contoh:

ts
// Create - empty form
formHelper.openForm('c');

// Update - prefill form with data
formHelper.openForm('u', {
  user_id: 123,
  name: 'John Doe',
});

// Update - with helper data
formHelper.openForm(
  'u',
  { user_id: 123, name: 'John' },
  { 
    user: fullUserObject,
    roles: rolesArray,
  }
);

// Delete - show what will be deleted
formHelper.openForm('d', { id: 123 }, { name: 'John Doe' });

Deprecated Methods

DEPRECATED

Methods berikut masih available untuk backward compatibility tapi akan removed di v1.0.0. Gunakan alternatives yang recommended.

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

@deprecated Use form.fieldName directly, getFormField(key) untuk single field, atau getFormRaw() untuk entire form.

Why Deprecated: Method ini redundant. Direct access ke form property lebih clear dan idiomatic.

Migration:

ts
// ❌ Old (deprecated)
const name = formHelper.get('name');
const allData = formHelper.get();

// ✅ New (recommended)
const name = formHelper.form.name;
const allData = formHelper.getFormRaw();
toggleDisplay()
ts
toggleDisplay(val?: boolean): void

@deprecated Use setDisplay(boolean) instead.

Why Deprecated: Toggle behavior (no argument) is unpredictable. Explicit boolean is clearer.

Migration:

ts
// ❌ Old (deprecated)
formHelper.toggleDisplay();      // Toggle current state
formHelper.toggleDisplay(true);  // Set to true

// ✅ New (recommended)
formHelper.setDisplay(!formHelper.onDisplay());  // Explicit toggle
formHelper.setDisplay(true);                     // Set to true
toggleDisable()
ts
toggleDisable(val?: boolean): void

@deprecated Use setDisable(boolean) instead.

Why Deprecated: Same reason as toggleDisplay() - explicit boolean is clearer.

Migration:

ts
// ❌ Old (deprecated)
formHelper.toggleDisable();
formHelper.toggleDisable(false);

// ✅ New (recommended)
formHelper.setDisable(!formHelper.onDisable());
formHelper.setDisable(false);

Types

Contains:

Mode

ts
type Mode = 'Tambah' | 'Kelola' | 'Delete';

CRUD operation modes dalam Bahasa Indonesia (long form).

Values:

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

Usage: Digunakan untuk display di UI (modal title, button labels, etc).

Contoh:

vue
<template>
  <h2>Form {{ formHelper.getMode() }} User</h2>
  <!-- Shows: "Form Tambah User" or "Form Kelola User" -->
</template>

ModeShort

ts
type ModeShort = 'c' | 'u' | 'd';

Short code representation of CRUD modes.

Values:

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

Usage: Digunakan sebagai method arguments untuk brevity.

FormErrors

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

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

Structure:

  • Keys: Field paths dalam dot notation
  • Values: Array of error messages

Contoh:

ts
const errors: FormErrors = {
  "name": ["The name field is required"],
  "email": ["Invalid email format", "Email already exists"],
  "address.street": ["The street field is required"],
  "tags.0": ["Invalid tag format"],
  "items[1].quantity": ["Must be greater than 0"]
};

ControlState

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

UI control state untuk modal display dan form disabled state.

Contains:

display
ts
display: boolean

Whether the modal/form is visible.

Usage:

vue
<Modal :show="formHelper.control.display">
disabled
ts
disabled: boolean

Whether the form inputs are disabled.

Usage:

vue
<input :disabled="formHelper.control.disabled" />

FormHelperObject

ts
interface FormHelperObject<
  TForm extends Record<string, unknown> = Record<string, unknown>,
  THelper = any
>

FormHelper composable return object. Lihat useFormHelper Returns untuk complete documentation.

Generic Types:

  • TForm - Form data type
  • THelper - Helper/presentation data type (optional)

Examples

Contains:


1. Full CRUD Workflow

Complete component dengan create/edit/delete dalam satu place.

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

interface ProductForm {
  product_id?: number;
  name: string;
  price: number;
}

interface ProductHelper {
  product?: {
    id: number;
    name: string;
    price: number;
  };
}

const products = ref<ProductHelper['product'][]>([]);

const formHelper = useFormHelper<ProductForm, ProductHelper>({
  name: '',
  price: 0,
});

const loadProducts = async () => {
  const response = await api.get('/products');
  products.value = response.data;
};

const openCreate = () => {
  formHelper.openForm('c');
};

const openEdit = (product: ProductHelper['product']) => {
  formHelper.openForm(
    'u',
    {
      product_id: product!.id,
      name: product!.name,
      price: product!.price,
    },
    { product: product }
  );
};

const openDelete = (product: ProductHelper['product']) => {
  formHelper.openForm('d', { product_id: product!.id }, { product });
};

const handleSubmit = async () => {
  if (formHelper.isModeDelete()) {
    await handleDelete();
    return;
  }

  formHelper.setDisable(true);
  formHelper.clearFormError();

  try {
    const formData = formHelper.getAsFormData();

    if (formHelper.isModeCreate()) {
      await api.post('/products', formData);
    } else {
      await api.post(`/products/${formHelper.form.product_id}`, formData);
    }

    formHelper.setDisplay(false);
    await loadProducts();
  } catch (error: any) {
    if (error.response?.data?.errors) {
      formHelper.setErrors(error.response.data.errors);
    }
  } finally {
    formHelper.setDisable(false);
  }
};

const handleDelete = async () => {
  formHelper.setDisable(true);

  try {
    await api.delete(`/products/${formHelper.form.product_id}`);
    formHelper.setDisplay(false);
    await loadProducts();
  } finally {
    formHelper.setDisable(false);
  }
};

loadProducts();
</script>

<template>
  <div>
    <button @click="openCreate">Add Product</button>

    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="product in products" :key="product.id">
          <td>{{ product.name }}</td>
          <td>${{ product.price }}</td>
          <td>
            <button @click="openEdit(product)">Edit</button>
            <button @click="openDelete(product)">Delete</button>
          </td>
        </tr>
      </tbody>
    </table>

    <Modal :show="formHelper.onDisplay()" @close="formHelper.setDisplay(false)">
      <h2>{{ formHelper.getMode() }} Product</h2>

      <!-- Delete Confirmation -->
      <div v-if="formHelper.isModeDelete()">
        <p>Delete product: {{ formHelper.helper?.product?.name }}?</p>
        <button @click="handleDelete" :disabled="formHelper.onDisable()">
          Confirm Delete
        </button>
      </div>

      <!-- Create/Edit Form -->
      <form v-else @submit.prevent="handleSubmit">
        <div>
          <label>Name</label>
          <input 
            v-model="formHelper.form.name" 
            :disabled="formHelper.onDisable()"
          />
          <span v-if="formHelper.hasError('name')" class="error">
            {{ formHelper.getErrors('name')[0] }}
          </span>
        </div>

        <div>
          <label>Price</label>
          <input 
            v-model.number="formHelper.form.price" 
            type="number"
            :disabled="formHelper.onDisable()"
          />
          <span v-if="formHelper.hasError('price')" class="error">
            {{ formHelper.getErrors('price')[0] }}
          </span>
        </div>

        <button type="submit" :disabled="formHelper.onDisable()">
          {{ formHelper.isModeCreate() ? 'Create' : 'Update' }}
        </button>
      </form>
    </Modal>
  </div>
</template>

Key Takeaways:

  • Single component handles all CRUD operations
  • openForm() method setup mode, data, dan helper
  • Mode checking determines submit behavior
  • Form simplified dengan 2 fields (name, price)

2. Validation Error Handling

Laravel error response integration dengan per-field error display.

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

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

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

const handleSubmit = async () => {
  formHelper.setDisable(true);
  formHelper.clearFormError();  // Clear previous errors

  try {
    const formData = formHelper.getAsFormData();
    await api.post('/users/register', formData);

    alert('Registration successful!');
    formHelper.setDisplay(false);
  } catch (error: any) {
    // Laravel returns errors in this structure:
    // {
    //   "errors": {
    //     "name": ["The name field is required"],
    //     "email": ["Invalid email", "Email already exists"],
    //     "password": ["Password too short"]
    //   }
    // }
    
    if (error.response?.data?.errors) {
      formHelper.setErrors(error.response.data.errors);
    } else {
      alert('An error occurred');
    }
  } finally {
    formHelper.setDisable(false);
  }
};
</script>

<template>
  <Modal :show="formHelper.onDisplay()" @close="formHelper.setDisplay(false)">
    <form @submit.prevent="handleSubmit">
      <div class="field">
        <label>Name</label>
        <input 
          v-model="formHelper.form.name"
          :disabled="formHelper.onDisable()"
          :class="{ 'error-input': formHelper.hasError('name') }"
        />
        <div v-if="formHelper.hasError('name')" class="error-messages">
          <p v-for="(error, idx) in formHelper.getErrors('name')" :key="idx">
            {{ error }}
          </p>
        </div>
      </div>

      <div class="field">
        <label>Email</label>
        <input 
          v-model="formHelper.form.email"
          type="email"
          :disabled="formHelper.onDisable()"
          :class="{ 'error-input': formHelper.hasError('email') }"
        />
        <!-- Show all error messages for field -->
        <div v-if="formHelper.hasError('email')" class="error-messages">
          <p v-for="(error, idx) in formHelper.getErrors('email')" :key="idx">
            {{ error }}
          </p>
        </div>
      </div>

      <div class="field">
        <label>Password</label>
        <input 
          v-model="formHelper.form.password"
          type="password"
          :disabled="formHelper.onDisable()"
          :class="{ 'error-input': formHelper.hasError('password') }"
        />
        <div v-if="formHelper.hasError('password')" class="error-messages">
          <p v-for="(error, idx) in formHelper.getErrors('password')" :key="idx">
            {{ error }}
          </p>
        </div>
      </div>

      <div class="field">
        <label>Confirm Password</label>
        <input 
          v-model="formHelper.form.password_confirmation"
          type="password"
          :disabled="formHelper.onDisable()"
          :class="{ 'error-input': formHelper.hasError('password_confirmation') }"
        />
        <div v-if="formHelper.hasError('password_confirmation')" class="error-messages">
          <p v-for="(error, idx) in formHelper.getErrors('password_confirmation')" :key="idx">
            {{ error }}
          </p>
        </div>
      </div>

      <button type="submit" :disabled="formHelper.onDisable()">
        Register
      </button>
    </form>
  </Modal>
</template>

<style scoped>
.error-input {
  border-color: red;
}

.error-messages {
  color: red;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}
</style>

Key Takeaways:

  • clearFormError() sebelum submit untuk clear previous errors
  • Laravel error response langsung compatible dengan setErrors()
  • hasError() untuk conditional styling
  • getErrors() returns array - show first atau loop all messages

3. Nested Field Access

Dot notation support untuk nested objects dan array elements.

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

interface AddressForm {
  user: {
    name: string;
    email: string;
  };
  address: {
    street: string;
    city: string;
    zip: string;
  };
  phones: string[];
  metadata: {
    tags: string[];
    preferences: {
      theme: string;
      notifications: boolean;
    };
  };
}

const formHelper = useFormHelper<AddressForm>({
  user: { name: '', email: '' },
  address: { street: '', city: '', zip: '' },
  phones: ['', ''],
  metadata: {
    tags: [],
    preferences: { theme: 'light', notifications: true },
  },
});

// Simulate Laravel validation errors with dot notation
const simulateValidationErrors = () => {
  formHelper.setErrors({
    'user.name': ['Name is required'],
    'user.email': ['Invalid email format'],
    'address.street': ['Street is required'],
    'address.city': ['City is required'],
    'phones.0': ['Invalid phone format'],
    'phones.1': ['Phone already exists'],
    'metadata.tags.0': ['Tag must be alphanumeric'],
    'metadata.preferences.theme': ['Invalid theme'],
  });
};

// Access nested fields programmatically
const logNestedFields = () => {
  console.log('User name:', formHelper.getFormField('user.name'));
  console.log('Street:', formHelper.getFormField('address.street'));
  console.log('First phone:', formHelper.getFormField('phones.0'));
  console.log('Theme:', formHelper.getFormField('metadata.preferences.theme'));
  
  // Bracket notation also works
  console.log('Second phone:', formHelper.getFormField('phones[1]'));
};
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <!-- Nested object fields -->
    <div class="section">
      <h3>User Info</h3>
      
      <input v-model="formHelper.form.user.name" placeholder="Name" />
      <span v-if="formHelper.hasError('user.name')" class="error">
        {{ formHelper.getErrors('user.name')[0] }}
      </span>

      <input v-model="formHelper.form.user.email" placeholder="Email" />
      <span v-if="formHelper.hasError('user.email')" class="error">
        {{ formHelper.getErrors('user.email')[0] }}
      </span>
    </div>

    <!-- Deeply nested object -->
    <div class="section">
      <h3>Address</h3>
      
      <input v-model="formHelper.form.address.street" placeholder="Street" />
      <span v-if="formHelper.hasError('address.street')" class="error">
        {{ formHelper.getErrors('address.street')[0] }}
      </span>

      <input v-model="formHelper.form.address.city" placeholder="City" />
      <span v-if="formHelper.hasError('address.city')" class="error">
        {{ formHelper.getErrors('address.city')[0] }}
      </span>
    </div>

    <!-- Array elements -->
    <div class="section">
      <h3>Phones</h3>
      
      <div v-for="(phone, idx) in formHelper.form.phones" :key="idx">
        <input 
          v-model="formHelper.form.phones[idx]" 
          :placeholder="`Phone ${idx + 1}`"
        />
        <!-- Error dengan dot notation untuk array index -->
        <span v-if="formHelper.hasError(`phones.${idx}`)" class="error">
          {{ formHelper.getErrors(`phones.${idx}`)[0] }}
        </span>
      </div>
    </div>

    <!-- Nested array in object -->
    <div class="section">
      <h3>Metadata</h3>
      
      <div v-for="(tag, idx) in formHelper.form.metadata.tags" :key="idx">
        <input 
          v-model="formHelper.form.metadata.tags[idx]" 
          :placeholder="`Tag ${idx + 1}`"
        />
        <span v-if="formHelper.hasError(`metadata.tags.${idx}`)" class="error">
          {{ formHelper.getErrors(`metadata.tags.${idx}`)[0] }}
        </span>
      </div>

      <select v-model="formHelper.form.metadata.preferences.theme">
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
      <span v-if="formHelper.hasError('metadata.preferences.theme')" class="error">
        {{ formHelper.getErrors('metadata.preferences.theme')[0] }}
      </span>
    </div>

    <button type="button" @click="simulateValidationErrors">
      Test Validation
    </button>
    <button type="button" @click="logNestedFields">
      Log Nested Fields
    </button>
  </form>
</template>

Key Takeaways:

  • Direct access: formHelper.form.user.name
  • Programmatic access: getFormField('user.name')
  • Array elements: phones.0 atau phones[0] (both work)
  • Deeply nested: metadata.preferences.theme
  • Error keys match field paths exactly

4. FormData Serialization Options

Different scenarios untuk FormData conversion dengan berbagai options.

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

interface FileUploadForm {
  title: string;
  description: string;
  tags: string[];
  metadata: {
    author: string;
    version: string;
  };
  files: File[];
  settings: {
    public: boolean;
    notify: boolean;
  };
  internal_id: string;  // Will be skipped
  computed_value: string;  // Will be skipped
}

const formHelper = useFormHelper<FileUploadForm>({
  title: '',
  description: '',
  tags: [],
  metadata: { author: '', version: '' },
  files: [],
  settings: { public: false, notify: true },
  internal_id: 'temp-123',
  computed_value: 'computed',
});

// Scenario 1: Basic usage (default behavior)
const submitBasic = async () => {
  const formData = formHelper.getAsFormData();
  
  // FormData structure:
  // title = 'My Document'
  // description = 'Description text'
  // tags[0] = 'vue'
  // tags[1] = 'typescript'
  // metadata[author] = 'John'
  // metadata[version] = '1.0'
  // files[0] = File
  // files[1] = File
  // settings[public] = '0'
  // settings[notify] = '1'
  // internal_id = 'temp-123'
  // computed_value = 'computed'
  
  await api.post('/documents', formData);
};

// Scenario 2: Arrays as JSON (Laravel common pattern)
const submitArraysAsJson = async () => {
  const formData = formHelper.getAsFormData(
    true,   // arrayAsJson
    false,  // objectAsJson
  );
  
  // FormData structure:
  // title = 'My Document'
  // tags = '["vue","typescript"]'  ← JSON string
  // metadata[author] = 'John'
  // metadata[version] = '1.0'
  // files = '["[object File]","[object File]"]'  ← Files also as JSON
  // settings[public] = '0'
  
  await api.post('/documents', formData);
};

// Scenario 3: Objects as JSON
const submitObjectsAsJson = async () => {
  const formData = formHelper.getAsFormData(
    false,  // arrayAsJson
    true,   // objectAsJson
  );
  
  // FormData structure:
  // title = 'My Document'
  // tags[0] = 'vue'
  // tags[1] = 'typescript'
  // metadata = '{"author":"John","version":"1.0"}'  ← JSON string
  // files[0] = File
  // files[1] = File
  // settings = '{"public":false,"notify":true}'  ← JSON string
  
  await api.post('/documents', formData);
};

// Scenario 4: Both arrays and objects as JSON
const submitAllAsJson = async () => {
  const formData = formHelper.getAsFormData(
    true,  // arrayAsJson
    true,  // objectAsJson
  );
  
  // FormData structure:
  // title = 'My Document'
  // tags = '["vue","typescript"]'
  // metadata = '{"author":"John","version":"1.0"}'
  // files = '[File, File]' (serialized)
  // settings = '{"public":false,"notify":true}'
  
  await api.post('/documents', formData);
};

// Scenario 5: With prefix
const submitWithPrefix = async () => {
  const formData = formHelper.getAsFormData(
    false,
    false,
    'doc_',  // prefix
  );
  
  // FormData structure:
  // doc_title = 'My Document'
  // doc_description = 'Description text'
  // doc_tags[0] = 'vue'
  // doc_metadata[author] = 'John'
  // doc_settings[public] = '0'
  
  await api.post('/documents', formData);
};

// Scenario 6: Skip internal fields
const submitWithSkip = async () => {
  const formData = formHelper.getAsFormData(
    false,
    false,
    '',
    ['internal_id', 'computed_value']  // skipKeys
  );
  
  // FormData structure:
  // title = 'My Document'
  // tags[0] = 'vue'
  // metadata[author] = 'John'
  // settings[public] = '0'
  // (internal_id and computed_value NOT included)
  
  await api.post('/documents', formData);
};

// Scenario 7: Combined options
const submitCombined = async () => {
  const formData = formHelper.getAsFormData(
    true,   // Arrays as JSON
    true,   // Objects as JSON
    'upload_',  // Prefix
    ['internal_id', 'computed_value']  // Skip keys
  );
  
  // FormData structure:
  // upload_title = 'My Document'
  // upload_tags = '["vue","typescript"]'
  // upload_metadata = '{"author":"John","version":"1.0"}'
  // upload_settings = '{"public":false,"notify":true}'
  // (files serialized, internals skipped)
  
  await api.post('/documents', formData);
};
</script>

<template>
  <div>
    <h2>FormData Serialization Examples</h2>
    
    <button @click="submitBasic">
      1. Basic (Default)
    </button>
    
    <button @click="submitArraysAsJson">
      2. Arrays as JSON
    </button>
    
    <button @click="submitObjectsAsJson">
      3. Objects as JSON
    </button>
    
    <button @click="submitAllAsJson">
      4. All as JSON
    </button>
    
    <button @click="submitWithPrefix">
      5. With Prefix
    </button>
    
    <button @click="submitWithSkip">
      6. Skip Keys
    </button>
    
    <button @click="submitCombined">
      7. Combined Options
    </button>
  </div>
</template>

Key Takeaways:

  • Default behavior: Nested keys dengan bracket notation
  • arrayAsJson: true - Arrays menjadi JSON string (common untuk Laravel)
  • objectAsJson: true - Objects menjadi JSON string
  • prefix - Add prefix ke semua keys (untuk namespacing)
  • skipKeys - Exclude internal/computed fields dari submission
  • Options bisa di-combine sesuai backend requirements

5. Helper Data Pattern

Separation antara form (editable) dan helper (context/lookup) data.

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

// Form: Only editable fields
interface OrderForm {
  order_id?: number;
  customer_id: number;
  product_id: number;
  quantity: number;
  notes: string;
}

// Helper: Full context objects + lookups
interface OrderHelper {
  order?: {
    id: number;
    order_number: string;
    customer: {
      id: number;
      name: string;
      email: string;
      address: string;
    };
    product: {
      id: number;
      name: string;
      price: number;
      stock: number;
    };
    created_at: string;
    total: number;
  };
  customers: Array<{
    id: number;
    name: string;
    email: string;
  }>;
  products: Array<{
    id: number;
    name: string;
    price: number;
    stock: number;
  }>;
}

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

const customers = ref([
  { id: 1, name: 'John Doe', email: 'john@example.com' },
  { id: 2, name: 'Jane Smith', email: 'jane@example.com' },
]);

const products = ref([
  { id: 1, name: 'Laptop', price: 1000, stock: 5 },
  { id: 2, name: 'Mouse', price: 25, stock: 50 },
]);

// Create order
const openCreate = () => {
  formHelper.openForm('c', undefined, {
    customers: customers.value,
    products: products.value,
  });
};

// Edit order
const openEdit = (order: OrderHelper['order']) => {
  formHelper.openForm(
    'u',
    // Form: Only IDs and editable fields
    {
      order_id: order!.id,
      customer_id: order!.customer.id,
      product_id: order!.product.id,
      quantity: 1,  // Assume quantity not editable in this example
      notes: '',
    },
    // Helper: Full objects + lookups
    {
      order: order,
      customers: customers.value,
      products: products.value,
    }
  );
};

// Computed values from helper
const selectedProduct = computed(() => {
  if (!formHelper.helper?.products || !formHelper.form.product_id) {
    return null;
  }
  return formHelper.helper.products.find(
    p => p.id === formHelper.form.product_id
  );
});

const estimatedTotal = computed(() => {
  if (!selectedProduct.value) return 0;
  return selectedProduct.value.price * formHelper.form.quantity;
});

const canSubmit = computed(() => {
  if (!selectedProduct.value) return false;
  return formHelper.form.quantity <= selectedProduct.value.stock;
});
</script>

<template>
  <Modal :show="formHelper.onDisplay()">
    <h2>{{ formHelper.getMode() }} Order</h2>

    <!-- Display-only info from helper (if edit mode) -->
    <div v-if="formHelper.isModeUpdate() && formHelper.helper?.order" class="info-section">
      <p><strong>Order Number:</strong> {{ formHelper.helper.order.order_number }}</p>
      <p><strong>Created:</strong> {{ formHelper.helper.order.created_at }}</p>
      <p><strong>Original Total:</strong> ${{ formHelper.helper.order.total }}</p>
    </div>

    <form @submit.prevent="handleSubmit">
      <!-- Customer dropdown - from helper.customers -->
      <div class="field">
        <label>Customer</label>
        <select v-model="formHelper.form.customer_id">
          <option value="0">Select Customer</option>
          <option 
            v-for="customer in formHelper.helper?.customers" 
            :key="customer.id" 
            :value="customer.id"
          >
            {{ customer.name }} ({{ customer.email }})
          </option>
        </select>
      </div>

      <!-- Show selected customer details from helper -->
      <div v-if="formHelper.form.customer_id" class="customer-details">
        <p class="text-sm text-gray-500">
          Selected: {{ 
            formHelper.helper?.customers.find(
              c => c.id === formHelper.form.customer_id
            )?.name 
          }}
        </p>
      </div>

      <!-- 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>Estimated Total:</strong> ${{ estimatedTotal }}</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 || formHelper.onDisable()"
      >
        {{ formHelper.isModeCreate() ? 'Create Order' : 'Update Order' }}
      </button>
    </form>
  </Modal>
</template>

Key Takeaways:

  • Form: Hanya field yang di-submit (IDs, quantities, notes)
  • Helper: Full objects untuk display + dropdowns
  • Helper provides context tanpa pollute form data
  • Computed values derived dari helper data
  • Submit hanya kirim form, tidak include helper
  • Edit mode: Form shows IDs, helper shows full object details

6. 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;
      push: boolean;
    };
    privacy: {
      profile_visible: boolean;
      show_email: boolean;
    };
  };
  social: {
    twitter: string;
    github: string;
    linkedin: string;
  };
}

const formHelper = useFormHelper<ProfileForm>({
  user: {
    name: 'John Doe',
    email: 'john@example.com',
    bio: 'Software developer',
  },
  preferences: {
    theme: 'dark',
    notifications: {
      email: true,
      sms: false,
      push: true,
    },
    privacy: {
      profile_visible: true,
      show_email: false,
    },
  },
  social: {
    twitter: '@johndoe',
    github: 'johndoe',
    linkedin: 'johndoe',
  },
});

// 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 and push unchanged
      }
    }
  });
  
  // Result:
  // preferences: {
  //   theme: 'dark',          ← Unchanged
  //   notifications: {
  //     email: false,         ← Updated
  //     sms: false,           ← Unchanged
  //     push: true,           ← Unchanged
  //   },
  //   privacy: {              ← Unchanged
  //     profile_visible: true,
  //     show_email: false,
  //   }
  // }
};

// Scenario 3: Update multiple nested fields
const updateMultiple = () => {
  formHelper.modifyForm({
    user: {
      bio: 'Updated bio',
    },
    preferences: {
      theme: 'light',
    },
    social: {
      twitter: '@newhandle',
      github: 'newusername',
    }
  });
  
  // Only specified fields updated, rest unchanged
};

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

// Scenario 5: Compare with Object.assign (wrong way)
const wrongWayWithAssign = () => {
  // ❌ This replaces entire nested object
  Object.assign(formHelper.form.preferences, {
    notifications: {
      email: false,
    }
  });
  
  // Result: LOST sms and push fields!
  // preferences: {
  //   theme: 'dark',
  //   notifications: {
  //     email: false,     ← Only this remains
  //     // sms LOST!
  //     // push LOST!
  //   },
  //   privacy: {          ← Still exists (not replaced)
  //     profile_visible: true,
  //     show_email: false,
  //   }
  // }
};

// Scenario 6: 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'
  console.log(formHelper.form.preferences.theme);  // 'light'
  
  // Reset to original defaults
  formHelper.setDefault();
  
  console.log(formHelper.form.user.name);  // 'John Doe'
  console.log(formHelper.form.preferences.theme);  // 'dark'
};

// Scenario 7: Practical use - wizard form steps
const wizardStep1 = () => {
  formHelper.modifyForm({
    user: {
      name: 'User Input',
      email: 'user@example.com',
    }
  });
};

const wizardStep2 = () => {
  // Previous step data (user) preserved
  formHelper.modifyForm({
    preferences: {
      theme: 'dark',
      notifications: {
        email: true,
        sms: true,
      }
    }
  });
};

const wizardStep3 = () => {
  // All previous data still intact
  formHelper.modifyForm({
    social: {
      twitter: '@handle',
      github: 'username',
    }
  });
  
  // Submit complete form
  const finalData = formHelper.getFormRaw();
  // Contains all data from step 1, 2, and 3
};
</script>

<template>
  <div>
    <h2>Deep Merge Examples</h2>
    
    <button @click="updateName">Update Name Only</button>
    <button @click="updateEmailNotification">Update Email Notification</button>
    <button @click="updateMultiple">Update Multiple Fields</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)