From ed8ae03e571f7a578f8dee305e84c5929da1e611 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 14 May 2026 16:48:53 +0600 Subject: [PATCH 1/8] feat(settings): support plain field-id keys for dependency lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin-ui's formatSettingsData() rebuilds dependency_key as a dot-path (parent.child.field) and the values map is keyed by that dot-path. Dependencies declared with a plain field id (e.g., 'commission_type' instead of 'commission.commission.commission_type') therefore failed to resolve via values[dep.key]. Add an id-keyed fallback so both formats work side by side: - Export buildIdIndex(schema) from settings-formatter — produces a { field_id: dependency_key } map from the hierarchical schema. - evaluateDependencies() accepts an optional idIndex argument. When values[dep.key] is undefined and an idIndex is supplied, the function resolves dep.key as a field id and re-reads values via the index. - SettingsProvider builds the idIndex (memoised on schema) and threads it through shouldDisplay → evaluateDependencies. Backwards compatible: callers that don't pass idIndex see identical behavior. Consumers whose backend guarantees globally-unique field ids (e.g., flat-storage schemas) can now use id-keyed dependencies, which stay valid across structural moves (no parent path to update). Co-Authored-By: Claude Opus 4.7 --- src/components/settings/settings-context.tsx | 11 ++- src/components/settings/settings-formatter.ts | 67 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/components/settings/settings-context.tsx b/src/components/settings/settings-context.tsx index 4fc8e6a..7d4a4d8 100644 --- a/src/components/settings/settings-context.tsx +++ b/src/components/settings/settings-context.tsx @@ -10,6 +10,7 @@ import { } from 'react'; import type { SaveButtonRenderProps, SettingsElement } from './settings-types'; import { + buildIdIndex, evaluateDependencies, extractValues, formatSettingsData, @@ -355,12 +356,18 @@ export function SettingsProvider({ [schema, onChange, keyToScopeMap, activeSubpage, activePage] ); + // Field-id → dependency_key index, used as a fallback when a dependency + // declares its `key` as a plain field id instead of the reconstructed + // dot-path. Memoized on the schema since ids and dep_keys don't change + // at runtime. + const idIndex = useMemo(() => buildIdIndex(schema), [schema]); + // Dependency evaluation const shouldDisplay = useCallback( (element: SettingsElement): boolean => { - return evaluateDependencies(element, values); + return evaluateDependencies(element, values, idIndex); }, - [values] + [values, idIndex] ); // Navigation helpers diff --git a/src/components/settings/settings-formatter.ts b/src/components/settings/settings-formatter.ts index 3e288e3..9e8b78d 100644 --- a/src/components/settings/settings-formatter.ts +++ b/src/components/settings/settings-formatter.ts @@ -294,12 +294,64 @@ export function extractValues( return values; } +/** + * Builds a `{ field_id: dependency_key }` map from a hierarchical schema. + * + * Used by `evaluateDependencies` to resolve a dependency `key` that is + * declared as a plain field id (e.g. `"product_info_generate"`) instead + * of the full reconstructed dot-path (e.g. + * `"product_generation.product_image_section.product_info_generate"`). + * + * Consumers may pass id-keyed dependencies when their backend already + * guarantees globally-unique field ids; the dot-path remains supported + * for backwards compatibility. + */ +export function buildIdIndex( + schema: SettingsElement[] +): Record { + const idIndex: Record = {}; + + const walk = (elements: SettingsElement[]) => { + for (const el of elements) { + if ( + el.type === 'field' && + el.id && + el.dependency_key && + el.id !== el.dependency_key + ) { + // First writer wins — if two fields share an id (a schema + // bug consumers should detect with their own validator), + // we don't silently clobber the earlier mapping. + if (!(el.id in idIndex)) { + idIndex[el.id] = el.dependency_key; + } + } + if (el.children && el.children.length > 0) { + walk(el.children); + } + } + }; + + walk(schema); + return idIndex; +} + /** * Evaluates whether a field should be displayed based on its dependencies. + * + * Dependency `key` resolution: + * 1. Look up `values[dep.key]` directly (existing dot-path behavior). + * 2. If that yields `undefined` and an `idIndex` is supplied, treat + * `dep.key` as a plain field id and resolve via `idIndex[dep.key]` + * to its real `dependency_key` before reading from `values`. + * + * The `idIndex` is optional. When omitted, behavior is identical to the + * previous version of this function. */ export function evaluateDependencies( element: SettingsElement, - values: Record + values: Record, + idIndex?: Record ): boolean { if (!element.dependencies || element.dependencies.length === 0) { return element.display !== false; @@ -308,7 +360,18 @@ export function evaluateDependencies( return element.dependencies.every((dep) => { if (!dep.key) return true; - const currentValue = values[dep.key]; + let currentValue = values[dep.key]; + + // Field-id fallback: dep.key may be a plain id (not a dot-path). + // Resolve it through the idIndex to the underlying dependency_key + // and re-read from values. + if (currentValue === undefined && idIndex) { + const resolved = idIndex[dep.key]; + if (resolved !== undefined) { + currentValue = values[resolved]; + } + } + const comparison = dep.comparison || '=='; const expectedValue = dep.value; const effect = dep.effect || 'show'; From a509c38819a530c6057a27eef50da3b77ae57fa4 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Mon, 18 May 2026 12:48:36 +0600 Subject: [PATCH 2/8] refactor(settings): formatter preserves server-supplied dependency_key; falls back to id The formatter used to rebuild `dependency_key` on every render by joining the parent chain into a dot-path. That silently overwrote whatever the server sent. Now we prefer the server value and fall back to the element id when missing (Phase 1 of the dependency_key cleanup; consumers are updated in Phase 2). - formatSettingsData: keep `child.dependency_key` if present, else `child.id` - formatSettingsData: same fallback for the root page (was hard-coded `''`) - Add jest unit tests covering nested preservation, nested fallback, and the page-itself case Co-Authored-By: Claude Opus 4.7 --- .../settings/settings-formatter.test.ts | 76 +++++++++++++++++++ src/components/settings/settings-formatter.ts | 10 ++- 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 src/components/settings/settings-formatter.test.ts diff --git a/src/components/settings/settings-formatter.test.ts b/src/components/settings/settings-formatter.test.ts new file mode 100644 index 0000000..c2d42bc --- /dev/null +++ b/src/components/settings/settings-formatter.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from '@jest/globals'; +import { formatSettingsData } from './settings-formatter'; +import type { SettingsElement } from './settings-types'; + +describe('settings-formatter dependency_key handling', () => { + it('preserves server-supplied dependency_key without overwriting (nested)', () => { + // Field is nested two levels deep (page -> section -> field) so that + // the legacy overwrite behavior (parent.dependency_key + '.' + id) + // would yield a dot-path different from the server value. + const input: SettingsElement[] = [ + { id: 'general', type: 'page' } as SettingsElement, + { + id: 'commission_section', + type: 'section', + page_id: 'general', + } as SettingsElement, + { + id: 'commission_type', + type: 'field', + section_id: 'commission_section', + dependency_key: 'commission_type', + } as SettingsElement, + ]; + + const out = formatSettingsData(input); + const page = out.find((e) => e.id === 'general')!; + const section = page.children!.find( + (c) => c.id === 'commission_section' + )!; + const field = section.children!.find( + (c) => c.id === 'commission_type' + )!; + + expect(field.dependency_key).toBe('commission_type'); + }); + + it('falls back to id when server omits dependency_key (nested)', () => { + const input: SettingsElement[] = [ + { id: 'general', type: 'page' } as SettingsElement, + { + id: 'commission_section', + type: 'section', + page_id: 'general', + } as SettingsElement, + { + id: 'commission_type', + type: 'field', + section_id: 'commission_section', + } as SettingsElement, + ]; + + const out = formatSettingsData(input); + const page = out.find((e) => e.id === 'general')!; + const section = page.children!.find( + (c) => c.id === 'commission_section' + )!; + const field = section.children!.find( + (c) => c.id === 'commission_type' + )!; + + expect(field.dependency_key).toBe('commission_type'); + }); + + it('uses element id (not empty string) for the dependency_key on a page itself', () => { + const input: SettingsElement[] = [ + { id: 'general', type: 'page' } as SettingsElement, + ]; + + const out = formatSettingsData(input); + const page = out.find((e) => e.id === 'general')!; + + // Pages used to receive `dependency_key = ''`. They should now fall + // back to their id when the server omits the field. + expect(page.dependency_key).toBe('general'); + }); +}); diff --git a/src/components/settings/settings-formatter.ts b/src/components/settings/settings-formatter.ts index 9e8b78d..6d2372b 100644 --- a/src/components/settings/settings-formatter.ts +++ b/src/components/settings/settings-formatter.ts @@ -149,7 +149,7 @@ export function formatSettingsData(data: SettingsElement[]): SettingsElement[] { element.description = element.description || ''; element.hook_key = element.hook_key || `settings_${element.id}`; - element.dependency_key = ''; + element.dependency_key = element.dependency_key || element.id; roots.push(element); continue; } @@ -185,9 +185,11 @@ export function formatSettingsData(data: SettingsElement[]): SettingsElement[] { child.display = child.display !== undefined ? child.display : true; child.hook_key = `${parent.hook_key}_${child.id}`; - child.dependency_key = [parent.dependency_key, child.id] - .filter(Boolean) - .join('.'); + // Prefer the server-supplied dependency_key; fall back to the + // element id when missing. The legacy behavior reconstructed a + // dot-path from the parent chain, which silently overwrote the + // server value (see Phase 1 dependency_key cleanup). + child.dependency_key = child.dependency_key || child.id; // ── Field-specific defaults ── if (child.type === 'field') { From 82fd6bd1f1c7685f28ffc319d16770d55fda24d4 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Mon, 18 May 2026 14:12:36 +0600 Subject: [PATCH 3/8] refactor(settings): consumers read element.id; dependency_key becomes vestigial Now that the server emits and consumes flat element ids exclusively (Phase 2 of the dependency_key cleanup), every plugin-ui consumer was keying off element.dependency_key for values, errors, dirty tracking, and onChange callbacks. Replace those reads with element.id: - field-renderer.tsx: merged element value/validationError lookups - fields.tsx: all 20 onChange(element.dependency_key!, ...) call sites - settings-context.tsx: scopeFieldKeysMap, findElement, comments - settings-formatter.ts: * extractValues now keys by el.id * validations/dependencies self pointer uses child.id * buildIdIndex collapses to identity (kept for API stability; evaluateDependencies fallback path is now a no-op for properly keyed schemas, slated for removal in Task 11) - settings-types.ts: SettingsProps.values doc comment dependency_key is left on the type and on the formatter back-compat assignments to avoid breaking external consumers in this commit; Task 11 removes the residue. Co-Authored-By: Claude Opus 4.7 --- src/components/settings/field-renderer.tsx | 6 +-- src/components/settings/fields.tsx | 38 +++++++++---------- src/components/settings/settings-context.tsx | 16 ++++---- src/components/settings/settings-formatter.ts | 37 ++++++++---------- src/components/settings/settings-types.ts | 2 +- 5 files changed, 46 insertions(+), 53 deletions(-) diff --git a/src/components/settings/field-renderer.tsx b/src/components/settings/field-renderer.tsx index 0bf8091..53954c0 100644 --- a/src/components/settings/field-renderer.tsx +++ b/src/components/settings/field-renderer.tsx @@ -48,11 +48,11 @@ export function FieldRenderer({ return null; } - // Merge current value from context + // Merge current value from context (keyed by element id) const mergedElement: SettingsElement = { ...element, - value: element.dependency_key ? (values[element.dependency_key] ?? element.value) : element.value, - validationError: element.dependency_key ? errors[element.dependency_key] : undefined, + value: element.id ? (values[element.id] ?? element.value) : element.value, + validationError: element.id ? errors[element.id] : undefined, }; const fieldProps: FieldComponentProps = { diff --git a/src/components/settings/fields.tsx b/src/components/settings/fields.tsx index 201b9fb..714a7ba 100644 --- a/src/components/settings/fields.tsx +++ b/src/components/settings/fields.tsx @@ -173,7 +173,7 @@ export function TextField({ element, onChange, ...rest }: FieldComponentProps) { onChange(element.dependency_key!, e.target.value)} + onChange={(e) => onChange(element.id, e.target.value)} placeholder={ element.placeholder ? String(element.placeholder) : undefined } @@ -197,7 +197,7 @@ export function ShowHideField({ element, onChange }: FieldComponentProps) { onChange(element.dependency_key!, e.target.value)} + onChange={(e) => onChange(element.id, e.target.value)} placeholder={ element.placeholder ? String(element.placeholder) : undefined } @@ -235,7 +235,7 @@ export function NumberField({ element, onChange, ...rest }: FieldComponentProps) value={String(element.value ?? element.default ?? "")} onChange={(e) => onChange( - element.dependency_key!, + element.id, e.target.value === "" ? "" : Number(e.target.value), ) } @@ -267,7 +267,7 @@ export function TextareaField({ element, onChange, ...rest }: FieldComponentProp