@icomponents/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/package.json +165 -0
- package/src/assets/custom/icon-dir.tsx +105 -0
- package/src/assets/custom/icon-layout-compact.tsx +84 -0
- package/src/assets/custom/icon-layout-default.tsx +48 -0
- package/src/assets/custom/icon-layout-full.tsx +68 -0
- package/src/assets/custom/icon-sidebar-floating.tsx +62 -0
- package/src/assets/custom/icon-sidebar-inset.tsx +53 -0
- package/src/assets/custom/icon-sidebar-sidebar.tsx +48 -0
- package/src/assets/custom/icon-theme-dark.tsx +36 -0
- package/src/assets/custom/icon-theme-light.tsx +35 -0
- package/src/assets/custom/icon-theme-system.tsx +113 -0
- package/src/components/admin-page-header.tsx +29 -0
- package/src/components/color-theme-switch.tsx +93 -0
- package/src/components/coming-soon.tsx +16 -0
- package/src/components/command-menu.tsx +98 -0
- package/src/components/config-drawer.tsx +401 -0
- package/src/components/confirm-dialog.dom.test.tsx +60 -0
- package/src/components/confirm-dialog.tsx +75 -0
- package/src/components/data-table/bulk-actions.tsx +193 -0
- package/src/components/data-table/column-header.tsx +66 -0
- package/src/components/data-table/data-table.dom.test.tsx +54 -0
- package/src/components/data-table/data-table.tsx +189 -0
- package/src/components/data-table/faceted-filter.tsx +156 -0
- package/src/components/data-table/index.ts +5 -0
- package/src/components/data-table/pagination.tsx +127 -0
- package/src/components/data-table/tanstack-table.d.ts +12 -0
- package/src/components/data-table/toolbar.tsx +88 -0
- package/src/components/data-table/view-options.tsx +49 -0
- package/src/components/date-picker.tsx +39 -0
- package/src/components/detail-card.tsx +63 -0
- package/src/components/language-switch.tsx +38 -0
- package/src/components/layout/app-sidebar.tsx +59 -0
- package/src/components/layout/app-title.tsx +62 -0
- package/src/components/layout/header.tsx +61 -0
- package/src/components/layout/main.tsx +34 -0
- package/src/components/layout/nav-group.tsx +185 -0
- package/src/components/layout/nav-user.tsx +115 -0
- package/src/components/layout/screen-header.tsx +119 -0
- package/src/components/layout/team-switcher.tsx +84 -0
- package/src/components/layout/top-nav.tsx +68 -0
- package/src/components/layout/types.ts +44 -0
- package/src/components/learn-more.tsx +35 -0
- package/src/components/long-text.tsx +80 -0
- package/src/components/navigation-progress.tsx +18 -0
- package/src/components/password-input.tsx +36 -0
- package/src/components/profile-dropdown.tsx +106 -0
- package/src/components/search.tsx +38 -0
- package/src/components/select-dropdown.tsx +62 -0
- package/src/components/sign-out-dialog.tsx +31 -0
- package/src/components/skip-to-main.tsx +10 -0
- package/src/components/stat-tile.tsx +56 -0
- package/src/components/status-badge.test.ts +23 -0
- package/src/components/status-badge.tsx +49 -0
- package/src/components/theme-switch.tsx +61 -0
- package/src/components/ui/alert-dialog.tsx +134 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/avatar.tsx +38 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/base-ui-state-attrs.dom.test.tsx +80 -0
- package/src/components/ui/button.tsx +51 -0
- package/src/components/ui/calendar.tsx +174 -0
- package/src/components/ui/card.tsx +74 -0
- package/src/components/ui/checkbox.tsx +26 -0
- package/src/components/ui/collapsible.tsx +18 -0
- package/src/components/ui/command.tsx +160 -0
- package/src/components/ui/dialog.tsx +129 -0
- package/src/components/ui/dropdown-menu.tsx +254 -0
- package/src/components/ui/form.tsx +155 -0
- package/src/components/ui/input-otp.tsx +67 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/label.tsx +21 -0
- package/src/components/ui/popover.tsx +54 -0
- package/src/components/ui/radio-group.tsx +37 -0
- package/src/components/ui/scroll-area.tsx +57 -0
- package/src/components/ui/select.tsx +213 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/sheet.tsx +128 -0
- package/src/components/ui/sidebar.tsx +702 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +26 -0
- package/src/components/ui/switch.tsx +25 -0
- package/src/components/ui/table.tsx +89 -0
- package/src/components/ui/tabs.tsx +51 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/tooltip.tsx +65 -0
- package/src/config/fonts.ts +2 -0
- package/src/context/direction-provider.tsx +61 -0
- package/src/context/font-provider.tsx +70 -0
- package/src/context/language-provider.tsx +73 -0
- package/src/context/layout-provider.tsx +81 -0
- package/src/context/search-provider.tsx +48 -0
- package/src/context/theme-provider.tsx +108 -0
- package/src/hooks/sort-url.test.ts +16 -0
- package/src/hooks/use-dialog-state.tsx +15 -0
- package/src/hooks/use-mobile.tsx +16 -0
- package/src/hooks/use-table-url-state.ts +243 -0
- package/src/i18n/common.ts +164 -0
- package/src/i18n/create-i18n.test.ts +50 -0
- package/src/i18n/create-i18n.ts +67 -0
- package/src/i18n/types.ts +22 -0
- package/src/i18n/use-translation.ts +6 -0
- package/src/lib/clipboard.test.ts +46 -0
- package/src/lib/clipboard.ts +29 -0
- package/src/lib/cookies.ts +39 -0
- package/src/lib/format-cell.test.ts +57 -0
- package/src/lib/format-cell.ts +63 -0
- package/src/lib/handle-server-error.test.ts +85 -0
- package/src/lib/handle-server-error.ts +39 -0
- package/src/lib/random-id.ts +13 -0
- package/src/lib/show-submitted-data.tsx +14 -0
- package/src/lib/utils.test.ts +35 -0
- package/src/lib/utils.ts +78 -0
- package/src/secure-context.contract.test.ts +63 -0
- package/src/testids.contract.test.ts +113 -0
- package/src/testids.ts +8 -0
- package/src/theme-contract.test.ts +72 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Контракт локализации: переводы НЕ монолитны. Каждая фича/слайс несёт свой `NamespaceBundle`
|
|
3
|
+
* (co-located с кодом слайса), а собираются они в composition root — тем же приёмом, что каталог
|
|
4
|
+
* прав (`createPermissionCatalog`) и список шрифтов (`FontProvider fonts`). Ядро даёт механизм и
|
|
5
|
+
* свои бандлы; продукт добавляет свои. Ни у кого нет одного общего `locales/*.json`.
|
|
6
|
+
*/
|
|
7
|
+
export const LOCALES = ["ru", "en"] as const;
|
|
8
|
+
export type Locale = (typeof LOCALES)[number];
|
|
9
|
+
|
|
10
|
+
/** Словарь фичи: ПЛОСКИЙ или вложенный набор строк, один и тот же ключевой состав на каждый язык. */
|
|
11
|
+
export type Messages = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Вклад одной фичи в i18n: имя неймспейса (= слайс) + словари на каждый язык.
|
|
15
|
+
* `useTranslation("<ns>")` в компонентах фичи читает ровно этот бандл.
|
|
16
|
+
*/
|
|
17
|
+
export type NamespaceBundle = {
|
|
18
|
+
/** Имя неймспейса — обычно совпадает со слайсом: "companies", "nav", "audit"… Уникально. */
|
|
19
|
+
ns: string;
|
|
20
|
+
/** Словари по языкам. Ключи ОБЯЗАНЫ совпадать между языками (это проверяет тест-страж). */
|
|
21
|
+
messages: Record<Locale, Messages>;
|
|
22
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Реэкспорт хука перевода из ui, чтобы фичи (iam, продуктовые пакеты, apps/web) НЕ тянули
|
|
3
|
+
* `react-i18next` в свои зависимости — он остаётся деталью реализации пакета ui. Фичи импортируют
|
|
4
|
+
* `useTranslation` отсюда: `import { useTranslation } from "@icomponents/react/i18n/use-translation"`.
|
|
5
|
+
*/
|
|
6
|
+
export { Trans, useTranslation } from "react-i18next";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { copyToClipboard } from "./clipboard";
|
|
4
|
+
|
|
5
|
+
// `navigator` is a read-only global under the bun runtime, so we swap it via a configurable property
|
|
6
|
+
// descriptor (and restore the original after each case) rather than plain assignment.
|
|
7
|
+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (descriptor) Object.defineProperty(globalThis, "navigator", descriptor);
|
|
11
|
+
else delete (globalThis as { navigator?: unknown }).navigator;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function stubClipboard(writeText: ((t: string) => Promise<void>) | undefined) {
|
|
15
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
16
|
+
configurable: true,
|
|
17
|
+
value: { clipboard: writeText ? { writeText } : {} },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("copyToClipboard — never hangs, never throws, reports confirmation", () => {
|
|
22
|
+
it("returns true when the write resolves", async () => {
|
|
23
|
+
let seen = "";
|
|
24
|
+
stubClipboard(async (t) => {
|
|
25
|
+
seen = t;
|
|
26
|
+
});
|
|
27
|
+
expect(await copyToClipboard("hello")).toBe(true);
|
|
28
|
+
expect(seen).toBe("hello");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns false when the Clipboard API is absent (no writeText)", async () => {
|
|
32
|
+
stubClipboard(undefined);
|
|
33
|
+
expect(await copyToClipboard("x")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns false when the write rejects (blocked/insecure origin)", async () => {
|
|
37
|
+
stubClipboard(() => Promise.reject(new Error("NotAllowedError")));
|
|
38
|
+
expect(await copyToClipboard("x")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns false instead of hanging when the write never settles (unfocused doc / headless)", async () => {
|
|
42
|
+
// A promise that never resolves — the real failure mode. With no timeout this test would hang.
|
|
43
|
+
stubClipboard(() => new Promise<void>(() => {}));
|
|
44
|
+
expect(await copyToClipboard("x", 30)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy `text` to the clipboard, resolving to whether the write was CONFIRMED. Never throws, and —
|
|
3
|
+
* critically — never hangs.
|
|
4
|
+
*
|
|
5
|
+
* `navigator.clipboard.writeText` is deceptively unreliable: it rejects when the Clipboard API is
|
|
6
|
+
* blocked or absent (insecure origin, permission policy), and it can hang FOREVER (never settling)
|
|
7
|
+
* when the document isn't focused — a background tab, or a headless browser. An `await` on it therefore
|
|
8
|
+
* risks stranding the caller mid-handler, so the user is told nothing at all: neither success nor
|
|
9
|
+
* failure. That silent stall is exactly the failure mode this guards against.
|
|
10
|
+
*
|
|
11
|
+
* Contract: a `false` result means "not copied" — the caller MUST still surface the value some other
|
|
12
|
+
* way (e.g. show the link in a toast) so the action never fails silently.
|
|
13
|
+
*/
|
|
14
|
+
export async function copyToClipboard(text: string, timeoutMs = 1000): Promise<boolean> {
|
|
15
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
16
|
+
try {
|
|
17
|
+
const write = navigator.clipboard?.writeText(text);
|
|
18
|
+
if (!write) return false; // no Clipboard API at all
|
|
19
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
20
|
+
timer = setTimeout(() => reject(new Error("clipboard write timed out")), timeoutMs);
|
|
21
|
+
});
|
|
22
|
+
await Promise.race([write, timeout]);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
} finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie utility functions using manual document.cookie approach
|
|
3
|
+
* Replaces js-cookie dependency for better consistency
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get a cookie value by name
|
|
10
|
+
*/
|
|
11
|
+
export function getCookie(name: string): string | undefined {
|
|
12
|
+
if (typeof document === "undefined") return undefined;
|
|
13
|
+
|
|
14
|
+
const value = `; ${document.cookie}`;
|
|
15
|
+
const parts = value.split(`; ${name}=`);
|
|
16
|
+
if (parts.length === 2) {
|
|
17
|
+
const cookieValue = parts.pop()?.split(";").shift();
|
|
18
|
+
return cookieValue;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set a cookie with name, value, and optional max age
|
|
25
|
+
*/
|
|
26
|
+
export function setCookie(name: string, value: string, maxAge: number = DEFAULT_MAX_AGE): void {
|
|
27
|
+
if (typeof document === "undefined") return;
|
|
28
|
+
|
|
29
|
+
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Remove a cookie by setting its max age to 0
|
|
34
|
+
*/
|
|
35
|
+
export function removeCookie(name: string): void {
|
|
36
|
+
if (typeof document === "undefined") return;
|
|
37
|
+
|
|
38
|
+
document.cookie = `${name}=; path=/; max-age=0`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { formatCellDate, toCellPrimitive } from "./format-cell";
|
|
4
|
+
|
|
5
|
+
const isPrimitive = (v: unknown) => ["string", "number", "boolean"].includes(typeof v);
|
|
6
|
+
|
|
7
|
+
describe("formatCellDate (date-ish cell columns)", () => {
|
|
8
|
+
it("turns a Date into a stable display string, never a Date/object", () => {
|
|
9
|
+
const out = formatCellDate(new Date("2026-07-05T11:36:00.000Z"));
|
|
10
|
+
expect(typeof out).toBe("string");
|
|
11
|
+
expect(out).toBe("2026-07-05T11:36:00.000Z");
|
|
12
|
+
expect(out).not.toBeInstanceOf(Date);
|
|
13
|
+
});
|
|
14
|
+
it("passes an already-formatted string through readably (no corruption)", () => {
|
|
15
|
+
expect(formatCellDate("1970-01-01T00:00:00.000Z")).toBe("1970-01-01T00:00:00.000Z");
|
|
16
|
+
});
|
|
17
|
+
it("returns the '—' fallback for null and undefined", () => {
|
|
18
|
+
expect(formatCellDate(null)).toBe("—");
|
|
19
|
+
expect(formatCellDate(undefined)).toBe("—");
|
|
20
|
+
});
|
|
21
|
+
it("returns the '—' fallback for an Invalid Date instead of throwing", () => {
|
|
22
|
+
expect(formatCellDate(new Date("not-a-date"))).toBe("—");
|
|
23
|
+
});
|
|
24
|
+
it("always returns a primitive", () => {
|
|
25
|
+
for (const v of [new Date(), "x", 5, true, null, undefined, { a: 1 }]) {
|
|
26
|
+
expect(isPrimitive(formatCellDate(v))).toBe(true);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("toCellPrimitive (generic non-date cell guard)", () => {
|
|
32
|
+
it("passes primitive strings/numbers/booleans through unchanged (no corruption of parameter/actor text)", () => {
|
|
33
|
+
expect(toCellPrimitive("per_minute")).toBe("per_minute");
|
|
34
|
+
expect(toCellPrimitive(5)).toBe(5);
|
|
35
|
+
expect(toCellPrimitive(false)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it("formats a leaked Date to a display string", () => {
|
|
38
|
+
expect(toCellPrimitive(new Date("2026-07-05T11:36:00.000Z"))).toBe("2026-07-05T11:36:00.000Z");
|
|
39
|
+
});
|
|
40
|
+
it("returns the '—' fallback for null/undefined", () => {
|
|
41
|
+
expect(toCellPrimitive(null)).toBe("—");
|
|
42
|
+
expect(toCellPrimitive(undefined)).toBe("—");
|
|
43
|
+
});
|
|
44
|
+
it("returns the '—' fallback for a blank/empty string (never-blank-cell — AC-F4-4)", () => {
|
|
45
|
+
expect(toCellPrimitive("")).toBe("—");
|
|
46
|
+
});
|
|
47
|
+
it("returns a safe string for any other object, never a raw object", () => {
|
|
48
|
+
const out = toCellPrimitive({ nested: true });
|
|
49
|
+
expect(typeof out).toBe("string");
|
|
50
|
+
expect(isPrimitive(out)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
it("always returns a primitive", () => {
|
|
53
|
+
for (const v of [new Date(), "x", 5, true, null, undefined, { a: 1 }, [1, 2]]) {
|
|
54
|
+
expect(isPrimitive(toCellPrimitive(v))).toBe(true);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Defensive render-boundary belt: guarantees a PRIMITIVE reaches a table <td>.
|
|
2
|
+
// A raw non-primitive (e.g. a Date) rendered as a React child throws
|
|
3
|
+
// "Objects are not valid as a React child", which trips the page CatchBoundary
|
|
4
|
+
// to a 500. These helpers format/guard the value at the render boundary rather
|
|
5
|
+
// than trusting the upstream shape. Mirrors packages/audit/src/ui/lib/format.ts
|
|
6
|
+
// (formatEventTimestamp) and the ISO-slice convention in clients-credentials.
|
|
7
|
+
// The "—" fallback mirrors the existing empty-cell convention across the console.
|
|
8
|
+
|
|
9
|
+
export type CellPrimitive = string | number | boolean;
|
|
10
|
+
|
|
11
|
+
const EMPTY = "—";
|
|
12
|
+
|
|
13
|
+
function safeString(value: unknown): string {
|
|
14
|
+
try {
|
|
15
|
+
const s = String(value);
|
|
16
|
+
return s.length > 0 ? s : EMPTY;
|
|
17
|
+
} catch {
|
|
18
|
+
return EMPTY;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Date-ish columns (tenant-users `createdAt`, config-history `timestamp`).
|
|
24
|
+
* - Date → stable ISO display string (same shape the backends already
|
|
25
|
+
* emit via `.toISOString()`); Invalid Date → the "—" fallback.
|
|
26
|
+
* - string → passed through readably (no corruption of the already
|
|
27
|
+
* formatted upstream value; keeps ISSUE-015 seed value as-is).
|
|
28
|
+
* - number / boolean → stringified.
|
|
29
|
+
* - null / undefined → the "—" fallback.
|
|
30
|
+
* - any other object → a safe string (never a raw object).
|
|
31
|
+
* ALWAYS returns a primitive string.
|
|
32
|
+
*/
|
|
33
|
+
export function formatCellDate(value: unknown): string {
|
|
34
|
+
if (value === null || value === undefined) return EMPTY;
|
|
35
|
+
if (value instanceof Date) {
|
|
36
|
+
return Number.isNaN(value.getTime()) ? EMPTY : value.toISOString();
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === "string") return value;
|
|
39
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
40
|
+
return safeString(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generic guard for every OTHER cell in the affected tables. Guarantees a
|
|
45
|
+
* primitive can never reach <td>.
|
|
46
|
+
* - non-empty string / number / boolean → passed through unchanged (no
|
|
47
|
+
* corruption of non-date text such as
|
|
48
|
+
* `parameter` / `actor`); an empty string
|
|
49
|
+
* returns the "—" fallback instead.
|
|
50
|
+
* - Date → formatCellDate (display string).
|
|
51
|
+
* - null / undefined → the "—" fallback.
|
|
52
|
+
* - any other object → a safe string.
|
|
53
|
+
* ALWAYS returns a primitive.
|
|
54
|
+
*/
|
|
55
|
+
export function toCellPrimitive(value: unknown): CellPrimitive {
|
|
56
|
+
if (value === null || value === undefined) return EMPTY;
|
|
57
|
+
// An empty string is a blank cell — render the "—" empty-value convention
|
|
58
|
+
// (never-blank-cell, NFR-NO-RAW-OBJECT), matching the null/undefined fallback.
|
|
59
|
+
if (typeof value === "string") return value.length > 0 ? value : EMPTY;
|
|
60
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
61
|
+
if (value instanceof Date) return formatCellDate(value);
|
|
62
|
+
return safeString(value);
|
|
63
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { errorToMessage } from "./handle-server-error";
|
|
4
|
+
|
|
5
|
+
describe("errorToMessage (pure toast-boundary extractor — ISSUE-006 / NFR-NO-RAW-OBJECT)", () => {
|
|
6
|
+
it("returns a plain object body's message", () => {
|
|
7
|
+
expect(errorToMessage({ message: "boom" })).toBe("boom");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns an Error instance's message", () => {
|
|
11
|
+
expect(errorToMessage(new Error("kapow"))).toBe("kapow");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("unwraps a structured Eden validation-body error value (never [object Object])", () => {
|
|
15
|
+
const out = errorToMessage({
|
|
16
|
+
status: 422,
|
|
17
|
+
value: { type: "validation", message: "Expected number to be greater or equal to 1" },
|
|
18
|
+
});
|
|
19
|
+
expect(out).toBe("Expected number to be greater or equal to 1");
|
|
20
|
+
expect(out).not.toBe("[object Object]");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("REJECTS a stringified '[object Object]' .message and falls through to the validation summary (live decimal-422 regression, G22)", () => {
|
|
24
|
+
// Eden/Elysia collapse a structured 422 validation body into an Error whose .message is
|
|
25
|
+
// literally "[object Object]"; the extractor must reject it and use the body's own summary.
|
|
26
|
+
const out = errorToMessage(
|
|
27
|
+
{
|
|
28
|
+
status: 422,
|
|
29
|
+
message: "[object Object]",
|
|
30
|
+
value: { type: "validation", summary: "value must be an integer" },
|
|
31
|
+
},
|
|
32
|
+
"Не удалось применить лимит"
|
|
33
|
+
);
|
|
34
|
+
expect(out).toBe("value must be an integer");
|
|
35
|
+
expect(out).not.toBe("[object Object]");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("REJECTS an Error whose message is '[object Object]' and uses the localized fallback", () => {
|
|
39
|
+
const out = errorToMessage(new Error("[object Object]"), "Не удалось применить лимит");
|
|
40
|
+
expect(out).toBe("Не удалось применить лимит");
|
|
41
|
+
expect(out).not.toBe("[object Object]");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("unwraps a plain {message} server body carried under .value", () => {
|
|
45
|
+
expect(errorToMessage({ status: 403, value: { message: "Forbidden" } })).toBe("Forbidden");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns a string body carried directly under .value", () => {
|
|
49
|
+
expect(errorToMessage({ status: 400, value: "raw string error" })).toBe("raw string error");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("maps a 204 to the no-content message", () => {
|
|
53
|
+
expect(errorToMessage({ status: 204 })).toBe("No content.");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("falls back to the provided fallback when no message can be extracted, never [object Object]", () => {
|
|
57
|
+
const out = errorToMessage(
|
|
58
|
+
{ status: 500, value: { unexpected: true } },
|
|
59
|
+
"Не удалось применить лимит"
|
|
60
|
+
);
|
|
61
|
+
expect(out).toBe("Не удалось применить лимит");
|
|
62
|
+
expect(out).not.toBe("[object Object]");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("uses the default fallback when called with one argument", () => {
|
|
66
|
+
expect(errorToMessage(null)).toBe("Something went wrong!");
|
|
67
|
+
expect(errorToMessage(undefined)).toBe("Something went wrong!");
|
|
68
|
+
expect(errorToMessage({ foo: 1 })).toBe("Something went wrong!");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("NEVER returns [object Object] for any object input", () => {
|
|
72
|
+
for (const input of [
|
|
73
|
+
{ a: 1 },
|
|
74
|
+
{ value: { b: 2 } },
|
|
75
|
+
{ value: [1, 2] },
|
|
76
|
+
new Error("x"),
|
|
77
|
+
{ message: 5 },
|
|
78
|
+
]) {
|
|
79
|
+
const out = errorToMessage(input);
|
|
80
|
+
expect(typeof out).toBe("string");
|
|
81
|
+
expect(out.length).toBeGreaterThan(0);
|
|
82
|
+
expect(out).not.toBe("[object Object]");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { toast } from "sonner";
|
|
2
|
+
|
|
3
|
+
// Pure toast-boundary extractor. Mirrors the F1 defensive belt
|
|
4
|
+
// (format-cell.ts / toCellPrimitive): always yields a PRIMITIVE string so a raw
|
|
5
|
+
// object/Error can never reach toast text as "[object Object]" (NFR-NO-RAW-OBJECT).
|
|
6
|
+
// Kept side-effect-free so it is unit-testable without a DOM/toast harness.
|
|
7
|
+
export function errorToMessage(error: unknown, fallback = "Something went wrong!"): string {
|
|
8
|
+
// A usable message is a non-empty string that is not a useless stringified object. Eden/Elysia
|
|
9
|
+
// put "[object Object]" into an Error's `.message` for a structured validation (422) body — that
|
|
10
|
+
// must be REJECTED here so we fall through to the structured body's own message/summary or the
|
|
11
|
+
// localized fallback, never surfacing "[object Object]" to the user (NFR-NO-RAW-OBJECT).
|
|
12
|
+
const ok = (s: unknown): s is string =>
|
|
13
|
+
typeof s === "string" && s.length > 0 && !/^\[object [A-Za-z]/.test(s);
|
|
14
|
+
|
|
15
|
+
if (ok(error)) return error;
|
|
16
|
+
|
|
17
|
+
if (error && typeof error === "object") {
|
|
18
|
+
if ("message" in error && ok(error.message)) return error.message;
|
|
19
|
+
if ("value" in error && ok(error.value)) return error.value;
|
|
20
|
+
if ("value" in error && error.value && typeof error.value === "object") {
|
|
21
|
+
if ("message" in error.value && ok(error.value.message)) return error.value.message;
|
|
22
|
+
// Elysia/TypeBox validation body carries a human `summary` when there's no plain message.
|
|
23
|
+
if ("summary" in error.value && ok(error.value.summary)) return error.value.summary;
|
|
24
|
+
}
|
|
25
|
+
if ("status" in error && Number(error.status) === 204) {
|
|
26
|
+
return "No content.";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function handleServerError(error: unknown) {
|
|
34
|
+
if (process.env.NODE_ENV !== "production") {
|
|
35
|
+
console.log(error);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
toast.error(errorToMessage(error));
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A random id for client-side idempotency keys / correlation ids. Prefers `crypto.randomUUID()`, but
|
|
3
|
+
* that is only defined in a **secure context** (https or localhost) — on a plain-http dev/preview stand
|
|
4
|
+
* it is `undefined` and a bare `crypto.randomUUID()` throws, silently breaking every mutation. This
|
|
5
|
+
* falls back to a timestamp+random token so client mutations work everywhere. (Server code runs under
|
|
6
|
+
* Bun/Node where `crypto.randomUUID` is always available, so it can call it directly.)
|
|
7
|
+
*/
|
|
8
|
+
export function randomId(): string {
|
|
9
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
10
|
+
return crypto.randomUUID();
|
|
11
|
+
}
|
|
12
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { toast } from "sonner";
|
|
2
|
+
|
|
3
|
+
export function showSubmittedData(
|
|
4
|
+
data: unknown,
|
|
5
|
+
title: string = "You submitted the following values:"
|
|
6
|
+
) {
|
|
7
|
+
toast.message(title, {
|
|
8
|
+
description: (
|
|
9
|
+
<pre className="mt-2 w-full overflow-x-auto rounded-md bg-slate-950 p-4">
|
|
10
|
+
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
11
|
+
</pre>
|
|
12
|
+
),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { getDisplayNameInitials } from "./utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Инициалы попадают в аватар пользователя — на каждый экран, где есть шапка. Функция маленькая,
|
|
7
|
+
* но её граничные случаи (пустая строка, одно слово, лишние пробелы) видны сразу и всем,
|
|
8
|
+
* а тестов у неё до сих пор не было.
|
|
9
|
+
*/
|
|
10
|
+
describe("getDisplayNameInitials", () => {
|
|
11
|
+
it("два слова — первые буквы первого и последнего", () => {
|
|
12
|
+
expect(getDisplayNameInitials("Ada Lovelace")).toBe("AL");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("три слова — берётся первое и ПОСЛЕДНЕЕ, среднее игнорируется", () => {
|
|
16
|
+
expect(getDisplayNameInitials("Иван Иванович Петров")).toBe("ИП");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("одно слово — первые две буквы", () => {
|
|
20
|
+
expect(getDisplayNameInitials("Ada")).toBe("AD");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("слово из одной буквы — возвращает её одну, не падает", () => {
|
|
24
|
+
expect(getDisplayNameInitials("A")).toBe("A");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("пустая строка и одни пробелы — вопросительный знак", () => {
|
|
28
|
+
expect(getDisplayNameInitials("")).toBe("?");
|
|
29
|
+
expect(getDisplayNameInitials(" ")).toBe("?");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("лишние пробелы по краям и внутри не создают пустых частей", () => {
|
|
33
|
+
expect(getDisplayNameInitials(" Ada Lovelace ")).toBe("AL");
|
|
34
|
+
});
|
|
35
|
+
});
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
export function cn(...inputs: ClassValue[]) {
|
|
5
|
+
return twMerge(clsx(inputs));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sleep(ms: number = 1000) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generates page numbers for pagination with ellipsis
|
|
14
|
+
* @param currentPage - Current page number (1-based)
|
|
15
|
+
* @param totalPages - Total number of pages
|
|
16
|
+
* @returns Array of page numbers and ellipsis strings
|
|
17
|
+
*
|
|
18
|
+
* Examples:
|
|
19
|
+
* - Small dataset (≤5 pages): [1, 2, 3, 4, 5]
|
|
20
|
+
* - Near beginning: [1, 2, 3, 4, '...', 10]
|
|
21
|
+
* - In middle: [1, '...', 4, 5, 6, '...', 10]
|
|
22
|
+
* - Near end: [1, '...', 7, 8, 9, 10]
|
|
23
|
+
*/
|
|
24
|
+
export function getPageNumbers(currentPage: number, totalPages: number) {
|
|
25
|
+
const maxVisiblePages = 5; // Maximum number of page buttons to show
|
|
26
|
+
const rangeWithDots = [];
|
|
27
|
+
|
|
28
|
+
if (totalPages <= maxVisiblePages) {
|
|
29
|
+
// If total pages is 5 or less, show all pages
|
|
30
|
+
for (let i = 1; i <= totalPages; i++) {
|
|
31
|
+
rangeWithDots.push(i);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
// Always show first page
|
|
35
|
+
rangeWithDots.push(1);
|
|
36
|
+
|
|
37
|
+
if (currentPage <= 3) {
|
|
38
|
+
// Near the beginning: [1] [2] [3] [4] ... [10]
|
|
39
|
+
for (let i = 2; i <= 4; i++) {
|
|
40
|
+
rangeWithDots.push(i);
|
|
41
|
+
}
|
|
42
|
+
rangeWithDots.push("...", totalPages);
|
|
43
|
+
} else if (currentPage >= totalPages - 2) {
|
|
44
|
+
// Near the end: [1] ... [7] [8] [9] [10]
|
|
45
|
+
rangeWithDots.push("...");
|
|
46
|
+
for (let i = totalPages - 3; i <= totalPages; i++) {
|
|
47
|
+
rangeWithDots.push(i);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
// In the middle: [1] ... [4] [5] [6] ... [10]
|
|
51
|
+
rangeWithDots.push("...");
|
|
52
|
+
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
|
53
|
+
rangeWithDots.push(i);
|
|
54
|
+
}
|
|
55
|
+
rangeWithDots.push("...", totalPages);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return rangeWithDots;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Initials from a display name: first character of the first word + first
|
|
64
|
+
* character of the last word. One word only: first two characters. Empty: `?`.
|
|
65
|
+
*/
|
|
66
|
+
export function getDisplayNameInitials(displayName: string): string {
|
|
67
|
+
// Индексный доступ читается через `?.`/`??`, а не через «мы же проверили length»: при
|
|
68
|
+
// `noUncheckedIndexedAccess` (так собирают продукты, вендорящие этот пакет) компилятор
|
|
69
|
+
// из проверки длины НЕ выводит, что parts[0] определён, и файл перестаёт типизироваться.
|
|
70
|
+
const parts = displayName.trim().split(/\s+/).filter(Boolean);
|
|
71
|
+
if (parts.length === 0) return "?";
|
|
72
|
+
if (parts.length === 1) {
|
|
73
|
+
return (parts[0] ?? "").slice(0, 2).toUpperCase();
|
|
74
|
+
}
|
|
75
|
+
const first = parts[0]?.[0] ?? "";
|
|
76
|
+
const last = parts[parts.length - 1]?.[0] ?? "";
|
|
77
|
+
return (first + last).toUpperCase();
|
|
78
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { Glob } from "bun";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Страж: клиентский код НЕ зовёт `crypto.randomUUID()` напрямую.
|
|
7
|
+
*
|
|
8
|
+
* `crypto.randomUUID` определён только в **secure context** — https или localhost. На стенде по
|
|
9
|
+
* обычному http (`http://moon:3000`, любой preview без TLS) его нет, и вызов бросает TypeError ДО
|
|
10
|
+
* fetch. Мутация не уходит, ошибки на экране нет: кнопка нажимается, и не происходит ничего. Человек
|
|
11
|
+
* жмёт ещё раз.
|
|
12
|
+
*
|
|
13
|
+
* Это ровно тот молчаливый провал, который запрещён. Ловится он дёшево — здесь, а не в браузере на
|
|
14
|
+
* стенде: e2e (`billing.e2e.ts`) нашёл его в биллинге уже после того, как `randomId()` был написан
|
|
15
|
+
* специально ради этой ловушки — и всё равно один экран звал `crypto.randomUUID()` мимо него.
|
|
16
|
+
*
|
|
17
|
+
* Серверный код (Bun/Node) — можно: там `crypto.randomUUID` есть всегда.
|
|
18
|
+
*/
|
|
19
|
+
const REPO = new URL("../../..", import.meta.url).pathname;
|
|
20
|
+
|
|
21
|
+
/** Каталоги, чей код исполняется В БРАУЗЕРЕ. */
|
|
22
|
+
const CLIENT_ROOTS = [
|
|
23
|
+
"packages/ui/src",
|
|
24
|
+
"packages/iam/src/features",
|
|
25
|
+
"packages/billing/src/ui",
|
|
26
|
+
"packages/dashboard/src/ui",
|
|
27
|
+
"apps/web/src/client",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Единственное место, где вызов законен, — сам `randomId()` с его fallback'ом. */
|
|
31
|
+
const ALLOWED = ["lib/random-id.ts"];
|
|
32
|
+
|
|
33
|
+
/** Только браузерные файлы слайса: `server/`, `application/`, `db/` крутятся под Bun. */
|
|
34
|
+
const SERVER_DIR = /(^|\/)(server|application|db|domain|bin)\//;
|
|
35
|
+
|
|
36
|
+
/** Ищем ВЫЗОВ, а не упоминание: комментарий, объясняющий правило, — не нарушение правила. */
|
|
37
|
+
function stripComments(src: string): string {
|
|
38
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("secure context: клиентские мутации работают и без https", () => {
|
|
42
|
+
it("ни один браузерный файл не зовёт crypto.randomUUID() напрямую — только randomId()", async () => {
|
|
43
|
+
const offenders: string[] = [];
|
|
44
|
+
|
|
45
|
+
for (const root of CLIENT_ROOTS) {
|
|
46
|
+
const cwd = `${REPO}/${root}`;
|
|
47
|
+
try {
|
|
48
|
+
for await (const rel of new Glob("**/*.{ts,tsx}").scan({ cwd })) {
|
|
49
|
+
if (/\.test\.tsx?$/.test(rel)) continue;
|
|
50
|
+
if (SERVER_DIR.test(rel)) continue;
|
|
51
|
+
if (ALLOWED.some((a) => rel.endsWith(a))) continue;
|
|
52
|
+
|
|
53
|
+
const src = stripComments(await Bun.file(`${cwd}/${rel}`).text());
|
|
54
|
+
if (src.includes("crypto.randomUUID(")) offenders.push(`${root}/${rel}`);
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// каталога нет в этом продукте
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
expect(offenders).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
});
|