Skip to content

Commit 577a6fd

Browse files
author
amar-python
committed
feat: 13 frontend features + 76 vitest tests (audit filters, export, header mapping, cancel, diff, notifications, templates)
1 parent 6bdfcee commit 577a6fd

12 files changed

Lines changed: 2214 additions & 0 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
{
2+
"name": "tanstack_start_ts",
3+
"private": true,
4+
"sideEffects": false,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite dev",
8+
"build": "vite build",
9+
"build:dev": "vite build --mode development",
10+
"preview": "vite preview",
11+
"lint": "eslint .",
12+
"format": "prettier --write .",
13+
"test": "vitest run",
14+
"test:watch": "vitest",
15+
"test:ui": "vitest --ui"
16+
},
17+
"dependencies": {
18+
"@hookform/resolvers": "^5.2.2",
19+
"@radix-ui/react-accordion": "^1.2.12",
20+
"@radix-ui/react-alert-dialog": "^1.1.15",
21+
"@radix-ui/react-aspect-ratio": "^1.1.8",
22+
"@radix-ui/react-avatar": "^1.1.11",
23+
"@radix-ui/react-checkbox": "^1.3.3",
24+
"@radix-ui/react-collapsible": "^1.1.12",
25+
"@radix-ui/react-context-menu": "^2.2.16",
26+
"@radix-ui/react-dialog": "^1.1.15",
27+
"@radix-ui/react-dropdown-menu": "^2.1.16",
28+
"@radix-ui/react-hover-card": "^1.1.15",
29+
"@radix-ui/react-label": "^2.1.8",
30+
"@radix-ui/react-menubar": "^1.1.16",
31+
"@radix-ui/react-navigation-menu": "^1.2.14",
32+
"@radix-ui/react-popover": "^1.1.15",
33+
"@radix-ui/react-progress": "^1.1.8",
34+
"@radix-ui/react-radio-group": "^1.3.8",
35+
"@radix-ui/react-scroll-area": "^1.2.10",
36+
"@radix-ui/react-select": "^2.2.6",
37+
"@radix-ui/react-separator": "^1.1.8",
38+
"@radix-ui/react-slider": "^1.3.6",
39+
"@radix-ui/react-slot": "^1.2.4",
40+
"@radix-ui/react-switch": "^1.2.6",
41+
"@radix-ui/react-tabs": "^1.1.13",
42+
"@radix-ui/react-toggle": "^1.1.10",
43+
"@radix-ui/react-toggle-group": "^1.1.11",
44+
"@radix-ui/react-tooltip": "^1.2.8",
45+
"@tailwindcss/vite": "^4.2.1",
46+
"@tanstack/react-query": "^5.101.1",
47+
"@tanstack/react-router": "^1.170.16",
48+
"@tanstack/react-start": "^1.168.26",
49+
"@tanstack/router-plugin": "^1.168.18",
50+
"class-variance-authority": "^0.7.1",
51+
"clsx": "^2.1.1",
52+
"cmdk": "^1.1.1",
53+
"date-fns": "^4.1.0",
54+
"embla-carousel-react": "^8.6.0",
55+
"input-otp": "^1.4.2",
56+
"lucide-react": "^0.575.0",
57+
"react": "^19.2.0",
58+
"react-day-picker": "^9.14.0",
59+
"react-dom": "^19.2.0",
60+
"react-hook-form": "^7.71.2",
61+
"react-resizable-panels": "^4.6.5",
62+
"recharts": "^2.15.4",
63+
"sonner": "^2.0.7",
64+
"tailwind-merge": "^3.5.0",
65+
"tailwindcss": "^4.2.1",
66+
"tw-animate-css": "^1.3.4",
67+
"vaul": "^1.1.2",
68+
"vite-tsconfig-paths": "^6.0.2",
69+
"zod": "^3.24.2"
70+
},
71+
"devDependencies": {
72+
"@eslint/js": "^9.32.0",
73+
"@lovable.dev/vite-tanstack-config": "2.7.7",
74+
"@testing-library/jest-dom": "^7.0.0",
75+
"@testing-library/react": "^16.3.2",
76+
"@testing-library/user-event": "^14.6.3",
77+
"@types/node": "^22.16.5",
78+
"@types/react": "^19.2.0",
79+
"@types/react-dom": "^19.2.0",
80+
"@vitejs/plugin-react": "^5.2.0",
81+
"@vitest/ui": "^4.1.10",
82+
"eslint": "^9.32.0",
83+
"eslint-config-prettier": "^10.1.1",
84+
"eslint-plugin-prettier": "^5.2.6",
85+
"eslint-plugin-react-hooks": "^5.2.0",
86+
"eslint-plugin-react-refresh": "^0.4.20",
87+
"globals": "^15.15.0",
88+
"jsdom": "^30.0.1",
89+
"nitro": "3.0.260603-beta",
90+
"prettier": "^3.7.3",
91+
"typescript": "^5.8.3",
92+
"typescript-eslint": "^8.56.1",
93+
"vite": "^8.0.16",
94+
"vitest": "^4.1.10",
95+
"xlsx": "^0.18.5"
96+
}
97+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// src/__tests__/audit-filters.test.ts
2+
// Tests the filter logic that the AuditPage component applies to jobs.
3+
// Rather than rendering the full component (which needs TanStack Router
4+
// and other providers), we extract the predicate and run it directly —
5+
// the same logic the component uses via useMemo.
6+
7+
import { describe, it, expect } from "vitest";
8+
9+
// ─── mirror of the Job type in audit.tsx ─────────────────────────────────────
10+
11+
type JobStatus = "reading"|"uploading"|"processing"|"done"|"duplicate"|"error"|"interrupted"|"cancelled";
12+
13+
type Job = {
14+
id: string; name: string; status: JobStatus; createdAt: number;
15+
overwritten?: boolean; size: number; batchId: string; progress: number;
16+
cancellationReason?: string;
17+
};
18+
19+
// ─── extracted filter predicate (mirrors the useMemo in AuditPage) ────────────
20+
21+
function applyFilters(
22+
jobs: Job[],
23+
{ search, statusFilter, dateFrom, dateTo }: {
24+
search: string; statusFilter: string; dateFrom: string; dateTo: string;
25+
},
26+
): Job[] {
27+
return jobs.filter((j) => {
28+
if (search && !j.name.toLowerCase().includes(search.toLowerCase())) return false;
29+
if (statusFilter !== "all") {
30+
if (statusFilter === "overwritten") {
31+
if (j.status !== "done" || !j.overwritten) return false;
32+
} else if (j.status !== statusFilter) {
33+
return false;
34+
}
35+
}
36+
if (dateFrom) {
37+
if (j.createdAt < new Date(dateFrom).getTime()) return false;
38+
}
39+
if (dateTo) {
40+
if (j.createdAt > new Date(dateTo).getTime() + 86_400_000) return false;
41+
}
42+
return true;
43+
});
44+
}
45+
46+
// ─── fixtures ─────────────────────────────────────────────────────────────────
47+
48+
function job(overrides: Partial<Job> & Pick<Job, "id" | "name" | "status" | "createdAt">): Job {
49+
return { size: 1000, batchId: "b1", progress: 100, ...overrides };
50+
}
51+
52+
const JAN1 = new Date("2026-01-01T00:00:00Z").getTime();
53+
const JAN2 = new Date("2026-01-02T00:00:00Z").getTime();
54+
const JAN3 = new Date("2026-01-03T00:00:00Z").getTime();
55+
56+
const JOBS: Job[] = [
57+
job({ id: "j1", name: "sales-2026.csv", status: "done", createdAt: JAN1 }),
58+
job({ id: "j2", name: "contacts.csv", status: "done", createdAt: JAN2, overwritten: true }),
59+
job({ id: "j3", name: "products.csv", status: "error", createdAt: JAN2 }),
60+
job({ id: "j4", name: "orders.csv", status: "duplicate", createdAt: JAN3 }),
61+
job({ id: "j5", name: "inventory.csv", status: "cancelled", createdAt: JAN3 }),
62+
job({ id: "j6", name: "SALES-archive.csv",status: "done", createdAt: JAN1 }),
63+
];
64+
65+
const NONE = { search: "", statusFilter: "all", dateFrom: "", dateTo: "" };
66+
67+
// ─── search (F1) ──────────────────────────────────────────────────────────────
68+
69+
describe("F1 – filename search", () => {
70+
it("returns all jobs when search is empty", () => {
71+
expect(applyFilters(JOBS, NONE)).toHaveLength(JOBS.length);
72+
});
73+
74+
it("filters by exact substring", () => {
75+
const result = applyFilters(JOBS, { ...NONE, search: "contacts" });
76+
expect(result.map((j) => j.id)).toEqual(["j2"]);
77+
});
78+
79+
it("is case-insensitive", () => {
80+
const result = applyFilters(JOBS, { ...NONE, search: "SALES" });
81+
expect(result.map((j) => j.id)).toContain("j1");
82+
expect(result.map((j) => j.id)).toContain("j6");
83+
});
84+
85+
it("returns empty array when nothing matches", () => {
86+
expect(applyFilters(JOBS, { ...NONE, search: "zzz-no-match" })).toHaveLength(0);
87+
});
88+
89+
it("matches partial filenames", () => {
90+
expect(applyFilters(JOBS, { ...NONE, search: "ord" }).map((j) => j.id)).toEqual(["j4"]);
91+
});
92+
});
93+
94+
// ─── status filter (F1) ───────────────────────────────────────────────────────
95+
96+
describe("F1 – status filter", () => {
97+
it("'all' returns every job", () => {
98+
expect(applyFilters(JOBS, NONE)).toHaveLength(6);
99+
});
100+
101+
it("'done' returns only done jobs (including overwritten)", () => {
102+
const result = applyFilters(JOBS, { ...NONE, statusFilter: "done" });
103+
expect(result.every((j) => j.status === "done")).toBe(true);
104+
expect(result).toHaveLength(3); // j1, j2, j6
105+
});
106+
107+
it("'overwritten' returns only done+overwritten jobs", () => {
108+
const result = applyFilters(JOBS, { ...NONE, statusFilter: "overwritten" });
109+
expect(result.map((j) => j.id)).toEqual(["j2"]);
110+
});
111+
112+
it("'error' returns only failed jobs", () => {
113+
const result = applyFilters(JOBS, { ...NONE, statusFilter: "error" });
114+
expect(result.map((j) => j.id)).toEqual(["j3"]);
115+
});
116+
117+
it("'duplicate' returns only duplicate jobs", () => {
118+
const result = applyFilters(JOBS, { ...NONE, statusFilter: "duplicate" });
119+
expect(result.map((j) => j.id)).toEqual(["j4"]);
120+
});
121+
122+
it("'cancelled' returns only cancelled jobs", () => {
123+
const result = applyFilters(JOBS, { ...NONE, statusFilter: "cancelled" });
124+
expect(result.map((j) => j.id)).toEqual(["j5"]);
125+
});
126+
127+
it("non-overwritten done job does NOT appear under 'overwritten'", () => {
128+
const result = applyFilters(JOBS, { ...NONE, statusFilter: "overwritten" });
129+
expect(result.map((j) => j.id)).not.toContain("j1");
130+
});
131+
});
132+
133+
// ─── date range (F1) ──────────────────────────────────────────────────────────
134+
135+
describe("F1 – date range filter", () => {
136+
it("dateFrom excludes jobs before the date", () => {
137+
const result = applyFilters(JOBS, { ...NONE, dateFrom: "2026-01-02" });
138+
const ids = result.map((j) => j.id);
139+
expect(ids).not.toContain("j1");
140+
expect(ids).not.toContain("j6");
141+
expect(ids).toContain("j2");
142+
expect(ids).toContain("j4");
143+
});
144+
145+
it("dateTo includes jobs on the specified day; jobs strictly after it are excluded", () => {
146+
// The filter adds 86400000ms to make the day inclusive, so midnight of
147+
// the NEXT day (JAN2 UTC) also passes. Only JAN3 and later are excluded.
148+
const result = applyFilters(JOBS, { ...NONE, dateTo: "2026-01-01" });
149+
const ids = result.map((j) => j.id);
150+
expect(ids).toContain("j1"); // JAN1 ✓
151+
expect(ids).toContain("j6"); // JAN1 ✓
152+
expect(ids).not.toContain("j4"); // JAN3 — excluded
153+
expect(ids).not.toContain("j5"); // JAN3 — excluded
154+
});
155+
156+
it("dateFrom + dateTo together form a closed range", () => {
157+
// dateTo "2026-01-02" allows up to JAN2 + 86400s = JAN3 midnight,
158+
// so JAN3 midnight timestamps are included. Confirm JAN1 is excluded.
159+
const result = applyFilters(JOBS, { ...NONE, dateFrom: "2026-01-02", dateTo: "2026-01-02" });
160+
const ids = result.map((j) => j.id);
161+
expect(ids).toContain("j2"); // JAN2 ✓
162+
expect(ids).toContain("j3"); // JAN2 ✓
163+
expect(ids).not.toContain("j1"); // JAN1 — below dateFrom ✗
164+
expect(ids).not.toContain("j6"); // JAN1 — below dateFrom ✗
165+
});
166+
167+
it("empty date strings do not filter by date", () => {
168+
const result = applyFilters(JOBS, { ...NONE, dateFrom: "", dateTo: "" });
169+
expect(result).toHaveLength(6);
170+
});
171+
});
172+
173+
// ─── combined filters ─────────────────────────────────────────────────────────
174+
175+
describe("F1 – combined filters", () => {
176+
it("search + status combined narrow correctly", () => {
177+
// "sales" matches j1 and j6; filtering to 'done' keeps both
178+
const result = applyFilters(JOBS, { ...NONE, search: "sales", statusFilter: "done" });
179+
expect(result.map((j) => j.id).sort()).toEqual(["j1", "j6"].sort());
180+
});
181+
182+
it("search + date + status combined to a single result", () => {
183+
const result = applyFilters(JOBS, { search: "contacts", statusFilter: "overwritten", dateFrom: "2026-01-02", dateTo: "2026-01-02" });
184+
expect(result.map((j) => j.id)).toEqual(["j2"]);
185+
});
186+
187+
it("returns empty when search and status conflict", () => {
188+
const result = applyFilters(JOBS, { ...NONE, search: "products", statusFilter: "done" });
189+
expect(result).toHaveLength(0); // j3 is 'error', not 'done'
190+
});
191+
});

0 commit comments

Comments
 (0)