Merge branch 'feat/drawer-filters-wired-plus-table-responsive' into 'main'

feat: wire drawer toolbar filters + records table responsive hiding

See merge request 2-6/2-6-4/terravault/ordinis!67
This commit is contained in:
Александр Зимин
2026-05-11 14:10:34 +00:00
2 changed files with 97 additions and 15 deletions
@@ -59,6 +59,14 @@ type Props = {
* - 'sections': all buckets vertically scrollable + section chips strip * - 'sections': all buckets vertically scrollable + section chips strip
* на верху для quick jump. Per handoff Screen 3 RecordDrawer design. */ * на верху для quick jump. Per handoff Screen 3 RecordDrawer design. */
layout?: 'tabs' | 'sections' layout?: 'tabs' | 'sections'
/** Field name search filter (drawer toolbar). Case-insensitive substring match
* против labelOf(key) AND key. Empty = no filter. */
searchFilter?: string
/** Show only fields с non-empty values (drawer toolbar 'только заполненные').
* Empty = number 0, '', null, undefined, false, []. */
onlyFilled?: boolean
/** Notifies parent of visible/total field counters (for drawer toolbar 'N / M'). */
onCounterChange?: (visible: number, total: number) => void
} }
const ajv = new Ajv({ allErrors: true, strict: false }) const ajv = new Ajv({ allErrors: true, strict: false })
@@ -104,6 +112,9 @@ export const SchemaDrivenForm = ({
formId, formId,
onDirtyChange, onDirtyChange,
layout = 'tabs', layout = 'tabs',
searchFilter,
onlyFilled,
onCounterChange,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabId>('identity') const [activeTab, setActiveTab] = useState<TabId>('identity')
@@ -134,6 +145,48 @@ export const SchemaDrivenForm = ({
const idSource = (schema as { 'x-id-source'?: string })['x-id-source'] const idSource = (schema as { 'x-id-source'?: string })['x-id-source']
// === Drawer toolbar filtering ===
// search: substring match (case-insensitive) против key OR labelOf(key)
// onlyFilled: hide fields где data[key] empty (null/undefined/''/false/[]/0)
// Применяется только в sections layout (drawer use case).
const dataValues = (mode === 'edit' ? defaultValues?.data : undefined) ?? {}
const isFieldVisible = useMemo(() => {
const search = (searchFilter ?? '').trim().toLowerCase()
return (key: string): boolean => {
if (search) {
const label = labelOf(key, properties[key]).toLowerCase()
if (!key.toLowerCase().includes(search) && !label.includes(search)) return false
}
if (onlyFilled) {
const v = (dataValues as Record<string, unknown>)[key]
if (v == null || v === '' || v === false || v === 0) return false
if (Array.isArray(v) && v.length === 0) return false
if (typeof v === 'object' && Object.keys(v as object).length === 0) return false
}
return true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchFilter, onlyFilled, properties, dataValues, t])
// Apply filter ON TOP of bucketing — каждый bucket после filter содержит
// visible subset. Used для render + counters.
const filteredBuckets = useMemo(
() => ({
identity: buckets.identity.filter(isFieldVisible),
description: buckets.description.filter(isFieldVisible),
extra: buckets.extra.filter(isFieldVisible),
}),
[buckets, isFieldVisible],
)
// Counter visible/total для drawer toolbar caption "N / M".
const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length
const visibleFieldCount =
filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length
useEffect(() => {
onCounterChange?.(visibleFieldCount, totalFieldCount)
}, [visibleFieldCount, totalFieldCount, onCounterChange])
const submit: SubmitHandler<FormValues> = (values) => { const submit: SubmitHandler<FormValues> = (values) => {
if (values.validFrom && values.validTo) { if (values.validFrom && values.validTo) {
const from = new Date(values.validFrom) const from = new Date(values.validFrom)
@@ -225,7 +278,7 @@ export const SchemaDrivenForm = ({
> >
{tab.label} {tab.label}
<span className="text-mono text-mute"> <span className="text-mono text-mute">
{buckets[tab.id as TabId].length} {filteredBuckets[tab.id as TabId].length}
</span> </span>
</button> </button>
))} ))}
@@ -288,7 +341,7 @@ export const SchemaDrivenForm = ({
/> />
)} )}
/> />
{buckets.identity.map((key) => ( {filteredBuckets.identity.map((key) => (
<FieldRenderer <FieldRenderer
key={key} key={key}
fieldKey={key} fieldKey={key}
@@ -308,13 +361,13 @@ export const SchemaDrivenForm = ({
id={`form-section-${formId ?? 'default'}-description`} id={`form-section-${formId ?? 'default'}-description`}
className={sectionVisible('description') ? 'block scroll-mt-12' : 'hidden'} className={sectionVisible('description') ? 'block scroll-mt-12' : 'hidden'}
> >
{showAllSections && buckets.description.length > 0 && ( {showAllSections && filteredBuckets.description.length > 0 && (
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]"> <h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
{t('form.tabs.description')} {t('form.tabs.description')}
</h3> </h3>
)} )}
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{buckets.description.map((key) => ( {filteredBuckets.description.map((key) => (
<FieldRenderer <FieldRenderer
key={key} key={key}
fieldKey={key} fieldKey={key}
@@ -334,13 +387,13 @@ export const SchemaDrivenForm = ({
id={`form-section-${formId ?? 'default'}-extra`} id={`form-section-${formId ?? 'default'}-extra`}
className={sectionVisible('extra') ? 'block scroll-mt-12' : 'hidden'} className={sectionVisible('extra') ? 'block scroll-mt-12' : 'hidden'}
> >
{showAllSections && buckets.extra.length > 0 && ( {showAllSections && filteredBuckets.extra.length > 0 && (
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]"> <h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
{t('form.tabs.extra')} {t('form.tabs.extra')}
</h3> </h3>
)} )}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{buckets.extra.map((key) => ( {filteredBuckets.extra.map((key) => (
<FieldRenderer <FieldRenderer
key={key} key={key}
fieldKey={key} fieldKey={key}
@@ -178,6 +178,13 @@ function DictionaryDetail() {
const [closeReason, setCloseReason] = useState('') const [closeReason, setCloseReason] = useState('')
// Dirty-field count surfaced by SchemaDrivenForm → shown в RecordDrawer footer. // Dirty-field count surfaced by SchemaDrivenForm → shown в RecordDrawer footer.
const [recordDirtyCount, setRecordDirtyCount] = useState(0) const [recordDirtyCount, setRecordDirtyCount] = useState(0)
// Drawer toolbar state per handoff Screen 3 line 279 (search + onlyFilled + counter).
const [drawerSearch, setDrawerSearch] = useState('')
const [drawerOnlyFilled, setDrawerOnlyFilled] = useState(false)
const [drawerCount, setDrawerCount] = useState<{ visible: number; total: number }>({
visible: 0,
total: 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)
@@ -831,12 +838,12 @@ function DictionaryDetail() {
</TableHeaderCell> </TableHeaderCell>
)} )}
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell> <TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
<TableHeaderCell>name / code</TableHeaderCell> <TableHeaderCell hideBelow="md">name / code</TableHeaderCell>
{extraColumns.map((c) => ( {extraColumns.map((c) => (
<TableHeaderCell key={c.key}>{c.label}</TableHeaderCell> <TableHeaderCell key={c.key} hideBelow="lg">{c.label}</TableHeaderCell>
))} ))}
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell> <TableHeaderCell hideBelow="sm">{t('dict.col.scope')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell> <TableHeaderCell hideBelow="lg">{t('dict.col.validFrom')}</TableHeaderCell>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell> <TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -866,7 +873,7 @@ function DictionaryDetail() {
) : null} ) : null}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell hideBelow="md">
{recordDisplayName( {recordDisplayName(
r.data, r.data,
detailQuery.data?.defaultLocale ?? 'ru-RU', detailQuery.data?.defaultLocale ?? 'ru-RU',
@@ -876,14 +883,14 @@ function DictionaryDetail() {
const v = r.data?.[c.key] const v = r.data?.[c.key]
if (v == null || v === '') { if (v == null || v === '') {
return ( return (
<TableCell key={c.key}> <TableCell key={c.key} hideBelow="lg">
<span className="text-cell text-mute/70"></span> <span className="text-cell text-mute/70"></span>
</TableCell> </TableCell>
) )
} }
const text = String(v) const text = String(v)
return ( return (
<TableCell key={c.key}> <TableCell key={c.key} hideBelow="lg">
<span <span
className={[ className={[
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell', 'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell',
@@ -897,10 +904,10 @@ function DictionaryDetail() {
</TableCell> </TableCell>
) )
})} })}
<TableCell> <TableCell hideBelow="sm">
<Badge variant="info">{r.dataScope}</Badge> <Badge variant="info">{r.dataScope}</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell hideBelow="lg">
<span className="text-cell text-mute"> <span className="text-cell text-mute">
{new Date(r.validFrom).toLocaleDateString()} {new Date(r.validFrom).toLocaleDateString()}
</span> </span>
@@ -963,6 +970,8 @@ function DictionaryDetail() {
onClose={() => { onClose={() => {
setEdit({ kind: 'closed' }) setEdit({ kind: 'closed' })
setRecordDirtyCount(0) setRecordDirtyCount(0)
setDrawerSearch('')
setDrawerOnlyFilled(false)
}} }}
caption={ caption={
edit.kind === 'edit' edit.kind === 'edit'
@@ -973,6 +982,20 @@ function DictionaryDetail() {
formId="record-form" formId="record-form"
dirtyCount={recordDirtyCount} dirtyCount={recordDirtyCount}
isPending={Boolean(activeMutation?.isPending)} isPending={Boolean(activeMutation?.isPending)}
// Toolbar показывается только в edit mode где filters имеют смысл.
// Create mode — все поля пустые, "только заполненные" даст empty list.
toolbar={
edit.kind === 'edit'
? {
search: drawerSearch,
onSearchChange: setDrawerSearch,
onlyFilled: drawerOnlyFilled,
onOnlyFilledChange: setDrawerOnlyFilled,
counterVisible: drawerCount.visible,
counterTotal: drawerCount.total,
}
: undefined
}
onDelete={ onDelete={
edit.kind === 'edit' edit.kind === 'edit'
? () => setEdit({ kind: 'close-confirm', record: edit.record }) ? () => setEdit({ kind: 'close-confirm', record: edit.record })
@@ -996,6 +1019,9 @@ function DictionaryDetail() {
onCancel={() => setEdit({ kind: 'closed' })} onCancel={() => setEdit({ kind: 'closed' })}
formId="record-form" formId="record-form"
layout="sections" layout="sections"
searchFilter={drawerSearch}
onlyFilled={drawerOnlyFilled}
onCounterChange={(visible, total) => setDrawerCount({ visible, total })}
onDirtyChange={setRecordDirtyCount} onDirtyChange={setRecordDirtyCount}
/> />
)} )}
@@ -1033,6 +1059,9 @@ function DictionaryDetail() {
onCancel={() => setEdit({ kind: 'closed' })} onCancel={() => setEdit({ kind: 'closed' })}
formId="record-form" formId="record-form"
layout="sections" layout="sections"
searchFilter={drawerSearch}
onlyFilled={drawerOnlyFilled}
onCounterChange={(visible, total) => setDrawerCount({ visible, total })}
onDirtyChange={setRecordDirtyCount} onDirtyChange={setRecordDirtyCount}
/> />
)} )}