Phase 1a из плана: только swap библиотеки, realm/URL остаются на
auth.nstart.space/realms/nstart. Phase 2 (intgr с altum auth-service)
— отдельный эпик.
Зачем. oidc-client-ts с automaticSilentRenew триггерил бесконечный
POST /token loop когда refresh_token истекал (см. MR !269 revert,
~30+ 400'к в console, страница залипала на 403). Altum давно ушёл
с oidc-client-ts на keycloak-js по той же причине.
Новая модель.
- src/lib/keycloak.ts — singleton + initKeycloak(check-sso, PKCE) +
ensureFreshToken(N) wrapper над kc.updateToken(). Зеркало
altum/src/lib/keycloak.ts.
- src/stores/authStore.ts — Zustand store: {user, isAuthenticated,
isLoading, error}. checkAuth() ждёт initKeycloak, wireKeycloakEvents()
→ onAuthSuccess/Refresh/Logout/TokenExpired re-снэпшотят store.
- src/auth/useAuth.ts — compat shim для react-oidc-context API
(auth.user.profile, isAuthenticated, signinSilent, signinRedirect,
signoutRedirect, removeUser, error). Все 11 callsites продолжают
работать без правок (только сменили путь import'а).
- src/api/client.ts — request interceptor дёргает ensureFreshToken(30)
ПЕРЕД каждым запросом + attaches Bearer ${getToken()}. 401 interceptor
делает ensureFreshToken(0) + retry один раз. Никаких таймеров, никаких
setAccessToken/setUnauthorizedHandler holder'ов — keycloak-js сам source
of truth.
- src/main.tsx — убран <AuthProvider>, <TokenSync />. Просто
useAuthStore.getState().checkAuth() на старте, потом обычный render.
- public/silent-check-sso.html — endpoint для silentCheckSsoRedirectUri.
Удалены.
- src/auth/TokenSync.tsx — не нужен, ensureFreshToken на запросах.
- src/auth/oidcConfig.ts — не нужен, конфиг внутри lib/keycloak.ts.
- npm deps: react-oidc-context, oidc-client-ts.
Добавлены deps: keycloak-js@26.2.4, zustand@5.0.13.
Tests: 171/171 passed, TSC чистый.
Migration callsites — все unchanged по семантике благодаря compat shim.
Симптом: пользователь видел ~30+ POST на Keycloak /token endpoint
с 400 Bad Request, страница залипала.
Корень. MR !267 добавил useEffect в TokenSync который на
auth.user?.expired === true делал signinSilent → catch → signinRedirect.
MR !268 добавил interceptor триггер на 403 + !accessToken.
Цикл:
1. token expires, refresh_token тоже (Keycloak SSO Session Idle прошёл)
2. useEffect видит expired=true → signinSilent → POST /token с refresh
3. Keycloak возвращает 400 invalid_grant (refresh expired)
4. catch → signinRedirect, но oidc-client-ts automaticSilentRenew=true
параллельно тоже пытается → ещё POST /token → ещё 400
5. signinRedirect не успевает редиректнуть до следующего render'а
6. reauthing.current=false → useEffect fire'ит снова → новый signinSilent
7. → ещё 400, и так в loop ~30 раз пока что-то не разрулит
Откатываю обе ветки. Возвращаемся к v2.42.3 поведению: silent renew
автоматически пробует один раз, на failure пользователь видит 403
(или stale data из cache) и делает relogin вручную. Не идеально, но
не спамит Keycloak и не залипает на endlessly.
Backend ScopeContext admin → RESTRICTED (MR !267 backend часть)
остаётся — это безвредная правка, помогает юзерам с admin ролью
без явного INTERNAL.
Симптом: после ~30 мин админ видит 403 на /admin/*, перелогин чинит.
Цепочка событий:
1. access_token истекает
2. oidc-client-ts автоматический silent renew падает (refresh_token expired
за 30 мин SSO Session Idle / iframe заблокирован)
3. auth.user становится undefined (oidc убирает после full expiry)
4. TokenSync setAccessToken(null) — accessToken в apiClient зануляется
5. Следующий запрос идёт БЕЗ Authorization header
6. Backend (ORDINIS_AUTH_REQUIRED=false) пропускает как anonymous →
ScopeContext = PUBLIC → controller с requireInternal() кидает 403
401-handler не срабатывает на 403 → юзер залипает.
Также мой предыдущий useEffect в TokenSync (MR !267) триггерит на
auth.user?.expired === true, но если user удалён полностью —
optional-chaining вернёт undefined и useEffect не сработает.
Fix: расширил interceptor чтобы на (status === 403 && accessToken == null)
тоже вызывать unauthorizedHandler. Этот handler — single source of
truth, делает signinSilent → fallback signinRedirect.
Не цепляет legitimate 403 (когда у юзера ЕСТЬ токен но scope недостаточно —
например viewer пытается dropить INTERNAL dict). accessToken != null
в таких случаях → ветка не сработает.
1. useApproveDraft/useRejectDraft теперь инвалидируют ['drafts'] key
(my-drafts + by-dict lists). До этого инвалидировался только ['draft']
(single by id), поэтому after-approve "Мои" badge продолжал показывать
pending-счётчик даже когда все драфты уже APPROVED — useMyDrafts отдавал
стейл-кеш. useWithdrawDraft уже это делал, теперь подтянули approve/reject.
2. DictionaryEditorDialog: на десктопе (≥880px) modal теперь max-w 1400px
вместо max-w-5xl (880px после маппинга в SIZE_CLASSES.xl). На <880px
осталось w-[95vw] (compact mobile modal). На 1440p мониторе editor
больше не выглядит вжатым, tabs+schema+preview помещаются без скролла.
Override через panelClassName c `!` чтобы перебить SIZE_CLASSES.
Last item из QA report backlog'а. Banner + empty-state hint показывают
aggregate count, но не помогают find конкретные записи которые скоро
изменятся. Badge per row даёт O(1) visual scan.
Backend (read-api):
- ScheduledRecordsSummary.scheduledBusinessKeys: List<String> (capped 500)
- Repo method findScheduledBusinessKeys (DISTINCT subquery)
- Single fetch вместо N+1 per-row queries
Frontend:
- scheduledByKey Set<string> построен из summary
- Badge variant=info «Запланировано» рядом с businessKey badge'ом
- Title attribute с full RU/EN tooltip
- Не блокирует layout — gap-1.5 с pendingReview badge'ем (могут быть оба)
i18n keys для ru/en — short label «Запланировано» / «Scheduled».
Три связанных UX бага после v2.31.0 release:
1. **TimeTravel marks**: после !220 slider extended до now+30d, но отметок в
future window не было → blind drag. Backend теперь возвращает
upcomingValidFroms (top 50) в /scheduled-summary; frontend merges как marks.
2. **Non-empty banner**: empty-state hint работает только когда список пуст.
Если currently active записи есть AND ещё запланированы — пользователь
не видит. Добавлен info banner над таблицей с тем же текстом + CTA.
3. **Editor "Действует с"**: edit mode оставлял поле пустым (bitemporal
policy «новая версия с now»). User feedback: «показывает ничего, а даты
заданы» — выглядит как баг. Prefill nowIsoLocal() — теперь юзер видит
конкретную дату как точку отсчёта.
Fixes UX bug: пользователь создаёт запись с validFrom на будущую дату
(e.g. 18.05), открывает catalog 15.05 — пустая таблица, никаких хинтов
о том что запись существует.
Backend (read-api):
- New endpoint GET /api/v1/{dict}/records/scheduled-summary
- Returns {count, nearestValidFrom} для записей с validFrom > now AND
validTo > now в текущем scope view
- Single-aggregate JPA query (COUNT + MIN), respect scope filter same way
as listActive — INTERNAL scheduled не учитывается для PUBLIC consumer'а
Frontend:
- New useScheduledSummary hook + ScheduledRecordsSummary type
- Empty-state в dictionaries.$name now shows: "Запланировано N, ближайшая
на DD.MM [Перейти к этой дате]" с CTA который set'ает time-travel на
nearestValidFrom
- i18n keys для ru/en plural forms
Связан с MR !219 (TimeTravelPicker 30-day future window) — теперь slider
позволяет дойти до даты записи, и empty-state hint позволяет узнать что
запись существует.
Бэйдж 'На ревью' в sidebar (desktop + mobile) был захардкожен в 0 —
TODO comment ждал cheap /drafts/pending/count endpoint. На самом деле
existing /admin/reviews/pending и /admin/schema-reviews/pending уже
возвращают paged response с totalElements/total в metadata — request
size=1 даёт нужный counter с минимальным payload (~1 строка + 4
metadata field'а).
Новый hook useReviewsBadgeCount():
- Параллельно дёргает обе queue (record + schema)
- Суммирует totalElements (record) + total (schema)
- Полл каждые 60s (lighter чем 30s queue refresh — badge не critical)
- Gated на auth.isAuthenticated (anonymous не должен дёргать admin endpoint'ы)
Sidebar.tsx (оба варианта — desktop + mobile MobileSidebar):
- reviewsCount: 0 → reviews.total
Per /Users/zimin/Downloads/redesign/screens/03-editor-records.png редизайн
prototype editor layout отличается от current MVP:
1. TopBar absorbs editor title + version (was в PageHeader)
2. PageHeader удалён — title и breadcrumb теперь только в TopBar
3. Tab labels получили counts (Записи 127, Связи 6, Поля 12)
4. Action buttons распределены per role:
- Time-travel + AOI → InfoPanel bottom (close к data context)
- Schema edit + +Запись → tab toolbar right (close к tab actions)
Changes:
src/components/layout/TopBar.tsx
- Breadcrumb получил subtitle slot — mono '<name> · v<version>'
- useBreadcrumb fetches dict detail для editor route '/dictionaries/$name'
- Render: '← Справочники / Космические аппараты satellites · v1.0.0'
src/api/queries.ts
- useDictionaryDetail принимает name: string | undefined + enabled flag
- Guard для editor breadcrumb когда route не matches dict
src/routes/dictionaries.$name.tsx
- Removed <PageHeader> — title/breadcrumb теперь в TopBar
- EditorInfoPanel получает actions prop:
onTimeTravel + timeTravelActive + onAoi + aoiActive
- EditorTabBar получает counts (per-tab badge) + actions slot
(right-aligned Schema + +Запись buttons)
- extractOutgoingFkCount helper для relations count
src/components/editor/EditorInfoPanel.tsx
- actions prop с Time-travel + AOI buttons
- Rendered в panel выше Relations section, между metadata и FK list
- aria-pressed когда active (time-travel или AOI с filter)
src/components/editor/EditorTabBar (inline in route)
- counts prop: Partial<Record<EditorTab, number>>
- actions prop: ReactNode для right-aligned cluster
- inline count badge — text-mono text-mute next to label
Tests: 116 pass. TS strict clean.
Approval Workflow v2 Phase 4 — UX:
Backend:
- DraftController.myDrafts: GET /api/v1/drafts/me?page&size — все статусы
(PENDING/APPROVED/REJECTED/WITHDRAWN) текущего authenticated user, sorted
submitted_at DESC. Resolve maker_id из JWT subject (fallback "anonymous"
если auth off).
- Reuses existing DraftService.listByMaker (Phase 1 method, был не exposed).
Frontend:
- routes/my-drafts.tsx: новый маршрут с табличным view.
Колонки: businessKey / operation / status / submittedAt / reviewedAt /
reviewerId / comment / actions. Status badge tinted (warning/success/
error/neutral). PENDING строки имеют Withdraw кнопку (calls existing
useWithdrawDraft с confirm).
- api/queries.ts: useMyDrafts (refetch 30s — maker увидит когда reviewer
decide).
- api/mutations.ts: useWithdrawDraft теперь invalidate'ит ['drafts'] —
/me + by-dict обе видят изменение.
- routes/__root.tsx: nav link "Мои черновики" / "My drafts" (после Reviews).
- i18n RU/EN: 18 keys (nav.myDrafts, myDrafts.* col/action/status).
Pluralization для total через i18next plurals.
Why это нужно:
- До этого maker submit'нул draft и пропадал — единственный feedback был
badge "На review" в records list (только для текущего dict). Здесь — все
его submissions с history + withdraw для PENDING.
- Reviewer pool (D3=A) маленький, /reviews queue только для них. Maker
без role'а не видит queue, но должен tracking своих submissions.
Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
CEO plan v1 stretch — закрывает gap "Smart JSONB search across all dictionaries".
Last gap из v1 list shipped.
Migration 0018:
- CREATE EXTENSION pg_trgm (CNPG initdb обычно не enable'ит, добавили
явно с MARK_RAN preCondition если уже есть).
- CREATE INDEX idx_dict_records_data_trgm
ON dictionary_records USING GIN ((data::text) gin_trgm_ops)
- Trigram-based ILIKE с минимум 3 символа в query — index используется.
ILIKE '%pat%' на data::text возвращает любые matches в JSONB serialized.
Backend:
- New RecordSearchQuery (ordinis-domain): native SQL c ROW_NUMBER()
per-dict cap. SQL injection защищён PreparedStatement param + extension
whitelist (allowed_scopes — text[]).
- New SearchController (ordinis-rest-api): GET /api/v1/search?q=&size&perDict.
Default size=100, perDict=10. Min q length=3 (silently empty if shorter).
Scope filtering through ScopeContext — каждый caller видит только свои
scope levels. Results grouped per dict с display name + count + items.
Admin UI:
- New /search route с SearchInput + grouped Panel results.
- URL state ?q=… — share-friendly link "/search?q=SAR-X".
- Per-result link → /dictionaries/{dict}?q={businessKey} для drill-down.
- Min-3 hint, empty state, loading + error.
- New "Поиск" / "Search" tab в navigation header.
- i18n RU (с правильными plurals search.totalHits) + EN.
Behavior:
- Active records only (valid_from <= now() < valid_to).
- Per-dict cap 10 — защита от dominant-dict skew (один dict с 1000 matches
не забьёт результат).
- Total cap 100 — admin "find this code" use case достаточно. Browser-style
search-as-you-type (10k+ matches, instant) — отложен на v2 с dedicated
index server (Elastic / Meili).
Verify:
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
После migration apply'я index size на dictionary_records будет ~30-40%
data column. На текущих 5k records ~3-5 MB, acceptable.
CEO plan v1 stretch — закрывает gap "Time-travel UI (slider)".
Backend уже поддерживает ?at=<ISO> в read-api endpoints (DictionaryReadController):
findActiveAt(dict, key, at) возвращает active version на этот момент.
Был только UI gap.
UI:
- New toolbar button "Просмотр на дату" с ClockCounterClockwiseIcon.
aria-pressed когда timeTravelAt активен.
- Datetime picker раскрывается inline (не модал — quick interactions).
Native <input type=datetime-local>, конвертируется ↔ ISO.
- Active state ambient banner (orbit/8 background) если ?at= задан + picker
закрыт — показывает "Просмотр на: {date}" + "Сейчас" клир link.
- "Закрыть" кнопка для picker'а; "Сейчас" сбрасывает at.
URL searchParams:
- DictSearch type расширен `at?: string` (ISO datetime).
- validateSearch проверяет `Date.parse(at)` — invalid timestamps drop'ятся.
- Share-friendly: "ordinis.k8s.265.../dictionaries/spacecraft?at=2025-12-31T00:00:00Z"
работает per F5 + ссылка коллеге.
Records query:
- RecordsFilter type +at?: string.
- recordsQuery передаёт ?at=… backend'у в дополнение к as_scope/bbox/polygon.
- queryKey включает at — switching между моментами trigger'ит refetch.
i18n RU/EN: timeTravel.{button,label,viewing,clear,close}.
Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
Backend смену не требует — DictionaryReadController.list() уже принимал
@RequestParam(value = "at") OffsetDateTime at и роут'ил в RecordReadService.
Per-dictionary opt-in для Redis projection materialization. Default false
— projection-writer пропускает event'ы того dict'а, никаких dead writes
в Redis. Включается админ'ом через UI checkbox в Metadata tab.
Migration 0017:
- ALTER TABLE dictionary_definitions ADD COLUMN redis_projection_enabled
BOOLEAN NOT NULL DEFAULT false.
- Index idx_dict_def_redis_proj для быстрого filtering в list queries.
Backend:
- DictionaryDefinition entity: new field + getter/setter.
- DictionaryResponse DTO: returns flag в response.
- CreateDictionaryRequest DTO: optional Boolean (null/missing = no change).
- DictionaryDefinitionService.create + updateSchema: применяет flag из request.
- ProjectionWriter (projection-writer service):
* upsert() — early-return + skip counter если flag=false на dict.
* delete() — symmetric: skip если flag=false (защита от stale keys
после flag-flip; полный cleanup TODO Phase 2).
* New metric: ordinis_projection_records_skipped_total.
Admin UI:
- DictionaryEditorDialog Metadata tab: checkbox "Включить Redis-проекцию"
с описанием. Apply через CreateDictionaryRequest payload.
- DictionaryDefinition + CreateDictionaryRequest types updated.
- i18n RU/EN: schema.redisProjection.{label,hint}.
Что НЕ делается (deferred Phase 2):
- read-api Redis routing — read-api сейчас всегда идёт в PG read replica.
Когда dict опт-инится в флаг, projection материализуется, но read-api
ещё не использует. Read routing — отдельная feature с benchmark proof
(через JMH + k6 SLO test).
- One-shot cleanup job для stale keys после flag-flip from true→false.
Behavior change в production:
- До patch: projection-writer пишет в Redis для ВСЕХ dict events (40
dictionaries). Default flag=false после migration → projection-writer
пропускает всё → Redis keys для existing dicts становятся stale.
Это OK потому что read-api НИКОГДА не читал из Redis (writes были
dead). Stale keys eventually evicted Redis maxmemory policy.
- После patch: только dicts с redis_projection_enabled=true получают
материализацию. По default — нет. Admin opt-ins per-dict через UI.
Verify:
- mvn test (full reactor): SUCCESS, all green.
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration 0017 applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
Backend POST /api/v1/dictionaries/{name}/records/export-selected.csv:
принимает businessKeys[] (1..500), возвращает CSV с колонками
business_key, valid_from, valid_to, scope, data (JSONB inline).
Не-найденные ключи отдаются строкой с пустыми ячейками — admin видит
что ключ обработан, но данных нет. RFC 4180 экранирование.
requireReadAccess: для экспорта достаточно прав чтения (не write).
POST а не GET потому что >100 ключей не помещается в URL params
(Server: Request URI Too Long).
Frontend useBulkExportRecords: axios POST с responseType: blob →
temporary <a download> link → клик → revokeObjectURL через rAF.
Filename берётся из Content-Disposition header.
Toolbar: новая кнопка "Экспорт CSV" (variant=secondary) рядом с
"Закрыть выбранные" (variant=danger). Loading state через isPending.
Pagination для records list (PAGE_SIZE=50, client-side):
- visibleRecords = filteredRecords.slice(...)
- Footer "Записи 1–50 из 100, Стр. 1 из 2" с Prev/Next кнопками
- Auto-reset на page=0 при изменении filter/scope/AOI
- selectAll/visibleKeys теперь = только current page (WYSIWYG)
- При >50 записей видно навигацию, при <=50 footer скрыт
Прогон: 20/20 e2e + 60/60 rest-api + 89/89 admin-ui.
Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает
businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через
inject'ed DictionaryRecordService — каждый close в своей транзакции
(SERIALIZABLE), partial success возможен. Result: closed[] / skipped[]
(already_closed, not_found) / errors[].
Frontend: checkbox column + sticky toolbar когда selection.size > 0.
Header checkbox с indeterminate state для partial select. selectAll
включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении
фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG).
Bulk close dialog: count + reason input → confirm → результат панель
"Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors.
После успеха selection остаётся для skipped/errors — юзер видит что
ещё не сделано.
Лимит 500: защита от случайного огромного селекта или бага UI.
Дедупликация внутри batch'а silent skip — не error.
Раньше при page=50 admin не мог найти редкий dlq среди
delivered'ов: client-side filter показал бы 1 совпадение из 50,
а на page=1 могли быть ещё. Делаем server-side через ?status=.
Backend:
- WebhookDeliveryRepository.findBySubscriptionIdAndStatusOrderByCreatedAtDesc
— Spring Data derived query, JPA генерирует SQL.
- GET /admin/webhooks/subscriptions/{id}/deliveries?status=dlq —
опциональный param. Invalid status → 400 OrdinisException.
Allowed: pending, retrying, delivered, dlq.
UI:
- 4 chip'а pending/retrying/delivered/dlq над таблицей deliveries
(одинаковый паттерн со scope-чипами в dictionaries).
- Click toggle + reset page=0. 'Сбросить' появляется когда фильтр
активен.
- Status в URL пока не персистится — для одной session-страницы
не критично.
После создания subscription / rotate secret admin не имел способа
проверить что receiver жив и валидирует HMAC без ожидания реального
business event. Теперь:
- POST /api/v1/admin/webhooks/subscriptions/{id}/test
- Synchronous: receiver получает synthetic event с теми же headers
что у production delivery (X-Ordinis-Signature, X-Ordinis-Event-Type,
X-Ordinis-Scope) + дополнительный X-Ordinis-Test=true чтобы
receiver мог логировать/филтровать как тест.
- HMAC sign'ится через тот же HmacSigner — admin раскопировал secret
правильно если receiver валидирует. Иначе receiver вернёт 4xx
и UI покажет ошибку.
- SSRF guard validate URL host (private CIDR + allowed-hosts override)
— same policy как у dispatcher.
Bypass'ит outbox/dispatcher — нет webhook_deliveries row, нет attempt
count, нет следов в аудите доставок (это test, не business event).
UI:
- Кнопка 'Тест ping' с PaperPlaneTilt иконкой рядом с 'Сменить secret'
на webhook detail page.
- Inline Alert после ответа: success/failure/rejected с HTTP status,
latency, body preview / error message. Dismiss '✕'.
RBAC: RESTRICTED — endpoint отправляет HTTP request на user-controlled
URL (potential abuse vector если бы был INTERNAL).
Tests: rest-api compiles, 76 admin-ui passed.
Раньше для replay'а failed/DLQ delivery нужно было либо ждать
backoff schedule (до 24h), либо лезть в БД и руками сбрасывать
attempt_count + dlq_at. Теперь admin замечает failed delivery в
UI, фиксит receiver (cert renewal, URL update), кликает 'повторить'
— dispatcher подхватит на следующем sendTick.
Backend:
- POST /api/v1/admin/webhooks/deliveries/{id}/retry
- RBAC: RESTRICTED (можно spam'ить receiver = destructive)
- Использует existing WebhookDelivery.resetDlq() — атомарно
status=retrying, attemptCount=0, dlqAt=null, nextAttemptAt=now
UI:
- Колонка 'Действия' в delivery history таблице (на webhook detail page)
- IconButton 'Повторить' показывается только для status=dlq|retrying
(delivered/pending не имеет смысла retry'ить)
- Confirm dialog объясняет что receiver получит payload снова
- onSuccess invalidate ['webhooks','deliveries'] + ['webhooks','dlq']
Tests: 75 admin-ui passed, rest-api compiles clean. Логика
resetDlq() уже покрыта unit tests в WebhookDeliveryTest.
Admin кликает 'AOI фильтр' на странице словаря → открывается
dialog с картой OSM. Рисует rectangle (→ bbox CSV) или
polygon → onApply применяется к recordsQuery как ?bbox= или
?polygon=GeoJSON.
Server-side parsing уже есть (BoundingBox.parse, GeoJsonPolygon.parse
в ordinis-rest-api) — подключили UI поверх.
Активный AOI отображается inline над таблицей с кнопкой
'Сбросить'. Edit shape возможен через leaflet-draw edit/remove
controls.
Зависимости: leaflet 1.9.4 + react-leaflet 5 + leaflet-draw 1.0.4
+ types. Bundle +231KB gzip 62KB — приемлемо для AOI-only feature
(map работает без MapLibre WASM, OSM tiles — публичные).
Picker self-contained: монтируется/уничтожается с диалогом,
invalidateSize 100ms после open чтобы Leaflet корректно посчитал
container size после modal animation.
Поля spacecraft.status, satellite_type_code и другие FK с x-references
теперь показывают dropdown с записями из целевого справочника вместо
свободного ввода. Label формируется как '<code> — <name.ru-RU>' если
target имеет localized name.
Fallback на TextInput когда target dict недоступен (scope-hide → 404)
или пуст — admin может ввести значение вручную, валидация серверная.
Кеш 5 минут (referenced data slow-moving) через TanStack Query.
UI tests +2 (75 total).
5 ошибок TS, накопленных за прошлые итерации — теперь pnpm tsc зелёный:
- SchemaDrivenForm: убран unused import formatIsoDateTime + unused
prop `path` (передавался в FieldRenderer, но FieldRendererProps его
не объявлял и FieldBody не использовал)
- dictionaries.$name: удалена неиспользуемая функция toIsoDate
- api/client.ts: добавлен `$schema?: string` в JsonSchema type
(используется в buildSchemaJson и тестируется в types.test.ts)