@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)
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:
import { useFormHelper } from '@bpmlib/utils-form-helper';Types:
import type {
Mode,
ModeShort,
FormErrors,
ControlState,
FormHelperObject,
} from '@bpmlib/utils-form-helper';Installation & Setup
Requirements
Peer Dependencies
Library ini memerlukan peer dependencies berikut:
Wajib:
npm install vue@^3.3.0| Dependency | Versi | Status | Deskripsi |
|---|---|---|---|
vue | ^3.3.0 | Required | Vue 3 framework |
Package Installation
npm install @bpmlib/utils-form-helperyarn add @bpmlib/utils-form-helperpnpm add @bpmlib/utils-form-helperbun install @bpmlib/utils-form-helperImport
Basic Import:
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:
<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 modalformproperty - Reactive form dataonDisplay()/setDisplay()- Modal visibility controlonDisable()/setDisable()- Form disabled state (during submission)
Comprehensive Example
Full CRUD dengan validation dan helper data:
<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 modehelperproperty - Data tambahan untuk display (roles lookup, full user object)isModeCreate()/isModeUpdate()/isModeDelete()- Mode checkinggetMode()- Returns 'Tambah' / 'Kelola' / 'Delete' untuk display
Core Concepts
Library ini didesain untuk pattern umum dalam family project: form dalam modal dengan CRUD operations.
Modal-Based Form Pattern
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:
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 everythingCRUD Mode System
Composable menggunakan mode system dengan dua format:
Mode Types:
- Short code (
ModeShort):'c'|'u'|'d' - Long form (
Mode):'Tambah'|'Kelola'|'Delete'
Mapping:
'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:
<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:
const formHelper = useFormHelper({ name: '', email: '' });
formHelper.form.name = 'John'; // Reactive2. default - Deep cloned defaults:
// Default values di-clone untuk prevent reference issues
formHelper.setDefault(); // Reset form to defaultsWhy Deep Cloning:
Tanpa deep clone, modifying form bisa affect defaults:
// ❌ 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 unchangedContoh:
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:
{
"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:
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:
<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:
- Lookup data - Dropdown options, reference data
- Full object - Form hanya field IDs, helper menyimpan full object
- Computed display - Derived data untuk UI
Contoh:
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' },
],
}
);
};<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:
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:
// 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:
// 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 structureTHelper- Optional helper/presentation data structure
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
initData | TForm | {} | Initial form values |
defaultData | TForm | initData | Default values untuk reset (jika berbeda dari initData) |
Returns: FormHelperObject<TForm, THelper>
Contains:
- Reactive State
- Display & Control Methods
- Form Data Methods
- Error Handling Methods
- Mode Management Methods
- Serialization Method
- Convenience Method
- Deprecated Methods
Reactive State
form
form: TFormReactive form data. Dapat di-modify langsung atau via modifyForm().
⚠️ Penting - Penggunaan .value:
- Di
<script>: Tidak perlu.value→formHelper.form.name - Di
<template>: Tidak perlu.value(auto-unwrap) →formHelper.form.name
Contoh:
const formHelper = useFormHelper({ name: '', email: '' });
// Direct access (reactive)
formHelper.form.name = 'John';
console.log(formHelper.form.email); // ''control
control: ControlStateUI control state untuk modal visibility dan form disabled state.
Structure:
{
display: boolean; // Modal visibility
disabled: boolean; // Form inputs disabled
}Contoh:
<template>
<Modal :show="formHelper.control.display">
<input :disabled="formHelper.control.disabled" />
</Modal>
</template>default
default: TForm // Getter/setterDefault form values (deep cloned). Used by setDefault() untuk reset form.
Contoh:
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
errors: FormErrorsValidation errors dalam flat dot notation (Laravel-compatible).
Structure:
{
[fieldPath: string]: string[];
}Contoh:
formHelper.errors = {
"name": ["Required"],
"address.street": ["Too short"],
"tags.0": ["Invalid format"]
};helper
helper: THelper | undefined // Getter/setterOptional helper/presentation data. Tidak di-submit, hanya untuk display context.
Contoh:
// Set helper
formHelper.helper = {
user: fullUserObject,
roles: rolesArray,
};
// Access helper
const roles = formHelper.helper?.roles;
// Clear helper
formHelper.helper = undefined;Display & Control Methods
onDisplay()
onDisplay(): booleanCheck apakah modal/form sedang displayed.
Returns: true jika displayed, false jika hidden
Contoh:
<template>
<Modal :show="formHelper.onDisplay()">
...
</Modal>
</template>onDisable()
onDisable(): booleanCheck apakah form sedang disabled.
Returns: true jika disabled, false jika enabled
Contoh:
<template>
<button :disabled="formHelper.onDisable()">
Submit
</button>
</template>setDisplay()
setDisplay(val: boolean): voidSet modal/form visibility.
Parameters:
val-trueuntuk show,falseuntuk hide
Contoh:
// Show modal
formHelper.setDisplay(true);
// Hide modal (after success submit)
formHelper.setDisplay(false);setDisable()
setDisable(val: boolean): voidSet form disabled state.
Parameters:
val-trueuntuk disable,falseuntuk enable
Contoh:
const handleSubmit = async () => {
formHelper.setDisable(true); // Disable during submit
try {
await api.post('/data', formHelper.getAsFormData());
} finally {
formHelper.setDisable(false); // Re-enable
}
};allFalse()
allFalse(): voidReset semua control states ke false (hide modal, enable form).
Contoh:
// Reset everything
formHelper.allFalse();
// Same as:
// formHelper.setDisplay(false);
// formHelper.setDisable(false);Form Data Methods
setDefault()
setDefault(): voidReset current form ke default values (deep cloned).
Contoh:
const formHelper = useFormHelper({ name: 'Default' });
formHelper.form.name = 'Changed';
formHelper.setDefault();
console.log(formHelper.form.name); // 'Default'modifyDefault()
modifyDefault(newObj: TForm): voidUpdate stored default values menggunakan deep merge.
Parameters:
newObj- New default values (akan di-merge dengan existing defaults)
Contoh:
const formHelper = useFormHelper({
user: { name: '', email: '' }
});
// Update defaults
formHelper.modifyDefault({
user: { name: 'New Default' }
// email tetap ''
});modifyForm()
modifyForm(newObj: TForm, defaultToo?: boolean): voidUpdate current form values menggunakan deep merge.
Parameters:
newObj- New form values (akan di-merge dengan existing form)defaultToo- Jikatrue, update defaults juga
Contoh:
// Update form only
formHelper.modifyForm({
user: { name: 'John' }
});
// Update form AND defaults
formHelper.modifyForm(
{ user: { email: 'new@example.com' } },
true
);getFormRaw()
getFormRaw(): TFormGet non-reactive (raw) copy dari entire form data.
Returns: Raw copy of form object
Contoh:
const formData = formHelper.getFormRaw();
// Modify tanpa affect reactive form
formData.extra = 'value';
formData.name = 'Changed';
console.log(formHelper.form.name); // UnchangedgetFormField()
getFormField(keyOrPath: string | keyof TForm): anyGet 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:
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'); // undefinedError Handling Methods
setErrors()
setErrors(err: FormErrors): voidSet validation errors (replaces all existing errors).
Parameters:
err- Error object dalam flat dot notation
Contoh:
// Laravel response
catch (error: any) {
if (error.response?.data?.errors) {
formHelper.setErrors(error.response.data.errors);
// {
// "name": ["Required"],
// "email": ["Invalid format"]
// }
}
}getErrors()
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:
const nameErrors = formHelper.getErrors('name');
// ['The name field is required']
const allErrors = formHelper.getErrors();
// []hasError()
hasError(fieldName: string): booleanCheck apakah specific field memiliki validation errors.
Parameters:
fieldName- Field name dalam dot notation
Returns: true jika field has errors, false otherwise
Contoh:
<template>
<input v-model="formHelper.form.name" />
<span v-if="formHelper.hasError('name')" class="error">
{{ formHelper.getErrors('name')[0] }}
</span>
</template>clearFormError()
clearFormError(): voidClear all validation errors.
Contoh:
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()
getMode(): ModeGet current CRUD mode (long form).
Returns: 'Tambah' | 'Kelola' | 'Delete'
Contoh:
<template>
<h2>{{ formHelper.getMode() }} User</h2>
<!-- Shows: "Tambah User" or "Kelola User" -->
</template>getModeShort()
getModeShort(): ModeShortGet current CRUD mode (short code).
Returns: 'c' | 'u' | 'd'
Contoh:
const mode = formHelper.getModeShort(); // 'c'
if (mode === 'c') {
// Create logic
}setMode()
setMode(crud?: ModeShort): voidSet CRUD mode.
Parameters:
crud- Mode short code:'c'(create),'u'(update),'d'(delete). Default:'c'
Contoh:
formHelper.setMode('u'); // Set to update mode
formHelper.getMode(); // 'Kelola'isModeCreate()
isModeCreate(): booleanCheck apakah current mode adalah create/add.
Returns: true jika mode is 'Tambah' (create)
Contoh:
if (formHelper.isModeCreate()) {
await api.post('/users', formData);
} else {
await api.put(`/users/${id}`, formData);
}isModeUpdate()
isModeUpdate(): booleanCheck apakah current mode adalah update/edit.
Returns: true jika mode is 'Kelola' (update)
Contoh:
<template>
<button v-if="formHelper.isModeUpdate()">
Update
</button>
</template>isModeDelete()
isModeDelete(): booleanCheck apakah current mode adalah delete.
Returns: true jika mode is 'Delete'
Contoh:
<template>
<div v-if="formHelper.isModeDelete()">
<p>Confirm deletion?</p>
</div>
<form v-else>
<!-- Form fields -->
</form>
</template>isFormMode()
isFormMode(): booleanCheck apakah current mode menggunakan form (create atau update, bukan delete).
Returns: true jika mode is create atau update, false jika delete
Contoh:
<template>
<form v-if="formHelper.isFormMode()">
<!-- Show form for create/update -->
</form>
<div v-else>
<!-- Show delete confirmation -->
</div>
</template>isModeIn()
isModeIn(mode: Mode | ModeShort): booleanCheck apakah current mode matches provided mode.
Parameters:
mode- Mode to check (accepts both short and long format)
Returns: true jika current mode matches
Contoh:
formHelper.isModeIn('c'); // true if create
formHelper.isModeIn('Tambah'); // same as above
formHelper.isModeIn('u'); // true if update
formHelper.isModeIn('Kelola'); // same as aboveSerialization Method
getAsFormData()
getAsFormData(
arrayAsJson?: boolean,
objectAsJson?: boolean,
prefix?: string,
skipKeys?: Array<string>
): FormDataConvert form data ke FormData object untuk multipart/form-data submission.
Parameters:
| Name | Type | Default | Description |
|---|---|---|---|
arrayAsJson | boolean | false | Serialize arrays as JSON string Lihat selengkapnya |
objectAsJson | boolean | false | Serialize objects as JSON string Lihat selengkapnya |
prefix | string | '' | Add prefix to all keys Lihat selengkapnya |
skipKeys | string[] | [] | 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):
// form.tags = ['vue', 'typescript']
// FormData:
tags[0] = 'vue'
tags[1] = 'typescript'With arrayAsJson=true:
// 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):
// form.meta = { key: 'value', count: 5 }
// FormData:
meta[key] = 'value'
meta[count] = '5'With objectAsJson=true:
// 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:
// 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:
const formData = formHelper.getAsFormData(
false,
false,
'',
['internal_id', 'temp_data']
);
// internal_id dan temp_data tidak included dalam FormDataUse Case: Skip internal/computed fields yang tidak perlu di-submit.
Contoh:
// 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-isboolean→'1'(true) atau'0'(false)- Other primitives →
String(value)
Convenience Method
openForm()
openForm(mode: ModeShort, formData?: TForm, helperData?: THelper): voidOpen modal form dengan proper setup (all-in-one method).
Parameters:
| Name | Type | Default | Description |
|---|---|---|---|
mode | ModeShort | - | CRUD mode: 'c', 'u', 'd' |
formData | TForm | - | Optional data untuk prefill form Lihat selengkapnya |
helperData | THelper | - | Optional presentation/lookup data Lihat selengkapnya |
formData
Data untuk prefill form (typically untuk edit/delete modes).
Behavior:
- Mode
'c'(create):formDataignored, form reset to defaults - Mode
'u'(update):formDatamerged into form - Mode
'd'(delete):formDatamerged into form
Example:
// 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:
openForm('u', formData, {
user: fullUserObject,
roles: rolesDropdownOptions,
permissions: permissionsLookup,
});Behavior:
- Jika provided,
helperdi-set kehelperData - Jika not provided dan mode
'c',helperdi-clear - Jika not provided dan mode bukan
'c',helperunchanged (keep existing)
Use Case: Lihat Helper Data Pattern untuk details.
What This Method Does:
- Set CRUD mode
- Reset control states (display=false, disabled=false)
- Handle form data:
- Create mode → reset to defaults
- Edit/Delete mode → merge provided data
- Handle helper data
- Show modal (display=true)
Contoh:
// 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()
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:
// ❌ Old (deprecated)
const name = formHelper.get('name');
const allData = formHelper.get();
// ✅ New (recommended)
const name = formHelper.form.name;
const allData = formHelper.getFormRaw();toggleDisplay()
toggleDisplay(val?: boolean): void@deprecated Use setDisplay(boolean) instead.
Why Deprecated: Toggle behavior (no argument) is unpredictable. Explicit boolean is clearer.
Migration:
// ❌ 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 truetoggleDisable()
toggleDisable(val?: boolean): void@deprecated Use setDisable(boolean) instead.
Why Deprecated: Same reason as toggleDisplay() - explicit boolean is clearer.
Migration:
// ❌ Old (deprecated)
formHelper.toggleDisable();
formHelper.toggleDisable(false);
// ✅ New (recommended)
formHelper.setDisable(!formHelper.onDisable());
formHelper.setDisable(false);Types
Contains:
Mode
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:
<template>
<h2>Form {{ formHelper.getMode() }} User</h2>
<!-- Shows: "Form Tambah User" or "Form Kelola User" -->
</template>ModeShort
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
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:
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
interface ControlState {
display: boolean;
disabled: boolean;
}UI control state untuk modal display dan form disabled state.
Contains:
display
display: booleanWhether the modal/form is visible.
Usage:
<Modal :show="formHelper.control.display">disabled
disabled: booleanWhether the form inputs are disabled.
Usage:
<input :disabled="formHelper.control.disabled" />FormHelperObject
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 typeTHelper- Helper/presentation data type (optional)
Examples
Contains:
- 1. Full CRUD Workflow
- 2. Validation Error Handling
- 3. Nested Field Access
- 4. FormData Serialization Options
- 5. Helper Data Pattern
- 6. Partial Form Updates (Deep Merge)
1. Full CRUD Workflow
Complete component dengan create/edit/delete dalam satu place.
<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.
<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 stylinggetErrors()returns array - show first atau loop all messages
3. Nested Field Access
Dot notation support untuk nested objects dan array elements.
<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.0atauphones[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.
<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 stringprefix- 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.
<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.
<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: trueupdates both form and defaults- Useful for wizard forms, partial updates, incremental data collection
setDefault()always resets to stored defaults (unless modified withdefaultToo)
Links
- Repository: GitLab
- Registry: NPM Registry