feat: wire drawer toolbar filters + records table responsive hiding
Closing 2 handoff gaps:
1) DRAWER TOOLBAR FILTERS (handoff Screen 3 line 279)
SchemaDrivenForm получил три new optional props:
- searchFilter (string) — case-insensitive substring против field key + label
- onlyFilled (boolean) — hide fields с empty data[key]
(null/undefined/''/false/0/empty-array/empty-object)
- onCounterChange (visible, total) — surface field counter parent'у
Filter применяется поверх bucketing — все три buckets (identity/description/
extra) фильтруются. UI рендерит filteredBuckets.X.map(...) вместо raw bucket'ов.
В sections layout (drawer use case) если section после filter empty —
sticky header не рендерится. Tabs layout backward-compat (filter применяется
но visually invisible если user в другом tab'е).
dictionaries.$name.tsx callsite:
- State: drawerSearch, drawerOnlyFilled, drawerCount {visible, total}
- Reset on drawer close
- RecordDrawer.toolbar prop wired только в edit mode (create — все поля
empty, 'только заполненные' даст 0/0)
- SchemaDrivenForm receives все три filter props
Result: user может typing 'mass' в drawer search → видит только mass_kg /
data_mass / etc.; toggle 'только заполненные' → hide unused optional fields.
2) RECORDS TABLE RESPONSIVE COLUMN HIDING (handoff line 466)
Records table в dictionaries.$name.tsx применяет hideBelow API из MR !59:
- businessKey: всегда visible
- name/code: hideBelow='md' (768px)
- extra schema columns: hideBelow='lg' (1024px)
- scope badge: hideBelow='sm' (640px)
- validFrom: hideBelow='lg'
- actions: всегда visible
На mobile 375px только businessKey + actions visible — chrome остаётся
usable. Tablet 768+ adds name/scope. Desktop 1024+ adds schema columns.
Tests: 116 pass.
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user