UI fix:
- Close-only modals showing a redundant second close button now use
hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.
Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
preserving payload shape, minimal vite module type, frontend body/query-key
types, SystemInfo for Settings.
Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.
Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readdirSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
/**
|
|
* Guards against the "two Zavřít buttons" bug class.
|
|
*
|
|
* The shared <Modal> (src/admin/ui/Modal.tsx) ALWAYS renders a primary submit
|
|
* button (text = `submitText`) and — unless `hideCancel` is set — a secondary
|
|
* cancel button (text = `cancelText`, default "Zrušit"). A close-only modal
|
|
* therefore sets `submitText="Zavřít"`; if it ALSO leaves the cancel button on,
|
|
* the user sees two buttons that both just close the dialog — and when
|
|
* `cancelText` is "Zavřít" too, two literally-identical "Zavřít" buttons
|
|
* (the production bug found on the "paid received invoice" detail modal).
|
|
*
|
|
* Rule enforced: any <Modal> whose `submitText` is a "Zavřít…" label (i.e. its
|
|
* only action is to close) MUST pass `hideCancel`. Close-only modals get a
|
|
* single button. See PlanCellModal / PlanCategoriesModal for the correct shape.
|
|
*/
|
|
|
|
const ADMIN_DIR = join(__dirname, "..", "admin");
|
|
|
|
function tsxFiles(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) out.push(...tsxFiles(full));
|
|
else if (entry.name.endsWith(".tsx")) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Extract every `<Modal …>` opening-tag substring from a source string.
|
|
* Brace- and string-aware so arrow functions (`=>`) and conditional props
|
|
* (`{cond ? "a" : "b"}`) inside the tag don't terminate it early.
|
|
*/
|
|
function modalOpeningTags(src: string): string[] {
|
|
const tags: string[] = [];
|
|
let i = 0;
|
|
while ((i = src.indexOf("<Modal", i)) !== -1) {
|
|
const after = src[i + 6];
|
|
// Must be the <Modal> component, not <ModalSomething>.
|
|
if (after && !/[\s>/]/.test(after)) {
|
|
i += 6;
|
|
continue;
|
|
}
|
|
let depth = 0;
|
|
let quote: string | null = null;
|
|
let j = i + 6;
|
|
for (; j < src.length; j++) {
|
|
const c = src[j];
|
|
if (quote) {
|
|
if (c === quote && src[j - 1] !== "\\") quote = null;
|
|
continue;
|
|
}
|
|
if (c === '"' || c === "'" || c === "`") quote = c;
|
|
else if (c === "{") depth++;
|
|
else if (c === "}") depth--;
|
|
else if (c === ">" && depth === 0) break;
|
|
}
|
|
tags.push(src.slice(i, j + 1));
|
|
i = j + 1;
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
/** Value of a JSX prop: `name="..."`, `name={...}` (balanced), or boolean. */
|
|
function propValue(tag: string, name: string): string | null {
|
|
const re = new RegExp(`\\b${name}\\b`);
|
|
const m = re.exec(tag);
|
|
if (!m) return null;
|
|
let k = m.index + m[0].length;
|
|
while (k < tag.length && /\s/.test(tag[k])) k++;
|
|
if (tag[k] !== "=") return ""; // boolean prop (e.g. `hideCancel`)
|
|
k++;
|
|
while (k < tag.length && /\s/.test(tag[k])) k++;
|
|
if (tag[k] === '"' || tag[k] === "'") {
|
|
const q = tag[k];
|
|
const end = tag.indexOf(q, k + 1);
|
|
return tag.slice(k + 1, end);
|
|
}
|
|
if (tag[k] === "{") {
|
|
let depth = 0;
|
|
for (let p = k; p < tag.length; p++) {
|
|
if (tag[p] === "{") depth++;
|
|
else if (tag[p] === "}" && --depth === 0) return tag.slice(k, p + 1);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
describe("Modal close-only buttons", () => {
|
|
const offenders: string[] = [];
|
|
|
|
for (const file of tsxFiles(ADMIN_DIR)) {
|
|
const src = readFileSync(file, "utf8");
|
|
for (const tag of modalOpeningTags(src)) {
|
|
const submit = propValue(tag, "submitText");
|
|
const isCloseOnly = submit != null && submit.includes("Zavřít");
|
|
const hasHideCancel = propValue(tag, "hideCancel") != null;
|
|
if (isCloseOnly && !hasHideCancel) {
|
|
const title = propValue(tag, "title") ?? "(no title)";
|
|
offenders.push(`${file.replace(ADMIN_DIR, "admin")} — ${title}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
it("close-only modals (submitText='Zavřít') set hideCancel — no duplicate close button", () => {
|
|
expect(
|
|
offenders,
|
|
`These <Modal>s render a close-only 'Zavřít' submit alongside a redundant ` +
|
|
`cancel button (two buttons that both just close). Add hideCancel:\n` +
|
|
offenders.map((o) => ` • ${o}`).join("\n"),
|
|
).toEqual([]);
|
|
});
|
|
});
|