Phase 4 last optional UX из design doc — bulk operations:
- Multi-select column в reviewer queue (Checkbox per row + select-all
header). Set<draftId> selection state, persisted между refetch'ами.
- "Approve выбранные" — confirm dialog → parallel approveDraft mutations
через Promise.all. Каждая mutation independent: если одна fail
(409 self_approve_forbidden / draft_not_pending race), остальные
продолжают.
- "Reject выбранные" — Modal с TextArea для shared reason (required,
matches backend reject_reason_required validation). Reason одинаковый
для всех — bulk обычно "duplicate registration" / "wrong scope" / etc.
- Bulk result Alert: approved count / rejected count / failed list с
per-draft reason'ами (err code из 409). User видит точно что прошло
и что нет.
- Auto-clear selection после bulk operation. Queue refetch'ит через
refetchInterval=30s, плюс mutation onSuccess invalidate'ит ['drafts'].
i18n RU/EN: 22 keys (selectAll/selectRow/selectedCount/approveSelected/
rejectSelected/clear/confirmApprove/rejectModal*/confirmReject/cancel/
resultTitle/approvedCount/rejectedCount/failedCount). Pluralization
через i18next plurals.
Why ship without soak data:
- Implementation не зависит от queue depth — это просто loop'ит над
выбранными drafts.
- Reviewer на любом dict с queue >5 уже выгадывает время.
- Если queue staying small после soak — feature не bothering нас (UI
toolbar показывается только когда anySelected).
Tests: vitest 89/89, tsc clean, prod build 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.
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.
Фильтры audit log теперь живут в URL search params: dict, action, user,
bk, from, to, page, size. Result:
- reload не сбрасывает фильтр
- админ может поделиться ссылкой "audit за вчера на ground_station"
- browser back/forward работает по фильтрам как навигация
- ссылка из логов / тикета сразу открывает нужный срез
Chip-list над таблицей показывает активные фильтры; клик на chip
убирает только этот фильтр (не сбрасывает остальные). UX выгрузка
без копания в свёрнутой панели.
Apply на Enter для TextInput'ов user/businessKey — быстрее чем кнопка.
Validation в validateSearch: action — enum, page — non-negative int,
size — whitelist (50/100/200). Невалидные query params silently
дропаются (не throw — admin-ui остаётся на странице).
Раньше при 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-страницы
не критично.
Раньше records list показывал только businessKey / name / scope /
locale / validFrom. На spacecraft с 14 полями admin не видел статуса
или типа КА — должен был открыть edit modal каждой записи чтобы
понять что есть OPERATIONAL и что есть LOST.
Drop unhelpful 'locale' колонку (для не-localized словарей всегда '—').
Replace на до 2 schema-driven 'значимых' колонок:
- FK поля (x-references) — синий fill, маркируют ссылку на dict
- Enum поля — серый fill, fixed allow-list
Spacecraft теперь показывает в таблице: businessKey, name,
satellite_type_code (FK), status (FK), scope, validFrom — всю
полезную информацию за один взгляд.
Schema interpretation:
- detailQuery.data.schemaJson.properties → filter scalar non-localized
- Skip name/code/businessKey (уже показаны)
- Take первые 2 enum/reference (column count fits 1280px viewport)
- humanize key для header label (snake_case → Snake Case)
Tests: 89 passed (без изменений — render-логика, integration test
требует QueryClient + schema fixture, ROI низкий).
Раньше для записей с x-localized name (КА, антенна, ground_station,
operator) колонка 'name / code' показывала literal '[object Object]'
потому что String({'ru-RU': 'Канопус', 'en-US': 'Kanopus'}) =
toString() стандартный → '[object Object]'.
Решение:
- Новый shared util lib/locales.ts:
- pickLocalized(value, defaultLocale) — извлекает строку из i18n
объекта или plain string. Fallback на first non-empty key
если defaultLocale не найден.
- recordDisplayName(data, defaultLocale) — name → code → '—' chain.
- Records list использует recordDisplayName с dict.defaultLocale
(берётся из DictionaryDetail).
- SchemaDrivenForm дедуплицирован — был свой локальный pickLocalized,
теперь импортирует из util'ы.
Tests: +13 (76 → 89), unit tests на pickLocalized + recordDisplayName
покрывают все edge cases (plain string, localized object, fallback
chain, null/undefined input).
После создания 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.
Раньше search/scope/AOI filters жили только в локальном useState —
F5 сбрасывал, ссылку с применёнными фильтрами не расшарить.
Теперь:
- ?q=text — search query
- ?scopes=PUBLIC,INTERNAL — comma-separated subset (empty = все)
- ?bbox=west,south,east,north — bbox AOI (server-side spatial filter)
Polygon AOI остаётся в local state — слишком длинно (50+ точек) для
URL, рендерили бы /dictionaries/foo?polygon={...} с сотнями символов.
validateSearch на route + Route.useSearch() читает; navigate replace:true
обновляет URL без новой history entry на каждый keystroke search'а.
Bootstrap: при mount если ?bbox= — рендерим bbox-AOI initial state;
картинку с прямоугольником сразу видно в picker'е если открыть.
Use cases:
- Bookmark filtered view словаря
- Поделиться ссылкой '/dictionaries/spacecraft?q=Канопус&scopes=PUBLIC'
- F5 не теряет filters
На странице словаря над таблицей появилась filter bar:
- SearchInput (поиск по businessKey + JSON.stringify(data) — покрывает
локализованные name, code, описания)
- 3 scope chips PUBLIC/INTERNAL/RESTRICTED с цветными dot'ами,
toggle включает/исключает scope. Empty Set = все scope видны
(default state).
- Когда фильтры активны — показываем 'Найдено X из Y' + кнопка
Сбросить.
- Если фильтр выдал 0 результатов — EmptyState с пояснением
что под фильтр ничего не подошло.
Фильтрация client-side через useMemo. Записей в одном словаре
обычно ≤100, JSON.stringify + includes() мгновенно. AOI остаётся
server-side (геометрия требует PostGIS) — фильтры комбинируются.
i18n: RU + EN ключи.
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.
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)
Перенесли scope-визуальные константы (SCOPE_ORDER, SCOPE_DOT,
SCOPE_ACCENT) в `lib/scope-style.ts` для переиспользования. На detail
странице (`dictionaries/$name`) добавили top accent strip 4px цветом
scope + цветной чип в breadcrumb рядом с back-link. Теперь при переходе
из списка в карточку scope сразу виден без чтения badge в таблице.
40 справочников в плоском grid тяжело сканировать. Разделили на секции
PUBLIC / INTERNAL / RESTRICTED с цветными accent-полосами слева у карточек
(emerald / amber / rose). Search фильтрует across all sections, пустые
секции скрыты. Цвет даёт instant scan, секции — explicit разделение.
Backend:
- DictionaryRecordRepository.countActiveGroupedByDictionary — один GROUP BY
по всем справочникам с фильтром по scope, без N+1.
- DictionaryResponse.recordCount (nullable) + factory from(d, count).
- DictionaryDefinitionController передаёт счётчики в list/get,
фильтрованные по currentScopes() пользователя.
Frontend:
- SearchInput над сеткой карточек, client-side filter по
name/displayName/description (case-insensitive, useDeferredValue).
- Badge "N записей" (i18n plural ru/en) в углу карточки.
- "Показано N из M" рядом с поиском.
- EmptyState когда поиск пустой.
react-oidc-context + oidc-client-ts. Confidential client тоже работает —
SPA просто не использует client_secret.
- src/auth/oidcConfig.ts — UserManager settings (authority, redirect_uri,
PKCE, automaticSilentRenew). VITE_KEYCLOAK_{URL,REALM,CLIENT_ID} с дефолтами
на nstart realm + ordinis client.
- src/auth/TokenSync.tsx — синкает access_token в localStorage и axios default
header после login/refresh. Existing API код не трогали — interceptor уже
читает localStorage.
Bonus: 401 interceptor триггерит signinSilent → signinRedirect fallback.
- src/auth/AuthBadge.tsx — header'ный бейдж: "Войти" если anonymous, имя +
logout если залогинен.
- main.tsx обёрнут в <AuthProvider>.
- __root.tsx показывает AuthBadge рядом с language switch.
- .env.example с конфигом Keycloak.
- i18n auth.login / auth.logout (RU + EN).
Keycloak client `ordinis` — настроить:
Valid redirect URIs: https://ordinis.k8s.265.nstart.cloud/*,
https://ordinis.k8s.264.nstart.cloud/*,
http://localhost:5173/*
Web Origins: те же + (или "+"), PKCE Code Challenge: S256
До этого audit_log entity была определена, но никто туда не писал — реальный
аудит шёл только через Hibernate Envers (без user/IP/trace). Теперь:
- AuditLogger service пишет в той же транзакции что и CRUD (DictionaryRecordService).
Захватывает: user (JWT sub либо preferred_username), scope, IP (X-Forwarded-For),
UA, trace_id (MDC), request_id. Не ломает основной flow при сбое — только
громко логирует.
- AuditLogRepository: гибкий search() с nullable фильтрами (dictionary, user,
action, businessKey, from, to) + Pageable.
- AuditAdminController: GET /admin/audit (paginated) + /admin/audit/export.csv
(cap 10k строк против OOM).
Frontend:
- /audit route: фильтр-панель, таблица с цветными бейджами (CREATE/UPDATE/CLOSE),
expand-row с JSON before/after diff, footer IP/UA/req_id, кнопка экспорта CSV.
Pagination 50/100/200.
- /outbox route: stat-cards pending/DLQ + DLQ-таблица с last_error.
- Nav links + i18n (RU + EN).
Tests (Vitest):
- src/lib/dates.ts (вынесено из SchemaDrivenForm + dictionaries.$name): parseFormDate,
formatIsoDate, formatIsoDateTime, extractTime, combineDateTime, localTzOffset, nowIsoLocal
- src/lib/dates.test.ts (20 tests): TZ-safety, pure-date local midnight, far-future filter,
round-trip parse/format, time extraction в local TZ, clear-button (null Date → '')
- src/components/schema/types.test.ts (31 tests): buildSchemaJson всех 9 kinds + x-unique +
x-id-source, parseSchemaJson roundtrip, diffSchemas (8 breaking detection cases),
suggestVersionBump (major/minor/patch + breaking-wins-mix)
CI:
- Откатил dual resolve-tag (BACKEND/FRONTEND) на single resolve-tag.
GitLab variable interpolation в trigger jobs не подхватывает dotenv от needs-job
→ admin-ui-only push не доезжал до infra. Now backwards compat path работает
как раньше с UPSTREAM_IMAGE_TAG.
- Backend docker jobs (maven-package + 4 docker-*) теперь без rules:changes —
catch ImagePullBackOff если frontend push шёл без backend rebuild. BuildKit cache
делает повторный билд идентичных layers ~30 sec.
- Path-filter оставлен на maven-test (тесты не запускаются на frontend-only PR
— они бэкендовые) и на docker-admin-ui (frontend-only).
Run: pnpm test → 51 passed.
CI: path-filtering (наконец-то)
- Backend jobs (maven-test, maven-package, docker-app/read-api/projection-writer/migrations,
resolve-backend-tag) запускаются только при backend-changes (Java модули, pom.xml, CI yml).
- Frontend (docker-admin-ui, resolve-frontend-tag) — только при ordinis-admin-ui/**.
- Trigger downstream передаёт BACKEND_IMAGE_TAG и/или FRONTEND_IMAGE_TAG. Backwards
compat для старого UPSTREAM_IMAGE_TAG сохранён в infra pipeline.
- Frontend-only PR теперь не катит backend rolling update (raньше каждый push
тегал ВСЕ сервисы новым sha и роллил).
Edit dialog UX (bitemporal):
- При Edit validFrom default = now() (а не historical validFrom существующей записи).
Раньше юзер сдвигал validFrom назад → backend не находил активной версии в этот
момент → 'record_not_active'. Теперь по умолчанию 'новая версия с этого момента'.
- nowIsoLocal() в timezone браузера (с offset).
- validTo default пустой = бессрочно.
Backend: понятное сообщение для record_not_active с инструкцией.
History:
- Drawer на детальной странице записи (timeline всех версий через GET /records/{key}/history)
- Иконка ClockCounterClockwise в actions колонке таблицы записей
- Каждая версия показывает v(version), validFrom/To, updatedBy + expand JSON
Schema diff (breaking detection):
- Util diffSchemas() сравнивает старую/новую схему и классифицирует изменения:
breaking (removed/typeChanged/becameRequired/enumRemoved/maxLengthTightened/
patternChanged/newRequiredField), compatible (added optional/enumAdded/
becameOptional), metadata (description)
- В DictionaryEditorDialog при breaking → Alert с детальным списком + Save заблокирован
- При compatible → Alert info со списком
- suggestVersionBump: major для breaking, minor для compatible field changes,
patch для metadata-only
x-unique:
- Чекбокс «Уникальное» на PropertyEditor → emit x-unique: true в schema
- Backend enforcement позже — отдельный PR с partial unique index
x-id-source:
- SingleSelect в Метаданных «Использовать как ID» → emit x-id-source на schema root
- В SchemaDrivenForm если установлен — businessKey input скрывается, на submit
derived из data[idSource]
- Подсказка с именем поля
DateTime для validFrom/validTo:
- Заменил DatePicker (date-only) на DateTimeField компонент: DatePicker + TextInput
type=time рядом
- Default time: validFrom=00:00, validTo=23:59 (запись действует до конца дня
иначе зануляется в полночь)
- Поддерживает 2-часовые интервалы (запуск КА в 14:30 → сход в 02:15 etc.)
Bug fix:
- parseSchemaJson изменил сигнатуру с PropertyDef[] на {properties, idSource},
старый код в DictionaryEditorDialog падал "{} is not iterable". Поправлено
через destructuring const parsed = parseSchemaJson(...).
i18n: 25+ новых ключей (RU/EN) — schema.unique, schema.idSource, schema.diff.*,
history.*, form.idSourceNote.
Несколько связанных багов в форме записей:
DatePicker:
- launch_date / decay_date (format=date) и validFrom/validTo рендерились как
raw text input → теперь @nstart/ui DatePicker с RU локалью.
- DatePickerProvider добавлен в main.tsx с DATE_PICKER_LOCALE_RU.
- Far-future infinity dates (year>=9999) скрываются (пустое поле).
- ISO date format на submit: yyyy-MM-dd.
Nested object → JsonField fallback:
- orbit (type=object с raan_deg/eccentricity/...) и antennas (array of object)
показывали [object Object] в text input. Добавлен JsonField — TextArea
с JSON.stringify/parse, локальный state для невалидного промежуточного
ввода + inline parse error.
Edit raw record fetch:
- Read-api flatten'ит i18n в строку выбранной локали → form для edit получал
data.name = 'Terra' вместо {ru-RU, en-US}. На Edit теперь fetch raw record
через writer GET /api/v1/dictionaries/{name}/records/{businessKey}.
- nginx regex location ~ ^/api/v1/dictionaries/ → writer (вместо узкого regex
только для /dictionaries/{name}). GET schema, GET raw record, mutations —
всё на writer. Read-api держит /api/v1/{name}/records/* (другой URL pattern).
- LoadingBlock пока тащим raw запись + Alert на ошибке.
Свои Modal/Button/inputs выкинуты — берём nstart-ui v0.1.3 как в других corp
React приложениях. Установка через .npmrc → repo.nstart.cloud/repository/npm-hosted/.
Что сделано:
- .npmrc: scope @nstart на corp Nexus + min-release-age=7
- deps: @nstart/ui ^0.1.3, @phosphor-icons/react ^2.1.10
- bumped peer deps: i18next ^26 + react-i18next ^17 (требование nstart-ui)
- styles.css: импорт @nstart/ui/styles/{fonts,theme}.css + critical @source
директива (без неё Tailwind v4 tree-shaking дропнул бы все классы из dist либы)
- main.tsx: <NStartUiProvider> на корне
- DROP src/components/ui/Modal.tsx (свой Modal больше не нужен)
Routes:
- __root: LanguageSwitch вместо двух кнопок RU/EN, токены ultramarain/regolith/carbon
- dictionaries.index: PageHeader + Badge для scope chip + Alert/EmptyState/LoadingBlock
- dictionaries.$name: PageHeader с actions, Modal/Panel/Table/TableRow/Cell,
IconButton с Phosphor (PencilSimple, XCircle), Button variant=primary/danger/ghost
Form (SchemaDrivenForm):
- TextInput/TextArea/SingleSelect/Checkbox + FieldLabel/Hint/Error из @nstart/ui
- FormSection + FormGrid columns=1|2 для группировки
- FormActions align=end с Button loading state
- Alert variant=error для server-side ошибок (422 IdempotencyConflict / 409 InProgress)
- ajv валидация по schema_json остаётся такой же (Draft-07, как у backend)
Bundle: 192KB → 659KB gzip — тянет echarts/lottie/brand assets из nstart-ui,
для internal admin tool приемлемо (cached after first load).
После regen routeTree.gen.ts /dictionaries/$name стал child route /dictionaries.
dictionaries.tsx рендерил карточки без <Outlet/> — клик по карточке менял URL,
но child компонент негде было рендерить, поэтому видна та же страница со списком.
Разнёс по конвенции:
- dictionaries.tsx — layout с <Outlet/>
- dictionaries.index.tsx — карточки списка (matches /dictionaries)
- dictionaries.$name.tsx — без изменений (matches /dictionaries/{name})