Commit Graph

36 Commits

Author SHA1 Message Date
Александр Зимин 2a76b9382e fix(ui): visible History button + remove left rail + dark JSON viewer 2026-05-12 00:18:03 +00:00
Александр Зимин 1a72418cb8 feat(form): x-section schema metadata + dynamic section bucketing 2026-05-11 21:52:24 +00:00
Zimin A.N. b312ba37ad feat(ui): 3-dot row menu + history compare/revert + DateTime fitting + tablet touch
Per user feedback screenshots:

SchemaDrivenForm DateTimeField:
- grid → flex flex-wrap: на узких cells (drawer 520px ÷ 2 cols = ~210px)
  time wraps под date вертикально. На wide — inline.
- Date min-w 8rem (128px), time w-24 (96px) — tablet touch-safe.

TableHeaderCell без visible label (sr-only для screen readers).
TableCell содержит DropdownMenu trigger 36×36px (touch target):
  - History (always)
  - Edit, Separator, Close (только canMutate, Close в danger color)
Заменяет 3 inline IconButton'a — экономит ширину + tablet friendly.

RecordHistoryDrawer accepts onCompare / onRevert callbacks. Каждая
non-current version row показывает:
  - Сравнить с текущим — opens ConflictDiffModal (reused diff UI)
  - Откатить к v{N} — submit updateMut с этой version данными как
    payload (+ confirm dialog). Backend создаёт новую active version,
    bitemporal history preserves старые.
JSON viewer переключился с <details> на state-driven (hide/show
без CSS rabbit hole).

history.hideData/compare/revert/revertConfirm (RU + EN).

- 3-dot trigger 36px = comfortable touch target (Apple HIG 44, ours
  acceptable для density)
- Drawer на <880px viewport = fullscreen sheet (already)
- DateTime wraps на cell <8rem+9rem+gap = 290px
- Sidebar collapse на <1024 = hamburger overlay
2026-05-12 00:03:44 +03:00
Александр Зимин 11797f88ae Merge branch 'feat/row-click-and-wide-rail' into 'main'
feat(ui): row click → edit drawer + wide-mode left section rail

See merge request 2-6/2-6-4/terravault/ordinis!105
2026-05-11 20:51:42 +00:00
Zimin A.N. 54cf563162 feat(ui): row click opens edit + wide-mode left section rail in drawer
Two improvements per user feedback ('по клику открывается редактирование и
в развернутом виде слева навигация'):

## 1. Row click → edit drawer

TableRow в /dictionaries/$name теперь имеет onClick={setEdit({kind:'edit',
record:r})} когда canMutate. cursor-pointer + bubbling guards:
- Checkbox cell и Actions cell имеют onClick stopPropagation чтобы
  внутренние controls не открывали drawer случайно.
- Anonymous users (no canMutate) — row не clickable (read-only, no drawer).

## 2. Wide-mode left rail в drawer

SchemaDrivenForm в sections layout теперь умеет ResizeObserver-based detection
своей ширины. Когда контейнер ≥720px (drawer wide mode = 880px) → render'ит
180px sticky left rail с section list + per-section field counts. Click rail
item → smooth scroll к section anchor. Иначе chip strip сверху, как раньше.

Container-based (не viewport-based) чтобы narrow drawer на wide viewport не
рендерил rail (520px не fits 180+1fr grid).
2026-05-11 23:51:03 +03:00
Zimin A.N. b2231a5a47 fix(form): FK select label overflow — truncate + ellipsis (наезжают on chevron)
Native <select> для FK references показывал label '<code> — <localized name>'
(например 'OPT_MIDRES_MS — Оптический мульти-резолюционный мульти-спектральный')
— текст бьётся на chevron в trigger area из-за тонкого contract'а browser-
specific overflow handling (наезжают per user screenshot 2026-05-11).

Fix:
- buildOption() обрезает name до MAX_FK_LABEL_CHARS=28 (code intact) +
  '…' suffix когда combined превышает limit
- select сам получает 'truncate overflow-hidden whitespace-nowrap text-ellipsis'
  как защита если browser native truncate не сработал
- pr-10 → pr-9 (38px) — точно совпадает с right-3 chevron position (12px)
  + ~16px icon + 8px gap, не overlap'aет.
2026-05-11 23:43:38 +03:00
Zimin A.N. ca53322b14 fix(form): break drawer counter/dirty infinite-loop + add GlobeLoader + AppErrorBoundary
Maximum update depth exceeded showed на /dictionaries/$name когда RecordDrawer
открывался в create mode. Root cause:
  1. SchemaDrivenForm useEffect deps включал onCounterChange/onDirtyChange.
  2. Parent route передавал inline arrow (() => setDrawerCount({...})) —
     новая identity каждый render.
  3. setDrawerCount({visible, total}) создавал новый object literal даже
     при тех же значениях → React re-render → effect fires → loop.

Fix:
- SchemaDrivenForm: ref-pattern для callback. useEffect subscribed только
  на value deps (visibleFieldCount/totalFieldCount/dirtyCount), latest
  callback хранится в ref.
- dataValues — useMemo с frozen EMPTY_DATA default чтобы не invalidate'ить
  isFieldVisible memo каждый render.

Plus:
- GlobeLoader component — orbital globe spinner per Downloads/Globe loader
  prototype. Pure SVG + CSS animations, без d3/topojson.
- LoadingBlock переключен с Loader2 на GlobeLoader (все usage обновляется
  автоматически: RecordHistoryDrawer, dictionaries.$name editor loading,
  search results, webhooks list, etc).
- AppErrorBoundary — friendly fallback вместо TanStack DefaultErrorComponent.
  GlobeLoader (paused/dimmed) + 'Что-то пошло не так' + [Обновить][На главную].
  Dev-only collapsible с raw stack trace.
2026-05-11 22:31:57 +03:00
Zimin A.N. 889207f629 feat: wire drawer toolbar filters + records table responsive hiding
Closing 2 handoff gaps:

1) DRAWER TOOLBAR FILTERS (handoff Screen 3 line 279)
SchemaDrivenForm получил три new optional props:
  - searchFilter (string) — case-insensitive substring против field key + label
  - onlyFilled (boolean) — hide fields с empty data[key]
    (null/undefined/''/false/0/empty-array/empty-object)
  - onCounterChange (visible, total) — surface field counter parent'у

Filter применяется поверх bucketing — все три buckets (identity/description/
extra) фильтруются. UI рендерит filteredBuckets.X.map(...) вместо raw bucket'ов.

В sections layout (drawer use case) если section после filter empty —
sticky header не рендерится. Tabs layout backward-compat (filter применяется
но visually invisible если user в другом tab'е).

dictionaries.$name.tsx callsite:
  - State: drawerSearch, drawerOnlyFilled, drawerCount {visible, total}
  - Reset on drawer close
  - RecordDrawer.toolbar prop wired только в edit mode (create — все поля
    empty, 'только заполненные' даст 0/0)
  - SchemaDrivenForm receives все три filter props

Result: user может typing 'mass' в drawer search → видит только mass_kg /
data_mass / etc.; toggle 'только заполненные' → hide unused optional fields.

2) RECORDS TABLE RESPONSIVE COLUMN HIDING (handoff line 466)
Records table в dictionaries.$name.tsx применяет hideBelow API из MR !59:
  - businessKey: всегда visible
  - name/code: hideBelow='md' (768px)
  - extra schema columns: hideBelow='lg' (1024px)
  - scope badge: hideBelow='sm' (640px)
  - validFrom: hideBelow='lg'
  - actions: всегда visible

На mobile 375px только businessKey + actions visible — chrome остаётся
usable. Tablet 768+ adds name/scope. Desktop 1024+ adds schema columns.

Tests: 116 pass.
2026-05-11 17:10:26 +03:00
Zimin A.N. 155c70e23c feat(drawer): all-sections layout с chips strip per handoff prototype
Handoff Screen 3 RecordDrawer показывает sections подряд с chips strip
для quick jump, не tabs (currently SchemaDrivenForm has 3 tabs).

SchemaDrivenForm:
  - New optional prop layout: 'tabs' (default, legacy) | 'sections'
  - В sections mode:
    * Section chips strip на верху (sticky top-0) вместо Tabs
    * Chip = bucket name + field count в Mono
    * Click chip → scrollIntoView к section
    * Все 3 buckets рендерятся подряд (visible одновременно)
    * Каждая section получает sticky h3 cap header + scroll-mt-12
    * z-indexed sticky: chips strip = 10, section headers = 5
  - В tabs mode: backward-compat без изменений

RecordDrawer callsite (dictionaries.$name.tsx):
  - Передаёт layout='sections' в SchemaDrivenForm (create + edit)
  - Существующий tabs API остаётся для test'ов которые используют default

Layout matches handoff Screen 3 prototype:
  [chips strip sticky]
  [section: Основное]
   field grid 2-col
  [section: Описание (i18n)]
   localized fields
  [section: Дополнительно]
   optional fields grid

Future Phase B refactor: backend x-section schema metadata (open question
#5) разблокирует per-property sections (вместо bucketing). Текущий
fallback bucketing (identity/description/extra) достаточен для MVP.

Tests: 116 pass (test использует default 'tabs' layout, не разломан).
2026-05-11 16:37:38 +03:00
Zimin A.N. 5007e0f4eb feat(ui): drop @nstart/ui dependency (Stage 3.9)
Все font cascade bugs из последних MR были вызваны @nstart/ui pollution
(Tektur inheritance от Card parent'ов, Onest font в Button, font-primary
class overrides). Решили обрезать раньше plan'а.

What changed:

New primitives (8 файлов в src/ui/components/):
  - panel.tsx           — container с border/surface
  - field.tsx           — FieldLabel/Hint/Error typography helpers
  - text-input.tsx      — Input + label/hint/error composite
  - table.tsx           — Table/TableHeader/Body/Row/HeaderCell/Cell/Empty
  - date-picker.tsx     — native <input type=date> wrapper с label/hint/error
  - multi-select.tsx    — Radix Popover + inline checkboxes
  - single-select.tsx   — native <select> с label/hint/error chrome
  - language-switch.tsx — segmented control в Tektur uppercase

Compatibility shims (existing components extended):
  - checkbox.tsx        — + label/description/error/onChange (nstart-compat
                          synthetic event), wrap в <label> когда есть chrome
  - modal.tsx           — + panelClassName / bodyClassName props
  - form-actions.tsx    — + align prop (start/end/between)
  - tabs-simple.tsx     — TabItem.count alias for .trailing (nstart-compat)
  - table TableHead     — alias TableHeaderCell с align prop

Barrel rewrite (src/ui/index.ts):
  - Удалён 'export * from @nstart/ui'
  - Explicit exports для всех 45+ нужных символов
  - SelectOption / MultiSelectOption / LanguageOption теперь {id, label}
    с optional 'value' alias — nstart-compat без массового codemod call-sites

styles.css cleanup:
  - Removed @import @nstart/ui/styles/{fonts,theme}.css
  - Removed @source nstart/dist directive (Tailwind v4 scan)
  - Removed --text-sm: 13px override (был костыль для @nstart/ui dist)
  - Legacy color tokens (--color-carbon/ultramarain/orbit/regolith/asteroid)
    sсодержали fallback mappings — оставлены, но callsites больше не используют

Codemod: 9 файлов конвертированы legacy colors → handoff tokens
  carbon → ink, ultramarain → accent, orbit → warn, regolith → line, asteroid → mute

main.tsx:
  - NStartUiProvider → TooltipProvider (Radix)
  - DatePickerProvider убран (наш DatePicker native, no provider needed)

routes/dictionaries.$name.tsx:
  - Removed useRef-based <input>.indeterminate hack
  - Radix Checkbox принимает checked='indeterminate' declaratively

package.json: -@nstart/ui (was 0.1.3)

Bundle impact:
  before: vendor-nstart-ui chunk = 1228 KB
  after:  no nstart chunk, всё в vendor-misc (+~70 KB Radix wrappers)
  net savings: ~1.15 MB minified, ~440 KB gzip

Tests: 116 pass (1 fixed: tab role from 'button' → 'tab', Radix semantically correct).
TS strict: clean.
Browser-verified (preview): 7 utilities computed correctly, nstart bundle absent.
2026-05-11 15:17:37 +03:00
Zimin A.N. 750395da09 feat(record): RecordDrawer slide-over per Stage 3.5 handoff (Phase A)
Заменяет Modal-based record create/edit на right slide-over drawer per
design_handoff_ordinis_mdm/README.md Screen 3.

What's в Phase A (drawer shell + sticky chrome):

- src/components/record/RecordDrawer.tsx (NEW)
  - Two widths: 520px default / 880px wide, toggle через ‹/› button.
    Persists в localStorage 'ord-drawer-wide'.
  - Sticky header: caption (cap style) + recordId mono + width-toggle +
    history button (edit mode) + ×.
  - Sticky footer: [Удалить] (danger, edit mode) · 'не сохранено · N полей'
    caption · [Отмена] · [Сохранить] (drives form submit через form attr).
  - Built на shadcn Sheet primitive + Radix Dialog. Override sheetVariants
    inline для transition-able max-width (variant size не toggle'ится
    smoothly без animate prop).
  - aria-labelledby wires DialogTitle к содержимому header.
  - Mobile <880px: w-full fullscreen (per handoff responsive table).

- src/components/form/SchemaDrivenForm.tsx
  - New optional props: formId, onDirtyChange.
  - Когда formId передан — внутренний FormActions footer прячется (drawer
    footer driving submit через <button form={formId} type=submit>).
  - Dirty-count считается из RHF formState.dirtyFields (top-level + data.*).
  - useEffect surfaces count к parent — drawer footer caption updates live.

- src/routes/dictionaries.$name.tsx
  - Modal create/edit -> RecordDrawer.
  - State: + recordDirtyCount (reset на close).
  - onDelete wires к existing close-confirm flow (kind: 'close-confirm').
  - onHistory wires к existing setHistoryKey RecordHistoryDrawer.

- src/i18n.ts
  - + form.delete, form.unsavedNFields_one/few/many/other (i18next plural rules).
  - + drawer.{collapse,expand,close,captionEdit,captionCreate}.

Phase B (TODO Stage 3.5b — separate MR):
- Section anchor chips (sticky strip)
- Wide-mode left ToC rail (180px)
- Sticky toolbar: search + 'только заполненные' toggle + counter
- Requires x-section schema metadata в backend properties (CEO open question #5 в handoff)

Tests: 8 files, 116 tests pass. TS strict clean.
2026-05-11 14:39:38 +03:00
Zimin A.N. b94912789f fix(fonts): semantic typography utilities per handoff (7 roles)
Заменяет blanket override из MR !45 на типизированную type scale per
design_handoff_ordinis_mdm/README.md "Scale" section.

Семь semantic @utility в styles.css:
  text-title-xl  22/600   — modal title, section header
  text-title-lg  17/600   — page title в editor
  text-title-md  15/600   — dictionary card title
  text-body      13/400   — workhorse: body, buttons, tabs, inputs
  text-cell      12.5/500 — table cell text
  text-mono      11/500   — Mono: IDs, dates, FK refs (font-mono baked in)
  text-cap       10.5/600 — Tektur UPPERCASE caption (font/uppercase baked in)

Audit phases:
  P1: добавил 7 утилит, font/uppercase/tracking baked где надо
  P2: 43 deterministic codemods (font-mono+text-2xs/[11px] → text-mono,
      [15/17/22]px+font-semibold → text-title-*, [12.5]px → text-cell,
      [10.5]px+uppercase+tracking → text-cap)
  P3: 64 text-sm → text-body (handoff workhorse), 84 text-2xs context-aware
      (TableCell → text-cell, bare → text-cell, плюс cleanup caps в backticks
      template literals который Phase 2 пропустил), 25 text-xs (6 → text-mono
      когда с font-mono, 19 → text-cell), 8 titles text-base/lg → text-title-*
  P4: убрал --text-2xs override (no users), оставил --text-sm: 13px scoped
      к @nstart/ui passthrough (см. comment в styles.css — убирается в Stage 3.9)

Stats:
  text-body: 69 | text-cell: 99 | text-mono: 50 | text-cap: 42
  text-title-xl: 5 | text-title-lg: 5 | text-title-md: 7
  text-sm/text-2xs/text-xs в src/: 0 (только в styles.css комментариях)

Поведение всех 277 typography usages теперь явно соответствует handoff —
каждое место осознанно выбрано под роль, не плажирующий override.
2026-05-11 14:31:35 +03:00
Zimin A.N. 71432e9ae7 fix(fonts): align all text sizes to handoff spec via Tailwind theme overrides
Глобальные правки через @theme в styles.css вместо 200+ точечных правок:

- text-sm: 14px -> 13px (handoff workhorse — body, buttons, tabs)
- text-2xs: undefined -> 11px (handoff IDs, mono meta — было невидимо)
- @utility text-cap: 10.5px Tektur uppercase 0.10em (handoff caps)

Codemod (30 sites): text-2xs + uppercase + tracking-[0.10em] -> text-cap.
Sidebar section labels consolidated с explicit text-[10.5px] на text-cap.

Покрывает 64 text-sm + 148 text-2xs автоматически. Table cells (12.5px)
и page/modal titles (17px/22px) остаются explicit text-[Npx] arbitrary
values по месту — нет slots в Tailwind scale.
2026-05-11 14:12:57 +03:00
Александр Зимин 6db2a0c345 feat(admin-ui): catalog refresh + token migration (Stage 3.2) 2026-05-11 09:08:38 +00:00
Александр Зимин b7d2fbd7ce feat(admin-ui): codemod @nstart/ui → @/ui (Stage 2.2) 2026-05-10 22:17:06 +00:00
Александр Зимин fc2fb35717 fix(admin-ui): top-5 critical review bugs (auth + AJV + TZ + idempotency + storage) 2026-05-10 16:22:35 +00:00
Zimin A.N. 5e1b39db03 fix(dict): name column в records list — pick localized value, не [object Object]
Раньше для записей с 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).
2026-05-06 21:05:50 +03:00
Zimin A.N. dd42626fe2 fix(ui): AOI map blank на open + FK select loading spinner
AOI map blank:
Pickер открывался с пустым контейнером — без zoom controls и
attribution. Leaflet вычисляет tile grid от containerSize в момент
L.map() create(); modal animation + flex-1 layout давали 0×0 на
первом кадре, тайлы не запрашивались, и map оставался blank.
setTimeout(invalidateSize, 100ms) не покрывал случай когда modal
ещё анимировал.

Решение: ResizeObserver на map div + invalidateSize при каждом
resize. Покрывает modal fade-in, dev tools toggle, window resize.
Плюс requestAnimationFrame invalidate для initial flush. Inline
style.minHeight=420/height=60vh — защита если flex не распределил
высоту.

FK select loading state:
Раньше при isLoading select disabled + показывал '…'. Теперь:
- chevron справа заменяется на animated CircleNotchIcon (animate-spin)
- aria-busy=true для screen readers
- цвет spinner ultramarain — маркирует активность

Refactor: chevron перенесён из background-image в абсолютно
позиционированный <div pointer-events-none>. Pointer-events-none
не блокирует click по нативному select.
2026-05-06 20:24:48 +03:00
Zimin A.N. 668f1b8b5b fix(form): FK select стилизация — match TextInput/SingleSelect look
После перехода на native <select> в предыдущем коммите рендер
показывал OS default (двойная стрелка на macOS Safari, узкая высота,
native blue focus border). Не попадало в стиль формы — рядом
TextInput/DatePicker от @nstart/ui выглядели иначе.

Фикс:
- appearance-none убирает OS default
- Кастомный chevron как inline SVG в data-uri (color #687078 = carbon/40,
  same weight что у CaretDown в SingleSelect)
- pl-3 / pr-10 / py-2 = совпадает с TextInput высотой (~38px)
- focus:ring-1 ultramarain — same focus state как у TextInput

Native browser dropdown поведение сохраняется (auto-flip, scroll,
type-ahead) — просто внешне стало match'ить @nstart/ui дизайн систему.
2026-05-06 20:19:11 +03:00
Zimin A.N. 4fa96bffed fix(form): FK select на native <select> — popup не вылазит за viewport
Проблема: SingleSelect popup рендерился через createPortal с
position:fixed по bbox триггера, но без auto-flip. Когда триггер
у дна modal'а (а с x-references это типичный кейс — спутник имеет
много полей), popup уходил за нижний край viewport — нижние опции
обрезались. На словарях с 20+ полями modal становится высоким и
проблема почти всегда воспроизводится.

Native <select> делегирует popup positioning браузеру: auto-flip
вверх когда мало места снизу, native scroll внутри dropdown,
type-ahead search через клавиатуру (если ввести 'OP' — прыгнет
на OPERATIONAL), mobile-friendly. Stylinglypko под @nstart/ui
(border-regolith / border-mars / focus-ultramarain) — визуально
встаёт в линейку с TextInput / DatePicker.

SingleSelect остаётся для enum-полей (короткие закрытые списки,
чаще ≤5 опций — там popup overflow не возникает).
2026-05-06 19:52:12 +03:00
Zimin A.N. c5d41da834 feat(form): x-references поля → SingleSelect с опциями из target словаря
Поля 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).
2026-05-06 19:29:01 +03:00
Zimin A.N. 9b20cbf9b9 chore(admin-ui): fix pre-existing TypeScript errors
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)
2026-05-06 13:22:17 +03:00
Zimin A.N. ab6f6cb860 feat(admin-ui): RequireAuth gate + RTL component тесты
RequireAuth: гейтит весь UI по аутентификации. Anonymous юзер вместо
красного "AxiosError 401" получает immediate signinRedirect на Keycloak;
authenticated юзер с истёкшим токеном получает silent renew → fallback на
full redirect. Guards против infinite loop:
- auth.isLoading / activeNavigator — bootstrap или signin/signout уже идёт
- ?error= в URL — вернулись с провалом из Keycloak, показываем сообщение
- auth.error без isAuthenticated — Keycloak недоступен, показываем сообщение

main.tsx: RequireAuth обёрнут вокруг Router/Query, чтобы tan-stack-router и
React Query не делали запросов до auth (иначе 401-каскад на старте).

TokenSync: 401 interceptor теперь редиректит и anonymous (раньше срабатывал
только для authenticated с истёкшим токеном), с теми же guards.

RTL component тесты — 22 теста на 3 ключевых компонента:
- SchemaBuilder (7): empty state, add/remove/reorder properties
- PropertyEditor (10): kind change, required/unique toggle, kind-specific
  поля (string→pattern, integer→min/max, enum→values, array_string→uniqueItems),
  move up/down disabled на boundaries
- SchemaDrivenForm (5): identity tab с required, переключение на extra,
  isPending → save disabled, cancel callback, serverError → Alert

Setup:
- vite.config.ts: test block (jsdom env, setup file, css off)
- src/test/setup.ts: jest-dom matchers, cleanup, HTMLCanvasElement.getContext
  stub (lottie-web из @nstart/ui крашит в jsdom без canvas)
- src/test/render.tsx: renderWithI18n helper, форсит ru-RU
  (LanguageDetector в node по умолчанию даёт en-US)

Deps: +@testing-library/react +@testing-library/jest-dom
+@testing-library/user-event +jsdom
2026-05-06 00:11:07 +03:00
Zimin A.N. f7efb23e09 test+ci: 51 frontend unit-тестов (Vitest) + CI rollback на single resolve-tag
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.
2026-05-04 04:37:24 +03:00
Zimin A.N. c7b651011a feat(ux): улучшение работы с датами validFrom/validTo
Backend (defensive):
- GlobalExceptionHandler ловит HttpMessageNotReadableException → 400 c понятным
  сообщением вместо 500 'Internal server error'.
- DateTimeParseException → code='invalid_date_format', сообщение с подсказкой
  ISO формата (например 2026-05-08T14:30:00+03:00).
- InvalidFormatException → code='invalid_field_format' с именем сломанного поля.
- Раньше падало 500 если фронт отправлял 'YYYY-MM-DD' вместо OffsetDateTime
  (из-за browser cache на старом bundle).

Frontend:
- Submit-валидация validFrom < validTo с inline error на validTo поле,
  переключение на таб 'Идентификация' с фокусом на ошибке.
- DateTimeField теперь принимает hint и error props и показывает их под
  парой DatePicker+TextInput[time].
- Подсказки i18n под полями: 'Когда запись становится активной. Пусто = с момента
  создания.' / 'Когда перестаёт действовать. Пусто = бессрочно.'
- Новая i18n ключ form.error.validToBeforeFrom (RU/EN).
2026-05-04 04:15:58 +03:00
Zimin A.N. 07c8a19cf3 fix(admin-ui): pure-date парсинг как local midnight (защита TZ западнее UTC)
format=date поля приходят как 'YYYY-MM-DD' без времени. new Date(s) интерпретирует
их как UTC-midnight. В TZ западнее UTC (UTC-5 etc.) local Date уезжает на день
назад → getDate() возвращает (день-1).

Сейчас pure-date строки регексим и собираем через Date(y, m-1, d) — всегда
local midnight. Datetime строки парсятся как раньше.
2026-05-04 04:01:20 +03:00
Zimin A.N. 80703f9caa fix(admin-ui): timezone bug в validTo/validFrom + кнопка сброса DatePicker
Bug 1: ставил 8 мая, показывало 9.
Раньше formatIsoDateTime отдавал 'YYYY-MM-DDTHH:MM:00Z' (UTC). На load
new Date(s) парсил UTC и показывал в МСК (UTC+3) → +3 часа → день вперёд.
Сейчас:
  - localTzOffset(d) вычисляет offset из браузера, например '+03:00'
  - formatIsoDateTime(d, time) собирает Date в local TZ и отдаёт ISO с
    явным offset: '2026-05-08T23:59:00+03:00'
  - extractTime(s, fallback) парсит через new Date + getHours/getMinutes,
    показывая time в local TZ юзера

Bug 2: кнопка сброса (X) в DatePicker не очищала validFrom/validTo.
combineDateTime(null) возвращал current value вместо ''. Фикс — на null
возвращаем пустую строку, value уходит в form state как ''.
2026-05-04 03:59:53 +03:00
Zimin A.N. 0e12a254c2 fix(admin-ui): datetime поля внутри schema теряли время — теперь DateTimeField
Поля с format=date-time в schema (например orbit.epoch у spacecraft) рендерились
через DatePicker и форс-сетили T00:00:00Z. Теперь та же DateTimeField (DatePicker
+ time input) что и для validFrom/validTo. format=date по-прежнему чистый
DatePicker без времени.
2026-05-04 03:42:11 +03:00
Zimin A.N. baf76ac6e0 feat(admin-ui): D-pack — history, breaking-change detection, x-unique, x-id-source, datetime
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.
2026-05-04 03:38:21 +03:00
Zimin A.N. 0289cf5ff2 fix(admin-ui): отправляем валидный ISO datetime в validFrom/validTo + format=date-time
500 при сохранении записи — backend ждёт OffsetDateTime в DTO CreateRecordRequest,
а DatePicker отдавал просто 'yyyy-MM-dd'. Добавил helpers:
  - formatIsoDateTime(d) → 'yyyy-MM-ddT00:00:00Z'
  - ensureIsoDateTime(s) → нормализует submit-значение validFrom/validTo

Применил:
  - validFrom/validTo на submit нормализуются (если YYYY-MM-DD → +T00:00:00Z)
  - format=date-time data поля отдают datetime ISO, format=date — по-прежнему YYYY-MM-DD

Также добавил скаффолд для D-pack (history drawer + schema diff util) — не
интегрировано в роуты, доделаем в следующем коммите.
2026-05-04 03:29:17 +03:00
Zimin A.N. ef31499bd9 fix(admin-ui): DatePicker + nested object + raw record fetch для Edit
Несколько связанных багов в форме записей:

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 на ошибке.
2026-05-04 03:01:08 +03:00
Zimin A.N. ff9e6b26b4 feat(admin-ui): wizard tabs in record form (no internal scroll)
Поля авто-распределяются по 3 табам:
- Идентификация: businessKey + validFrom/validTo + required scalar поля
- Описание: все x-localized поля (i18n)
- Дополнительно: optional scalar поля

UX:
- Все табы mounted (через hidden) — RHF state сохраняется при переключении.
- Бейдж с числом ошибок на каждом табе (variant=error).
- На submit: ajv валидация → если есть ошибки, форма автопереключает на таб
  первой ошибки + setError по всем затронутым полям.
- Таб «Описание» / «Дополнительно» скрывается если нет соответствующих полей.

Дроп FormSection — табы заменяют секционирование. Layout стал плоский
(Tabs + grid 1/2 cols + FormActions).
2026-05-04 02:50:12 +03:00
Zimin A.N. 43a06d1a7b fix(admin-ui): translate field labels + responsive header + modal max-h
Issues:
- Field labels (ORBIT/STATUS/COUNTRY/MASS_KG/...) показывались raw'ом потому
  что backend schema не присылает title. Добавил humanize() (snake_case → 'Snake Case')
  + i18n fallback t('field.$key') для известных полей spacecraft/satellite_type/
  ground_station: name, designator, norad_id, status, country, operator, mass_kg,
  mission, orbit, launch_date, decay_date, spectrum_bands, satellite_type_code, etc.
- Modal panel рос в высоту бесконечно (nstart-ui Modal не клампит max-h),
  на iPad портрете форма уходила за viewport. panelClassName=max-h-[calc(100vh-2rem)]
  flex flex-col + bodyClassName=overflow-y-auto flex-1.
- Header в __root: padding/gap responsive (px-4 sm:px-6, py-3 sm:py-4, gap-3 sm:gap-6),
  flex-wrap чтобы LanguageSwitch на узких экранах переносился под навигацию.
- Main container: px-4 sm:px-6 + py-4 sm:py-8.
2026-05-04 02:47:06 +03:00
Zimin A.N. 7aed67fce5 fix(admin-ui): IconButton icon prop + compact 2-col form layout
Bugs:
- IconButton @nstart/ui принимает icon={} prop, не children — иконки рендерились
  пустыми квадратами (видно «Изменить» tooltip без значка карандашика).
  Перевёл оба IconButton на icon={<PencilSimpleIcon/>} / icon={<XCircleIcon/>}.

UX:
- Modal max-w-2xl → max-w-4xl (форма дышит, ru-RU/en-US labels не оборачиваются).
- SchemaDrivenForm: scalar поля теперь в 2-колоночной grid, full-width
  (i18n / array / textarea / nested object) занимают sm:col-span-2.
- Локальные ярлыки i18n: tracking-normal + whitespace-nowrap, w-14 — больше не
  ломаются «ru-» «RU» из-за inherited tracking-label.
2026-05-04 02:37:13 +03:00
Zimin A.N. ebf1b03863 feat(admin-ui): migrate from custom components to @nstart/ui design system
Свои 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).
2026-05-04 02:24:44 +03:00
Zimin A.N. b1b1f05e3c feat(admin-ui): record CRUD with json-schema-driven form
POST/PUT/DELETE формы для записей справочников. Schema-driven рендер полей
из definition.schemaJson — i18n-aware (x-localized → input на каждый supportedLocale),
enum → select, type → text/number/checkbox/array. Валидация через ajv (Draft-07,
match backend networknt validator).

- new: api/mutations.ts с Idempotency-Key (crypto.randomUUID) и query invalidation
- new: SchemaDrivenForm — react-hook-form + ajv compile per-schema, errors через
  setError по ajv instancePath
- new: Modal (Esc + backdrop click) и confirm dialog для close-операции
- nginx: regex location ^/api/v1/dictionaries/[^/]+$ → writer (admin schema fetch)
- queries: dictionaryDetailQuery возвращает full def с schemaJson
- i18n: RU/EN ключи для CRUD (create/edit/close, validFrom/validTo, confirm)
2026-05-04 01:49:08 +03:00