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
@@ -4,8 +4,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components'; import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
import { import {
FieldTextInput, FieldPasswordInput, FieldNumberInput, FieldTextInput,
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor FieldPasswordInput,
FieldNumberInput,
FieldLocalSelect,
FieldAsyncSelect,
FieldRichTextEditor,
} from '@repo/ui/form'; } from '@repo/ui/form';
import type { LoadOptionsFn } 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)); await new Promise((resolve) => setTimeout(resolve, 500));
const allUsers = Array.from({ length: 20 }, (_, i) => ({ const allUsers = Array.from({ length: 20 }, (_, i) => ({
id: i + 1, 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 pageSize = 5;
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize); const paginated = filtered.slice(start, start + pageSize);
return { return {
options: paginated, options: paginated,
hasMore: start + pageSize < filtered.length hasMore: start + pageSize < filtered.length,
}; };
}; };
@@ -50,13 +54,20 @@ const MOCK_VENDORS = [
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => { const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500)); 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 }; return { options: filtered, hasMore: false };
}; };
import { import {
compose, required, rangeLength, compose,
positiveNumber, simplePassword, required,
complexPassword, phoneValidator, rangeValue rangeLength,
positiveNumber,
simplePassword,
complexPassword,
phoneValidator,
rangeValue,
} from '@repo/ui/validators'; } from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
@@ -64,20 +75,32 @@ export default function ValidationBankDemo() {
const t = useFormDemoTranslation(); const t = useFormDemoTranslation();
// Compose the Zod schema using the atomic validators // Compose the Zod schema using the atomic validators
const validationSchema = useMemo(() => z.object({ const validationSchema = useMemo(
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)), () =>
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)), z.object({
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)), username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)), simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)), complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
phone: compose(z.string(), required(t.validation.phone), phoneValidator()), age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }), score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees), phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }), department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }), assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor), prefilledVendor: z.object(
richTextNotes: z.string().min(15, t.errors.notesMin15), { id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() },
}), [t]); { 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>; type ValidationFormValues = z.infer<typeof validationSchema>;
@@ -96,10 +119,10 @@ export default function ValidationBankDemo() {
emptyVendor: null as any, emptyVendor: null as any,
prefilledAsyncMulti: [ prefilledAsyncMulti: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' }, { 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, ] as any,
richTextNotes: '', richTextNotes: '',
} },
}); });
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data); const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
@@ -110,7 +133,9 @@ export default function ValidationBankDemo() {
<Paper p="xl" withBorder radius="md"> <Paper p="xl" withBorder radius="md">
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md"> <Stack gap="md">
<Title order={5} c="brand">{t.sections.validationBankTitle}</Title> <Title order={5} c="brand">
{t.sections.validationBankTitle}
</Title>
<Divider mb="sm" /> <Divider mb="sm" />
<FieldTextInput <FieldTextInput
@@ -163,7 +188,9 @@ export default function ValidationBankDemo() {
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" /> <Divider mb="sm" />
<FieldLocalSelect<Department> <FieldLocalSelect<Department>
@@ -190,7 +217,9 @@ export default function ValidationBankDemo() {
withAsterisk 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" /> <Divider mb="sm" />
<Group grow align="flex-start"> <Group grow align="flex-start">
@@ -229,7 +258,9 @@ export default function ValidationBankDemo() {
withAsterisk 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" /> <Divider mb="sm" />
<FieldRichTextEditor <FieldRichTextEditor
@@ -240,13 +271,17 @@ export default function ValidationBankDemo() {
withAsterisk withAsterisk
/> />
<Button type="submit" mt="md">{t.common.submit}</Button> <Button type="submit" mt="md">
{t.common.submit}
</Button>
</Stack> </Stack>
</form> </form>
</Paper> </Paper>
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)"> <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> <Code block>{JSON.stringify(data, null, 2)}</Code>
</Paper> </Paper>
</Stack> </Stack>
@@ -12,22 +12,9 @@ import { compose, required, rangeLength } from '@repo/ui/validators';
export const createFullPageSchema = (t: any) => { export const createFullPageSchema = (t: any) => {
return z.object({ return z.object({
// Code: Required, length between 3 and 10 characters. // 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: Required, length between 3 and 50 characters.
name: compose(z.string(), required(t.fields.name), rangeLength(3, 50, t.fields.name)), 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(),
}); });
}; };
/**
* 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> <Routes>
<Route path="/index" element={<IndexPage />} /> <Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} /> <Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="edit" />} /> <Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="duplicate" />} /> <Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="create" />} /> <Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${fullPageModuleConfig.webUrl}/index`} replace={true} />} /> <Route path="/" element={<Navigate to={`${fullPageModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} /> <Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes> </Routes>
@@ -2,10 +2,12 @@
"title": "Full Page", "title": "Full Page",
"detail_page_title": "Detail Full Page", "detail_page_title": "Detail Full Page",
"create_page_title": "New Full Page", "create_page_title": "New Full Page",
"edit_page_title": "Edit Full Page",
"duplicate_page_title": "Duplicate Full Page", "duplicate_page_title": "Duplicate Full Page",
"description": "An example module of a <1>full page layout</1> for detailed forms.", "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.", "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.", "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.", "duplicate_page_description": "Copy and modify information from an existing record to quickly create a new one.",
"fields": { "fields": {
"code": "Code", "code": "Code",
@@ -2,10 +2,12 @@
"title": "Halaman Penuh", "title": "Halaman Penuh",
"detail_page_title": "Detail Halaman Penuh", "detail_page_title": "Detail Halaman Penuh",
"create_page_title": "Buat Halaman Penuh Baru", "create_page_title": "Buat Halaman Penuh Baru",
"edit_page_title": "Ubah Halaman Penuh",
"duplicate_page_title": "Duplikat Halaman Penuh", "duplicate_page_title": "Duplikat Halaman Penuh",
"description": "Contoh penerapan <1>tata letak halaman penuh</1> untuk formulir detail.", "description": "Contoh penerapan <1>tata letak halaman penuh</1> untuk formulir detail.",
"detail_page_description": "Lihat dan kelola informasi terperinci terkait data ini.", "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.", "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.", "duplicate_page_description": "Salin dan sesuaikan informasi dari data yang sudah ada untuk mempercepat pembuatan data baru.",
"fields": { "fields": {
"code": "Kode", "code": "Kode",
@@ -1,146 +1,69 @@
import { import { Paper, Box, Stack } from '@repo/ui/components';
Paper, import { FieldTextInput } from '@repo/ui/form';
Box, import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
Text, import { useMemo } from 'react';
Divider, import { fullPageModuleConfig } from '../../domain/constants';
TextInput, import { createFullPageSchema } from '../../domain/validators/full-page.validator';
Select, import { useForm } from 'react-hook-form';
Textarea, import { zodResolver } from '@hookform/resolvers/zod';
Stack,
Breadcrumbs,
Anchor,
Flex,
Title,
Button,
Group,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { ChevronRight, Database } from 'lucide-react';
export default function FullPagePageForm({ formPageType }: { formPageType: 'edit' | 'create' | 'duplicate' }) { export default function FullPagePageForm({ formPageType }: { formPageType: FormPageType }) {
const { t } = useEnterpriseModuleTranslationContext(); 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 ( return (
<Box pb="xl"> <EnterpriseFormPageProvider
{/* --- INLINED PAGE HEADER --- */} formControl={formControl}
<Box mb="xl" mt="xs"> formPageType={formPageType}
<Breadcrumbs ignoreKeyDuplicate={['code']}
mb="md" highlightDataKey="code"
separator={<ChevronRight size={12} strokeWidth={3} style={{ color: 'var(--mantine-color-gray-5)' }} />} pageHeaderProps={{
> title: title?.title,
<Anchor description: title?.description,
href="#" breadcrumbs: [
c="dimmed" { label: t('nav:example-module'), type: 'text' },
size="xs" { label: t('nav:example-full-page'), type: 'link', href: `${fullPageModuleConfig.webUrl}/index` },
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 --- */}
<Paper withBorder shadow="sm" radius="md" p="xl"> <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}> <Box maw={600}>
<Stack gap="lg"> <Stack gap="lg">
<TextInput <FieldTextInput
control={formControl.control}
name="code"
label={t('fields.code')} label={t('fields.code')}
placeholder="e.g. WID-001" placeholder="e.g. WID-001"
required required
defaultValue={formPageType === 'edit' ? 'WID-001' : ''}
radius="md" radius="md"
/> />
<TextInput <FieldTextInput
name="name"
control={formControl.control}
label={t('fields.name')} label={t('fields.name')}
placeholder="e.g. Dashboard Widget" placeholder="e.g. Dashboard Widget"
required 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" radius="md"
/> />
</Stack> </Stack>
</Box> </Box>
</Paper> </Paper>
</Box> </EnterpriseFormPageProvider>
); );
} }
@@ -50,6 +50,7 @@
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"save": "Save", "save": "Save",
"save_changes": "Save Changes",
"print": "Print", "print": "Print",
"confirm": "Confirm", "confirm": "Confirm",
"cancel": "Cancel", "cancel": "Cancel",
@@ -50,6 +50,7 @@
"edit": "Ubah", "edit": "Ubah",
"delete": "Hapus", "delete": "Hapus",
"save": "Simpan", "save": "Simpan",
"save_changes": "Simpan Perubahan",
"print": "Cetak", "print": "Cetak",
"confirm": "Confirm", "confirm": "Confirm",
"cancel": "Cancel", "cancel": "Cancel",
@@ -4,10 +4,10 @@
"min_length": "{{field}} minimal {{min}} karakter", "min_length": "{{field}} minimal {{min}} karakter",
"min_len": "{{field}} minimal {{min}} karakter", "min_len": "{{field}} minimal {{min}} karakter",
"max_len": "{{field}} maksimal {{max}} karakter", "max_len": "{{field}} maksimal {{max}} karakter",
"range_len": "{{field}} harus antara {{min}} dan {{max}} karakter", "range_len": "Panjang {{field}} harus antara {{min}} hingga {{max}} karakter",
"min_val": "{{field}} minimal bernilai {{min}}", "min_val": "Nilai {{field}} minimal {{min}}",
"max_val": "{{field}} maksimal bernilai {{max}}", "max_val": "Nilai {{field}} maksimal {{max}}",
"range_val": "{{field}} harus bernilai antara {{min}} dan {{max}}", "range_val": "Nilai {{field}} harus berada di antara {{min}} hingga {{max}}",
"must_be_positive": "{{field}} harus bernilai positif", "must_be_positive": "{{field}} harus bernilai positif",
"invalid_email": "Format email tidak valid", "invalid_email": "Format email tidak valid",
"invalid_phone": "Format nomor telepon tidak valid", "invalid_phone": "Format nomor telepon tidak valid",
@@ -78,12 +78,7 @@ export interface UseAsyncPaginateReturn<T> {
export function useAsyncPaginate<T extends Record<string, any>>( export function useAsyncPaginate<T extends Record<string, any>>(
options: UseAsyncPaginateOptions<T>, options: UseAsyncPaginateOptions<T>,
): UseAsyncPaginateReturn<T> { ): UseAsyncPaginateReturn<T> {
const { const { loadOptions, valueKey, debounceMs = 300, defaultOptions } = options;
loadOptions,
valueKey,
debounceMs = 300,
defaultOptions,
} = options;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// State // State
@@ -191,9 +186,7 @@ export function useAsyncPaginate<T extends Record<string, any>>(
// If the API returned items but ALL were duplicates, treat as exhausted. // If the API returned items but ALL were duplicates, treat as exhausted.
// This prevents infinite scroll loops on naive APIs that ignore pagination. // This prevents infinite scroll loops on naive APIs that ignore pagination.
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0 const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0 ? false : hasMore;
? false
: hasMore;
setCache((prev) => { setCache((prev) => {
const next = new Map(prev); const next = new Map(prev);
@@ -235,7 +228,7 @@ export function useAsyncPaginate<T extends Record<string, any>>(
// on every cache update. Instead, we read cache inside via the state setter's prev. // on every cache update. Instead, we read cache inside via the state setter's prev.
// The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions — // The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions —
// this is acceptable because the callback is only called when we're NOT already fetching. // this is acceptable because the callback is only called when we're NOT already fetching.
// eslint-disable-next-line react-hooks/exhaustive-deps
[], [],
); );
@@ -259,7 +252,6 @@ export function useAsyncPaginate<T extends Record<string, any>>(
// No cache entry — fetch page 1 // No cache entry — fetch page 1
fetchPage(debouncedSearch, 1); fetchPage(debouncedSearch, 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]); }, [debouncedSearch]);
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -368,9 +368,10 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
} }
} catch (error: any) { } catch (error: any) {
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`; const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
const message = error?.response?.data?.message;
const defaultErrorMessage = t('common:notifications.actionFailed', { const defaultErrorMessage = t('common:notifications.actionFailed', {
action: t(actionKey), action: t(actionKey),
message: error?.message || 'Unknown error', message: message ?? error?.message ?? 'Unknown error',
}); });
const customErrorMessage = const customErrorMessage =
typeof currentConfig?.errorMessage === 'function' typeof currentConfig?.errorMessage === 'function'
@@ -505,11 +506,12 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
total_failed: 0, total_failed: 0,
}; };
} catch (error: any) { } catch (error: any) {
const message = error?.response?.data?.message;
return { return {
total_items: ids.length, total_items: ids.length,
total_success: 0, total_success: 0,
total_failed: ids.length, total_failed: ids.length,
messages: [error?.message || 'Unknown error'], messages: [message ?? error?.message ?? 'Unknown error'],
}; };
} }
}, },
@@ -766,7 +768,12 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
params.success({ rowData, rowCount }); params.success({ rowData, rowCount });
} catch (error: any) { } catch (error: any) {
// Display an error notification if the request fails // Display an error notification if the request fails
notifications.show({ title: t('common:notifications.errorTitle'), message: error?.message, color: 'red' }); const message = error?.response?.data?.message;
notifications.show({
title: t('common:notifications.errorTitle'),
message: message ?? error?.message,
color: 'red',
});
params.fail(); params.fail();
} }
}, },
@@ -366,6 +366,8 @@ export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> e
export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig { export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
formPageType: FormPageType; formPageType: FormPageType;
formControl: UseFormReturn<any>;
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions']; customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
onDataLoaded?: (data: E) => void; onDataLoaded?: (data: E) => void;
@@ -382,6 +384,8 @@ export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> ext
ignoreKeyUpdate?: (keyof E)[]; ignoreKeyUpdate?: (keyof E)[];
/** Type-safe array of entity keys to exclude when duplicating a record. */ /** Type-safe array of entity keys to exclude when duplicating a record. */
ignoreKeyDuplicate?: (keyof E)[]; ignoreKeyDuplicate?: (keyof E)[];
initialValueCreate?: Partial<E>;
presetDuplicate?(payload: any): Promise<any>; presetDuplicate?(payload: any): Promise<any>;
/** Configuration for the save confirmation modal. When provided, a confirmation dialog is shown before saving. */
saveModalConfig?: ActionModalConfig;
} }
@@ -1,7 +1,6 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import type { BaseEntity } from '@repo/core-api/data-services'; import type { BaseEntity } from '@repo/core-api/data-services';
import { FormPageType } from '../entities/entity'; import { FormPageType } from '../entities/entity';
export interface FormPageContextValue<E extends BaseEntity = BaseEntity> { export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
formType: FormPageType; formType: FormPageType;
isCreate: boolean; isCreate: boolean;
@@ -11,8 +10,8 @@ export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
initialData: Partial<E> | null; initialData: Partial<E> | null;
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
save: (data: any) => Promise<void>; handleSave: (data: any) => Promise<void>;
cancel: () => void; handleCancel: () => void;
// Draft specific // Draft specific
hasDraft: boolean; hasDraft: boolean;
applyDraft: () => void; applyDraft: () => void;
@@ -3,10 +3,12 @@ export * from './constant/';
export * from './entities/entity'; export * from './entities/entity';
export * from './hooks/use-module.context'; export * from './hooks/use-module.context';
export * from './hooks/use-form-page.context';
export * from './providers/module.provider'; export * from './providers/module.provider';
export * from './providers/index-page.provider'; export * from './providers/index-page.provider';
export * from './providers/detail-page.provider'; export * from './providers/detail-page.provider';
export * from './providers/form-page.provider';
export * from './components/module-page-header'; export * from './components/module-page-header';
export * from './components/action-confirmation-modal'; export * from './components/action-confirmation-modal';
export * from './components/bulk-action-confirmation'; export * from './components/bulk-action-confirmation';
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { BaseEntity } from '@repo/core-api/data-services'; import { BaseEntity } from '@repo/core-api/data-services';
import { Check, Unlock, Copy, SquarePen, PauseCircle, Plus, RotateCcw, Trash, X, Lock } from 'lucide-react'; import { Check, Unlock, Copy, SquarePen, PauseCircle, Plus, RotateCcw, Trash, X, Lock } from 'lucide-react';
@@ -17,6 +17,65 @@ import {
} from '../hooks/use-module.context'; } from '../hooks/use-module.context';
import { shortcutsData } from '../../../constants'; import { shortcutsData } from '../../../constants';
// ---------------------------------------------------------------------------
// Pure helper functions (extracted outside component to avoid re-creation)
// ---------------------------------------------------------------------------
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
const staticTitle = pageProvide?.title;
if (!showHighlight || !detailData) {
return { flatTitle: staticTitle, title: staticTitle };
} else {
const highlightData = detailData && detailData[key];
const flatTitle = `${staticTitle} | ${highlightData}`;
return {
flatTitle,
title: (
<span>
{staticTitle}
{highlightData && (
<span
style={{
fontWeight: 400,
marginLeft: '8px',
}}
>
| {highlightData}
</span>
)}
</span>
),
};
}
}
function makeBreadcrumbs(
showHighlight: boolean,
key: string,
pageProvide: ModulePageHeaderProps | any,
detailData: any,
) {
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
return pageProvide.breadcrumbs;
} else {
const highlightData = detailData && detailData[key];
const breadcrumbs = [
...staticBreadcrumbs,
{
type: 'text',
label: `${highlightData}`,
},
];
return breadcrumbs;
}
}
// ---------------------------------------------------------------------------
// EnterpriseDetailPageProvider
// ---------------------------------------------------------------------------
export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) { export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) {
const { const {
children, children,
@@ -69,30 +128,77 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
const [detailData, setDetailData] = useState<E | any>(); const [detailData, setDetailData] = useState<E | any>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const loadData = useCallback(async () => { // ---------------------------------------------------------------------------
if (!dataId) return; // Stable refs for consumer-provided callbacks to prevent infinite loops.
setIsLoading(true); // These callbacks may be unstable (new reference each render) if the consumer
try { // doesn't memoize them. Using refs lets us reference the latest version
const response = await dataServices.getOne(dataId); // without adding them to useCallback dependency arrays.
if (response && response.data) { // ---------------------------------------------------------------------------
const data = response.data?.data; const onDataLoadedRef = useRef(onDataLoaded);
setDetailData(data as E); onDataLoadedRef.current = onDataLoaded;
if (onDataLoaded) onDataLoaded(data as E);
const onClickCreateRef = useRef(onClickCreate);
onClickCreateRef.current = onClickCreate;
const onClickEditRef = useRef(onClickEdit);
onClickEditRef.current = onClickEdit;
const onClickDuplicateRef = useRef(onClickDuplicate);
onClickDuplicateRef.current = onClickDuplicate;
const onClickDeleteRef = useRef(onClickDelete);
onClickDeleteRef.current = onClickDelete;
const onClickActivateRef = useRef(onClickActivate);
onClickActivateRef.current = onClickActivate;
const onClickDeactivateRef = useRef(onClickDeactivate);
onClickDeactivateRef.current = onClickDeactivate;
const onClickConfirmRef = useRef(onClickConfirm);
onClickConfirmRef.current = onClickConfirm;
const onClickCancelRef = useRef(onClickCancel);
onClickCancelRef.current = onClickCancel;
const onClickRollbackRef = useRef(onClickRollback);
onClickRollbackRef.current = onClickRollback;
const onClickHoldRef = useRef(onClickHold);
onClickHoldRef.current = onClickHold;
const loadData = useCallback(
async (id: string) => {
if (!id) return;
setIsLoading(true);
try {
const response = await dataServices.getOne(id);
if (response && response.data) {
const data = response.data?.data;
setDetailData(data as E);
onDataLoadedRef.current?.(data as E);
}
} catch (error: any) {
const message = error?.response?.data?.message;
notifications.show({
title: t('common:notifications.errorTitle'),
message: message ?? error?.message,
color: 'red',
});
} finally {
setIsLoading(false);
} }
} catch (error: any) { },
notifications.show({ [dataServices],
title: t('common:notifications.errorTitle'), );
message: error?.message,
color: 'red', // Ref always holds the latest loadData to avoid stale closures in the effect
}); const loadDataRef = useRef(loadData);
} finally { loadDataRef.current = loadData;
setIsLoading(false);
}
}, [dataId, dataServices]);
useEffect(() => { useEffect(() => {
loadData(); if (dataId) loadDataRef.current(dataId);
}, [loadData]); }, [dataId]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 1. Action Confirmation Modal State & Handlers // 1. Action Confirmation Modal State & Handlers
@@ -211,13 +317,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
navigation.navigateToIndex(); navigation.navigateToIndex();
} else { } else {
closeActionModal(); closeActionModal();
await loadData(); if (dataId) loadDataRef.current(dataId);
} }
} catch (error: any) { } catch (error: any) {
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`; const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
const message = error?.response?.data?.message;
const defaultErrorMessage = t('common:notifications.actionFailed', { const defaultErrorMessage = t('common:notifications.actionFailed', {
action: t(actionKey), action: t(actionKey),
message: error?.message || 'Unknown error', message: message ?? error?.message ?? 'Unknown error',
}); });
const customErrorMessage = const customErrorMessage =
@@ -236,7 +343,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
throw error; throw error;
} }
}, },
[dataServices, closeActionModal, navigation, loadData, modalConfigMap, t], [dataServices, closeActionModal, navigation, dataId, modalConfigMap, t],
); );
// --- Handler functions that open the confirmation modal --- // --- Handler functions that open the confirmation modal ---
@@ -270,7 +377,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 2. Action Dispatcher (Optimized with Switch Case & Complete Deps) // 2. Action Dispatcher (Optimized with Switch Case & Stable Deps via Refs)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const handleActionClick = useCallback( const handleActionClick = useCallback(
async (key: string) => { async (key: string) => {
@@ -282,14 +389,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
// --- CREATE --- // --- CREATE ---
case ModuleAction.CREATE: case ModuleAction.CREATE:
if (!privileges.ALLOW_CREATE) return; if (!privileges.ALLOW_CREATE) return;
if (onClickCreate) onClickCreate(); if (onClickCreateRef.current) onClickCreateRef.current();
else navigation.navigateToCreate(); else navigation.navigateToCreate();
break; break;
// --- EDIT --- // --- EDIT ---
case ModuleAction.EDIT: case ModuleAction.EDIT:
if (!privileges.ALLOW_EDIT || !hasValidData) return; if (!privileges.ALLOW_EDIT || !hasValidData) return;
if (onClickEdit) onClickEdit(currentData); if (onClickEditRef.current) onClickEditRef.current(currentData);
else if (editMode === 'FULL') navigation.navigateToEdit(dataId!); else if (editMode === 'FULL') navigation.navigateToEdit(dataId!);
else setIsActiveEditMode(true); else setIsActiveEditMode(true);
break; break;
@@ -297,56 +404,56 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
// --- DUPLICATE --- // --- DUPLICATE ---
case ModuleAction.DUPLICATE: case ModuleAction.DUPLICATE:
if (!privileges.ALLOW_CREATE || !hasValidData) return; if (!privileges.ALLOW_CREATE || !hasValidData) return;
if (onClickDuplicate) onClickDuplicate(currentData); if (onClickDuplicateRef.current) onClickDuplicateRef.current(currentData);
else navigation.navigateToDuplicate(dataId!); else navigation.navigateToDuplicate(dataId!);
break; break;
// --- DELETE --- // --- DELETE ---
case ModuleAction.DELETE: case ModuleAction.DELETE:
if (!privileges.ALLOW_DELETE || !hasValidData) return; if (!privileges.ALLOW_DELETE || !hasValidData) return;
if (onClickDelete) onClickDelete(currentData); if (onClickDeleteRef.current) onClickDeleteRef.current(currentData);
else await handleDelete(currentData); else await handleDelete(currentData);
break; break;
// --- ACTIVATE --- // --- ACTIVATE ---
case ModuleAction.ACTIVATE: case ModuleAction.ACTIVATE:
if (!privileges.ALLOW_ACTIVATE || !hasValidData) return; if (!privileges.ALLOW_ACTIVATE || !hasValidData) return;
if (onClickActivate) onClickActivate(currentData); if (onClickActivateRef.current) onClickActivateRef.current(currentData);
else await handleActivate(currentData); else await handleActivate(currentData);
break; break;
// --- DEACTIVATE --- // --- DEACTIVATE ---
case ModuleAction.DEACTIVATE: case ModuleAction.DEACTIVATE:
if (!privileges.ALLOW_DEACTIVATE || !hasValidData) return; if (!privileges.ALLOW_DEACTIVATE || !hasValidData) return;
if (onClickDeactivate) onClickDeactivate(currentData); if (onClickDeactivateRef.current) onClickDeactivateRef.current(currentData);
else await handleDeactivate(currentData); else await handleDeactivate(currentData);
break; break;
// --- CONFIRM --- // --- CONFIRM ---
case ModuleAction.CONFIRM: case ModuleAction.CONFIRM:
if (!privileges.ALLOW_CONFIRM || !hasValidData) return; if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
if (onClickConfirm) onClickConfirm(currentData); if (onClickConfirmRef.current) onClickConfirmRef.current(currentData);
else await handleConfirm(currentData); else await handleConfirm(currentData);
break; break;
// --- CANCEL --- // --- CANCEL ---
case ModuleAction.CANCEL: case ModuleAction.CANCEL:
if (!privileges.ALLOW_CANCEL || !hasValidData) return; if (!privileges.ALLOW_CANCEL || !hasValidData) return;
if (onClickCancel) onClickCancel(currentData); if (onClickCancelRef.current) onClickCancelRef.current(currentData);
else await handleCancel(currentData); else await handleCancel(currentData);
break; break;
// --- ROLLBACK --- // --- ROLLBACK ---
case ModuleAction.ROLLBACK: case ModuleAction.ROLLBACK:
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return; if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
if (onClickRollback) onClickRollback(currentData); if (onClickRollbackRef.current) onClickRollbackRef.current(currentData);
else await handleRollback(currentData); else await handleRollback(currentData);
break; break;
// --- HOLD --- // --- HOLD ---
case ModuleAction.HOLD: case ModuleAction.HOLD:
if (!privileges.ALLOW_HOLD || !hasValidData) return; if (!privileges.ALLOW_HOLD || !hasValidData) return;
if (onClickHold) onClickHold(currentData); if (onClickHoldRef.current) onClickHoldRef.current(currentData);
else await handleHold(currentData); else await handleHold(currentData);
break; break;
@@ -355,24 +462,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
break; break;
} }
}, },
[ [navigation, privileges, dataId, detailData, editMode],
navigation,
privileges,
dataId,
detailData,
editMode,
setIsActiveEditMode,
onClickCreate,
onClickEdit,
onClickDuplicate,
onClickDelete,
onClickActivate,
onClickDeactivate,
onClickConfirm,
onClickCancel,
onClickRollback,
onClickHold,
],
); );
/** Platform-aware shortcut label for the Create action. */ /** Platform-aware shortcut label for the Create action. */
@@ -549,73 +639,32 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
() => ({ () => ({
detailData, detailData,
isLoading, isLoading,
reload: loadData, reload: async () => {
if (dataId) await loadDataRef.current(dataId);
},
isPartialEdit: editMode === 'PARTIAL', isPartialEdit: editMode === 'PARTIAL',
isActiveEditMode, isActiveEditMode,
setIsActiveEditMode, setIsActiveEditMode,
}), }),
[detailData, isLoading, loadData, editMode, isActiveEditMode, setIsActiveEditMode], [detailData, isLoading, dataId, editMode, isActiveEditMode, setIsActiveEditMode],
); );
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) { // ---------------------------------------------------------------------------
const staticTitle = pageProvide?.title; // Page header (title, breadcrumbs)
if (!showHighlight || !detailData) { // ---------------------------------------------------------------------------
return { flatTitle: staticTitle, title: staticTitle };
} else {
const highlightData = detailData && detailData[key];
const flatTitle = `${staticTitle} | ${highlightData}`;
return {
flatTitle,
title: (
<span>
{staticTitle}
{highlightData && (
<span
style={{
fontWeight: 400,
marginLeft: '8px',
// color: 'var(--mantine-color-dimmed)',
}}
>
| {highlightData}
</span>
)}
</span>
),
};
}
}
function makeBreadcrumbs(
showHighlight: boolean,
key: string,
pageProvide: ModulePageHeaderProps | any,
detailData: any,
) {
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
return pageProvide.breadcrumbs;
} else {
const highlightData = detailData && detailData[key];
const breadcrumbs = [
...staticBreadcrumbs,
{
type: 'link',
label: `${highlightData}`,
href: `${config.webUrl}/detail/${dataId}`,
},
];
return breadcrumbs;
}
}
const pageHeaderPropsValue = useMemo(() => { const pageHeaderPropsValue = useMemo(() => {
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData); const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
document.title = title.flatTitle;
const breadcrumbs = makeBreadcrumbs(showHighlightDataOnBreadcrumbs, highlightDataKey, pageHeaderProps, detailData); const breadcrumbs = makeBreadcrumbs(showHighlightDataOnBreadcrumbs, highlightDataKey, pageHeaderProps, detailData);
return { ...pageHeaderProps, title: title.title, breadcrumbs: breadcrumbs }; return { ...pageHeaderProps, title: title.title, breadcrumbs, _flatTitle: title.flatTitle };
}, [pageHeaderProps, dataId, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]); }, [pageHeaderProps, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]);
// Side effect: update document.title (must NOT be inside useMemo)
useEffect(() => {
if (pageHeaderPropsValue._flatTitle) {
document.title = pageHeaderPropsValue._flatTitle;
}
}, [pageHeaderPropsValue._flatTitle]);
return ( return (
<DetailPageContext.Provider value={contextValue}> <DetailPageContext.Provider value={contextValue}>
@@ -0,0 +1,406 @@
import { BaseEntity } from '@repo/core-api/data-services';
import {
ActionModalState,
EnterpriseFormPageConfig,
FormPageType,
ModuleAction,
ModuleActionType,
} from '../entities/entity';
import { SaveCheck } from 'lucide-react';
import {
useEnterpriseModuleConfigContext,
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleNavigationContext,
useEnterpriseModuleTranslationContext,
} from '../hooks/use-module.context';
import { useParams } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { notifications } from '@mantine/notifications';
import { lodash } from '@repo/utils';
import { FormPageContext } from '../hooks/use-form-page.context';
import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header';
import { FormProvider } from 'react-hook-form';
import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components';
import { ActionConfirmationModal } from '../components/action-confirmation-modal';
const EMPTY_ARRAY: any[] = [];
// ---------------------------------------------------------------------------
// Pure helper functions (extracted outside component to avoid re-creation)
// ---------------------------------------------------------------------------
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
const staticTitle = pageProvide?.title;
if (!showHighlight || !detailData) {
return { flatTitle: staticTitle, title: staticTitle };
} else {
const highlightData = detailData && detailData[key];
const flatTitle = `${staticTitle} | ${highlightData}`;
return {
flatTitle,
title: (
<span>
{staticTitle}
{highlightData && <span style={{ fontWeight: 400, marginLeft: '8px' }}>| {highlightData}</span>}
</span>
),
};
}
}
function makeBreadcrumbs(
showHighlight: boolean,
key: string,
pageProvide: ModulePageHeaderProps | any,
detailData: any,
formPageType: FormPageType,
labelBreadcrumbsCreate: string,
labelBreadcrumbsEdit: string,
) {
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
const breadcrumbs = pageProvide?.breadcrumbs ?? [];
if (formPageType === 'CREATE' && breadcrumbs.length > 0) {
const isCreate = formPageType === 'CREATE' || formPageType === 'DUPLICATE';
return [
...breadcrumbs,
{
type: 'text',
label: isCreate ? labelBreadcrumbsCreate : labelBreadcrumbsEdit,
},
];
}
return breadcrumbs;
} else {
const highlightData = detailData && detailData[key];
const breadcrumbs = [
...staticBreadcrumbs,
{
type: 'text',
label: `${highlightData}`,
},
];
return breadcrumbs;
}
}
// ---------------------------------------------------------------------------
// EnterpriseFormPageProvider
// ---------------------------------------------------------------------------
export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseFormPageConfig<E>) {
const {
children,
pageHeaderProps,
px,
py,
formPageType,
onDataLoaded,
customPageActions,
showHighlightData = true,
showHighlightDataOnBreadcrumbs = true,
highlightDataKey = 'code',
statusKey = 'status',
getCustomStatusBadgeConfig,
ignoreKeyUpdate = EMPTY_ARRAY,
ignoreKeyDuplicate = EMPTY_ARRAY,
presetDuplicate,
formControl,
saveModalConfig,
} = props;
const { t } = useEnterpriseModuleTranslationContext();
const navigation = useEnterpriseModuleNavigationContext();
const { config, privileges } = useEnterpriseModuleConfigContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const params = useParams();
const dataId = params.dataId;
const { moduleKey } = config;
const [detailData, setDetailData] = useState<E | any>();
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { reset, handleSubmit } = formControl;
// ---------------------------------------------------------------------------
// Stable refs for consumer-provided callbacks to prevent infinite loops.
// These callbacks may be unstable (new reference each render) if the consumer
// doesn't memoize them. Using refs lets us reference the latest version
// without adding them to useCallback dependency arrays.
// ---------------------------------------------------------------------------
const onDataLoadedRef = useRef(onDataLoaded);
onDataLoadedRef.current = onDataLoaded;
const presetDuplicateRef = useRef(presetDuplicate);
presetDuplicateRef.current = presetDuplicate;
const loadData = useCallback(
async (id: string) => {
if (!id) return;
setIsLoading(true);
try {
const response = await dataServices.getOne(id);
if (response && response.data) {
const data = response.data?.data;
setDetailData(data as E);
onDataLoadedRef.current?.(data as E);
let formPayload = { ...data };
if (formPageType === 'EDIT') formPayload = lodash.omit(formPayload, ignoreKeyUpdate) as E;
if (formPageType === 'DUPLICATE') {
formPayload = lodash.omit(formPayload, ignoreKeyDuplicate) as E;
if (presetDuplicateRef.current) formPayload = await presetDuplicateRef.current(formPayload);
}
reset(formPayload);
}
} catch (error: any) {
const message = error?.response?.data?.message;
notifications.show({
title: t('common:notifications.errorTitle'),
message: message ?? error?.message,
color: 'red',
});
} finally {
setIsLoading(false);
}
},
[dataServices],
);
// Ref always holds the latest loadData to avoid stale closures in the effect
const loadDataRef = useRef(loadData);
loadDataRef.current = loadData;
useEffect(() => {
if (dataId) loadDataRef.current(dataId);
}, [dataId]);
// ---------------------------------------------------------------------------
// Save Confirmation Modal State & Handlers
// (Pattern follows detail-page.provider.tsx)
// ---------------------------------------------------------------------------
/** Default (closed) modal state — stable reference to avoid re-creating on every render. */
const CLOSED_MODAL: ActionModalState<E> = useMemo(() => ({ opened: false, action: null, data: null }), []);
const [actionModalState, setActionModalState] = useState<ActionModalState<E>>(CLOSED_MODAL);
/** Pending form data to be saved after modal confirmation */
const pendingFormDataRef = useRef<any>(null);
/** Close the confirmation modal and reset state. */
const closeActionModal = useCallback(() => {
pendingFormDataRef.current = null;
setActionModalState(CLOSED_MODAL);
}, [CLOSED_MODAL]);
const handleSave = useCallback(
async (data: any) => {
setIsSaving(true);
try {
if (formPageType === 'CREATE' || formPageType === 'DUPLICATE') {
await dataServices.create(data);
notifications.show({
title: t('common:notifications.successTitle'),
message: t('common:notifications.createSuccess'),
color: 'green',
});
} else if (formPageType === 'EDIT') {
if (!dataId) throw new Error('Data ID is required for editing');
await dataServices.edit(dataId, data);
notifications.show({
title: t('common:notifications.successTitle'),
message: t('common:notifications.updateSuccess'),
color: 'green',
});
}
navigation.navigateToIndex();
} catch (error: any) {
const message = error?.response?.data?.message;
notifications.show({
title: t('common:notifications.errorTitle'),
message: message ?? error?.message,
color: 'red',
});
} finally {
setIsSaving(false);
}
},
[dataId, dataServices, formPageType, navigation, t],
);
/**
* Initiate save flow: validate form first, then always open confirmation modal.
* Uses saveModalConfig for custom overrides, or default SAVE translations from ACTION_TRANSLATION_MAP.
*/
const initiateSave = useCallback(() => {
handleSubmit((validData: any) => {
// Store validated data and show confirmation modal
pendingFormDataRef.current = validData;
setActionModalState({
opened: true,
action: ModuleAction.SAVE,
data: validData,
config: saveModalConfig,
});
})();
}, [handleSubmit, saveModalConfig]);
/**
* Execute save after modal confirmation.
* Called by ActionConfirmationModal after the user confirms.
*/
const executeSave = useCallback(
async (_action: ModuleActionType, _data: E, meta?: Record<string, unknown>) => {
const formData = pendingFormDataRef.current;
if (!formData) return;
// Merge optional meta from modal form body into the save payload
const payload = meta ? { ...formData, ...meta } : formData;
// eslint-disable-next-line no-useless-catch
try {
await handleSave(payload);
closeActionModal();
} catch (error) {
// Rethrow so the modal knows submission failed
// and re-enables the buttons for the user to try again or cancel
throw error;
}
},
[handleSave, closeActionModal],
);
// ---------------------------------------------------------------------------
// Page header (title, breadcrumbs)
// ---------------------------------------------------------------------------
const pageHeaderPropsValue = useMemo(() => {
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
const breadcrumbs = makeBreadcrumbs(
showHighlightDataOnBreadcrumbs,
highlightDataKey,
pageHeaderProps,
detailData,
formPageType,
t('common:actions.create'),
t('common:actions.edit'),
);
return { ...pageHeaderProps, title: title.title, breadcrumbs, _flatTitle: title.flatTitle };
}, [
pageHeaderProps,
detailData,
showHighlightData,
showHighlightDataOnBreadcrumbs,
highlightDataKey,
formPageType,
t,
]);
// Side effect: update document.title (must NOT be inside useMemo)
useEffect(() => {
if (pageHeaderPropsValue._flatTitle) {
document.title = pageHeaderPropsValue._flatTitle;
}
}, [pageHeaderPropsValue._flatTitle]);
const pageActions = useMemo(() => {
const { ALLOW_CREATE, ALLOW_EDIT } = privileges;
const isCreate = formPageType === 'CREATE' || formPageType === 'DUPLICATE';
const canSave = isCreate ? ALLOW_CREATE : ALLOW_EDIT;
// 1. Declare action with Privilege & Module Type conditions directly
const rawActions = [
canSave && {
key: ModuleAction.SAVE,
label: isCreate ? t('common:actions.save') : t('common:actions.save_changes'),
icon: <SaveCheck size={16} />,
intent: 'primary',
variant: 'filled',
onClick: () => initiateSave(),
},
].filter(Boolean) as PageActionProps[]; // Remove all false/null/undefined
// 3. Inject custom actions
return customPageActions && detailData ? customPageActions(detailData, rawActions) : rawActions;
}, [t, customPageActions, privileges, formPageType, initiateSave, detailData]);
const contextValue = useMemo(
() => ({
formType: formPageType,
isCreate: formPageType === 'CREATE',
isEdit: formPageType === 'EDIT',
isDuplicate: formPageType === 'DUPLICATE',
dataId,
initialData: detailData,
isLoading,
isSaving,
handleSave,
handleCancel: () => {},
hasDraft: false,
applyDraft: () => {},
discardDraft: () => {},
}),
[formPageType, dataId, detailData, isLoading, isSaving, handleSave],
);
return (
<FormPageContext.Provider value={contextValue}>
<CorePageContainer
px={px}
py={py}
headerSlot={
<ModulePageHeader
customButtonProps={() => {
return { size: 'sm', style: { fontSize: 12 } };
}}
actions={formPageType === 'CREATE' || detailData ? pageActions : []}
{...pageHeaderPropsValue}
moduleKey={moduleKey}
titleProps={{
fz: { base: 16, sm: 18 },
}}
miniTitleProps={{
fz: { base: 'md', sm: 'lg' },
}}
badges={
detailData ? (
<StatusBadge
status={detailData[statusKey as keyof typeof detailData] as string}
getCustomConfig={getCustomStatusBadgeConfig}
/>
) : undefined
}
/>
}
>
<FormProvider {...formControl}>
<form
onSubmit={(e) => {
e.preventDefault();
initiateSave();
}}
>
{children}
</form>
</FormProvider>
</CorePageContainer>
{/* Save Confirmation Modal */}
<ActionConfirmationModal<E>
modalState={actionModalState}
onClose={closeActionModal}
onExecute={executeSave}
t={t}
/>
</FormPageContext.Provider>
);
}
@@ -2,7 +2,7 @@ import { EnterpriseIndexPageConfig, ModuleAction } from '../entities/entity';
import { IndexPageContext } from '../hooks/use-index-page.context'; import { IndexPageContext } from '../hooks/use-index-page.context';
import { CorePageContainer, PageActionProps } from '../../../components'; import { CorePageContainer, PageActionProps } from '../../../components';
import { ModulePageHeader } from '../components/module-page-header'; import { ModulePageHeader } from '../components/module-page-header';
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { import {
useEnterpriseModuleConfigContext, useEnterpriseModuleConfigContext,
@@ -20,15 +20,20 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
const { moduleKey } = config; const { moduleKey } = config;
const { ALLOW_CREATE } = privileges; const { ALLOW_CREATE } = privileges;
// Stable refs for consumer-provided callbacks to prevent infinite loops.
// These callbacks may be unstable (new reference each render) if the consumer doesn't memoize them.
const onClickCreateRef = useRef(onClickCreate);
onClickCreateRef.current = onClickCreate;
// Stable reference so the useEffect doesn't re-attach on every render. // Stable reference so the useEffect doesn't re-attach on every render.
const handleActionClick = useCallback( const handleActionClick = useCallback(
(key: string) => { (key: string) => {
if (key === ModuleAction.CREATE && ALLOW_CREATE) { if (key === ModuleAction.CREATE && ALLOW_CREATE) {
if (onClickCreate) onClickCreate(key); if (onClickCreateRef.current) onClickCreateRef.current(key);
else navigation.navigateToCreate(); else navigation.navigateToCreate();
} }
}, },
[onClickCreate, navigation, ALLOW_CREATE], [navigation, ALLOW_CREATE],
); );
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -76,6 +81,12 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
return customPageActions ? customPageActions(actions) : (actions as any[]); return customPageActions ? customPageActions(actions) : (actions as any[]);
}, [t, customPageActions, handleActionClick, ALLOW_CREATE]); }, [t, customPageActions, handleActionClick, ALLOW_CREATE]);
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
useEffect(() => {
const title = config.tabTitle || t('title');
if (title) document.title = title;
}, [config.tabTitle, t]);
return ( return (
<IndexPageContext.Provider value={{}}> <IndexPageContext.Provider value={{}}>
<CorePageContainer <CorePageContainer
@@ -43,7 +43,7 @@ export function EnterpriseModuleProvider<
const navigate = useNavigate(); const navigate = useNavigate();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 1. Config Slice (Static) // Config Slice (Static)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const storePrivileges = store((state: S) => state.privileges); const storePrivileges = store((state: S) => state.privileges);
@@ -60,29 +60,21 @@ export function EnterpriseModuleProvider<
}, [config, storePrivileges]); }, [config, storePrivileges]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 1b. Translation Slice (Dedicated context — decoupled from config) // Translation Slice (Dedicated context — decoupled from config)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const namespaces = useMemo(() => [config.translationNamespace, 'common'], [config.translationNamespace]); const namespaces = useMemo(() => [config.translationNamespace, 'common'], [config.translationNamespace]);
const { t } = useTranslation(namespaces); const { t } = useTranslation(namespaces);
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]); const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
// useEffect(() => {
// const title = config.tabTitle || t('title');
// if (title) {
// document.title = title;
// }
// }, [config.tabTitle, t]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 2. Data Service Slice (Stable refs) // Data Service Slice (Stable refs)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const dataServiceSlice = useMemo(() => { const dataServiceSlice = useMemo(() => {
return { dataServices }; return { dataServices };
}, [dataServices]); }, [dataServices]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 3. Selection Slice (Dynamic state) // Selection Slice (Dynamic state)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const selectedRows = store((state: S) => state.selectedRows); const selectedRows = store((state: S) => state.selectedRows);
const setSelectedRows = store((state: S) => state.setSelectedRows); const setSelectedRows = store((state: S) => state.setSelectedRows);
@@ -104,7 +96,7 @@ export function EnterpriseModuleProvider<
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 4. Modal Slice (For Single Page mode) // Modal Slice (For Single Page mode)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const [formState, setFormState] = useState<SinglePageFormState>({ open: false, formType: 'CREATE' }); const [formState, setFormState] = useState<SinglePageFormState>({ open: false, formType: 'CREATE' });
const [detailState, setDetailState] = useState<SinglePageModalState>({ open: false }); const [detailState, setDetailState] = useState<SinglePageModalState>({ open: false });
@@ -115,7 +107,7 @@ export function EnterpriseModuleProvider<
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 5. Navigation Slice // Navigation Slice
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const navigationSlice = useMemo(() => { const navigationSlice = useMemo(() => {
const isSingle = config.moduleCategory === 'SINGLE_PAGE'; const isSingle = config.moduleCategory === 'SINGLE_PAGE';