Skip to main content

13 — Two import modals, and which page to copy

Read this first: this repo contains two CSV import modals that do the same job, and reading them side by side is a fossil record — you will watch a pattern get duplicated four times, get extracted into one shared component, and leave one survivor behind for reasons the code itself explains. By the end you will know which one your new page uses, and which page you copy to get it.

Time: about 25 minutes. Assumes lesson 12.

One importer, four ancestors​

Open ImportModal.tsx in ui/ag-grid/. The component's own doc comment tells you where it came from:

/**
* Generalizes the CSV import modal duplicated across Deal/Employee/Timesheet/ContributionRate
* import modals (same idle→preview→importing→done stage machine, template download, and
* one-row-at-a-time create loop) into one config-driven component.
*/

Four modules each hand-wrote the same modal before anyone extracted it. That comment is the measurement that justifies this file existing: the pattern was proven four times over before it became shared code. Everything entity-specific now arrives through props:

export type ImportRowResult<TPayload> =
| { label: string; payload: TPayload; error?: undefined }
| { label: string; payload?: undefined; error: string };

export interface ImportModalProps<TPayload> {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
entityLabel: string;
entityLabelPlural: string;
templateFilename: string;
templateHeaders: string[];
exampleRows: string[][];
/** Optional help/prerequisites block shown above the upload step (e.g. "positions must already exist"). */
helpText?: ReactNode;
/* … parseRow JSDoc, quoted below … */
parseRow: (cells: Record<string, string>, rowNum: number) => ImportRowResult<TPayload>;
createFn: (payload: TPayload) => Promise<unknown>;
}

Read ImportRowResult closely. It is a discriminated union: a row parses to either a typed payload or an error string, never both, never neither. A parseRow that forgets to handle a bad row does not compile. The modal itself never learns what TPayload is — it just carries it from parseRow to createFn.

The stage machine​

The whole modal is one state variable:

type Stage = 'idle' | 'preview' | 'importing' | 'done';

idle shows the template-download and upload boxes. Choosing a file moves to preview — but only after two gates inside handleFileChange:

const text = (ev.target?.result as string).replace(/^/, '');
const rows = parseCsv(text);
if (rows.length < 2) {
setParseError('File has no data rows (only a header or is empty).');
return;
}
const header = rows[0].map(h => h.trim().toLowerCase());
const missing = requiredHeaders.filter(h => !header.includes(h));
if (missing.length > 0) {
setParseError(`Missing columns: ${missing.join(', ')}`);
return;
}

That first replace looks like it does nothing. The regex contains U+FEFF — an invisible byte-order mark. Excel prepends one when it saves UTF-8 CSVs, and without this line the first header parses as lastname and fails the missing check with an error message that looks insane to the user ("Missing columns: lastname" while they stare at a file that plainly has it). This is the kind of line you only write after meeting the bug. Note also that the template download writes that same BOM back out — so Excel opens the template with correct encoding, round-trip clean.

Headers are matched lowercased and by name, not by position — users reorder columns in spreadsheets, and a positional parser would silently put salaries in the phone-number field. preview shows the first 5 rows and the total count; clicking Import moves to importing, then done. Closing at any point runs handleClose, which resets every piece of state — the same fresh-mount problem you saw drawers solve with key in lesson 12, solved here by hand.

Import is a loop, not a batch​

for (let i = 0; i < allCells.length; i++) {
const rowNum = i + 2;
const parsed = parseRow(allCells[i], rowNum);
if (parsed.error !== undefined) {
outcomes.push({ rowNum, label: parsed.label, success: false, error: parsed.error });
} else {
try {
await createFn(parsed.payload as TPayload);
outcomes.push({ rowNum, label: parsed.label, success: true });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to import row';
outcomes.push({ rowNum, label: parsed.label, success: false, error: msg });
}
}
setProgress(i + 1);
}

One await per row, sequentially. That buys three things: real progress (the bar advances one row at a time), per-row error collection (rowNum is i + 2 — off by the header row, so the number matches what the user sees in their spreadsheet), and partial success. Row 40 failing does not roll back rows 1–39; the done stage shows "38 succeeded / 2 failed" badges and a table of exactly which rows failed and why. A row that fails parseRow never even reaches the network — the error branch pushes an outcome and skips createFn entirely.

And the last line of the loop's aftermath:

if (outcomes.some(r => r.success)) onSuccess?.();

onSuccess fires on any success, not total success — a partial import still changed the database, so the caller's grid must still refresh.

The modal that came first​

Now open EmployeeBulkImportModal.tsx. It is one of the four ancestors the doc comment named — the "Employee" in "Deal/Employee/Timesheet/ContributionRate" — and it is still alive, at roughly 460 lines to the shared component's 310. It carries its own parseCsv, its own downloadTemplate, its own copy of the stage machine, and one thing the shared modal cannot do alone: it resolves human-readable names to IDs, because the create API wants a positionId and a CSV author only knows names.

const positionMap = new Map(
positions.map(p => [`${p.positionName.toLowerCase()}|${p.departmentName.toLowerCase()}`, p.id])
);

The key is position|department because position names repeat across departments — "Manager" exists in three of them, and a name-only map would silently pick one.

Predict: you now hold both files. Which component owns row validation in each — the modal or the page — and at which stage does a bad position name actually surface: file-parse, preview, or import? Write your answer down for both modals before reading on.

The resolution is the same on one axis and different on the other. In both, structural validation (headers present, file non-empty) happens at parse time and gates entry to preview — but semantic validation happens only at import time. The bespoke modal does its lookup inside its import loop:

const positionId = positionMap.get(`${row.positionName.toLowerCase()}|${row.departmentName.toLowerCase()}`);

if (!positionId) {
importResults.push({ rowNum: row.rowNum, name, success: false, error: `Position "${row.positionName}" in "${row.departmentName}" not found` });
} else {

and the shared modal calls parseRow inside its loop — so in both, a row with a bad position name sails through preview and fails at Import. The difference is ownership: the bespoke modal owns everything itself, while the shared modal owns structure and delegates semantics to the page via parseRow. The parseRow doc comment says exactly where the lookup data comes from: "Runs synchronously against data the caller has already loaded (e.g. a position-name lookup map)." The example in that comment is not hypothetical — it is describing this employee modal, the ancestor the prop was distilled from.

Why the survivor has not been migrated​

The employee modal is engineering debt, and the honest answer for why it stands is that it does more than the shared surface covers. Read its done stage:

const hasPositionErrors = results.some(r => !r.success && r.error?.includes('not found'));
{hasPositionErrors && (
<Box p={3} bg="orange.50" borderRadius="md" border="1px solid" borderColor="orange.200">
<Text fontSize="sm" color="orange.800" fontWeight="600" mb={1}>Position not found?</Text>
{/* … */}
<Button size="xs" variant="outline" colorPalette="orange"
onClick={() => { handleClose(); void navigate('/positions'); }}>

When imports fail on unknown positions, the modal diagnoses the class of failure and renders a coaching box with a button that closes itself and navigates to /positions. The shared ImportModalProps has no slot for results-stage content — helpText renders above the upload step, before anything has failed, and it cannot reach handleClose. The bespoke preview is also curated: 6 readable columns instead of the shared modal's all-19-headers table, which for employees would be a horizontal scroll nobody reads.

Migrating it would mean adding two props to the shared component — a results-stage render slot (given the outcomes and a close function) and a preview-column subset — then deleting about 200 duplicated lines. That trade has not been paid yet. It is written down here so that when you meet the second page needing a results-stage coaching box, you know the generalization is due — the same duplicated-four-times threshold that created ImportModal in the first place.

The rule: new pages use the shared ImportModal. The bespoke modal is a fossil to learn from, not a pattern to copy.

Which page to copy​

For a whole new list page, the file to copy is JobRequisitions.tsx. It has everything Employees.tsx has — the same useEnterpriseGrid call shape from lesson 09, tabs with counts, saved views, CSV/PDF export, an analytics link — but at 470 lines to Employees' many hundreds, without the legacy freight, and its importer is the shared one:

<ImportModal<JobRequisitionCreateRequest>
open={importOpen}
onClose={() => { setImportOpen(false); }}
onSuccess={() => { activeApi()?.purgeInfiniteCache(); }}
entityLabel="Requisition"
entityLabelPlural="Requisitions"
templateFilename="job-requisitions-template.csv"
templateHeaders={['departmentName', 'positionName', 'requestedByLastName', 'requestedByFirstName', 'numVacancies', 'estimatedCost', 'description']}
exampleRows={[
['Information Technology', 'Software Engineer', 'Dela Cruz', 'Juan', '2', '150000', 'Backend engineer for the payroll platform'],
]}
parseRow={parseImportRow}
createFn={createJobRequisition}
/>

onSuccess purges the grid's infinite cache — the lesson 08 refresh move — so imported rows appear without a reload. And its parseImportRow is the shared-modal version of the name-to-ID lookup, three maps deep:

const departmentId = departmentMap.get(departmentName.toLowerCase());
if (!departmentId) return { label, error: `Department "${departmentName}" not found` };
const positionId = positionMap.get(`${positionName.toLowerCase()}|${departmentName.toLowerCase()}`);
if (!positionId) return { label, error: `Position "${positionName}" in "${departmentName}" not found` };

Early returns with the error variant of ImportRowResult, each message naming the exact value that failed — copied straight from the employee modal's error strings, now living in page code where they belong. The maps are built from useDepartments/usePositions/useAllEmployees — "data the caller has already loaded", per the prop's own doc comment.

The hand-off: this closes Part 2. You can now read every layer of a MotorPH list page — grid, hook, tabs, drawers, exports, imports — and you know the file to copy. The step-by-step recipe for building a new module page end to end (backend endpoint included) is Build a module page; the option-by-option grid reference is ../frontend/ag-grid.md. This course taught you to read the system; those docs tell you how to extend it.

Where this shows up in MotorPH​

Recap​

  • New pages import the shared ImportModal; the employee modal is a fossil — the doc comment records it generalizing four hand-written copies, and the survivor stands only because it does results-stage coaching the shared props cannot express yet.
  • ImportRowResult is a discriminated union so every parseRow must produce either a typed payload or an error string — a forgotten validation branch is a compile error, not a silent bad create.
  • Structure is validated at parse, semantics at import — in both modals — headers gate the preview, but name-to-ID lookups run in the import loop, and a row that fails parseRow never touches the network.
  • The import loop is sequential on purpose — one await per row buys real progress, spreadsheet-accurate row numbers, and partial success with a per-row error table; onSuccess fires on any success because a partial import still changed the database.
  • Copy JobRequisitions.tsx for a new list page — same useEnterpriseGrid shape as Employees but cleaner, with the shared importer, saved views, and the analytics link already wired; the extension recipe is Build a module page.

Next: 14 — The analytics page decoded.