refactor(form): migrate form components to use doc-entry pattern

- Replace FieldGroup/Field/Label with DE.Cell/DE.Field/DE.Label components
- Add ButtonAction component with preset styling for common form actions
- Simplify form layouts using doc-entry grid system
- Add support for disabled/readonly states in form fields
This commit is contained in:
Khafid Prayoga
2025-11-24 12:45:03 +07:00
parent 857478cd65
commit 044de2d965
4 changed files with 292 additions and 131 deletions

No files matched your search

@@ -3,14 +3,16 @@ import { toTypedSchema } from '@vee-validate/zod'
import { FieldArray } from 'vee-validate' import { FieldArray } from 'vee-validate'
// components // components
import * as DE from '~/components/pub/my-ui/doc-entry'
import { Form } from '~/components/pub/ui/form' import { Form } from '~/components/pub/ui/form'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { SelectRelations } from './fields' import { SelectRelations } from './fields'
import InputBase from '~/components/pub/my-ui/form/input-base.vue'
import ButtonAction from '~/components/pub/my-ui/form/button-action.vue'
const props = defineProps<{ const props = defineProps<{
title: string title: string
schema: any schema: any
isReadonly?: boolean
initialValues?: any initialValues?: any
}>() }>()
@@ -23,6 +25,8 @@ defineExpose({
setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate), setValues: (values: any, shouldValidate = true) => formRef.value?.setValues(values, shouldValidate),
values: computed(() => formRef.value?.values), values: computed(() => formRef.value?.values),
}) })
const { title = 'Kontak Pasien', isReadonly = false } = props
</script> </script>
<template> <template>
@@ -32,119 +36,77 @@ defineExpose({
keep-values keep-values
:validation-schema="formSchema" :validation-schema="formSchema"
:validate-on-mount="false" :validate-on-mount="false"
:initial-values="initialValues ? initialValues : {}"
validation-mode="onSubmit" validation-mode="onSubmit"
:initial-values="initialValues || { contacts: [{ relation: '', name: '', address: '', phone: '' }] }"
> >
<div> <div>
<p <p class="text-md mb-2 mt-1 font-semibold">
v-if="props.title" {{ title }}
class="text-md mb-2 mt-1 font-semibold"
>
{{ props.title || 'Kontak Pasien' }}
</p> </p>
</div> </div>
<div class="mb-5 space-y-4"> <div class="mb-5 space-y-4">
<FieldArray <FieldArray
v-slot="{ fields, push, remove }" v-slot="{ fields, push, remove }"
name="contacts" name="medicines"
> >
<template v-if="fields.length === 0">
{{ push({ name: '', dose: '', unit: '' }) }}
</template>
<div class="space-y-4"> <div class="space-y-4">
<div <DE.Block
v-for="(field, idx) in fields" v-for="(field, idx) in fields"
:key="field.key" :key="field.key"
class="rounded-lg border border-gray-200 bg-gray-50/50 p-4 dark:border-gray-700 dark:bg-gray-800/50" :col-count="5"
:cell-flex="false"
> >
<div class="mb-3 flex items-center justify-between"> <SelectRelations
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">Penanggung Jawab {{ idx + 1 }}</h4> :label="idx === 0 ? 'Hubungan dengan Pasien' : undefined"
<Button :field-name="`contacts[${idx}].relation`"
v-if="idx !== 0" placeholder="Pilih"
type="button" field-group-class="mb-0"
variant="outline" :is-disabled="isReadonly"
size="sm" />
class="text-red-600 hover:border-red-400 hover:bg-red-50 hover:text-red-700 dark:border-red-400 dark:text-red-400 dark:hover:border-red-300 dark:hover:bg-red-900/20" <InputBase
:label="idx === 0 ? 'Nama' : undefined"
:field-name="`contacts[${idx}].name`"
placeholder="Masukkan Nama"
:is-disabled="isReadonly"
/>
<InputBase
:label="idx === 0 ? 'Alamat' : undefined"
:field-name="`contacts[${idx}].address`"
placeholder="Masukkan Alamat"
:is-disabled="isReadonly"
/>
<InputBase
:label="idx === 0 ? 'Nomor HP' : undefined"
:field-name="`contacts[${idx}].phone`"
placeholder="081234567890"
:max-length="15"
numeric-only
:is-disabled="isReadonly"
/>
<DE.Cell class="flex items-end justify-start">
<ButtonAction
:disabled="isReadonly"
preset="delete"
title="Hapus"
icon-only
@click="remove(idx)" @click="remove(idx)"
> />
<Icon </DE.Cell>
name="i-lucide-trash-2" </DE.Block>
class="h-4 w-4"
/>
</Button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="space-y-2">
<Label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
is-required
>
Hubungan dengan Pasien
</Label>
<SelectRelations
:field-name="`contacts[${idx}].relation`"
placeholder="Pilih"
field-group-class="mb-0"
/>
</div>
<div class="space-y-2">
<Label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
is-required
>
Nama
</Label>
<InputBase
label=""
:field-name="`contacts[${idx}].name`"
placeholder="Masukkan Nama"
/>
</div>
<div class="space-y-2">
<Label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
is-required
>
Alamat
</Label>
<InputBase
:field-name="`contacts[${idx}].address`"
label=""
placeholder="Masukkan Alamat"
/>
</div>
<div class="space-y-2">
<Label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
is-required
>
Nomor HP
</Label>
<InputBase
:field-name="`contacts[${idx}].phone`"
label=""
placeholder="081234567890"
:max-length="15"
numeric-only
/>
</div>
</div>
</div>
</div> </div>
<Button <ButtonAction
type="button" preset="add"
variant="outline" label="Tambah Obat"
class="w-full rounded-md border border-primary bg-white px-4 py-2 text-primary hover:border-primary hover:bg-primary hover:text-white sm:w-auto sm:text-sm" title="Tambah Kontak ke Daftar Kontak"
@click="push({ relation: '', name: '', address: '', phone: '' })" :disabled="fields.length >= 5 || isReadonly"
> :full-width-mobile="true"
<Icon class="mt-4"
name="i-lucide-plus" @click="push({ name: '', dose: '', unit: '' })"
class="mr-2 h-4 w-4 align-middle transition-colors" />
/>
Tambah Penanggung Jawab
</Button>
</FieldArray> </FieldArray>
</div> </div>
</Form> </Form>
@@ -2,23 +2,25 @@
import { cn } from '~/lib/utils' import { cn } from '~/lib/utils'
import { relationshipCodes } from '~/lib/constants' import { relationshipCodes } from '~/lib/constants'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue' import * as DE from '~/components/pub/my-ui/doc-entry'
import Field from '~/components/pub/my-ui/form/field.vue'
import Select from '~/components/pub/my-ui/form/select.vue' import Select from '~/components/pub/my-ui/form/select.vue'
const props = defineProps<{ const props = defineProps<{
fieldName: string fieldName: string
label?: string
placeholder?: string placeholder?: string
class?: string class?: string
selectClass?: string selectClass?: string
fieldGroupClass?: string fieldGroupClass?: string
labelClass?: string labelClass?: string
isDisabled?: boolean isDisabled?: boolean
isRequired?: boolean
colSpan?: number
}>() }>()
const { fieldName = 'phoneNumber', class: containerClass, selectClass, fieldGroupClass } = props const { class: containerClass, selectClass, fieldGroupClass } = props
const emergencyContactOptions = Object.entries(relationshipCodes).map(([value, label]) => ({ const opts = Object.entries(relationshipCodes).map(([value, label]) => ({
label, label,
value, value,
...(value === 'other' && { priority: -1 }), ...(value === 'other' && { priority: -1 }),
@@ -26,8 +28,18 @@ const emergencyContactOptions = Object.entries(relationshipCodes).map(([value, l
</script> </script>
<template> <template>
<FieldGroup :class="cn('select-field-group', fieldGroupClass, containerClass)"> <DE.Cell
<Field :col-span="colSpan"
:class="cn('select-field-group', fieldGroupClass, containerClass)"
>
<DE.Label
:label-for="fieldName"
:class="cn('select-field-label', labelClass)"
:is-required="isRequired"
>
{{ label }}
</DE.Label>
<DE.Field
:id="fieldName" :id="fieldName"
:class="cn('select-field-wrapper')" :class="cn('select-field-wrapper')"
> >
@@ -38,10 +50,10 @@ const emergencyContactOptions = Object.entries(relationshipCodes).map(([value, l
<FormItem> <FormItem>
<FormControl> <FormControl>
<Select <Select
:disabled="isDisabled"
:id="fieldName" :id="fieldName"
v-bind="componentField" v-bind="componentField"
:is-disabled="isDisabled" :items="opts"
:items="emergencyContactOptions"
:placeholder="placeholder" :placeholder="placeholder"
:preserve-order="true" :preserve-order="true"
:class=" :class="
@@ -55,6 +67,6 @@ const emergencyContactOptions = Object.entries(relationshipCodes).map(([value, l
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
</Field> </DE.Field>
</FieldGroup> </DE.Cell>
</template> </template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { Button } from '~/components/pub/ui/button'
import { cn } from '~/lib/utils'
/**
* Button Action Component untuk form
* Support preset: add, delete, save, cancel
*/
interface Props {
/**
* Preset button type dengan styling bawaan
* - add: Button tambah (outline primary)
* - delete: Button hapus (ghost)
* - save: Button simpan (primary)
* - cancel: Button batal (secondary)
* - custom: Custom styling
*/
preset?: 'add' | 'delete' | 'save' | 'cancel' | 'custom'
/**
* Icon name (UnoCSS/Lucide)
* Default akan diset berdasarkan preset
*/
icon?: string
/**
* Button text
* Set ke empty string ('') untuk icon-only mode
*/
label?: string
/**
* Button title (tooltip)
* Wajib untuk icon-only buttons (accessibility)
*/
title?: string
/**
* Icon only mode (no label)
* Otomatis true jika label kosong
*/
iconOnly?: boolean
/**
* Button type
*/
type?: 'button' | 'submit' | 'reset'
/**
* Disabled state
*/
disabled?: boolean
/**
* Custom class untuk override styling
*/
class?: string
/**
* Responsive width (full width on mobile)
*/
fullWidthMobile?: boolean
/**
* Button size
*/
size?: 'default' | 'sm' | 'lg' | 'icon'
/**
* Button variant (override preset variant)
*/
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
}
const props = withDefaults(defineProps<Props>(), {
preset: 'custom',
type: 'button',
disabled: false,
fullWidthMobile: false,
iconOnly: false,
})
// Preset configurations
const presetConfig = {
add: {
variant: 'outline' as const,
icon: 'i-lucide-plus',
label: 'Tambah',
classes:
'border-primary bg-white text-primary hover:bg-primary hover:text-white dark:bg-slate-800 dark:border-primary dark:text-primary dark:hover:bg-primary dark:hover:text-white',
},
delete: {
variant: 'ghost' as const,
icon: 'i-lucide-trash-2',
label: '', // Default kosong untuk icon-only
classes:
'hover:bg-destructive hover:text-white hover:border-destructive dark:hover:bg-destructive dark:hover:text-white',
},
save: {
variant: 'default' as const,
icon: 'i-lucide-save',
label: 'Simpan',
classes: 'bg-primary text-primary-foreground hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/90',
},
cancel: {
variant: 'secondary' as const,
icon: 'i-lucide-x',
label: 'Batal',
classes: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 dark:bg-slate-700 dark:hover:bg-slate-600',
},
custom: {
variant: 'default' as const,
icon: '',
label: '',
classes: '',
},
}
const currentPreset = computed(() => presetConfig[props.preset])
const buttonVariant = computed(() => props.variant || currentPreset.value.variant)
const buttonIcon = computed(() => props.icon || currentPreset.value.icon)
// Label handling: gunakan prop label jika ada, fallback ke preset, atau undefined jika iconOnly
const buttonLabel = computed(() => {
if (props.label !== undefined) return props.label
return currentPreset.value.label
})
const buttonTitle = computed(() => props.title || buttonLabel.value)
// Deteksi icon-only mode
const isIconOnly = computed(() => {
return props.iconOnly || buttonLabel.value === '' || !buttonLabel.value
})
const buttonClasses = computed(() => {
// Base classes berbeda untuk icon-only vs with-label
const baseClasses = isIconOnly.value
? 'rounded-md p-2 w-9 h-9 transition-colors flex items-center justify-center' // Icon only: square button
: 'rounded-md px-4 py-2 transition-colors sm:text-sm' // With label: padding horizontal lebih besar
const widthClasses = props.fullWidthMobile && !isIconOnly.value ? 'w-full sm:w-auto' : ''
const presetClasses = currentPreset.value.classes
return cn(baseClasses, widthClasses, presetClasses, props.class)
})
</script>
<template>
<Button
:title="buttonTitle"
:type="type"
:variant="buttonVariant"
:size="size"
:disabled="disabled"
:class="buttonClasses"
>
<Icon
v-if="buttonIcon"
:name="buttonIcon"
:class="cn('h-4 w-4 align-middle transition-colors', !isIconOnly ? 'mr-2' : '')"
/>
<slot v-if="!isIconOnly">{{ buttonLabel }}</slot>
</Button>
</template>
+44 -22
View File
@@ -1,8 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormErrors } from '~/types/error' import type { FormErrors } from '~/types/error'
import FieldGroup from '~/components/pub/my-ui/form/field-group.vue'
import Field from '~/components/pub/my-ui/form/field.vue'
import Label from '~/components/pub/my-ui/form/label.vue'
import { Input } from '~/components/pub/ui/input' import { Input } from '~/components/pub/ui/input'
import { cn } from '~/lib/utils' import { cn } from '~/lib/utils'
@@ -11,7 +8,7 @@ import * as DE from '~/components/pub/my-ui/doc-entry'
const props = defineProps<{ const props = defineProps<{
fieldName: string fieldName: string
placeholder: string placeholder: string
label: string label?: string
errors?: FormErrors errors?: FormErrors
class?: string class?: string
colSpan?: number colSpan?: number
@@ -21,6 +18,8 @@ const props = defineProps<{
isDisabled?: boolean isDisabled?: boolean
rightLabel?: string rightLabel?: string
bottomLabel?: string bottomLabel?: string
suffixMsg?: string
iconName?: string
}>() }>()
function handleInput(event: Event) { function handleInput(event: Event) {
@@ -49,7 +48,7 @@ function handleInput(event: Event) {
<template> <template>
<DE.Cell :col-span="colSpan || 1"> <DE.Cell :col-span="colSpan || 1">
<DE.Label <DE.Label
v-if="label !== ''" v-if="label"
:label-for="fieldName" :label-for="fieldName"
:is-required="isRequired && !isDisabled" :is-required="isRequired && !isDisabled"
> >
@@ -63,27 +62,50 @@ function handleInput(event: Event) {
v-slot="{ componentField }" v-slot="{ componentField }"
:name="fieldName" :name="fieldName"
> >
<FormItem :class="cn(`relative`,)"> <FormItem>
<FormControl> <FormControl class="relative">
<Input <div class="relative w-full max-w-sm items-center">
:disabled="isDisabled" <Input
v-bind="componentField" :disabled="isDisabled"
:placeholder="placeholder" v-bind="componentField"
:maxlength="maxLength" :placeholder="placeholder"
:class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0', props.class)" :maxlength="maxLength"
autocomplete="off" :class="cn('focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0')"
aria-autocomplete="none" autocomplete="off"
autocorrect="off" aria-autocomplete="none"
autocapitalize="off" autocorrect="off"
spellcheck="false" autocapitalize="off"
@input="handleInput" spellcheck="false"
/> @input="handleInput"
<p v-show="rightLabel" class="text-gray-400 absolute top-0 right-3">{{ rightLabel }}</p> />
<span
v-if="suffixMsg"
class="absolute inset-y-0 end-0 flex items-center justify-center px-2 text-muted-foreground"
>
{{ suffixMsg }}
</span>
<Icon
v-if="iconName"
:name="iconName"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400"
/>
<p
v-show="rightLabel"
class="absolute right-3 top-0 text-gray-400"
>
{{ rightLabel }}
</p>
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
</DE.Field> </DE.Field>
<p v-show="bottomLabel" class="text-gray-400 mt-1">{{ bottomLabel }}</p> <p
v-show="bottomLabel"
class="text-gray-400"
>
{{ bottomLabel }}
</p>
</DE.Cell> </DE.Cell>
</template> </template>