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:
@@ -7,6 +7,11 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
LoadingBlock,
|
||||
@@ -20,7 +25,7 @@ import {
|
||||
TableRow,
|
||||
TextInput,
|
||||
} 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 {
|
||||
useAudit,
|
||||
useDictionaryDetail,
|
||||
@@ -897,7 +902,12 @@ function DictionaryDetail() {
|
||||
))}
|
||||
<TableHeaderCell hideBelow="sm">{t('dict.col.scope')}</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>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -976,29 +986,44 @@ function DictionaryDetail() {
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<IconButton
|
||||
label={t('history.title')}
|
||||
variant="default"
|
||||
icon={<ClockCounterClockwiseIcon weight="regular" />}
|
||||
onClick={() => setHistoryKey(r.businessKey)}
|
||||
/>
|
||||
{canMutate && (
|
||||
<>
|
||||
<IconButton
|
||||
label={t('dict.action.edit')}
|
||||
variant="default"
|
||||
icon={<PencilSimpleIcon weight="regular" />}
|
||||
onClick={() => setEdit({ kind: 'edit', record: r })}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('dict.action.close')}
|
||||
variant="danger"
|
||||
icon={<XCircleIcon weight="regular" />}
|
||||
onClick={() => setEdit({ kind: 'close-confirm', record: r })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
{/* 3-dot DropdownMenu per redesign prototype. Заменяет
|
||||
* inline icon trio (history / edit / close). Trigger:
|
||||
* 36px touch target (tablet-safe). Items: History,
|
||||
* Edit, Close (Edit/Close — только для canMutate). */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('dict.col.actions')}
|
||||
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"
|
||||
>
|
||||
<DotsThreeVerticalIcon weight="bold" size={18} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuItem onSelect={() => setHistoryKey(r.businessKey)}>
|
||||
<ClockCounterClockwiseIcon weight="regular" size={16} className="mr-2 text-ink-2" />
|
||||
{t('history.title')}
|
||||
</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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1191,6 +1216,45 @@ function DictionaryDetail() {
|
||||
onClose={() => setHistoryKey(undefined)}
|
||||
dictionaryName={name}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user