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
This commit is contained in:
@@ -802,15 +802,21 @@ type DateTimeFieldProps = {
|
|||||||
const DateTimeField = ({ label, value, defaultTime, onChange, hint, error }: DateTimeFieldProps) => {
|
const DateTimeField = ({ label, value, defaultTime, onChange, hint, error }: DateTimeFieldProps) => {
|
||||||
const dateValue = parseFormDate(value)
|
const dateValue = parseFormDate(value)
|
||||||
const timeValue = extractTime(value, defaultTime)
|
const timeValue = extractTime(value, defaultTime)
|
||||||
|
// flex-wrap вместо fixed grid: на узких ячейках (drawer 520px ÷ 2 cols =
|
||||||
|
// ~210px) time wraps под date. На wide (880px ÷ 2 = ~410px) — inline.
|
||||||
|
// min-w на date обеспечивает разумный размер до wrap'a. Время 6rem (96px)
|
||||||
|
// достаточно для HH:mm + tap target 44px touch-safe.
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
<div className="flex flex-wrap gap-2 items-end">
|
||||||
<DatePicker
|
<div className="flex-1 min-w-[8rem]">
|
||||||
label={label}
|
<DatePicker
|
||||||
value={dateValue}
|
label={label}
|
||||||
onChange={(d) => onChange(combineDateTime(d, timeValue, value ?? ''))}
|
value={dateValue}
|
||||||
/>
|
onChange={(d) => onChange(combineDateTime(d, timeValue, value ?? ''))}
|
||||||
<div className="w-28">
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 shrink-0">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="HH:mm"
|
label="HH:mm"
|
||||||
type="time"
|
type="time"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Alert, Badge, Drawer, LoadingBlock } from '@/ui'
|
import { Alert, Badge, Button, Drawer, LoadingBlock } from '@/ui'
|
||||||
|
import { ArrowsLeftRightIcon, ArrowCounterClockwiseIcon } from '@phosphor-icons/react'
|
||||||
import { useRecordHistory } from '@/api/queries'
|
import { useRecordHistory } from '@/api/queries'
|
||||||
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
|
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
|
||||||
|
|
||||||
@@ -8,11 +10,23 @@ type Props = {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
dictionaryName: string
|
dictionaryName: string
|
||||||
businessKey: string | undefined
|
businessKey: string | undefined
|
||||||
|
/** Optional — invoked when user clicks "Сравнить с текущим" на старой версии.
|
||||||
|
* Parent открывает diff view (например ConflictDiffModal с server vs that
|
||||||
|
* version'ом). Если не предоставлен, кнопка скрыта. */
|
||||||
|
onCompare?: (versionData: unknown, displayVersion: number) => void
|
||||||
|
/** Optional — invoked для "Откатить к v1.X.X". Parent submits update
|
||||||
|
* mutation с этой версией как payload. Если undefined — кнопка скрыта. */
|
||||||
|
onRevert?: (versionData: unknown, displayVersion: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey }: Props) => {
|
export const RecordHistoryDrawer = ({
|
||||||
|
open, onClose, dictionaryName, businessKey, onCompare, onRevert,
|
||||||
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data, isLoading, error } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
|
const { data, isLoading, error } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
|
||||||
|
// Per-row collapsible JSON viewer — выносим из <details> чтобы хранить
|
||||||
|
// expanded state поверх renders.
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
@@ -101,14 +115,51 @@ export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<details className="mt-2">
|
{/* Per-version action row — Compare / Revert + JSON toggle.
|
||||||
<summary className="text-cell text-accent cursor-pointer hover:underline">
|
* Compare показываем для не-current versions (нечего сравнивать
|
||||||
{t('history.viewData')}
|
* с самим собой). Revert тоже только для старых (на текущей
|
||||||
</summary>
|
* uno-op). Wrap чтобы планшетный portrait делал stack. */}
|
||||||
<pre className="text-mono mt-1 bg-line/30 rounded p-2 overflow-x-auto">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(rec.id)) next.delete(rec.id)
|
||||||
|
else next.add(rec.id)
|
||||||
|
return next
|
||||||
|
})}
|
||||||
|
className="text-cell text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{expanded.has(rec.id) ? t('history.hideData') : t('history.viewData')}
|
||||||
|
</button>
|
||||||
|
{!isLatest && onCompare && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onCompare(rec.data, displayVersion)}
|
||||||
|
leftIcon={<ArrowsLeftRightIcon weight="regular" size={14} />}
|
||||||
|
>
|
||||||
|
{t('history.compare', { defaultValue: 'Сравнить с текущим' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isLatest && onRevert && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRevert(rec.data, displayVersion)}
|
||||||
|
leftIcon={<ArrowCounterClockwiseIcon weight="regular" size={14} />}
|
||||||
|
>
|
||||||
|
{t('history.revert', { defaultValue: `Откатить к v${displayVersion}` })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expanded.has(rec.id) && (
|
||||||
|
<pre className="text-mono mt-2 bg-line/30 rounded p-2 overflow-x-auto text-cell">
|
||||||
{JSON.stringify(rec.data, null, 2)}
|
{JSON.stringify(rec.data, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -389,6 +389,10 @@ i18n
|
|||||||
'history.validTo': 'действует до',
|
'history.validTo': 'действует до',
|
||||||
'history.updatedBy': 'кем',
|
'history.updatedBy': 'кем',
|
||||||
'history.viewData': 'данные записи (JSON)',
|
'history.viewData': 'данные записи (JSON)',
|
||||||
|
'history.hideData': 'скрыть JSON',
|
||||||
|
'history.compare': 'Сравнить с текущим',
|
||||||
|
'history.revert': 'Откатить к v{{v}}',
|
||||||
|
'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.',
|
||||||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||||
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
||||||
'lineage.usedBy.count_one': '{{count}} связь',
|
'lineage.usedBy.count_one': '{{count}} связь',
|
||||||
@@ -895,6 +899,10 @@ i18n
|
|||||||
'history.validTo': 'valid to',
|
'history.validTo': 'valid to',
|
||||||
'history.updatedBy': 'by',
|
'history.updatedBy': 'by',
|
||||||
'history.viewData': 'record data (JSON)',
|
'history.viewData': 'record data (JSON)',
|
||||||
|
'history.hideData': 'hide JSON',
|
||||||
|
'history.compare': 'Compare with current',
|
||||||
|
'history.revert': 'Revert to v{{v}}',
|
||||||
|
'history.revertConfirm': 'Revert to v{{v}}? A new active version will be created.',
|
||||||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||||
'lineage.usedBy.title': 'Used by',
|
'lineage.usedBy.title': 'Used by',
|
||||||
'lineage.usedBy.count_one': '{{count}} reference',
|
'lineage.usedBy.count_one': '{{count}} reference',
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
@@ -20,7 +25,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react'
|
||||||
import {
|
import {
|
||||||
useAudit,
|
useAudit,
|
||||||
useDictionaryDetail,
|
useDictionaryDetail,
|
||||||
@@ -897,7 +902,12 @@ function DictionaryDetail() {
|
|||||||
))}
|
))}
|
||||||
<TableHeaderCell hideBelow="sm">{t('dict.col.scope')}</TableHeaderCell>
|
<TableHeaderCell hideBelow="sm">{t('dict.col.scope')}</TableHeaderCell>
|
||||||
<TableHeaderCell hideBelow="lg">{t('dict.col.validFrom')}</TableHeaderCell>
|
<TableHeaderCell hideBelow="lg">{t('dict.col.validFrom')}</TableHeaderCell>
|
||||||
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
|
{/* Actions column — без подписи per redesign prototype:
|
||||||
|
* 3-dot trigger открывает menu с History / Edit / Close.
|
||||||
|
* Aria-label на cell хедере для screen readers (visual: пусто). */}
|
||||||
|
<TableHeaderCell align="right" aria-label={t('dict.col.actions')}>
|
||||||
|
<span className="sr-only">{t('dict.col.actions')}</span>
|
||||||
|
</TableHeaderCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -976,29 +986,44 @@ function DictionaryDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end">
|
||||||
<IconButton
|
{/* 3-dot DropdownMenu per redesign prototype. Заменяет
|
||||||
label={t('history.title')}
|
* inline icon trio (history / edit / close). Trigger:
|
||||||
variant="default"
|
* 36px touch target (tablet-safe). Items: History,
|
||||||
icon={<ClockCounterClockwiseIcon weight="regular" />}
|
* Edit, Close (Edit/Close — только для canMutate). */}
|
||||||
onClick={() => setHistoryKey(r.businessKey)}
|
<DropdownMenu>
|
||||||
/>
|
<DropdownMenuTrigger asChild>
|
||||||
{canMutate && (
|
<button
|
||||||
<>
|
type="button"
|
||||||
<IconButton
|
aria-label={t('dict.col.actions')}
|
||||||
label={t('dict.action.edit')}
|
className="size-9 inline-flex items-center justify-center rounded-md text-mute hover:text-ink hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||||||
variant="default"
|
>
|
||||||
icon={<PencilSimpleIcon weight="regular" />}
|
<DotsThreeVerticalIcon weight="bold" size={18} />
|
||||||
onClick={() => setEdit({ kind: 'edit', record: r })}
|
</button>
|
||||||
/>
|
</DropdownMenuTrigger>
|
||||||
<IconButton
|
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||||
label={t('dict.action.close')}
|
<DropdownMenuItem onSelect={() => setHistoryKey(r.businessKey)}>
|
||||||
variant="danger"
|
<ClockCounterClockwiseIcon weight="regular" size={16} className="mr-2 text-ink-2" />
|
||||||
icon={<XCircleIcon weight="regular" />}
|
{t('history.title')}
|
||||||
onClick={() => setEdit({ kind: 'close-confirm', record: r })}
|
</DropdownMenuItem>
|
||||||
/>
|
{canMutate && (
|
||||||
</>
|
<>
|
||||||
)}
|
<DropdownMenuItem onSelect={() => setEdit({ kind: 'edit', record: r })}>
|
||||||
|
<PencilSimpleIcon weight="regular" size={16} className="mr-2 text-ink-2" />
|
||||||
|
{t('dict.action.edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setEdit({ kind: 'close-confirm', record: r })}
|
||||||
|
className="text-mars focus:text-mars focus:bg-mars/8"
|
||||||
|
>
|
||||||
|
<XCircleIcon weight="regular" size={16} className="mr-2" />
|
||||||
|
{t('dict.action.close')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -1191,6 +1216,45 @@ function DictionaryDetail() {
|
|||||||
onClose={() => setHistoryKey(undefined)}
|
onClose={() => setHistoryKey(undefined)}
|
||||||
dictionaryName={name}
|
dictionaryName={name}
|
||||||
businessKey={historyKey}
|
businessKey={historyKey}
|
||||||
|
// Compare: opens the conflict diff modal сравнивает selected version
|
||||||
|
// данные с текущим server state. Re-use ConflictDiffModal — оно
|
||||||
|
// exactly показывает "что было vs что есть" diff.
|
||||||
|
onCompare={canMutate ? (versionData) => {
|
||||||
|
if (!historyKey) return
|
||||||
|
const fakePayload: CreateRecordRequest = {
|
||||||
|
businessKey: historyKey,
|
||||||
|
data: versionData as Record<string, unknown>,
|
||||||
|
validFrom: undefined,
|
||||||
|
validTo: undefined,
|
||||||
|
}
|
||||||
|
setConflict({ payload: fakePayload, businessKey: historyKey })
|
||||||
|
} : undefined}
|
||||||
|
// Revert: submit update mutation с old version's data как payload.
|
||||||
|
// Backend создаст новую active version с этими data — старая history
|
||||||
|
// сохраняется (bitemporal). 409 на conflict перехватываем как при
|
||||||
|
// обычном edit.
|
||||||
|
onRevert={canMutate ? (versionData, version) => {
|
||||||
|
if (!historyKey) return
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
t('history.revertConfirm', { v: version, defaultValue: `Откатить к v${version}? Создастся новая активная версия.` }) as string
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
updateMut.mutate({
|
||||||
|
businessKey: historyKey,
|
||||||
|
payload: {
|
||||||
|
businessKey: historyKey,
|
||||||
|
data: versionData as Record<string, unknown>,
|
||||||
|
validFrom: undefined,
|
||||||
|
validTo: undefined,
|
||||||
|
},
|
||||||
|
idempotencyKey: recordIdempotencyKey,
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setHistoryKey(undefined)
|
||||||
|
resetRecordIdempotencyKey()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CascadeConfirmDialog
|
<CascadeConfirmDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user