feat: refactor option loading functions to support active status filtering

- Updated `loadDivisionOptions`, `loadBranchOptions`, `loadCustomerOptions`, `loadEmployeeOptionsForPurpose`, and `loadProductOptions` to utilize the new `createOptionLoader` function with active status parameters.
- Introduced `ACTIVE_LOOKUP_PARAMS` to streamline the inclusion of additional parameters in option loading.
- Added unit tests for `createOptionLoader` and new utility functions for managing product options, ensuring robust functionality and reliability.

These changes enhance the option loading mechanism across various modules, improving data retrieval and filtering capabilities.
This commit is contained in:
shancheas
2026-08-31 15:05:48 +07:00
parent 25f1b79042
commit 5a6b109a89
13 changed files with 161 additions and 19 deletions
@@ -1,12 +1,8 @@
import type { LoadOptionsFn } from '@repo/ui/form';
import { divisionsDataService } from '../divisions/domain/factories';
import type { DivisionEntity } from '../divisions/domain/entities';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader';
export const loadDivisionOptions: LoadOptionsFn<DivisionEntity> = async (search, page) => {
const result = await divisionsDataService.getMany({
params: { search, page, limit: 20 },
});
const rows = (result.data as { data?: DivisionEntity[]; meta?: { totalPages?: number } })?.data ?? [];
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
return { options: rows, hasMore: page < totalPages };
};
export const loadDivisionOptions = createOptionLoader<DivisionEntity>(
(config) => divisionsDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest';
import { createOptionLoader } from './create-option-loader';
describe('createOptionLoader', () => {
it('requests the first page with search and limit', async () => {
const getMany = vi.fn().mockResolvedValue({
data: { data: [{ id: 'a' }], meta: { totalPages: 1 } },
});
const load = createOptionLoader(getMany);
const result = await load('ada', 1, []);
expect(getMany).toHaveBeenCalledWith({
params: { search: 'ada', page: 1, limit: 20 },
});
expect(result).toEqual({ options: [{ id: 'a' }], hasMore: false });
});
it('merges extra params so lookups can require active status', async () => {
const getMany = vi.fn().mockResolvedValue({
data: { data: [{ id: 'a', status: 'active' }], meta: { totalPages: 3 } },
});
const load = createOptionLoader(getMany, { status: 'active' });
const result = await load('', 2, []);
expect(getMany).toHaveBeenCalledWith({
params: { search: '', page: 2, limit: 20, status: 'active' },
});
expect(result.hasMore).toBe(true);
});
});
@@ -1,11 +1,14 @@
import type { LoadOptionsFn } from '@repo/ui/form';
export const ACTIVE_LOOKUP_PARAMS = { status: 'active' } as const;
export function createOptionLoader<T>(
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
extraParams?: Record<string, unknown>,
): LoadOptionsFn<T> {
return async (search, page) => {
const result = await getMany({
params: { search, page, limit: 20 },
params: { search, page, limit: 20, ...extraParams },
});
const rows = (result.data as { data?: T[]; meta?: { totalPages?: number } })?.data ?? [];
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
@@ -1,5 +1,8 @@
import { branchesDataService } from '../../configuration/branches/domain/factories';
import type { BranchEntity } from '../../configuration/branches/domain/entities';
import { createOptionLoader } from './create-option-loader';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
export const loadBranchOptions = createOptionLoader<BranchEntity>((config) => branchesDataService.getMany(config));
export const loadBranchOptions = createOptionLoader<BranchEntity>(
(config) => branchesDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -1,5 +1,8 @@
import { customersDataService } from '../../configuration/customers/domain/factories';
import type { CustomerEntity } from '../../configuration/customers/domain/entities';
import { createOptionLoader } from './create-option-loader';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
export const loadCustomerOptions = createOptionLoader<CustomerEntity>((config) => customersDataService.getMany(config));
export const loadCustomerOptions = createOptionLoader<CustomerEntity>(
(config) => customersDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -6,7 +6,7 @@ import {
salesEmployeesDataService,
} from '../../configuration/employees/domain/factories';
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
import { createOptionLoader } from './create-option-loader';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
function employeeServiceForPurpose(purpose?: FieldPurpose) {
if (purpose === 'sales') {
@@ -20,7 +20,7 @@ function employeeServiceForPurpose(purpose?: FieldPurpose) {
export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> {
const service = employeeServiceForPurpose(purpose);
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config));
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config), ACTIVE_LOOKUP_PARAMS);
}
export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales');
@@ -5,6 +5,7 @@ import { loadBranchOptions } from '../../field/shared/load-branch-options';
import { loadCustomerOptions } from '../../field/shared/load-customer-options';
import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
import { relationLabel } from '../../field/shared/relation-label';
import { customerLocationFromSelection } from './sales-document.mapper';
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
import type { BranchEntity } from '../../configuration/branches/domain/entities';
import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
@@ -86,6 +87,13 @@ export function FormGeneral() {
loadOptions={loadCustomerOptions}
defaultOptions={customer ? [customer] : []}
renderLabel={relationLabel}
onSelect={(value) => {
const location = customerLocationFromSelection(Array.isArray(value) ? value[0] : value);
if (!location) return;
formControl.setValue('address', location.address, { shouldDirty: true, shouldValidate: true });
formControl.setValue('latitude', location.latitude, { shouldDirty: true, shouldValidate: true });
formControl.setValue('longitude', location.longitude, { shouldDirty: true, shouldValidate: true });
}}
/>
</SimpleGrid>
</Box>
@@ -14,6 +14,7 @@ import { useFieldArray, useWatch } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react';
import { CurrencyUtils } from '@repo/utils';
import { loadProductOptions } from './load-product-options';
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
import { relationLabel } from '../../field/shared/relation-label';
import type { ProductEntity } from '../../configuration/products/domain/entities';
@@ -58,6 +59,7 @@ export function FormProducts() {
<Table.Tbody>
{fields.map((field, index) => {
const line = products[index];
const excludedIds = new Set(selectedProductIdsExceptLine(products, index));
return (
<Table.Tr key={field.id}>
<Table.Td miw={240}>
@@ -67,7 +69,11 @@ export function FormProducts() {
valueKey="id"
labelKey="name"
searchable
loadOptions={loadProductOptions}
loadOptions={async (search, page, prevOptions) => {
const result = await loadProductOptions(search, page, prevOptions);
return { ...result, options: excludeProductIds(result.options, excludedIds) };
}}
filterOption={(item) => item.id == null || !excludedIds.has(String(item.id))}
defaultOptions={line?.product ? [line.product] : []}
renderLabel={relationLabel}
/>
@@ -1,5 +1,8 @@
import { productsDataService } from '../../configuration/products/domain/factories';
import type { ProductEntity } from '../../configuration/products/domain/entities';
import { createOptionLoader } from '../../field/shared/create-option-loader';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader';
export const loadProductOptions = createOptionLoader<ProductEntity>((config) => productsDataService.getMany(config));
export const loadProductOptions = createOptionLoader<ProductEntity>(
(config) => productsDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
customerLocationFromSelection,
mapSalesDocumentFromDto,
salesRequestToFormValues,
toSalesFilterPayload,
@@ -111,6 +112,29 @@ describe('sales document mapper', () => {
});
});
it('copies customer address and coordinates when a customer is picked', () => {
expect(
customerLocationFromSelection({
address: 'Jl Gatot Subroto No 8',
latitude: -6.2,
longitude: 106.8,
}),
).toEqual({
address: 'Jl Gatot Subroto No 8',
latitude: -6.2,
longitude: 106.8,
});
});
it('defaults missing customer location fields instead of leaving the previous customer', () => {
expect(customerLocationFromSelection({ address: 'Depot A' })).toEqual({
address: 'Depot A',
latitude: null,
longitude: null,
});
expect(customerLocationFromSelection(null)).toBeNull();
});
it('copies nested lookups onto form values for edit', () => {
const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse));
@@ -8,6 +8,24 @@ import type {
SalesLineEntity,
} from './sales-document.entity';
export function customerLocationFromSelection(
customer:
| {
address?: string | null;
latitude?: number | null;
longitude?: number | null;
}
| null
| undefined,
) {
if (!customer) return null;
return {
address: customer.address ?? '',
latitude: customer.latitude ?? null,
longitude: customer.longitude ?? null,
};
}
export function relationId(value: unknown): string | undefined {
if (value && typeof value === 'object' && 'id' in value) {
const id = (value as { id?: unknown }).id;
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
describe('sales line product options', () => {
const lines = [
{ product: { id: 'prd-1' } },
{ product: { id: 'prd-2' } },
{ product: null },
];
it('collects product ids from other lines only', () => {
expect(selectedProductIdsExceptLine(lines, 0)).toEqual(['prd-2']);
expect(selectedProductIdsExceptLine(lines, 2)).toEqual(['prd-1', 'prd-2']);
});
it('hides products already used on other lines', () => {
const options = [{ id: 'prd-1' }, { id: 'prd-2' }, { id: 'prd-3' }];
expect(excludeProductIds(options, new Set(selectedProductIdsExceptLine(lines, 2)))).toEqual([{ id: 'prd-3' }]);
});
it('keeps the current line product available', () => {
const options = [{ id: 'prd-1' }, { id: 'prd-3' }];
expect(excludeProductIds(options, new Set(selectedProductIdsExceptLine(lines, 0)))).toEqual([
{ id: 'prd-1' },
{ id: 'prd-3' },
]);
});
});
@@ -0,0 +1,18 @@
export function selectedProductIdsExceptLine(
lines: Array<{ product?: { id?: string | number } | null } | null | undefined>,
lineIndex: number,
): string[] {
return lines.flatMap((line, index) => {
const id = line?.product?.id;
if (index === lineIndex || id == null || id === '') return [];
return [String(id)];
});
}
export function excludeProductIds<T extends { id?: string | number }>(
options: T[],
excludeIds: ReadonlySet<string>,
): T[] {
if (excludeIds.size === 0) return options;
return options.filter((option) => option.id == null || !excludeIds.has(String(option.id)));
}