feat(i18n): add "save_changes" translations for English and Indonesian

fix(validation): improve Indonesian validation messages for clarity

refactor(ui): optimize useAsyncPaginate hook for better readability

fix(data-table): enhance error handling with detailed messages in EnterpriseDataTable

feat(enterprise-module): add form control to EnterpriseFormPageConfig and implement save confirmation modal

refactor(form-page): streamline form page context and improve save handling logic

feat(index-page): set document title based on config or translation key

refactor(module-provider): clean up comments and improve code organization
This commit is contained in:
Firman Ramdhani
2026-07-27 11:14:15 +07:00
parent b332da4808
commit e55c33069b
18 changed files with 776 additions and 363 deletions
@@ -3,9 +3,13 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
import {
FieldTextInput, FieldPasswordInput, FieldNumberInput,
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
import {
FieldTextInput,
FieldPasswordInput,
FieldNumberInput,
FieldLocalSelect,
FieldAsyncSelect,
FieldRichTextEditor,
} from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
@@ -30,16 +34,16 @@ const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const allUsers = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
email: `user${i + 1}@company.com`
email: `user${i + 1}@company.com`,
}));
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
const filtered = allUsers.filter((u) => u.email.toLowerCase().includes(search.toLowerCase()));
const pageSize = 5;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
hasMore: start + pageSize < filtered.length,
};
};
@@ -50,34 +54,53 @@ const MOCK_VENDORS = [
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
const filtered = MOCK_VENDORS.filter(
(v) => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()),
);
return { options: filtered, hasMore: false };
};
import {
compose, required, rangeLength,
positiveNumber, simplePassword,
complexPassword, phoneValidator, rangeValue
import {
compose,
required,
rangeLength,
positiveNumber,
simplePassword,
complexPassword,
phoneValidator,
rangeValue,
} from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
export default function ValidationBankDemo() {
const t = useFormDemoTranslation();
// Compose the Zod schema using the atomic validators
const validationSchema = useMemo(() => z.object({
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor),
richTextNotes: z.string().min(15, t.errors.notesMin15),
}), [t]);
const validationSchema = useMemo(
() =>
z.object({
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
prefilledVendor: z.object(
{ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() },
{ required_error: t.errors.vendorRequired },
),
emptyVendor: z.object(
{ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() },
{ required_error: t.errors.vendorRequired },
),
prefilledAsyncMulti: z
.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }))
.min(1, t.errors.min1Vendor),
richTextNotes: z.string().min(15, t.errors.notesMin15),
}),
[t],
);
type ValidationFormValues = z.infer<typeof validationSchema>;
@@ -96,10 +119,10 @@ export default function ValidationBankDemo() {
emptyVendor: null as any,
prefilledAsyncMulti: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
] as any,
richTextNotes: '',
}
},
});
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
@@ -110,62 +133,66 @@ export default function ValidationBankDemo() {
<Paper p="xl" withBorder radius="md">
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Title order={5} c="brand">{t.sections.validationBankTitle}</Title>
<Title order={5} c="brand">
{t.sections.validationBankTitle}
</Title>
<Divider mb="sm" />
<FieldTextInput
name="username"
control={control}
label={t.fields.customerName}
<FieldTextInput
name="username"
control={control}
label={t.fields.customerName}
description={t.validation.usernameRange}
withAsterisk
withAsterisk
/>
<Group grow align="flex-start">
<FieldPasswordInput
name="simplePass"
control={control}
label={t.validation.simplePassword}
<FieldPasswordInput
name="simplePass"
control={control}
label={t.validation.simplePassword}
description={t.descriptions.min6Chars}
withAsterisk
withAsterisk
/>
<FieldPasswordInput
name="complexPass"
control={control}
label={t.validation.complexPassword}
<FieldPasswordInput
name="complexPass"
control={control}
label={t.validation.complexPassword}
description={t.descriptions.min8Complex}
withAsterisk
withAsterisk
/>
</Group>
<Group grow align="flex-start">
<FieldNumberInput
name="age"
control={control}
label={t.fields.age}
<FieldNumberInput
name="age"
control={control}
label={t.fields.age}
description={t.validation.ageRange}
withAsterisk
withAsterisk
/>
<FieldNumberInput
name="score"
control={control}
label={t.validation.score}
<FieldNumberInput
name="score"
control={control}
label={t.validation.score}
description={t.descriptions.mustBePositive}
withAsterisk
withAsterisk
/>
</Group>
<FieldTextInput
name="phone"
control={control}
label={t.validation.phone}
<FieldTextInput
name="phone"
control={control}
label={t.validation.phone}
description={t.descriptions.formatPhone}
withAsterisk
withAsterisk
/>
<Title order={5} c="brand" mt="md">{t.sections.objectLevelValidations}</Title>
<Title order={5} c="brand" mt="md">
{t.sections.objectLevelValidations}
</Title>
<Divider mb="sm" />
<FieldLocalSelect<Department>
name="department"
control={control as any}
@@ -176,7 +203,7 @@ export default function ValidationBankDemo() {
clearable
withAsterisk
/>
<FieldAsyncSelect<Assignee>
multiple
name="assignees"
@@ -190,9 +217,11 @@ export default function ValidationBankDemo() {
withAsterisk
/>
<Title order={5} c="brand" mt="lg">{t.sections.validatedPrefilledObjects}</Title>
<Title order={5} c="brand" mt="lg">
{t.sections.validatedPrefilledObjects}
</Title>
<Divider mb="sm" />
<Group grow align="flex-start">
<FieldAsyncSelect
name="emptyVendor"
@@ -229,7 +258,9 @@ export default function ValidationBankDemo() {
withAsterisk
/>
<Title order={5} c="brand" mt="lg">{t.sections.richTextValidations}</Title>
<Title order={5} c="brand" mt="lg">
{t.sections.richTextValidations}
</Title>
<Divider mb="sm" />
<FieldRichTextEditor
@@ -240,13 +271,17 @@ export default function ValidationBankDemo() {
withAsterisk
/>
<Button type="submit" mt="md">{t.common.submit}</Button>
<Button type="submit" mt="md">
{t.common.submit}
</Button>
</Stack>
</form>
</Paper>
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
<Title order={6} mb="xs">{t.common.submittedData}</Title>
<Title order={6} mb="xs">
{t.common.submittedData}
</Title>
<Code block>{JSON.stringify(data, null, 2)}</Code>
</Paper>
</Stack>
@@ -12,22 +12,9 @@ import { compose, required, rangeLength } from '@repo/ui/validators';
export const createFullPageSchema = (t: any) => {
return z.object({
// Code: Required, length between 3 and 10 characters.
code: compose(z.string(), required(t.fields.code), rangeLength(3, 10, t.fields.code)),
code: compose(z.string(), required(t('fields.code'))),
// Name: Required, length between 3 and 50 characters.
name: compose(z.string(), required(t.fields.name), rangeLength(3, 50, t.fields.name)),
// Status: Required selection (typically from a dropdown/select).
status: compose(z.string(), required(t.fields.status)),
// Description: Optional text field.
description: z.string().optional(),
name: compose(z.string(), required(t('fields.name')), rangeLength(3, 50, t('fields.name'))),
});
};
/**
* Data Transfer Object (DTO) for the Full Page form.
* This type is automatically inferred from the Zod schema factory.
* Use this type as a generic for form initialization, e.g., `useForm<FullPageFormDTO>()`.
*/
export type FullPageFormDTO = z.infer<ReturnType<typeof createFullPageSchema>>;
@@ -37,9 +37,9 @@ export default function FullPageModule() {
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="edit" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="duplicate" />} />
<Route path="/create" element={<FormPage formPageType="create" />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${fullPageModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
@@ -2,10 +2,12 @@
"title": "Full Page",
"detail_page_title": "Detail Full Page",
"create_page_title": "New Full Page",
"edit_page_title": "Edit Full Page",
"duplicate_page_title": "Duplicate Full Page",
"description": "An example module of a <1>full page layout</1> for detailed forms.",
"detail_page_description": "View and manage detailed information for this record.",
"create_page_description": "Fill out the form below to add a new record to the system.",
"edit_page_description": "Update and modify the information of the selected record.",
"duplicate_page_description": "Copy and modify information from an existing record to quickly create a new one.",
"fields": {
"code": "Code",
@@ -2,10 +2,12 @@
"title": "Halaman Penuh",
"detail_page_title": "Detail Halaman Penuh",
"create_page_title": "Buat Halaman Penuh Baru",
"edit_page_title": "Ubah Halaman Penuh",
"duplicate_page_title": "Duplikat Halaman Penuh",
"description": "Contoh penerapan <1>tata letak halaman penuh</1> untuk formulir detail.",
"detail_page_description": "Lihat dan kelola informasi terperinci terkait data ini.",
"create_page_description": "Lengkapi formulir di bawah ini untuk menambahkan data baru ke dalam sistem.",
"edit_page_description": "Perbarui dan ubah informasi pada data yang dipilih.",
"duplicate_page_description": "Salin dan sesuaikan informasi dari data yang sudah ada untuk mempercepat pembuatan data baru.",
"fields": {
"code": "Kode",
@@ -1,146 +1,69 @@
import {
Paper,
Box,
Text,
Divider,
TextInput,
Select,
Textarea,
Stack,
Breadcrumbs,
Anchor,
Flex,
Title,
Button,
Group,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { ChevronRight, Database } from 'lucide-react';
import { Paper, Box, Stack } from '@repo/ui/components';
import { FieldTextInput } from '@repo/ui/form';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { useMemo } from 'react';
import { fullPageModuleConfig } from '../../domain/constants';
import { createFullPageSchema } from '../../domain/validators/full-page.validator';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export default function FullPagePageForm({ formPageType }: { formPageType: 'edit' | 'create' | 'duplicate' }) {
export default function FullPagePageForm({ formPageType }: { formPageType: FormPageType }) {
const { t } = useEnterpriseModuleTranslationContext();
const title = useMemo(() => {
if (formPageType === 'CREATE') {
return { title: t('create_page_title'), description: t('create_page_description') };
} else if (formPageType === 'EDIT') {
return { title: t('edit_page_title'), description: t('edit_page_description') };
} else if (formPageType === 'DUPLICATE') {
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
}
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => {
return createFullPageSchema(t);
}, [t]);
const formControl = useForm({ resolver: zodResolver(validator) });
return (
<Box pb="xl">
{/* --- INLINED PAGE HEADER --- */}
<Box mb="xl" mt="xs">
<Breadcrumbs
mb="md"
separator={<ChevronRight size={12} strokeWidth={3} style={{ color: 'var(--mantine-color-gray-5)' }} />}
>
<Anchor
href="#"
c="dimmed"
size="xs"
fw={500}
style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}
>
Database Clusters
</Anchor>
<Text
size="xs"
fw={600}
style={{
color: 'light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0))',
letterSpacing: '0.2px',
}}
>
{formPageType === 'create' ? 'Create Cluster' : 'Edit Cluster'}
</Text>
</Breadcrumbs>
<Group justify="space-between" align="center" wrap="nowrap">
<Flex gap="md" align="center">
<Box>
<Title
order={2}
fw={600}
mb={4}
style={{
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
letterSpacing: '-0.3px',
}}
>
{formPageType === 'create' ? 'Create New Cluster' : 'Edit Cluster Configuration'}
</Title>
<Text size="sm" c="dimmed" fw={400}>
Configure and provision a new highly available database cluster.
</Text>
</Box>
</Flex>
<Group gap="sm">
<Button variant="default" radius="md">
Cancel
</Button>
<Button
variant="filled"
color="indigo"
radius="md"
leftSection={<Database size={16} strokeWidth={2.5} />}
style={{ boxShadow: '0 4px 14px 0 rgba(76, 110, 245, 0.39)' }}
>
{formPageType === 'create' ? 'Deploy Cluster' : 'Save Changes'}
</Button>
</Group>
</Group>
<Divider mt={28} mb={0} color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
</Box>
{/* --- END INLINED PAGE HEADER --- */}
<EnterpriseFormPageProvider
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
description: title?.description,
breadcrumbs: [
{ label: t('nav:example-module'), type: 'text' },
{ label: t('nav:example-full-page'), type: 'link', href: `${fullPageModuleConfig.webUrl}/index` },
],
}}
>
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text
size="lg"
fw={600}
mb="xs"
style={{
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
letterSpacing: '-0.3px',
}}
>
{formPageType === 'create' ? 'Create Entity' : 'Edit Entity'}
</Text>
<Text size="sm" c="dimmed" mb="xl">
Fill in the required information to configure your entity properly. Fields marked with * are required.
</Text>
<Divider mb="xl" color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
<Box maw={600}>
<Stack gap="lg">
<TextInput
<FieldTextInput
control={formControl.control}
name="code"
label={t('fields.code')}
placeholder="e.g. WID-001"
required
defaultValue={formPageType === 'edit' ? 'WID-001' : ''}
radius="md"
/>
<TextInput
<FieldTextInput
name="name"
control={formControl.control}
label={t('fields.name')}
placeholder="e.g. Dashboard Widget"
required
defaultValue={formPageType === 'edit' ? 'Dashboard Widget' : ''}
radius="md"
/>
<Select
label={t('fields.status')}
placeholder="Select status"
data={['ACTIVE', 'INACTIVE']}
defaultValue="ACTIVE"
required
radius="md"
/>
<Textarea
label={t('fields.description')}
placeholder="Enter a detailed description..."
minRows={4}
defaultValue={formPageType === 'edit' ? 'Main dashboard widget' : ''}
radius="md"
/>
</Stack>
</Box>
</Paper>
</Box>
</EnterpriseFormPageProvider>
);
}