mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
fix: update pattern context
This commit is contained in:
@@ -80,76 +80,124 @@ Data tables across all modules (master-data, inventory, finance, purchase, etc.)
|
||||
- Apply to: search handlers, filter form submissions, reset handlers
|
||||
|
||||
3. **Create custom formikResetHandler function**
|
||||
- Clear each filter with `updateFilter(fieldName, defaultValue, true)`
|
||||
- Call `formik.resetForm({ values: { ...defaults } })`
|
||||
- Close the modal at the end
|
||||
- Attach to both button `onClick` and form `onReset` handler
|
||||
|
||||
**Optimization: Avoid useCallback for simple handlers**
|
||||
|
||||
- `useCallback` adds overhead and is only useful for complex logic or memoized child components
|
||||
- Simple pass-through handlers don't need it:
|
||||
- Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults)
|
||||
- Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts)
|
||||
- Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults
|
||||
- Call `filterModal.closeModal()` at the end
|
||||
- Attach to form `onReset` handler (not `formik.handleReset`)
|
||||
|
||||
```tsx
|
||||
// ✅ Good: Simple handler without useCallback
|
||||
const handleFilterChange = (val) => setFieldValue('location', val);
|
||||
const formikResetHandler = () => {
|
||||
resetFilter();
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) { toast.dismiss(); setDateErrorShown(false); }
|
||||
formik.resetForm({ values: { start_date: '', end_date: '', customers: [], filterBy: undefined } });
|
||||
filterModal.closeModal();
|
||||
};
|
||||
// ...
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
```
|
||||
|
||||
// ❌ Avoid: Unnecessary useCallback overhead
|
||||
const handleFilterChange = useCallback(
|
||||
**Optimization: Avoid useCallback and useMemo for trivial operations**
|
||||
|
||||
- `useCallback` and `useMemo` add overhead; only use them when the computation is expensive or the result is passed to a memoized child
|
||||
- Simple derivations and pass-through handlers don't need them:
|
||||
|
||||
```tsx
|
||||
// ✅ Good: plain derivation
|
||||
const data = isResponseSuccess(response) ? (response.data ?? []) : [];
|
||||
const meta =
|
||||
isResponseSuccess(response) && response.meta ? response.meta : null;
|
||||
|
||||
// ❌ Avoid: useMemo for trivial conditional access
|
||||
const data = useMemo(
|
||||
() => (isResponseSuccess(response) ? (response.data ?? []) : []),
|
||||
[response]
|
||||
);
|
||||
|
||||
// ✅ Good: simple handler
|
||||
const handleChange = (val) => setFieldValue('location', val);
|
||||
|
||||
// ❌ Avoid: unnecessary useCallback
|
||||
const handleChange = useCallback(
|
||||
(val) => setFieldValue('location', val),
|
||||
[setFieldValue]
|
||||
);
|
||||
```
|
||||
|
||||
- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render)
|
||||
|
||||
**Best practice: Store OptionType objects directly, not IDs**
|
||||
|
||||
For select inputs, store the complete `OptionType` object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object).
|
||||
For select inputs, store the complete `OptionType` object (or `OptionType[]` for multi-select) in both formik state and tableFilterState. `useTableFilter`'s `serializeValue` handles serialization automatically:
|
||||
|
||||
- `OptionType<T>` → serialized as `String(value)` in the query string
|
||||
- `OptionType<T>[]` → serialized as comma-separated values (CSV) — ideal for multi-select API params like `customer_ids`, `sales_ids`
|
||||
|
||||
```tsx
|
||||
// Type the useTableFilter with the filter state structure
|
||||
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
|
||||
search: string;
|
||||
locationFilter?: OptionType<string>;
|
||||
picFilter?: OptionType<string>;
|
||||
customers: OptionType<number>[]; // multi-select → serializes as CSV
|
||||
location?: OptionType<string>; // single-select → serializes as value string
|
||||
filterBy?: OptionType<string>; // single-select radio
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
locationFilter: undefined,
|
||||
picFilter: undefined
|
||||
customers: [],
|
||||
location: undefined,
|
||||
filterBy: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
locationFilter: 'location_id',
|
||||
picFilter: 'pic_id',
|
||||
customers: 'customer_ids', // serializes OptionType[] → "1,2,3"
|
||||
location: 'location_id', // serializes OptionType → "abc"
|
||||
filterBy: 'filter_by',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'kandangs-table',
|
||||
storeName: 'my-table',
|
||||
});
|
||||
|
||||
// Initialize formik with tableFilterState values (now typed OptionType objects)
|
||||
const formik = useFormik<KandangFilterType>({
|
||||
// Initialize formik directly from tableFilterState (no hardcoded defaults)
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
location: tableFilterState.locationFilter,
|
||||
pic: tableFilterState.picFilter,
|
||||
customers: tableFilterState.customers,
|
||||
location: tableFilterState.location,
|
||||
filterBy: tableFilterState.filterBy,
|
||||
},
|
||||
...
|
||||
});
|
||||
|
||||
// Handlers store the complete OptionType, not just the ID
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val) => setFieldValue('location', val),
|
||||
[setFieldValue]
|
||||
);
|
||||
|
||||
// Use formik values directly in select inputs (no computed helpers needed)
|
||||
<SelectInput
|
||||
value={formik.values.location}
|
||||
onChange={handleFilterLocationChange}
|
||||
...
|
||||
/>
|
||||
// Use formik values directly — no computed helpers needed
|
||||
<SelectInputCheckbox value={formik.values.customers} onChange={(val) => formik.setFieldValue('customers', Array.isArray(val) ? val : [])} />
|
||||
<SelectInput value={formik.values.location} onChange={(val) => formik.setFieldValue('location', val)} />
|
||||
<SelectInputRadio value={formik.values.filterBy ?? null} onChange={(val) => formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} />
|
||||
```
|
||||
|
||||
**Filter field naming convention**
|
||||
|
||||
- Multi-select fields: use plural entity name — `customers`, `salesPersons`, `locations`
|
||||
- Single-select fields: use descriptive camelCase — `filterBy`, `status`, `category`
|
||||
- No `Filter` suffix (e.g. avoid `customerFilter`, `locationFilter`)
|
||||
|
||||
**Filter modal: pass `openModal` directly, never use `enableReinitialize`**
|
||||
|
||||
`enableReinitialize: true` resets formik mid-interaction whenever `tableFilterState` changes, breaking the modal UX. Pass `filterModal.openModal` directly to the button — no ref wrapper needed. Formik retains its last state across open/close, which is acceptable UX (values sync with `tableFilterState` on submit and reset anyway).
|
||||
|
||||
```tsx
|
||||
// ❌ Avoid: enableReinitialize breaks modal mid-interaction
|
||||
const formik = useFormik({ initialValues: { ... }, enableReinitialize: true });
|
||||
|
||||
// ❌ Avoid: unnecessary ref indirection
|
||||
const handleFilterModalOpenRef = useRef(() => {});
|
||||
handleFilterModalOpenRef.current = () => { formik.setValues({...}); filterModal.openModal(); };
|
||||
|
||||
// ✅ Correct: pass openModal directly
|
||||
<ButtonFilter onClick={filterModal.openModal} ... />
|
||||
```
|
||||
|
||||
Include `filterModal.openModal` in the `useEffect` deps array when it's used inside the effect.
|
||||
|
||||
**Apply this pattern to:**
|
||||
|
||||
- Any data table component across any module that needs persistent filters
|
||||
@@ -159,7 +207,31 @@ const handleFilterLocationChange = useCallback(
|
||||
**Reference implementations:**
|
||||
|
||||
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
|
||||
- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)
|
||||
- `BalanceMonitoringTab` in `src/components/pages/report/finance/tab/` — multi-select + radio + date range
|
||||
|
||||
## SWR fetch pattern
|
||||
|
||||
Use `FinanceApi.getAllFetcher` (or the relevant service's `getAllFetcher`) when the result type matches the service generic `T`. When it differs, use `httpClientFetcher` with an explicit type:
|
||||
|
||||
```tsx
|
||||
// ✅ Same type as service generic — use getAllFetcher
|
||||
const { data } = useSWR(
|
||||
`${Api.basePath}${getTableFilterQueryString()}`,
|
||||
Api.getAllFetcher
|
||||
);
|
||||
|
||||
// ✅ Different type — use httpClientFetcher with explicit useSWR type
|
||||
const { data } = useSWR<
|
||||
BaseApiResponse<BalanceMonitoringRow[]>,
|
||||
AxiosError<BaseApiResponse>,
|
||||
SWRHttpKey
|
||||
>(
|
||||
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
|
||||
httpClientFetcher
|
||||
);
|
||||
```
|
||||
|
||||
Always name the `toQueryString` alias `getTableFilterQueryString` when destructuring from `useTableFilter`.
|
||||
|
||||
## Server-side sorting pattern
|
||||
|
||||
|
||||
Reference in New Issue
Block a user