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).
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ет.
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.
Раньше для записей с 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).
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.
После перехода на 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 дизайн систему.
Проблема: 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 не возникает).
Поля 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)
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
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.
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).
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 строки парсятся как раньше.
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 как ''.
Поля с format=date-time в schema (например orbit.epoch у spacecraft) рендерились
через DatePicker и форс-сетили T00:00:00Z. Теперь та же DateTimeField (DatePicker
+ time input) что и для validFrom/validTo. format=date по-прежнему чистый
DatePicker без времени.
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.
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) — не
интегрировано в роуты, доделаем в следующем коммите.
Несколько связанных багов в форме записей:
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 на ошибке.
Поля авто-распределяются по 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).
Свои 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).