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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Ajv, { type ErrorObject } from 'ajv'
|
||||
@@ -48,6 +48,12 @@ type Props = {
|
||||
serverError?: string | null
|
||||
onSubmit: (req: CreateRecordRequest) => void
|
||||
onCancel: () => void
|
||||
/** When provided, form renders WITHOUT internal Cancel/Save buttons —
|
||||
* footer is expected to be drawn by parent (e.g. RecordDrawer). External
|
||||
* submit triggered via `<button type="submit" form={formId}>`. */
|
||||
formId?: string
|
||||
/** Notifies parent of dirty-field count (for "N полей" footer caption). */
|
||||
onDirtyChange?: (dirtyCount: number) => void
|
||||
}
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: false })
|
||||
@@ -90,6 +96,8 @@ export const SchemaDrivenForm = ({
|
||||
serverError,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
formId,
|
||||
onDirtyChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('identity')
|
||||
@@ -165,8 +173,27 @@ export const SchemaDrivenForm = ({
|
||||
) : undefined,
|
||||
}))
|
||||
|
||||
// Surface dirty-field count к parent (RecordDrawer footer caption).
|
||||
// Используем formState.dirtyFields shallow keys + nested data keys.
|
||||
const dirtyCount = useMemo(() => {
|
||||
const top = Object.keys(formState.dirtyFields).filter(
|
||||
(k) => k !== 'data' && (formState.dirtyFields as Record<string, unknown>)[k],
|
||||
).length
|
||||
const dataDirty = formState.dirtyFields.data
|
||||
? Object.keys(formState.dirtyFields.data).length
|
||||
: 0
|
||||
return top + dataDirty
|
||||
}, [formState.dirtyFields])
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirtyCount)
|
||||
}, [dirtyCount, onDirtyChange])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submit)} className="space-y-4">
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit(submit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id as TabId)} />
|
||||
|
||||
<div className={activeTab === 'identity' ? 'block' : 'hidden'}>
|
||||
@@ -269,14 +296,19 @@ export const SchemaDrivenForm = ({
|
||||
|
||||
{serverError && <Alert variant="error">{serverError}</Alert>}
|
||||
|
||||
<FormActions align="end">
|
||||
<Button type="button" variant="ghost" onClick={onCancel} disabled={isPending}>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{isPending ? t('form.saving') : t('form.save')}
|
||||
</Button>
|
||||
</FormActions>
|
||||
{/* Когда formId не передан — внутренний footer (backward compat).
|
||||
* Когда передан — drawer footer (см. RecordDrawer) рисует кнопки и
|
||||
* сабмитит через атрибут form={formId} на <button type="submit">. */}
|
||||
{!formId && (
|
||||
<FormActions align="end">
|
||||
<Button type="button" variant="ghost" onClick={onCancel} disabled={isPending}>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{isPending ? t('form.saving') : t('form.save')}
|
||||
</Button>
|
||||
</FormActions>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user