From 733ed9746d0325d2bd7f2a00d0b439782ffdc7fd Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 20 May 2026 09:58:51 -0400 Subject: [PATCH] feat(field-management): add Hide All Separators bulk action Adds a footer button on the field order editor that flips Hidden=true on every separator field and moves them into "99 - Other Fields" in one click, so users can clean up page layouts without dragging each separator individually. - field-management.tsx: new footer button wired to editor handle - field-order-editor.tsx, types.ts: expose hideAllSeparators on the imperative handle - use-field-order-state.ts: implement bulk hide+regroup, mark dirty - use-field-order-state.test.ts: cover happy path, no-op, negative IDs, preserved "Other" ordering, and buildSavePayload persistence - test-setup.ts: stub ResizeObserver for jsdom (Radix uses it) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fieldmanagement/field-management.tsx | 35 ++++-- .../field-management/field-order-editor.tsx | 3 +- src/components/field-management/types.ts | 1 + .../use-field-order-state.test.ts | 111 ++++++++++++++++++ .../field-management/use-field-order-state.ts | 42 +++++++ src/test-setup.ts | 9 ++ 6 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 src/components/field-management/use-field-order-state.test.ts diff --git a/src/app/(web)/tools/fieldmanagement/field-management.tsx b/src/app/(web)/tools/fieldmanagement/field-management.tsx index d37a172..ba05b3c 100644 --- a/src/app/(web)/tools/fieldmanagement/field-management.tsx +++ b/src/app/(web)/tools/fieldmanagement/field-management.tsx @@ -8,7 +8,7 @@ import { FieldOrderEditor } from "@/components/field-management/field-order-edit import type { PageListItem, PageFieldData, FieldOrderEditorHandle } from "@/components/field-management"; import { fetchPageFieldData, savePageFieldOrder } from "@/components/field-management/actions"; import { ToolParams } from "@/lib/tool-params"; -import { ArrowLeft, Table, Loader2, EyeOff } from "lucide-react"; +import { ArrowLeft, Table, Loader2, EyeOff, SeparatorHorizontal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; @@ -73,6 +73,10 @@ export function FieldManagement({ params }: FieldManagementProps) { editorRef.current?.moveHiddenToOther(); }; + const handleHideAllSeparators = () => { + editorRef.current?.hideAllSeparators(); + }; + const handleClose = () => { router.back(); }; @@ -97,15 +101,26 @@ export function FieldManagement({ params }: FieldManagementProps) { hideFooter={step === 1 || isLoadingFields || !fieldData} footerExtra={ step === 2 && fieldData ? ( - +
+ + +
) : undefined } > diff --git a/src/components/field-management/field-order-editor.tsx b/src/components/field-management/field-order-editor.tsx index 0ce74d0..a67d6ff 100644 --- a/src/components/field-management/field-order-editor.tsx +++ b/src/components/field-management/field-order-editor.tsx @@ -42,7 +42,8 @@ export const FieldOrderEditor = forwardRef ({ getSavePayload: state.buildSavePayload, moveHiddenToOther: state.moveHiddenToOther, - }), [state.buildSavePayload, state.moveHiddenToOther]); + hideAllSeparators: state.hideAllSeparators, + }), [state.buildSavePayload, state.moveHiddenToOther, state.hideAllSeparators]); const totalFields = Object.values(state.groupedFields).reduce( (sum, ids) => sum + ids.length, diff --git a/src/components/field-management/types.ts b/src/components/field-management/types.ts index 363149f..290d058 100644 --- a/src/components/field-management/types.ts +++ b/src/components/field-management/types.ts @@ -44,4 +44,5 @@ export interface FieldOrderPayload { export interface FieldOrderEditorHandle { getSavePayload: () => FieldOrderPayload[]; moveHiddenToOther: () => void; + hideAllSeparators: () => void; } diff --git a/src/components/field-management/use-field-order-state.test.ts b/src/components/field-management/use-field-order-state.test.ts new file mode 100644 index 0000000..e700c48 --- /dev/null +++ b/src/components/field-management/use-field-order-state.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useFieldOrderState } from './use-field-order-state'; +import type { PageField } from './types'; + +const OTHER = '99 - Other Fields'; + +function makeField(overrides: Partial & { Page_Field_ID: number; Field_Name: string }): PageField { + return { + Page_Field_ID: overrides.Page_Field_ID, + Page_ID: 1, + Field_Name: overrides.Field_Name, + Group_Name: overrides.Group_Name ?? '1 - General', + View_Order: overrides.View_Order ?? 1, + Required: overrides.Required ?? false, + Hidden: overrides.Hidden ?? false, + Default_Value: null, + Filter_Clause: null, + Depends_On_Field: null, + Field_Label: null, + Writing_Assistant_Enabled: false, + isSeparator: overrides.isSeparator ?? false, + }; +} + +describe('useFieldOrderState > hideAllSeparators', () => { + it('flips Hidden to true and moves separators to "99 - Other Fields"', () => { + const fields: PageField[] = [ + makeField({ Page_Field_ID: 1, Field_Name: 'First_Name', Group_Name: '1 - General', View_Order: 1 }), + makeField({ Page_Field_ID: 2, Field_Name: 'Sep_A', Group_Name: '1 - General', View_Order: 2, isSeparator: true }), + makeField({ Page_Field_ID: 3, Field_Name: 'Last_Name', Group_Name: '2 - Name', View_Order: 3 }), + makeField({ Page_Field_ID: 4, Field_Name: 'Sep_B', Group_Name: '2 - Name', View_Order: 4, isSeparator: true }), + ]; + const { result } = renderHook(() => useFieldOrderState(fields)); + + act(() => { + result.current.hideAllSeparators(); + }); + + expect(result.current.fieldLookup.get(2)?.Hidden).toBe(true); + expect(result.current.fieldLookup.get(4)?.Hidden).toBe(true); + expect(result.current.fieldLookup.get(1)?.Hidden).toBe(false); + expect(result.current.fieldLookup.get(3)?.Hidden).toBe(false); + + expect(result.current.groupedFields['1 - General']).toEqual([1]); + expect(result.current.groupedFields['2 - Name']).toEqual([3]); + expect(result.current.groupedFields[OTHER]).toEqual([2, 4]); + expect(result.current.groupOrder[result.current.groupOrder.length - 1]).toBe(OTHER); + expect(result.current.isDirty).toBe(true); + }); + + it('preserves existing "Other" fields ahead of newly-moved separators', () => { + const fields: PageField[] = [ + makeField({ Page_Field_ID: 10, Field_Name: 'Existing_Other', Group_Name: OTHER, View_Order: 1 }), + makeField({ Page_Field_ID: 11, Field_Name: 'Sep_X', Group_Name: '1 - General', View_Order: 2, isSeparator: true }), + ]; + const { result } = renderHook(() => useFieldOrderState(fields)); + + act(() => { + result.current.hideAllSeparators(); + }); + + expect(result.current.groupedFields[OTHER]).toEqual([10, 11]); + }); + + it('no-ops when no separators exist', () => { + const fields: PageField[] = [ + makeField({ Page_Field_ID: 1, Field_Name: 'First_Name', Group_Name: '1 - General', View_Order: 1 }), + ]; + const { result } = renderHook(() => useFieldOrderState(fields)); + + act(() => { + result.current.hideAllSeparators(); + }); + + expect(result.current.isDirty).toBe(false); + expect(result.current.fieldLookup.get(1)?.Hidden).toBe(false); + }); + + it('handles auto-injected separators with negative Page_Field_IDs', () => { + const fields: PageField[] = [ + makeField({ Page_Field_ID: 1, Field_Name: 'First_Name', Group_Name: '1 - General', View_Order: 1 }), + makeField({ Page_Field_ID: -1, Field_Name: 'Sep_Injected', Group_Name: null, View_Order: 2, isSeparator: true }), + ]; + const { result } = renderHook(() => useFieldOrderState(fields)); + + act(() => { + result.current.hideAllSeparators(); + }); + + expect(result.current.fieldLookup.get(-1)?.Hidden).toBe(true); + expect(result.current.groupedFields[OTHER]).toContain(-1); + }); + + it('flips Hidden values so buildSavePayload persists Hidden=true for separators', () => { + const fields: PageField[] = [ + makeField({ Page_Field_ID: 1, Field_Name: 'First_Name', Group_Name: '1 - General', View_Order: 1 }), + makeField({ Page_Field_ID: 2, Field_Name: 'Sep_A', Group_Name: '1 - General', View_Order: 2, isSeparator: true }), + ]; + const { result } = renderHook(() => useFieldOrderState(fields)); + + act(() => { + result.current.hideAllSeparators(); + }); + + const payload = result.current.buildSavePayload(); + const sep = payload.find((p) => p.Field_Name === 'Sep_A'); + expect(sep?.Hidden).toBe(true); + expect(sep?.Group_Name).toBe(OTHER); + }); +}); diff --git a/src/components/field-management/use-field-order-state.ts b/src/components/field-management/use-field-order-state.ts index dc1441b..06f2a38 100644 --- a/src/components/field-management/use-field-order-state.ts +++ b/src/components/field-management/use-field-order-state.ts @@ -23,6 +23,7 @@ interface FieldOrderState { addGroup: (name: string) => void; removeGroup: (name: string) => void; moveHiddenToOther: () => void; + hideAllSeparators: () => void; updateField: (id: number, updates: Partial) => void; buildSavePayload: () => FieldOrderPayload[]; } @@ -162,6 +163,46 @@ export function useFieldOrderState(fields: PageField[]): FieldOrderState { setIsDirty(true); }, [fieldLookup]); + const hideAllSeparators = useCallback(() => { + const separatorIds = new Set(); + for (const field of fieldLookup.values()) { + if (field.isSeparator) separatorIds.add(field.Page_Field_ID); + } + if (separatorIds.size === 0) return; + + setFieldLookup((prev) => { + const next = new Map(prev); + for (const id of separatorIds) { + const existing = next.get(id); + if (existing && !existing.Hidden) { + next.set(id, { ...existing, Hidden: true }); + } + } + return next; + }); + + setGroupedFields((prev) => { + const next: GroupedFieldsMap = {}; + for (const [groupName, ids] of Object.entries(prev)) { + if (groupName === OTHER_FIELDS_GROUP) continue; + next[groupName] = ids.filter((id) => !separatorIds.has(id)); + } + const existingOther = prev[OTHER_FIELDS_GROUP] ?? []; + next[OTHER_FIELDS_GROUP] = [ + ...existingOther.filter((id) => !separatorIds.has(id)), + ...separatorIds, + ]; + return next; + }); + + setGroupOrder((prev) => { + if (prev.includes(OTHER_FIELDS_GROUP)) return prev; + return [...prev, OTHER_FIELDS_GROUP]; + }); + + setIsDirty(true); + }, [fieldLookup]); + const updateField = useCallback((id: number, updates: Partial) => { setFieldLookup((prev) => { const existing = prev.get(id); @@ -226,6 +267,7 @@ export function useFieldOrderState(fields: PageField[]): FieldOrderState { addGroup, removeGroup, moveHiddenToOther, + hideAllSeparators, updateField, buildSavePayload, }; diff --git a/src/test-setup.ts b/src/test-setup.ts index 4fa089a..0186974 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1,6 +1,15 @@ import '@testing-library/jest-dom'; import { vi } from 'vitest'; +if (typeof globalThis.ResizeObserver === 'undefined') { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver; +} + // Mock environment variables for tests vi.stubEnv('MINISTRY_PLATFORM_BASE_URL', 'https://test-mp.example.com'); vi.stubEnv('MINISTRY_PLATFORM_CLIENT_ID', 'test-client-id');