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>