diff --git a/docs/integration/api/pending-endpoints.md b/docs/integration/api/pending-endpoints.md new file mode 100644 index 0000000..e003b8c --- /dev/null +++ b/docs/integration/api/pending-endpoints.md @@ -0,0 +1,354 @@ +# Pending endpoints — frontend wishlist + +Эти 5 endpoint'ов нужны admin-UI для завершения UI-фич, заплейсхолженных в +sprint UI polish (MR !74→!93, mai-2026). Каждый блокирует конкретный +component на frontend — сегодня там показывается placeholder ("backend +changelog pending" notice / disabled button / `0` count). + +Frontend готов подключить через Orval — нужны только OpenAPI спеки. +Приоритет в порядке user-impact: 1 (highest) → 5 (nice-to-have). + +--- + +## 1. Schema versions timeline — `GET /api/v1/dictionaries/{name}/changelog` + +**Purpose:** History tab в editor (`/dictionaries/$name?tab=history`) и +Time-travel modal — список всех schema versions с change summaries. + +**Frontend usage:** + +- `HistoryTabContent` (`routes/dictionaries.$name.tsx:HistoryTabContent`) + показывает HEAD row + N исторических versions с `v{N} +X ~Y −Z` + change summary, who/when, diff link. +- `TimeTravelModal` использует timestamps versions для slider marks. + +**Route:** `GET /api/v1/dictionaries/{name}/changelog` + +**Query params:** + +| param | type | default | description | +|---|---|---|---| +| `limit` | int | 50 | max entries (cap 200) | +| `cursor` | string | — | opaque pagination token (next-page link) | +| `from` | ISO datetime | — | filter versions с `publishedAt >= from` | +| `to` | ISO datetime | — | filter versions с `publishedAt <= to` | + +**Response 200:** + +```json +{ + "dictionary": "satellites", + "currentVersion": "1.4.0", + "entries": [ + { + "version": "1.4.0", + "publishedAt": "2026-04-12T10:23:00Z", + "publishedBy": { "sub": "u-42", "name": "zimin.an" }, + "headers": "x-references массив + поле altitude", + "diff": { + "added": ["properties.altitude"], + "removed": [], + "changed": ["properties.operator (enum +1 value)"] + }, + "recordsAffected": 0, + "isCurrent": true + }, + { + "version": "1.3.2", + "publishedAt": "2026-03-30T08:11:00Z", + "publishedBy": { "sub": "u-17", "name": "petrov.iv" }, + "headers": "fix description перевод EN", + "diff": { "added": [], "removed": [], "changed": ["description.en"] }, + "recordsAffected": 0, + "isCurrent": false + } + ], + "nextCursor": "eyJ2IjoiMS4zLjEiLCJ0IjoiMjAyNi0wMy0xNVQwMDowMDowMFoifQ==" +} +``` + +**Errors:** 404 dict not found, 401 unauth. + +**Notes:** +- `diff` — best-effort summary, не полный JSON-patch. Если backend + считает дорого — допустим только counts: `{ "added": 3, "removed": 0, "changed": 2 }`. +- `headers` ≈ commit message от автора schema edit. Optional; покажем + "—" если empty. + +**Complexity:** medium. Нужна таблица `dictionary_schema_history` (если +ещё нет — версия + JSON snapshot + author + timestamp). + +--- + +## 2. Records snapshots — `GET /api/v1/dictionaries/{name}/snapshots` + +**Purpose:** Time-travel modal slider marks — точки во времени где +дествительно произошёл change в records (не каждая секунда). UX: +slider snaps к "интересным" моментам. + +**Frontend usage:** + +- `TimeTravelModal` (`components/timetravel/TimeTravelModal.tsx`) рендерит + marks на slider — каждая mark = snapshot point. Сейчас используем + `recordTimestamps` derived из `validFrom` — но это не учитывает closes. + +**Route:** `GET /api/v1/dictionaries/{name}/snapshots` + +**Query params:** + +| param | type | default | description | +|---|---|---|---| +| `from` | ISO datetime | now − 30d | start of window | +| `to` | ISO datetime | now | end of window | +| `granularity` | string | `day` | `hour` / `day` / `week` — bucket size | + +**Response 200:** + +```json +{ + "dictionary": "satellites", + "from": "2026-04-01T00:00:00Z", + "to": "2026-05-11T00:00:00Z", + "snapshots": [ + { + "at": "2026-04-01T00:00:00Z", + "totalRecords": 124, + "changedSinceLast": 0, + "label": "1 апр" + }, + { + "at": "2026-04-15T14:00:00Z", + "totalRecords": 127, + "changedSinceLast": 3, + "label": "15 апр" + }, + { + "at": "2026-05-11T00:00:00Z", + "totalRecords": 130, + "changedSinceLast": 5, + "label": "сейчас", + "isCurrent": true + } + ] +} +``` + +**Notes:** +- Backend free выбрать какие точки маркировать — main критерий ≥1 change + с прошлой snapshot. Пустые часы/дни skip. +- `label` — backend-formatted RU label для UI (избегаем locale logic + на front). + +**Complexity:** medium. Можно делать `SELECT DISTINCT date_trunc('day', valid_from)` ++ count(*) over window. Кеш ~5min на горячих dict'ах. + +--- + +## 3. Workflow state machine — workflow API + +**Purpose:** Workflow buttons в editor toolbar (`Request review`, +`Approve`, `Publish`, `Create draft`). Сейчас показываются placeholder / +hidden когда `approvalRequired=true` потому что нет actions. + +**Frontend usage:** + +- `WorkflowBanner` (`components/editor/WorkflowBanner.tsx`) показывает + "live" / "pending review" / "draft" badge. +- Action buttons прячутся пока нет API. После shipping — wire'им + кнопки в banner / editor toolbar. + +**Routes (4 endpoints, RESTful):** + +### 3.1 `POST /api/v1/dictionaries/{name}/drafts` + +Create draft от текущего HEAD. Returns draft id. + +```json +{ "fromVersion": "1.4.0", "reason": "обновить altitude диапазон" } +``` + +Response: +```json +{ "draftId": "d-42a7", "branchedFrom": "1.4.0", "status": "draft" } +``` + +### 3.2 `POST /api/v1/dictionaries/{name}/drafts/{draftId}/review` + +Submit draft for review. + +```json +{ "reviewers": ["u-17", "u-92"], "note": "проверьте altitude rounding" } +``` + +Response: 202 Accepted, `{ "status": "review_pending" }`. + +### 3.3 `POST /api/v1/dictionaries/{name}/drafts/{draftId}/decision` + +Reviewer's decision. + +```json +{ "decision": "approve", "comment": "OK" } +``` + +`decision`: `approve` | `request_changes` | `reject`. Response 200 с new status. + +### 3.4 `POST /api/v1/dictionaries/{name}/drafts/{draftId}/publish` + +Publish approved draft → becomes new HEAD. Optimistic-lock на текущую version. + +```json +{ "expectedHeadVersion": "1.4.0", "publishNote": "v1.5.0 — altitude диапазон" } +``` + +Response 200: +```json +{ "newVersion": "1.5.0", "publishedAt": "2026-05-11T22:00:00Z" } +``` + +409 если кто-то другой опубликовал свой draft первым (concurrent publish race). + +**Notes:** +- State machine: `draft` → `review_pending` → (`approved` | `changes_requested` | `rejected`) → (publish | discard). +- Все 4 endpoint'а должны вернуть updated draft state — frontend invalidate'ит + query keys по dictionary name. +- RBAC: только approvers могут вызвать 3.3 с decision=approve. + +**Complexity:** high. Нужны таблицы `drafts`, `draft_reviews`, state transition +guards. Webhook event hooks (frontend ожидает `dict.draft.*` events). + +--- + +## 4. PATCH schema fields — `PATCH /api/v1/dictionaries/{name}/schema` + +**Purpose:** Inline edit single field в Fields tab без открытия full +DictionaryEditorDialog. UX: clickable field row → inline form → save → +HEAD bumps minor version. + +**Frontend usage:** + +- `FieldsTabContent` (`routes/dictionaries.$name.tsx:FieldsTabContent`) + сейчас read-only. После endpoint — кнопка edit на каждой row. + +**Route:** `PATCH /api/v1/dictionaries/{name}/schema` + +**Body (RFC 6902 JSON-patch subset или structured ops — backend выбирает):** + +Option A — JSON Patch: +```json +{ + "expectedHeadVersion": "1.4.0", + "ops": [ + { + "op": "replace", + "path": "/properties/altitude/description", + "value": { "ru": "Высота орбиты в км", "en": "Orbit altitude in km" } + }, + { "op": "add", "path": "/properties/altitude/minimum", "value": 0 } + ] +} +``` + +Option B — structured (более ergonomic для UI): +```json +{ + "expectedHeadVersion": "1.4.0", + "field": "altitude", + "changes": { + "description": { "ru": "...", "en": "..." }, + "minimum": 0 + } +} +``` + +**Constraints:** допустимые ops — non-breaking changes только (description, +title, minimum/maximum bounds expansion, enum value добавление). Breaking +ops (rename, type change, remove) → 422 с `code: "breaking_change_requires_draft"`. + +**Response 200:** + +```json +{ + "newVersion": "1.4.1", + "schemaJson": { /* full updated schema */ } +} +``` + +**Errors:** + +| code | status | meaning | +|---|---|---| +| `breaking_change_requires_draft` | 422 | op требует workflow draft, не inline patch | +| `version_mismatch` | 409 | someone else patched schema first (refetch) | +| `validation_failed` | 422 | op breaks existing record validation | + +**Complexity:** medium-high. Нужен validator который проверяет что patch +не breaks существующие записи (semver-minor только). + +--- + +## 5. Batch outgoing FK counts — `GET /api/v1/dictionaries/graph/outgoing` + +**Purpose:** Catalog list (`/dictionaries`) — колонка `→` показывает +сколько FK rel'ов идёт ОТ каждого dict'а. Сейчас на front считаем +loop'ом per-row через `extractOutgoingFkCount(schemaJson)` — работает, +но требует все schemaJson на клиенте. + +**Frontend usage:** + +- `routes/dictionaries.index.tsx` catalog table — `→` column показывает + outgoing FK count. Если backend вернёт батчом — frontend сделает 1 + request вместо включения schemaJson в каждую row useDictionaries. + +**Route:** `GET /api/v1/dictionaries/graph/outgoing?dicts=satellites,operators,...` + +**Query params:** + +| param | type | default | description | +|---|---|---|---| +| `dicts` | comma-separated string | all visible | optional filter | +| `scope` | comma-separated string | all | `PUBLIC,INTERNAL` etc | + +**Response 200:** + +```json +{ + "outgoing": { + "satellites": { "fkCount": 3, "targets": ["operators", "launch_sites", "missions"] }, + "operators": { "fkCount": 0, "targets": [] }, + "missions": { "fkCount": 2, "targets": ["operators", "rockets"] } + } +} +``` + +**Notes:** +- `targets` опционально — если дорого, можно вернуть только `fkCount`. +- Скорость: вся таблица обычно ≤200 dict'ов — простой SELECT с + `jsonb_array_length(schema_json->'x-references')` или `LATERAL` + enumeration. Кеш 60s. + +**Complexity:** low. Это derived data из существующих schemaJson — +просто batch SELECT + aggregate. + +--- + +## Frontend integration timeline + +| Endpoint | Component | Estimate after backend ship | +|---|---|---| +| 1. Changelog | `HistoryTabContent`, `TimeTravelModal` | ~2h (Orval hook + render entries) | +| 2. Snapshots | `TimeTravelModal` slider marks | ~1h (replace mock derivation) | +| 3. Workflow API | `WorkflowBanner` + new buttons in toolbar | ~6h (4 mutations + state UI) | +| 4. PATCH schema | `FieldsTabContent` inline edit | ~4h (form + diff preview) | +| 5. Batch graph | `dictionaries.index` `→` column | ~30min (single query swap) | + +Total frontend work post-backend: **~14h** (1.5 sprint days). Per +endpoint frontend готов в течение часа после OpenAPI freeze. + +## Conventions reminder + +- Все endpoints должны быть Authenticated (Keycloak JWT, см. [auth.md](auth.md)). +- Errors следуют [errors.md](errors.md) — `{ code, message, details? }`. +- Optimistic-lock 409 везде где есть concurrent edits. +- Webhook events для async state changes — см. [webhooks.md](webhooks.md). +- Idempotency-Key header на mutating endpoints (Time-travel race + safety per existing API guidelines). diff --git a/docs/toc.yaml b/docs/toc.yaml index 5481108..0d3b7ca 100644 --- a/docs/toc.yaml +++ b/docs/toc.yaml @@ -29,6 +29,8 @@ items: href: integration/api/best-practices.md - name: Bundle справочников cuod href: integration/api/bundle-cuod.md + - name: Pending endpoints (admin-UI wishlist) + href: integration/api/pending-endpoints.md - name: Прикладные сценарии items: diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index 6d53956..a2c0ec9 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -31,6 +31,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3-force": "^3.0.0", + "d3-geo": "^3.1.1", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.0.2", "leaflet": "^1.9.4", @@ -44,7 +45,9 @@ "react-oidc-context": "^3.3.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "topojson-client": "^3.1.0", + "tw-animate-css": "^1.4.0", + "world-atlas": "^2.0.2" }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", @@ -59,9 +62,12 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/d3-force": "^3.0.10", + "@types/d3-geo": "^3.1.0", + "@types/geojson": "^7946.0.16", "@types/leaflet": "^1.9.21", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/topojson-client": "^3.1.5", "@vitejs/plugin-react": "^4.3.4", "conventional-changelog-conventionalcommits": "^9.3.1", "jsdom": "^29.1.1", diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index a77534c..71c8276 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: d3-force: specifier: ^3.0.0 version: 3.0.0 + d3-geo: + specifier: ^3.1.1 + version: 3.1.1 i18next: specifier: ^26.0.3 version: 26.0.8(typescript@5.9.3) @@ -107,9 +110,15 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + topojson-client: + specifier: ^3.1.0 + version: 3.1.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + world-atlas: + specifier: ^2.0.2 + version: 2.0.2 devDependencies: '@semantic-release/changelog': specifier: ^6.0.3 @@ -147,6 +156,12 @@ importers: '@types/d3-force': specifier: ^3.0.10 version: 3.0.10 + '@types/d3-geo': + specifier: ^3.1.0 + version: 3.1.0 + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 '@types/leaflet': specifier: ^1.9.21 version: 1.9.21 @@ -156,6 +171,9 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.14) + '@types/topojson-client': + specifier: ^3.1.5 + version: 3.1.5 '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)) @@ -1619,6 +1637,9 @@ packages: '@types/d3-force@3.0.10': resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1642,6 +1663,12 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/topojson-client@3.1.5': + resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==} + + '@types/topojson-specification@1.0.5': + resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1898,6 +1925,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -1966,6 +1996,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + d3-dispatch@3.0.1: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} @@ -1974,6 +2008,10 @@ packages: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + d3-quadtree@3.0.1: resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} engines: {node: '>=12'} @@ -2407,6 +2445,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -3422,6 +3464,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + topojson-client@3.1.0: + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} + hasBin: true + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -3677,6 +3723,9 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + world-atlas@2.0.2: + resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5017,6 +5066,10 @@ snapshots: '@types/d3-force@3.0.10': {} + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -5037,6 +5090,15 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/topojson-client@3.1.5': + dependencies: + '@types/geojson': 7946.0.16 + '@types/topojson-specification': 1.0.5 + + '@types/topojson-specification@1.0.5': + dependencies: + '@types/geojson': 7946.0.16 + '@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.0 @@ -5317,6 +5379,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@2.20.3: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -5386,6 +5450,10 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + d3-dispatch@3.0.1: {} d3-force@3.0.0: @@ -5394,6 +5462,10 @@ snapshots: d3-quadtree: 3.0.1 d3-timer: 3.0.1 + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + d3-quadtree@3.0.1: {} d3-timer@3.0.1: {} @@ -5856,6 +5928,8 @@ snapshots: ini@1.3.8: {} + internmap@2.0.3: {} + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -6697,6 +6771,10 @@ snapshots: dependencies: is-number: 7.0.0 + topojson-client@3.1.0: + dependencies: + commander: 2.20.3 + tough-cookie@6.0.1: dependencies: tldts: 7.0.30 @@ -6888,6 +6966,8 @@ snapshots: wordwrap@1.0.0: {} + world-atlas@2.0.2: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/ordinis-admin-ui/src/components/editor/ExportModal.tsx b/ordinis-admin-ui/src/components/editor/ExportModal.tsx index b816b1d..8f6a99d 100644 --- a/ordinis-admin-ui/src/components/editor/ExportModal.tsx +++ b/ordinis-admin-ui/src/components/editor/ExportModal.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Dialog, DialogContent } from '@/ui' +import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui' import type { DictionaryDetail, FlattenedRecord } from '@/api/client' import { cn } from '@/lib/utils' @@ -175,6 +175,16 @@ export function ExportModal({ return ( !v && onClose()}> + {/* Radix a11y: visually-hidden Title + Description. Visible heading + * рендерится ниже как кастомный layout. */} + + {t('export.title', { defaultValue: 'Экспорт' })} — {detail.name} + + + {t('export.description', { + defaultValue: 'Выбор формата, объёма и колонок для выгрузки.', + })} + {/* Header */}
diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index 4508069..e2905cf 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form' import { useTranslation } from 'react-i18next' import Ajv, { type ErrorObject } from 'ajv' @@ -70,6 +70,10 @@ type Props = { } const ajv = new Ajv({ allErrors: true, strict: false }) + +// Stable empty-object literal — используется как default для dataValues +// чтобы не создавать новый `{}` на каждый render (deps memo invalidation). +const EMPTY_DATA: Record = Object.freeze({}) addFormats(ajv) const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized']) @@ -149,7 +153,12 @@ export const SchemaDrivenForm = ({ // search: substring match (case-insensitive) против key OR labelOf(key) // onlyFilled: hide fields где data[key] empty (null/undefined/''/false/[]/0) // Применяется только в sections layout (drawer use case). - const dataValues = (mode === 'edit' ? defaultValues?.data : undefined) ?? {} + // useMemo чтобы default {} не создавал new object literal каждый render — + // иначе isFieldVisible useMemo ниже invalidate'ится → каскад re-renders. + const dataValues = useMemo( + () => (mode === 'edit' ? defaultValues?.data : undefined) ?? EMPTY_DATA, + [mode, defaultValues?.data], + ) const isFieldVisible = useMemo(() => { const search = (searchFilter ?? '').trim().toLowerCase() return (key: string): boolean => { @@ -183,9 +192,18 @@ export const SchemaDrivenForm = ({ const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length const visibleFieldCount = filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length + // Ref-pattern для callback: parent часто передаёт inline arrow + // (() => setState(...)) — identity меняется каждый render. Если включить + // callback в effect deps → effect fires → setState → re-render → loop + // ("Maximum update depth exceeded"). Держим latest callback в ref и + // вызываем по value-deps только. (review #infinite-loop-2026-05-11) + const onCounterChangeRef = useRef(onCounterChange) useEffect(() => { - onCounterChange?.(visibleFieldCount, totalFieldCount) - }, [visibleFieldCount, totalFieldCount, onCounterChange]) + onCounterChangeRef.current = onCounterChange + }) + useEffect(() => { + onCounterChangeRef.current?.(visibleFieldCount, totalFieldCount) + }, [visibleFieldCount, totalFieldCount]) const submit: SubmitHandler = (values) => { if (values.validFrom && values.validTo) { @@ -243,9 +261,15 @@ export const SchemaDrivenForm = ({ : 0 return top + dataDirty }, [formState.dirtyFields]) + // Same ref-pattern as onCounterChange: parent's inline callback identity + // changes every render → loop. Subscribe to value (dirtyCount) only. + const onDirtyChangeRef = useRef(onDirtyChange) useEffect(() => { - onDirtyChange?.(dirtyCount) - }, [dirtyCount, onDirtyChange]) + onDirtyChangeRef.current = onDirtyChange + }) + useEffect(() => { + onDirtyChangeRef.current?.(dirtyCount) + }, [dirtyCount]) // Section visibility — в 'sections' layout все buckets рендерятся подряд, // в 'tabs' — только active. Sections layout per handoff Screen 3 prototype. diff --git a/ordinis-admin-ui/src/components/layout/AppErrorBoundary.tsx b/ordinis-admin-ui/src/components/layout/AppErrorBoundary.tsx new file mode 100644 index 0000000..03b4696 --- /dev/null +++ b/ordinis-admin-ui/src/components/layout/AppErrorBoundary.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from 'react-i18next' +import { useRouter } from '@tanstack/react-router' +import { useState } from 'react' +import { Button } from '@/ui' +import { GlobeLoader } from '@/ui/components/globe-loader' + +/** + * AppErrorBoundary — friendly UI который рендерится через TanStack Router + * `errorComponent` когда route выкидывает unhandled exception (включая + * "Maximum update depth exceeded" infinite loops). + * + * Юзер видит: + * - illustrated GlobeLoader (статичный paused) + * - "Что-то пошло не так. Попробуйте обновить страницу" + * - кнопки [Обновить] / [На главную] + * - dev-only collapsible с raw error (только в dev mode, не в prod) + * + * НЕ показывает raw stack trace в prod — это утечка деталей реализации + * и тревожит юзера. + */ + +export type AppErrorBoundaryProps = { + error: Error + reset?: () => void +} + +export function AppErrorBoundary({ error, reset }: AppErrorBoundaryProps) { + const { t } = useTranslation() + const router = useRouter() + const [showDetails, setShowDetails] = useState(false) + // Vite dev mode → import.meta.env.DEV — показываем raw error для разработки. + const isDev = import.meta.env.DEV + + const handleRetry = () => { + if (reset) reset() + else router.invalidate() + } + + const handleHome = () => { + router.navigate({ to: '/dictionaries' }) + } + + return ( +
+
+ +
+

+ {t('error.boundary.title', { defaultValue: 'Что-то пошло не так' })} +

+

+ {t('error.boundary.description', { + defaultValue: + 'Произошла непредвиденная ошибка при отображении страницы. Попробуйте обновить — обычно это помогает. Если повторится, сообщите нам.', + })} +

+
+ + +
+ {isDev && ( +
+ + {showDetails && ( +
+              {error.message}
+              {error.stack ? `\n\n${error.stack}` : ''}
+            
+ )} +
+ )} +
+ ) +} diff --git a/ordinis-admin-ui/src/components/record/ConflictDiffModal.tsx b/ordinis-admin-ui/src/components/record/ConflictDiffModal.tsx index 455162f..1390b5e 100644 --- a/ordinis-admin-ui/src/components/record/ConflictDiffModal.tsx +++ b/ordinis-admin-ui/src/components/record/ConflictDiffModal.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ArrowsClockwiseIcon, WarningIcon } from '@phosphor-icons/react' -import { Dialog, DialogContent } from '@/ui' +import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui' import { useRecordRaw } from '@/api/queries' import type { CreateRecordRequest } from '@/api/client' import { cn } from '@/lib/utils' @@ -75,6 +75,17 @@ export function ConflictDiffModal({ return ( !v && onClose()}> + {/* Radix a11y: visually-hidden Title + Description satisfy + * DialogContent requirements. Visible header — custom layout ниже. */} + + {t('conflict.title', { defaultValue: 'Запись изменена другим пользователем' })} + + + {t('conflict.description', { + defaultValue: + 'Сравнение локальных правок с актуальным состоянием на сервере.', + })} + {/* Header */}
diff --git a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx index a2efc2d..9977019 100644 --- a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx +++ b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx @@ -156,6 +156,12 @@ export function RecordDrawer({ {caption} + {/* sr-only Description чтобы Radix не ругался на missing + * `aria-describedby` (a11y warning). Visible description нам не + * нужен — caption + recordId уже описывают drawer. */} + + {caption}{recordId ? ` (${recordId})` : ''} + {recordId && (
{recordId}
)} diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx index aaec01b..2860e21 100644 --- a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx +++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react' -import { Dialog, DialogContent } from '@/ui' +import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui' import type { DictionaryDetail } from '@/api/client' import { cn } from '@/lib/utils' @@ -127,6 +127,16 @@ export function TimeTravelModal({ return ( !v && onClose()}> + {/* Radix a11y: sr-only Title + Description. Visible header — custom layout ниже. */} + + {t('timeTravel.title', { defaultValue: 'Состояние справочника на момент времени' })} + + + {t('timeTravel.description', { + defaultValue: + 'Сравнение текущего состояния справочника с выбранной точкой во времени.', + })} + {/* Header */}
diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index 0ad56c6..087ef83 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar' import { TopBar } from '@/components/layout/TopBar' import { UpdateBanner } from '@/components/version/UpdateBanner' +import { AppErrorBoundary } from '@/components/layout/AppErrorBoundary' /** * Root layout (Stage 3.1 + 3.x mobile responsive): @@ -24,6 +25,9 @@ import { UpdateBanner } from '@/components/version/UpdateBanner' */ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ component: RootLayout, + // Catch unhandled errors inside any route — show friendly UI instead of + // TanStack default "Something went wrong! Hide Error" + raw stack trace. + errorComponent: AppErrorBoundary, }) function RootLayout() { diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index a5be970..8e13245 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -598,6 +598,25 @@ function DictionaryDetail() { return false }, [detailQuery.data?.schemaJson]) + // Full-page loader пока ОБА query (detail + records) не готовы. Иначе + // юзер видит partial render — узкая колонка без extra columns, info panel + // ещё не появилась, бар нагружается частями. Per user: "вот такого не + // должно быть, лучше лоадер". + if (detailQuery.isLoading || (!detailQuery.data && !detailQuery.error)) { + return ( +
+ +
+ ) + } + if (detailQuery.error) { + return ( + + {String(detailQuery.error)} + + ) + } + return (
{scope && ( diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx index 81eba94..65e3835 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx @@ -185,7 +185,13 @@ function DictionariesPage() { ) : null - if (isLoading) return + if (isLoading) { + return ( +
+ +
+ ) + } if (error) { return ( diff --git a/ordinis-admin-ui/src/styles.css b/ordinis-admin-ui/src/styles.css index 453deed..4f7d280 100644 --- a/ordinis-admin-ui/src/styles.css +++ b/ordinis-admin-ui/src/styles.css @@ -374,6 +374,44 @@ body { color: var(--color-mute); } +/* === GlobeLoader keyframes — orbital spinner (src/ui/components/globe-loader.tsx). + * Класс .globe-loader на wrapper'е активирует вложенные .globe-* animations + * через стандартные CSS animation rules. Transform-only — GPU-композитятся. */ +@keyframes ord-globe-spin { + to { transform: rotate(360deg); } +} +@keyframes ord-globe-pulse { + 0%, 100% { opacity: 0.18; } + 50% { opacity: 0.42; } +} +/* transform-box default = view-box для SVG elements — transform-origin + * считается в SVG user coordinates (viewBox 0 0 200 200). 100px 100px = + * центр глобуса. Прошлая версия с transform-box: fill-box ломала орбиты + * (origin становился offset bounding-box'а каждой группы — арки летали + * вокруг неправильной точки). Прототип HTML использует default — copy. */ +.globe-loader .globe-whirl { + transform-origin: 100px 100px; + animation: ord-globe-spin 3.6s linear infinite; +} +.globe-loader .globe-whirl-rev { + transform-origin: 100px 100px; + animation: ord-globe-spin 5.4s linear infinite reverse; +} +.globe-loader .globe-pulse { + transform-origin: 100px 100px; + animation: ord-globe-pulse 2.4s ease-in-out infinite; +} +/* Sphere/land внутри globe-loader rotates через d3 projection (rAF + * пересчитывает path d-attr), не через CSS transform — иначе land + * distort'илась бы при rotation (no projection math). */ +@media (prefers-reduced-motion: reduce) { + .globe-loader .globe-whirl, + .globe-loader .globe-whirl-rev, + .globe-loader .globe-pulse { + animation: none; + } +} + /* === Legacy fallback: старые компоненты используют nstart token names * (--color-carbon, --color-ultramarain etc). Маппим их на новые pending * полная миграция. === */ diff --git a/ordinis-admin-ui/src/ui/components/globe-loader.tsx b/ordinis-admin-ui/src/ui/components/globe-loader.tsx new file mode 100644 index 0000000..7b40eee --- /dev/null +++ b/ordinis-admin-ui/src/ui/components/globe-loader.tsx @@ -0,0 +1,225 @@ +import { useEffect, useId as useReactId, useRef, useState } from 'react' +import { geoOrthographic, geoPath, geoGraticule10 } from 'd3-geo' +import type { Feature, FeatureCollection, Geometry } from 'geojson' +import { cn } from '@/lib/utils' + +/** + * GlobeLoader — orbital globe spinner per /Users/zimin/Downloads/Globe loader + * prototype. Real Natural Earth land outline через d3-geo orthographic + * projection, rotating longitude continuously. Outer whirl arcs + pulsing + * ring CSS-only. + * + *

Lazy-loads world-atlas (~55kb) — пока геометрия не доехала, видны + * только sphere + graticule + orbital arcs (still looks like a loader). + * + *

Использование: + * // 64px default + * // larger + * // tint via currentColor + */ + +export type GlobeLoaderProps = { + /** Pixel size of the spinner square (default 64). */ + size?: number + /** Optional label rendered below. */ + label?: React.ReactNode + /** Extra wrapper class. */ + className?: string +} + +// Cache загруженной land geometry между mount'ами loader'а (типично загрузка +// списка → переход в editor → ещё loader). Без cache каждый GlobeLoader +// триггерил бы import заново — Vite кеширует chunk, но parse cost есть. +let LAND_CACHE: Feature | null = null +let LAND_PROMISE: Promise> | null = null + +async function loadLand(): Promise> { + if (LAND_CACHE) return LAND_CACHE + if (LAND_PROMISE) return LAND_PROMISE + LAND_PROMISE = (async () => { + const [{ feature }, world] = await Promise.all([ + import('topojson-client'), + import('world-atlas/land-110m.json'), + ]) + const data = (world as { default: unknown }).default ?? world + // topojson-client expects Topology с objects.land mesh. + const f = feature( + data as Parameters[0], + (data as { objects: { land: Parameters[1] } }).objects.land, + ) as Feature | FeatureCollection + const result = 'features' in f ? (f.features[0] as Feature) : f + LAND_CACHE = result + return result + })() + return LAND_PROMISE +} + +export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) { + const id = useReactId().replace(/:/g, '') + const tailA = `globe-tail-a-${id}` + const tailB = `globe-tail-b-${id}` + + const sphereRef = useRef(null) + const gratRef = useRef(null) + const landRef = useRef(null) + const [landReady, setLandReady] = useState(false) + + useEffect(() => { + // Setup projection: orthographic, R=70 (как в прототипе), tilt -15° (легкий + // northern lean). viewBox 200x200 — fixed; scale projection относительно него. + const projection = geoOrthographic() + .scale(70) + .translate([100, 100]) + .clipAngle(90) + .rotate([0, -15, 0]) + + const path = geoPath(projection) + const graticule = geoGraticule10() + + // Initial render (без land — оно lazy). + if (sphereRef.current) sphereRef.current.setAttribute('d', path({ type: 'Sphere' }) ?? '') + if (gratRef.current) gratRef.current.setAttribute('d', path(graticule) ?? '') + + let landFeature: Feature | null = LAND_CACHE + let cancelled = false + if (!landFeature) { + void loadLand().then((f) => { + if (cancelled) return + landFeature = f + if (landRef.current) landRef.current.setAttribute('d', path(f) ?? '') + setLandReady(true) + }) + } else { + if (landRef.current) landRef.current.setAttribute('d', path(landFeature) ?? '') + setLandReady(true) + } + + // Animate rotation — 8s per revolution per prototype. + const start = performance.now() + const period = 8000 + let raf = 0 + + const frame = (now: number) => { + if (cancelled) return + const t = ((now - start) % period) / period + const lambda = t * 360 + projection.rotate([lambda, -15, 0]) + if (gratRef.current) gratRef.current.setAttribute('d', path(graticule) ?? '') + if (landFeature && landRef.current) { + landRef.current.setAttribute('d', path(landFeature) ?? '') + } + raf = requestAnimationFrame(frame) + } + raf = requestAnimationFrame(frame) + + return () => { + cancelled = true + cancelAnimationFrame(raf) + } + }, []) + + return ( +

+
+ {/* Outer overlay svg: whirl arcs + pulse ring (CSS-animated). */} + + + + + + + + + + + + + + + {/* Pulsing outermost dashed ring */} + + + + + {/* Outer whirl: 270° tail arc + leading dot + ghost arc */} + + + + + + + {/* Inner whirl, counter-rotating */} + + + + + + + {/* Globe svg: real orthographic projection с rotating land. */} + + {/* Sphere — disk silhouette (fill = bg чтобы land не светилось через + * прозрачное место). */} + + {/* Graticule — subtle lat/lon grid. */} + + {/* Land — Natural Earth outline, rotated continuously. fill-opacity + * 0.88 чтобы slightly soften. */} + + +
+ {label && {label}} +
+ ) +} diff --git a/ordinis-admin-ui/src/ui/components/loading-block.tsx b/ordinis-admin-ui/src/ui/components/loading-block.tsx index e24e54a..90e2af3 100644 --- a/ordinis-admin-ui/src/ui/components/loading-block.tsx +++ b/ordinis-admin-ui/src/ui/components/loading-block.tsx @@ -1,14 +1,15 @@ -import { Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' +import { GlobeLoader } from './globe-loader' /** - * LoadingBlock — central spinner с подписью. Заменяет {@code @nstart/ui} - * LoadingBlock. Для inline placeholder используйте Skeleton. + * LoadingBlock — central spinner с подписью. Используется везде где + * загружаются данные / окна. С 11.05.2026 заменили Loader2 spinner на + * GlobeLoader (orbital globe loader per /Users/zimin/Downloads/Globe loader). * * Size variants: - * - sm: small inline spinner (py-4) - * - md: default centered (py-12) - * - lg: prominent (py-20) + * - sm: small inline (py-4, 32px globe) + * - md: default centered (py-12, 64px globe) + * - lg: prominent full-page (py-20, 96px globe) */ export type LoadingBlockProps = React.HTMLAttributes & { label?: React.ReactNode @@ -16,9 +17,9 @@ export type LoadingBlockProps = React.HTMLAttributes & { } const SIZES = { - sm: { wrapper: 'py-4', icon: 'size-3' }, - md: { wrapper: 'py-12', icon: 'size-5' }, - lg: { wrapper: 'py-20', icon: 'size-8' }, + sm: { wrapper: 'py-4', globe: 32 }, + md: { wrapper: 'py-12', globe: 64 }, + lg: { wrapper: 'py-20', globe: 96 }, } as const export function LoadingBlock({ className, label, size = 'md', ...props }: LoadingBlockProps) { @@ -28,14 +29,14 @@ export function LoadingBlock({ className, label, size = 'md', ...props }: Loadin role="status" aria-busy="true" className={cn( - 'flex flex-col items-center justify-center gap-2 text-mute', + 'flex flex-col items-center justify-center gap-3 text-ink', s.wrapper, className, )} {...props} > - - {label && {label}} + + {label && {label}}
) } diff --git a/ordinis-admin-ui/src/ui/index.ts b/ordinis-admin-ui/src/ui/index.ts index 6818fe1..0e60d6c 100644 --- a/ordinis-admin-ui/src/ui/index.ts +++ b/ordinis-admin-ui/src/ui/index.ts @@ -78,6 +78,7 @@ export { Panel, type PanelProps } from './components/panel' export { PageHeader, type PageHeaderProps } from './components/page-header' export { EmptyState, type EmptyStateProps } from './components/empty-state' export { LoadingBlock, type LoadingBlockProps } from './components/loading-block' +export { GlobeLoader, type GlobeLoaderProps } from './components/globe-loader' // === Tables === export {