chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
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>
This commit is contained in:
117
src/__tests__/modal-duplicate-close.test.ts
Normal file
117
src/__tests__/modal-duplicate-close.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user