Skip to main content

Form Component

Koleksi komponen form untuk membangun form dengan validasi, error handling, dan berbagai tipe input. Komponen-komponen ini menggunakan Formik untuk state management dan Mantine untuk styling.

Dependencies

Pastikan dependencies berikut sudah terinstal:

npm install formik yup @mantine/core @mantine/hooks @mantine/dates @mantine/dropzone @tabler/icons-react react-select react-select-async-paginate axios react-query

Struktur Komponen

components/
├── Field/
│ ├── InputField.tsx # Text/number input dengan validasi
│ ├── DropZoneField.tsx # File upload dengan drag-drop
│ ├── GenericAsyncSelect.tsx # Async single select dengan pagination
│ ├── GenericAsyncMultiSelect.tsx # Async multi-select dengan pagination
│ ├── CreateableSelect.tsx # Select dengan opsi custom
│ └── [FieldSpecific].tsx # Field-specific (30+ variants)
├── StaticSelect/
│ └── StaticSelect.tsx # Static dropdown select
├── MultiSelectField/
│ └── MultiSelectAnggota.tsx # Multi-select anggota dari API
├── HorizontalInputWrapper/
│ └── HorizontalInputWrapper.tsx # Horizontal layout wrapper
└── FormLaporanKegiatan/
└── FormLaporanKegiatan.tsx # Complete form example

InputField

Komponen input text/number yang terintegrasi dengan Formik dan mendukung validasi inline.

Props

PropTypeRequiredDefaultDescription
formikNamestring✅*-Nama field di Formik (required jika disabledFormik=false)
disabledFormikbooleanfalseDisable integrasi Formik (standalone mode)
labelstring-Label untuk input field
textOnlybooleanfalseHanya terima input text (a-z, A-Z, spasi)
numberOnlybooleanfalseHanya terima input angka (0-9)
onChangeValue(value: string) => void-Callback saat value berubah
requiredbooleanfalseMark field sebagai required
placeholderstring-Placeholder text
disabledbooleanfalseDisable input field
defaultValuestring-Default value (standalone mode)
valuestring-Current value (standalone mode)

Penggunaan dengan Formik

import { Formik, Form } from 'formik';
import * as Yup from 'yup';
import { InputField } from '@/components/Field/InputField';
import { Button } from '@mantine/core';

// Schema validasi
const validationSchema = Yup.object({
nama: Yup.string()
.required('Nama harus diisi')
.min(3, 'Minimal 3 karakter'),
email: Yup.string()
.email('Email tidak valid')
.required('Email harus diisi'),
noTelepon: Yup.string()
.matches(/^[0-9]{10,12}$/, 'Nomor telepon harus 10-12 digit')
.required('Nomor telepon harus diisi'),
});

function UserFormComponent() {
return (
<Formik
initialValues={{
nama: '',
email: '',
noTelepon: '',
}}
validationSchema={validationSchema}
onSubmit={(values) => {
console.log(values);
}}
>
{({ values, errors, touched, setFieldValue }) => (
<Form>
<InputField
formikName="nama"
label="Nama Lengkap"
placeholder="Masukkan nama"
required
textOnly
/>

<InputField
formikName="email"
label="Email"
placeholder="contoh@email.com"
required
type="email"
/>

<InputField
formikName="noTelepon"
label="Nomor Telepon"
placeholder="62812345678"
required
numberOnly
/>

<Button type="submit">Simpan</Button>
</Form>
)}
</Formik>
);
}

Penggunaan Standalone (Tanpa Formik)

import { useState } from 'react';
import { InputField } from '@/components/Field/InputField';

function StandaloneInputComponent() {
const [name, setName] = useState('');

return (
<InputField
disabledFormik={true}
label="Nama"
placeholder="Masukkan nama"
value={name}
onChangeValue={(value) => setName(value)}
textOnly
required
/>
);
}

Validasi Khusus

// Hanya text
<InputField
formikName="nama"
label="Nama"
textOnly
required
/>

// Hanya angka
<InputField
formikName="umur"
label="Umur"
numberOnly
required
/>

// Text dan angka (normal)
<InputField
formikName="alamat"
label="Alamat"
required
/>

StaticSelect

Komponen select dengan opsi yang sudah tetap (tidak dari API).

Props

PropTypeRequiredDefaultDescription
labelstring-Label untuk select field
placeholderstring-Placeholder text
dataArray<{value, label}>-Array opsi select
valuestring-Nilai yang dipilih
onChange(value: string) => void-Callback saat value berubah
requiredbooleanfalseMark field sebagai required
clearablebooleantrueBisa dikosongkan
errorstring-Error message
disabledbooleanfalseDisable select field

Contoh Dasar

import { useState } from 'react';
import StaticSelect from '@/components/StaticSelect/StaticSelect';

function StatusSelectComponent() {
const [status, setStatus] = useState('');

const statusOptions = [
{ value: 'active', label: 'Aktif' },
{ value: 'inactive', label: 'Tidak Aktif' },
{ value: 'pending', label: 'Tertunda' },
];

const handleStatusChange = (value: string) => {
setStatus(value);
console.log('Status dipilih:', value);
};

return (
<StaticSelect
label="Status Pengguna"
placeholder="Pilih status"
data={statusOptions}
value={status}
onChange={handleStatusChange}
required
/>
);
}

Dengan Error Handling

import { useState } from 'react';
import StaticSelect from '@/components/StaticSelect/StaticSelect';

function UserRoleSelectComponent() {
const [role, setRole] = useState('');
const [error, setError] = useState('');

const roleOptions = [
{ value: 'admin', label: 'Administrator' },
{ value: 'user', label: 'User' },
{ value: 'guest', label: 'Guest' },
];

const handleRoleChange = (value: string) => {
setRole(value);
if (!value) {
setError('Role harus dipilih');
} else {
setError('');
}
};

return (
<StaticSelect
label="Role Pengguna"
placeholder="Pilih role"
data={roleOptions}
value={role}
onChange={handleRoleChange}
error={error}
required
/>
);
}

HorizontalInputWrapper

Wrapper untuk layout horizontal form yang responsive.

Props

PropTypeRequiredDefaultDescription
labelstring-Label untuk field
withAsteriskbooleanfalseTampilkan asterisk untuk required field
childrenReactNode-Input field atau komponen apapun
errorstring-Error message

Contoh

import { TextInput } from '@mantine/core';
import HorizontalInputWrapper from '@/components/HorizontalInputWrapper/HorizontalInputWrapper';

function HorizontalFormComponent() {
const [nama, setNama] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState('');

return (
<div>
<HorizontalInputWrapper
label="Nama Lengkap"
withAsterisk
>
<TextInput
placeholder="Masukkan nama"
value={nama}
onChange={(e) => setNama(e.target.value)}
/>
</HorizontalInputWrapper>

<HorizontalInputWrapper
label="Email"
withAsterisk
error={error}
>
<TextInput
placeholder="contoh@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => {
if (!email.includes('@')) {
setError('Email tidak valid');
} else {
setError('');
}
}}
/>
</HorizontalInputWrapper>
</div>
);
}

Komponen ini otomatis mengubah layout dari horizontal ke vertikal di mobile.

GenericAsyncSelect

Single select dengan opsi dari API, mendukung pagination dan search.

Props & Configuration

type LoaderConfig<TItem> = {
endpoint: string; // Path API endpoint
token?: string; // Auth token
perPage?: number; // Default 10
staticParams?: CustomParam[];
staticFilters?: FilterRule[];
sort?: {
columnAccessor: string;
direction: 'asc' | 'desc';
};
searchToFilters?: (search: string) => FilterRule[];
extractItems?: (json: unknown) => TItem[];
mapItemToOption: (item: TItem) => Option<TItem>;
};

type GenericAsyncSelectProps<TItem> = {
name: string;
value: Option<TItem> | null;
onChange: (v: Option<TItem> | null) => void;
onBlur?: () => void;
error?: string;
loadOptions: ReturnType<typeof createAsyncPaginateLoader<TItem>>;
initialAdditional?: AdditionalGeneric;
placeholder?: string;
debounceTimeout?: number;
isDisabled?: boolean;
isClearable?: boolean;
};

Contoh

import { useSelector } from 'react-redux';
import {
createAsyncPaginateLoader,
GenericAsyncSelect,
Option,
} from '@/components/Field/GenericAsyncSelect';

interface Pegawai {
id: string;
nama: string;
nopeg: string;
}

function PegawaiSelectComponent() {
const { token } = useSelector((state: RootState) => state.auth);
const [selectedPegawai, setSelectedPegawai] = useState<Option<Pegawai> | null>(null);

const loadOptions = createAsyncPaginateLoader<Pegawai>({
endpoint: '/kepegawaian/informasi-data-pegawai/',
token,
perPage: 20,
staticFilters: [
{
field: 'status',
operation: 'equals',
value: 'aktif',
},
],
searchToFilters: (search) => [
{
field: 'nama',
operation: 'contains',
value: search,
},
],
extractItems: (json: any) => json.data,
mapItemToOption: (pegawai) => ({
value: pegawai.id,
label: `${pegawai.nama} (${pegawai.nopeg})`,
detail: pegawai,
}),
});

return (
<GenericAsyncSelect<Pegawai>
name="pegawai_id"
value={selectedPegawai}
onChange={setSelectedPegawai}
loadOptions={loadOptions}
placeholder="Cari pegawai..."
isClearable
/>
);
}

Multiple Filters & Sorting

const loadOptions = createAsyncPaginateLoader<Pegawai>({
endpoint: '/kepegawaian/informasi-data-pegawai/',
token,
perPage: 20,
staticFilters: [
{ field: 'status', operation: 'equals', value: 'aktif' },
{ field: 'dinas_id', operation: 'equals', value: dinasId },
],
sort: {
columnAccessor: 'nama',
direction: 'asc',
},
searchToFilters: (search) => [
{ field: 'nama', operation: 'ilike', value: search },
{ field: 'nopeg', operation: 'prefix', value: search },
],
extractItems: (json: any) => json.data,
mapItemToOption: (pegawai) => ({
value: pegawai.id,
label: pegawai.nama,
detail: pegawai,
}),
});

GenericAsyncMultiSelect

Komponen multi-select dengan opsi dari API, mendukung pagination dan search. Props hampir sama dengan GenericAsyncSelect, tapi value bertipe array.

Contoh

import {
createAsyncPaginateLoader,
GenericAsyncMultiSelect,
Option,
} from '@/components/Field/GenericAsyncMultiSelect';

interface Kegiatan {
id: string;
nama: string;
}

function KegiatanMultiSelectComponent() {
const { token } = useSelector((state: RootState) => state.auth);
const [selectedKegiatan, setSelectedKegiatan] = useState<Option<Kegiatan>[]>([]);

const loadOptions = createAsyncPaginateLoader<Kegiatan>({
endpoint: '/master-data/jenis-kegiatan/',
token,
perPage: 15,
searchToFilters: (search) => [
{ field: 'nama', operation: 'contains', value: search },
],
extractItems: (json: any) => json.data,
mapItemToOption: (kegiatan) => ({
value: kegiatan.id,
label: kegiatan.nama,
detail: kegiatan,
}),
});

return (
<GenericAsyncMultiSelect<Kegiatan>
name="kegiatan_ids"
value={selectedKegiatan}
onChange={setSelectedKegiatan}
loadOptions={loadOptions}
placeholder="Pilih kegiatan..."
isClearable
/>
);
}

MultiSelectAnggota

Komponen multi-select khusus untuk memilih anggota/pegawai dari API.

Contoh

import { MultiSelectAnggota } from '@/components/MultiSelectField/MultiSelectAnggota';
import { Button } from '@mantine/core';

function AnggotaSelectionComponent() {
return (
<div>
<h2>Pilih Anggota Tim</h2>
<MultiSelectAnggota />
<Button>Simpan Anggota</Button>
</div>
);
}

DropZoneField

Komponen file upload dengan drag-and-drop interface dan preview.

Props

PropTypeRequiredDefaultDescription
onDrop(files: File[]) => void-Callback saat file di-drop
initialFilesstring-Path file yang sudah upload (comma-separated)
labelstring-Label untuk field
disabledbooleanfalseDisable upload
Plus semua Mantine Dropzone props

Contoh

import { useState } from 'react';
import DropZoneField from '@/components/Field/DropZoneField';
import { Button } from '@mantine/core';

function FileUploadComponent() {
const [files, setFiles] = useState<File[]>([]);

const handleUpload = async () => {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});

try {
await uploadFiles(formData);
} catch (error) {
console.error(error);
}
};

return (
<div>
<DropZoneField
label="Upload Dokumen"
onDrop={(droppedFiles) => setFiles(droppedFiles)}
accept={{ 'application/pdf': ['.pdf'], 'image/*': ['.png', '.jpg'] }}
multiple
/>
<Button onClick={handleUpload}>Simpan Dokumen</Button>
</div>
);
}

FormLaporanKegiatan

Contoh implementasi form kompleks yang menggunakan beberapa komponen dengan Formik.

Props

PropTypeRequiredDefaultDescription
titlestring'Default Title'Judul form
idstring''ID untuk edit mode
disableboolean-Disable semua fields
disableInterfaceboolean-Disable UI elements

Contoh

import FormLaporanKegiatan from '@/components/FormLaporanKegiatan/FormLaporanKegiatan';

function LaporanKegiatanPage() {
return (
<div>
<h1>Form Laporan Kegiatan</h1>
<FormLaporanKegiatan title="Kirim Laporan Kegiatan" />
</div>
);
}

// Edit mode
function EditLaporanKegiatanPage({ id }) {
return (
<FormLaporanKegiatan title="Edit Laporan Kegiatan" id={id} />
);
}

Struktur Form Laporan Kegiatan

{
id: string;
jenis_kegiatan_id: string;
uraian_kegiatan: string;
tanggal: string;
waktu_start: string;
waktu_end: string;
lokasi: string;
}

Best Practices

Form Validation dengan Yup

import * as Yup from 'yup';

const validationSchema = Yup.object({
nama: Yup.string()
.required('Nama harus diisi')
.min(3, 'Minimal 3 karakter')
.max(100, 'Maksimal 100 karakter'),
email: Yup.string()
.email('Email tidak valid')
.required('Email harus diisi'),
noTelepon: Yup.string()
.matches(/^[0-9]{10,12}$/, 'Nomor telepon harus 10-12 digit')
.required('Nomor telepon harus diisi'),
tanggal: Yup.date()
.required('Tanggal harus dipilih')
.typeError('Format tanggal tidak valid'),
});

Error Handling

<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={async (values) => {
try {
const response = await submitForm(values);
showNotification({
title: 'Sukses',
message: 'Data berhasil disimpan',
color: 'green',
});
navigate('/success');
} catch (error) {
showNotification({
title: 'Error',
message: error.message,
color: 'red',
});
}
}}
>
{({ errors, touched, isSubmitting }) => (
<Form>
{/* Fields */}
<Button type="submit" loading={isSubmitting}>
Simpan
</Button>
</Form>
)}
</Formik>

Conditional Fields

{({ values }) => (
<>
<InputField
formikName="jenis"
label="Jenis"
/>

{values.jenis === 'khusus' && (
<InputField
formikName="keterangan_khusus"
label="Keterangan Khusus"
required
/>
)}
</>
)}

Dynamic Fields Array

import { FieldArray } from 'formik';

<FieldArray name="anggota">
{(arrayHelpers) => (
<div>
{values.anggota.map((anggota, index) => (
<div key={index}>
<InputField
formikName={`anggota.${index}.nama`}
label="Nama Anggota"
/>
<Button
onClick={() => arrayHelpers.remove(index)}
color="red"
>
Hapus
</Button>
</div>
))}
<Button onClick={() => arrayHelpers.push({ nama: '' })}>
+ Tambah Anggota
</Button>
</div>
)}
</FieldArray>

Contoh Form Lengkap

import { Formik, Form, FieldArray } from 'formik';
import * as Yup from 'yup';
import { Button, SimpleGrid, Group } from '@mantine/core';
import { InputField } from '@/components/Field/InputField';
import StaticSelect from '@/components/StaticSelect/StaticSelect';
import HorizontalInputWrapper from '@/components/HorizontalInputWrapper/HorizontalInputWrapper';
import { showNotification } from '@mantine/notifications';

const validationSchema = Yup.object({
nama: Yup.string().required('Nama harus diisi'),
email: Yup.string().email().required('Email harus diisi'),
status: Yup.string().required('Status harus dipilih'),
anggota: Yup.array().of(
Yup.object({
nama: Yup.string().required('Nama anggota harus diisi'),
})
),
});

function CompleteFormComponent() {
const statusOptions = [
{ value: 'aktif', label: 'Aktif' },
{ value: 'non-aktif', label: 'Non-Aktif' },
];

return (
<Formik
initialValues={{
nama: '',
email: '',
status: '',
anggota: [{ nama: '' }],
}}
validationSchema={validationSchema}
onSubmit={async (values) => {
try {
await submitForm(values);
showNotification({
title: 'Sukses',
message: 'Data berhasil disimpan',
color: 'green',
});
} catch (error) {
showNotification({
title: 'Error',
message: 'Gagal menyimpan data',
color: 'red',
});
}
}}
>
{({ values, errors, touched, isSubmitting }) => (
<Form>
<HorizontalInputWrapper label="Nama" withAsterisk>
<InputField
formikName="nama"
placeholder="Masukkan nama"
required
/>
</HorizontalInputWrapper>

<HorizontalInputWrapper label="Email" withAsterisk>
<InputField
formikName="email"
placeholder="contoh@email.com"
type="email"
required
/>
</HorizontalInputWrapper>

<HorizontalInputWrapper label="Status" withAsterisk>
<StaticSelect
label=""
placeholder="Pilih status"
data={statusOptions}
value={values.status}
onChange={(value) => setFieldValue('status', value)}
required
/>
</HorizontalInputWrapper>

<FieldArray name="anggota">
{(arrayHelpers) => (
<div>
{values.anggota.map((_, index) => (
<HorizontalInputWrapper
key={index}
label={`Anggota ${index + 1}`}
withAsterisk
>
<Group>
<InputField
formikName={`anggota.${index}.nama`}
placeholder="Nama anggota"
required
/>
<Button
onClick={() => arrayHelpers.remove(index)}
color="red"
variant="light"
>
Hapus
</Button>
</Group>
</HorizontalInputWrapper>
))}
<Button
onClick={() => arrayHelpers.push({ nama: '' })}
variant="default"
>
+ Tambah Anggota
</Button>
</div>
)}
</FieldArray>

<Group mt="xl" justify="flex-end">
<Button variant="default" type="reset">
Reset
</Button>
<Button type="submit" loading={isSubmitting}>
Simpan
</Button>
</Group>
</Form>
)}
</Formik>
);
}

export default CompleteFormComponent;

🔧 Field-Specific Components

Ada 30+ field-specific components yang tersedia untuk berbagai kebutuhan:

Location Fields

  • FieldProvinsi - Select provinsi
  • FieldKota - Select kota
  • FieldKecamatan - Select kecamatan
  • FieldKelurahan - Select kelurahan
  • FieldWilayah - Select wilayah
  • FieldWilayahMultiple - Multi-select wilayah

Employee Fields

  • FieldDataPegawai - Select pegawai dengan search
  • FieldPangkat - Select pangkat
  • FieldGolongan - Select golongan
  • FieldJabatan - Select jabatan
  • FieldEselon - Select eselon
  • FieldSKPD - Select SKPD

Master Data Fields

  • FieldJenisKegiatan - Select jenis kegiatan
  • FieldJenisPasal - Select jenis pasal
  • FieldJenisKondisi - Select kondisi barang
  • FieldJenisSatuan - Select satuan
  • FieldJenisUsaha - Select jenis usaha

Advanced Fields

  • FieldPhoneNumber - Phone number dengan format
  • FieldSelectAgama - Select agama
  • FieldPilihRegu - Select regu
  • FieldInformasiPenggunaPaginate - Select pengguna dengan pagination

🧪 Testing Examples

Unit Test InputField

import { render, screen, fireEvent } from '@testing-library/react';
import { Formik, Form } from 'formik';
import * as Yup from 'yup';
import { InputField } from '@/components/Field/InputField';

describe('InputField', () => {
it('should render input field with label', () => {
render(
<Formik
initialValues={{ name: '' }}
validationSchema={Yup.object({ name: Yup.string() })}
onSubmit={() => {}}
>
<Form>
<InputField formikName="name" label="Nama" />
</Form>
</Formik>
);

expect(screen.getByText('Nama')).toBeInTheDocument();
});

it('should update value on input change', () => {
const { container } = render(
<Formik
initialValues={{ name: '' }}
validationSchema={Yup.object()}
onSubmit={() => {}}
>
<Form>
<InputField formikName="name" label="Nama" />
</Form>
</Formik>
);

const input = container.querySelector('input');
fireEvent.change(input!, { target: { value: 'John Doe' } });
expect((input as HTMLInputElement).value).toBe('John Doe');
});

it('should accept only text when textOnly is true', () => {
const { container } = render(
<Formik
initialValues={{ name: '' }}
validationSchema={Yup.object()}
onSubmit={() => {}}
>
<Form>
<InputField formikName="name" label="Nama" textOnly />
</Form>
</Formik>
);

const input = container.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value: '123' } });
// Should not have numbers
expect(input.value).not.toContain('123');
});
});

🚀 Migration ke Project Lain

Langkah 1: Copy Komponen

# Copy field components
cp -r components/Field /path/to/new-project/components/

# Copy other form components
cp -r components/StaticSelect /path/to/new-project/components/
cp -r components/MultiSelectField /path/to/new-project/components/
cp -r components/HorizontalInputWrapper /path/to/new-project/components/

Langkah 2: Update Imports

Sesuaikan path imports sesuai struktur project:

// Dari
import { InputField } from '@/components/Field/InputField';
import StaticSelect from '@/components/StaticSelect/StaticSelect';

// Ke (sesuaikan dengan project Anda)
import { InputField } from '../components/Field/InputField';
import StaticSelect from '../components/StaticSelect/StaticSelect';

Langkah 3: Setup Axios & API

Pastikan axios instance dan httpHeader sudah di-setup:

// lib/axios/index.ts
import axios from 'axios';

export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});

export const httpHeader = (token: string) => ({
headers: {
Authorization: `Bearer ${token}`,
},
});

Langkah 4: Environment Variables

Tambahkan ke .env:

NEXT_PUBLIC_API_URL=https://your-api.com/api/

API Reference

InputField

  • Input text/number terintegrasi dengan Formik
  • Support validasi custom
  • Format khusus untuk text-only dan number-only

StaticSelect

  • React-Select wrapper dengan Mantine styling
  • Untuk opsi yang sudah tetap
  • Support error handling

HorizontalInputWrapper

  • Layout helper untuk form horizontal
  • Auto-responsive di mobile

GenericAsyncSelect / GenericAsyncMultiSelect

  • Async select dengan pagination
  • Customizable filter & mapping
  • Built-in search

DropZoneField

  • File upload dengan drag-drop
  • Preview gambar
  • Support multiple files

FormLaporanKegiatan

  • Contoh form kompleks
  • Mode create & update

Troubleshooting

Formik Value Tidak Update

Pastikan formikName sesuai dengan initialValues:

// ✅ Benar
<Formik
initialValues={{ nama: '' }}
onSubmit={() => {}}
>
<InputField formikName="nama" />
</Formik>

// ❌ Salah
<InputField formikName="nama_lengkap" />

Async Select Tidak Fetch Data

Pastikan token valid dan endpoint benar:

const { token } = useSelector((state: RootState) => state.auth);
if (!token) return <div>Loading...</div>;

const loadOptions = createAsyncPaginateLoader({
endpoint: '/correct-endpoint/',
token,
// ...
});

Validasi Tidak Berfungsi

Pastikan validationSchema sesuai dengan field names:

const validationSchema = Yup.object({
nama: Yup.string().required('Nama harus diisi'),
email: Yup.string().email().required('Email harus diisi'),
});

// Render errors
{errors.nama && touched.nama && <Text color="red">{errors.nama}</Text>}

Referensi


Terakhir diupdate: January 2026
Versi: 1.0.0