Merge branch 'feat/stage-3.5a-record-drawer' into 'main'
feat(record): Stage 3.5a RecordDrawer slide-over (Phase A) See merge request 2-6/2-6-4/terravault/ordinis!48
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 { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Ajv, { type ErrorObject } from 'ajv'
|
import Ajv, { type ErrorObject } from 'ajv'
|
||||||
@@ -48,6 +48,12 @@ type Props = {
|
|||||||
serverError?: string | null
|
serverError?: string | null
|
||||||
onSubmit: (req: CreateRecordRequest) => void
|
onSubmit: (req: CreateRecordRequest) => void
|
||||||
onCancel: () => 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 })
|
const ajv = new Ajv({ allErrors: true, strict: false })
|
||||||
@@ -90,6 +96,8 @@ export const SchemaDrivenForm = ({
|
|||||||
serverError,
|
serverError,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
formId,
|
||||||
|
onDirtyChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('identity')
|
const [activeTab, setActiveTab] = useState<TabId>('identity')
|
||||||
@@ -165,8 +173,27 @@ export const SchemaDrivenForm = ({
|
|||||||
) : undefined,
|
) : 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 (
|
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)} />
|
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id as TabId)} />
|
||||||
|
|
||||||
<div className={activeTab === 'identity' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'identity' ? 'block' : 'hidden'}>
|
||||||
@@ -269,14 +296,19 @@ export const SchemaDrivenForm = ({
|
|||||||
|
|
||||||
{serverError && <Alert variant="error">{serverError}</Alert>}
|
{serverError && <Alert variant="error">{serverError}</Alert>}
|
||||||
|
|
||||||
<FormActions align="end">
|
{/* Когда formId не передан — внутренний footer (backward compat).
|
||||||
<Button type="button" variant="ghost" onClick={onCancel} disabled={isPending}>
|
* Когда передан — drawer footer (см. RecordDrawer) рисует кнопки и
|
||||||
{t('form.cancel')}
|
* сабмитит через атрибут form={formId} на <button type="submit">. */}
|
||||||
</Button>
|
{!formId && (
|
||||||
<Button type="submit" variant="primary" loading={isPending}>
|
<FormActions align="end">
|
||||||
{isPending ? t('form.saving') : t('form.save')}
|
<Button type="button" variant="ghost" onClick={onCancel} disabled={isPending}>
|
||||||
</Button>
|
{t('form.cancel')}
|
||||||
</FormActions>
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" loading={isPending}>
|
||||||
|
{isPending ? t('form.saving') : t('form.save')}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useCallback, useState, useId, type ReactNode } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
ArrowLeftToLine,
|
||||||
|
ArrowRightToLine,
|
||||||
|
History,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Sheet, SheetPortal } from '@/ui'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { Button } from '@/ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecordDrawer — right slide-over для create/edit записи. Stage 3.5 per
|
||||||
|
* design_handoff_ordinis_mdm/README.md (Screen 3).
|
||||||
|
*
|
||||||
|
* <p>Layout (sticky frame, scrolling body):
|
||||||
|
* <pre>
|
||||||
|
* ┌───────────────────────────────────────┐
|
||||||
|
* │ HEADER (sticky) │
|
||||||
|
* │ caption · id · [‹/›] [⏱] [×] │
|
||||||
|
* ├───────────────────────────────────────┤
|
||||||
|
* │ BODY (scroll) │
|
||||||
|
* │ children — SchemaDrivenForm │
|
||||||
|
* ├───────────────────────────────────────┤
|
||||||
|
* │ FOOTER (sticky) │
|
||||||
|
* │ [Удалить] · "N полей" · [Отмена][Save]│
|
||||||
|
* └───────────────────────────────────────┘
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Two widths:
|
||||||
|
* <ul>
|
||||||
|
* <li>520px (default) — focused single-column edit.</li>
|
||||||
|
* <li>880px (wide) — for dense schemas + future ToC rail. Persists в
|
||||||
|
* localStorage key {@code ord-drawer-wide}.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Phase 1 (current): drawer shell + width toggle + sticky chrome.
|
||||||
|
* Phase 2 (TODO Stage 3.5b): section anchor chips, wide-mode ToC rail
|
||||||
|
* (180px), search field, "только заполненные" toggle. Требует {@code x-section}
|
||||||
|
* metadata в schemaJson — пока секционирование делает SchemaDrivenForm своими
|
||||||
|
* 3 tabs.
|
||||||
|
*
|
||||||
|
* <p>Drives form submit через атрибут {@code form={formId}} на footer Save
|
||||||
|
* button. SchemaDrivenForm должен получить тот же {@code formId} prop и
|
||||||
|
* скрыть свой внутренний footer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LS_KEY = 'ord-drawer-wide'
|
||||||
|
|
||||||
|
/** Hook: persisted boolean (drawer wide mode). */
|
||||||
|
function useDrawerWide(): [boolean, () => void] {
|
||||||
|
const [wide, setWide] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return window.localStorage.getItem(LS_KEY) === '1'
|
||||||
|
})
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setWide((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LS_KEY, next ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// localStorage может быть disabled (Safari private). Игнорируем —
|
||||||
|
// в памяти state сохраняется до закрытия таба, persist опционален.
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
return [wide, toggle]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecordDrawerProps = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
/** Title caption — "редактирование записи · {dictionary}" or "новая запись · {dictionary}". */
|
||||||
|
caption: string
|
||||||
|
/** Record business key / id — shown в header mono. Undefined для create. */
|
||||||
|
recordId?: string
|
||||||
|
/** Save button form attribute target. SchemaDrivenForm renders <form id={formId}>. */
|
||||||
|
formId: string
|
||||||
|
/** Dirty-field count для caption "не сохранено · N полей". 0 hides it. */
|
||||||
|
dirtyCount?: number
|
||||||
|
/** Disable footer buttons (mutation в полёте). */
|
||||||
|
isPending?: boolean
|
||||||
|
/** Show [Удалить] danger button (только edit mode, не create). */
|
||||||
|
onDelete?: () => void
|
||||||
|
/** Show [История] button + handler (только edit mode). */
|
||||||
|
onHistory?: () => void
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
caption,
|
||||||
|
recordId,
|
||||||
|
formId,
|
||||||
|
dirtyCount = 0,
|
||||||
|
isPending = false,
|
||||||
|
onDelete,
|
||||||
|
onHistory,
|
||||||
|
children,
|
||||||
|
}: RecordDrawerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [wide, toggleWide] = useDrawerWide()
|
||||||
|
// Уникальный id для DialogTitle/Description чтобы Radix mapping aria-labelledby.
|
||||||
|
const titleId = useId()
|
||||||
|
|
||||||
|
// Reset focus к save button при открытии — обычно user закончил поиск и хочет
|
||||||
|
// быстро save после check. Radix focus traps inside, default landing — first
|
||||||
|
// focusable. Save в footer первой стрелкой Tab был бы удобнее, но это
|
||||||
|
// нарушает default flow. Пока default.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<SheetPortal>
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/40',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 right-0 z-50 h-full bg-surface shadow-2xl border-l border-line',
|
||||||
|
'flex flex-col',
|
||||||
|
'transition-[max-width] duration-200 ease-out',
|
||||||
|
// Mobile <= 880px: fullscreen per handoff.
|
||||||
|
'w-full',
|
||||||
|
wide ? 'sm:max-w-[880px]' : 'sm:max-w-[520px]',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||||
|
'data-[state=closed]:duration-200 data-[state=open]:duration-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* === HEADER (sticky top) === */}
|
||||||
|
<header className="shrink-0 border-b border-line-2 px-5 py-3 flex items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<DialogPrimitive.Title id={titleId} className="text-cap text-mute truncate">
|
||||||
|
{caption}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
|
{recordId && (
|
||||||
|
<div className="text-mono text-ink-2 truncate mt-0.5">{recordId}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={wide ? t('drawer.collapse') : t('drawer.expand')}
|
||||||
|
title={wide ? t('drawer.collapse') : t('drawer.expand')}
|
||||||
|
onClick={toggleWide}
|
||||||
|
className="p-1.5 rounded-sm text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{wide ? <ArrowRightToLine size={16} /> : <ArrowLeftToLine size={16} />}
|
||||||
|
</button>
|
||||||
|
{onHistory && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('history.title')}
|
||||||
|
title={t('history.title')}
|
||||||
|
onClick={onHistory}
|
||||||
|
className="p-1.5 rounded-sm text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<History size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
aria-label={t('drawer.close')}
|
||||||
|
className="p-1.5 rounded-sm text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={2.25} />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* === BODY (scrolling) ===
|
||||||
|
* Phase 2 здесь будет: sticky toolbar (search + filled-only toggle +
|
||||||
|
* counter) → section anchor chips strip → optional left ToC rail в
|
||||||
|
* wide mode (CSS grid 180px / 1fr). Пока children — SchemaDrivenForm. */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
|
||||||
|
{/* === FOOTER (sticky bottom) === */}
|
||||||
|
<footer className="shrink-0 border-t border-line-2 px-5 py-3 flex items-center gap-3 bg-surface">
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="mr-1.5" />
|
||||||
|
{t('form.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0 text-cell text-mute truncate">
|
||||||
|
{dirtyCount > 0 && t('form.unsavedNFields', { count: dirtyCount })}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t('form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={formId}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? t('form.saving') : t('form.save')}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -324,7 +324,17 @@ i18n
|
|||||||
'form.cancel': 'Отмена',
|
'form.cancel': 'Отмена',
|
||||||
'form.save': 'Сохранить',
|
'form.save': 'Сохранить',
|
||||||
'form.saving': 'Сохранение...',
|
'form.saving': 'Сохранение...',
|
||||||
|
'form.delete': 'Удалить',
|
||||||
'form.error.required': 'Обязательное поле',
|
'form.error.required': 'Обязательное поле',
|
||||||
|
'form.unsavedNFields_one': 'не сохранено · {{count}} поле',
|
||||||
|
'form.unsavedNFields_few': 'не сохранено · {{count}} поля',
|
||||||
|
'form.unsavedNFields_many': 'не сохранено · {{count}} полей',
|
||||||
|
'form.unsavedNFields_other': 'не сохранено · {{count}} полей',
|
||||||
|
'drawer.collapse': 'Свернуть до 520px',
|
||||||
|
'drawer.expand': 'Развернуть до 880px',
|
||||||
|
'drawer.close': 'Закрыть',
|
||||||
|
'drawer.captionEdit': 'редактирование записи · {{dict}}',
|
||||||
|
'drawer.captionCreate': 'новая запись · {{dict}}',
|
||||||
'scope.PUBLIC': 'PUBLIC — публичный',
|
'scope.PUBLIC': 'PUBLIC — публичный',
|
||||||
'scope.INTERNAL': 'INTERNAL — внутренний',
|
'scope.INTERNAL': 'INTERNAL — внутренний',
|
||||||
'scope.RESTRICTED': 'RESTRICTED — ограниченный',
|
'scope.RESTRICTED': 'RESTRICTED — ограниченный',
|
||||||
@@ -822,6 +832,14 @@ i18n
|
|||||||
'form.cancel': 'Cancel',
|
'form.cancel': 'Cancel',
|
||||||
'form.save': 'Save',
|
'form.save': 'Save',
|
||||||
'form.saving': 'Saving...',
|
'form.saving': 'Saving...',
|
||||||
|
'form.delete': 'Delete',
|
||||||
|
'form.unsavedNFields_one': 'unsaved · {{count}} field',
|
||||||
|
'form.unsavedNFields_other': 'unsaved · {{count}} fields',
|
||||||
|
'drawer.collapse': 'Collapse to 520px',
|
||||||
|
'drawer.expand': 'Expand to 880px',
|
||||||
|
'drawer.close': 'Close',
|
||||||
|
'drawer.captionEdit': 'edit record · {{dict}}',
|
||||||
|
'drawer.captionCreate': 'new record · {{dict}}',
|
||||||
'form.error.required': 'Required field',
|
'form.error.required': 'Required field',
|
||||||
'scope.PUBLIC': 'PUBLIC',
|
'scope.PUBLIC': 'PUBLIC',
|
||||||
'scope.INTERNAL': 'INTERNAL',
|
'scope.INTERNAL': 'INTERNAL',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDepend
|
|||||||
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
|
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
|
||||||
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
||||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
|
import { RecordDrawer } from '@/components/record/RecordDrawer'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||||
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
|
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
|
||||||
import { nowIsoLocal } from '@/lib/dates'
|
import { nowIsoLocal } from '@/lib/dates'
|
||||||
@@ -172,6 +173,8 @@ function DictionaryDetail() {
|
|||||||
|
|
||||||
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
||||||
const [closeReason, setCloseReason] = useState('')
|
const [closeReason, setCloseReason] = useState('')
|
||||||
|
// Dirty-field count surfaced by SchemaDrivenForm → shown в RecordDrawer footer.
|
||||||
|
const [recordDirtyCount, setRecordDirtyCount] = useState(0)
|
||||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||||
const [aoiOpen, setAoiOpen] = useState(false)
|
const [aoiOpen, setAoiOpen] = useState(false)
|
||||||
@@ -897,19 +900,31 @@ function DictionaryDetail() {
|
|||||||
)}
|
)}
|
||||||
{/* === end records tab content === */}
|
{/* === end records tab content === */}
|
||||||
|
|
||||||
<Modal
|
<RecordDrawer
|
||||||
isOpen={edit.kind === 'create' || edit.kind === 'edit'}
|
open={edit.kind === 'create' || edit.kind === 'edit'}
|
||||||
onClose={() => setEdit({ kind: 'closed' })}
|
onClose={() => {
|
||||||
title={
|
setEdit({ kind: 'closed' })
|
||||||
edit.kind === 'create'
|
setRecordDirtyCount(0)
|
||||||
? t('dict.action.create')
|
}}
|
||||||
: edit.kind === 'edit'
|
caption={
|
||||||
? `${t('dict.action.edit')}: ${edit.record.businessKey}`
|
edit.kind === 'edit'
|
||||||
: ''
|
? t('drawer.captionEdit', { dict: name })
|
||||||
|
: t('drawer.captionCreate', { dict: name })
|
||||||
|
}
|
||||||
|
recordId={edit.kind === 'edit' ? edit.record.businessKey : undefined}
|
||||||
|
formId="record-form"
|
||||||
|
dirtyCount={recordDirtyCount}
|
||||||
|
isPending={Boolean(activeMutation?.isPending)}
|
||||||
|
onDelete={
|
||||||
|
edit.kind === 'edit'
|
||||||
|
? () => setEdit({ kind: 'close-confirm', record: edit.record })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onHistory={
|
||||||
|
edit.kind === 'edit'
|
||||||
|
? () => setHistoryKey(edit.record.businessKey)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
maxWidth="max-w-4xl"
|
|
||||||
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
|
|
||||||
bodyClassName="overflow-y-auto flex-1"
|
|
||||||
>
|
>
|
||||||
{detailQuery.data && edit.kind === 'create' && (
|
{detailQuery.data && edit.kind === 'create' && (
|
||||||
<SchemaDrivenForm
|
<SchemaDrivenForm
|
||||||
@@ -921,6 +936,8 @@ function DictionaryDetail() {
|
|||||||
serverError={serverError}
|
serverError={serverError}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => setEdit({ kind: 'closed' })}
|
onCancel={() => setEdit({ kind: 'closed' })}
|
||||||
|
formId="record-form"
|
||||||
|
onDirtyChange={setRecordDirtyCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -948,16 +965,18 @@ function DictionaryDetail() {
|
|||||||
// Edit в bitemporal model = «создаём новую версию с этого момента,
|
// Edit в bitemporal model = «создаём новую версию с этого момента,
|
||||||
// старая закрывается». validFrom оставляем пустым — handleSubmit
|
// старая закрывается». validFrom оставляем пустым — handleSubmit
|
||||||
// подставит nowIsoLocal() at submit-time (review #5: иначе stale
|
// подставит nowIsoLocal() at submit-time (review #5: иначе stale
|
||||||
// если user долго держит modal открытым).
|
// если user долго держит drawer открытым).
|
||||||
// validTo пустой = бессрочно (или до следующей правки).
|
// validTo пустой = бессрочно (или до следующей правки).
|
||||||
validFrom: '',
|
validFrom: '',
|
||||||
validTo: '',
|
validTo: '',
|
||||||
}}
|
}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => setEdit({ kind: 'closed' })}
|
onCancel={() => setEdit({ kind: 'closed' })}
|
||||||
|
formId="record-form"
|
||||||
|
onDirtyChange={setRecordDirtyCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</RecordDrawer>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={edit.kind === 'close-confirm'}
|
isOpen={edit.kind === 'close-confirm'}
|
||||||
|
|||||||
Reference in New Issue
Block a user