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:
Zimin A.N.
2026-05-11 17:10:26 +03:00
parent e5572d5346
commit 889207f629
2 changed files with 97 additions and 15 deletions
@@ -59,6 +59,14 @@ type Props = {
* - 'sections': all buckets vertically scrollable + section chips strip
* на верху для quick jump. Per handoff Screen 3 RecordDrawer design. */
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 })
@@ -104,6 +112,9 @@ export const SchemaDrivenForm = ({
formId,
onDirtyChange,
layout = 'tabs',
searchFilter,
onlyFilled,
onCounterChange,
}: Props) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabId>('identity')
@@ -134,6 +145,48 @@ export const SchemaDrivenForm = ({
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) => {
if (values.validFrom && values.validTo) {
const from = new Date(values.validFrom)
@@ -225,7 +278,7 @@ export const SchemaDrivenForm = ({
>
{tab.label}
<span className="text-mono text-mute">
{buckets[tab.id as TabId].length}
{filteredBuckets[tab.id as TabId].length}
</span>
</button>
))}
@@ -288,7 +341,7 @@ export const SchemaDrivenForm = ({
/>
)}
/>
{buckets.identity.map((key) => (
{filteredBuckets.identity.map((key) => (
<FieldRenderer
key={key}
fieldKey={key}
@@ -308,13 +361,13 @@ export const SchemaDrivenForm = ({
id={`form-section-${formId ?? 'default'}-description`}
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]">
{t('form.tabs.description')}
</h3>
)}
<div className="grid grid-cols-1 gap-3">
{buckets.description.map((key) => (
{filteredBuckets.description.map((key) => (
<FieldRenderer
key={key}
fieldKey={key}
@@ -334,13 +387,13 @@ export const SchemaDrivenForm = ({
id={`form-section-${formId ?? 'default'}-extra`}
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]">
{t('form.tabs.extra')}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{buckets.extra.map((key) => (
{filteredBuckets.extra.map((key) => (
<FieldRenderer
key={key}
fieldKey={key}