diff --git a/docs/lib.md b/docs/lib.md index cd17127d..9a0a2ee5 100644 --- a/docs/lib.md +++ b/docs/lib.md @@ -57,3 +57,9 @@ This component searches for all required rendering elements and renders the enti | :------- | :------- | :------: | :----------------------------------------------- | | name | `string` | yes | View name | | spec | `Spec` | yes | An [spec](./spec.md#specs) describing the entity | + +## Dotted property keys + +Dots in `spec.properties` keys are not supported. The library follows the [final-form field name](https://final-form.org/docs/final-form/field-names) convention: a dot is a path separator, so a property key like `a.b` is treated as the path `a` → `b`, not as a literal key of the value object. Values of such properties will not be resolved — fields render without data. + +In development mode `DynamicField` and `DynamicView` warn about such keys in the console. If your data source produces keys with dots, transform both the spec and the values before passing them to the library. diff --git a/src/lib/core/components/Form/DynamicField.tsx b/src/lib/core/components/Form/DynamicField.tsx index 61a71f8c..09b0ee81 100644 --- a/src/lib/core/components/Form/DynamicField.tsx +++ b/src/lib/core/components/Form/DynamicField.tsx @@ -6,7 +6,7 @@ import isString from 'lodash/isString'; import {isValidElementType} from 'react-is'; import type {MonacoEditorProps} from 'react-monaco-editor/lib/types'; -import {isCorrectSpec} from '../../helpers'; +import {isCorrectSpec, warnAboutDottedPropertyKeys} from '../../helpers'; import type {Spec, StringSpec} from '../../types'; import {Controller} from './Controller'; @@ -65,6 +65,10 @@ export const DynamicField: React.FC = ({ const {store: searchStore, setField, removeField, isHiddenField} = useSearchStore(); const shared = useFormSharedStore(externalShared); + React.useEffect(() => { + warnAboutDottedPropertyKeys(spec); + }, [spec]); + const context = React.useMemo( () => ({ config, diff --git a/src/lib/core/components/Form/__tests__/DynamicField.test.tsx b/src/lib/core/components/Form/__tests__/DynamicField.test.tsx index 661ccb32..8de87459 100644 --- a/src/lib/core/components/Form/__tests__/DynamicField.test.tsx +++ b/src/lib/core/components/Form/__tests__/DynamicField.test.tsx @@ -154,6 +154,10 @@ beforeEach(() => { }); }); +afterEach(() => { + jest.restoreAllMocks(); +}); + test('Form/hooks/DynamicField', () => { const mirror: WonderMirror = {field: {}, controller: {}}; let form = null as FormApi | null; @@ -406,3 +410,66 @@ test('Form/hooks/DynamicField', () => { .find((err) => Boolean(err)), ); }); + +test('warns about spec property keys containing dots', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const stringSpec = { + type: SpecTypes.String, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Field'}, + } as const; + + const dottedSpec: ObjectSpec = { + type: SpecTypes.Object, + properties: { + 'agent.cluster': stringSpec, + namespace: stringSpec, + 'agent.resources': { + type: SpecTypes.Object, + properties: {'limits.memory': stringSpec}, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Resources'}, + }, + servers: { + type: SpecTypes.Array, + items: { + type: SpecTypes.Object, + properties: {'net.host': stringSpec}, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Server'}, + }, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Servers'}, + }, + }, + viewSpec: {type: 'base', layout: 'section', layoutTitle: 'Candidate'}, + }; + + render( + +
+ {() => } + +
, + ); + + expect(warn).toHaveBeenCalledTimes(1); + + const message = warn.mock.calls[0][0] as string; + + ['agent.cluster', 'agent.resources', 'limits.memory', 'net.host'].forEach((key) => { + expect(message).toContain(key); + }); + expect(message).not.toContain('namespace,'); +}); + +test('does not warn when spec property keys have no dots', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + render( + +
+ {() => } + +
, + ); + + expect(warn).not.toHaveBeenCalled(); +}); diff --git a/src/lib/core/components/View/DynamicView.tsx b/src/lib/core/components/View/DynamicView.tsx index 4096fd1a..e47f0fa4 100644 --- a/src/lib/core/components/View/DynamicView.tsx +++ b/src/lib/core/components/View/DynamicView.tsx @@ -3,7 +3,7 @@ import React from 'react'; import {isValidElementType} from 'react-is'; import type {MonacoEditorProps} from 'react-monaco-editor/lib/types'; -import {isCorrectSpec} from '../../helpers'; +import {isCorrectSpec, warnAboutDottedPropertyKeys} from '../../helpers'; import type {FormValue, Spec} from '../../types'; import {ViewController} from './ViewController'; @@ -36,6 +36,10 @@ export const DynamicView = ({ const DynamicFormsCtx = useCreateContext(); const shared = useViewSharedStore(externalShared); + React.useEffect(() => { + warnAboutDottedPropertyKeys(spec); + }, [spec]); + const context = React.useMemo( () => ({ config, diff --git a/src/lib/core/components/View/__tests__/DynamicView.test.tsx b/src/lib/core/components/View/__tests__/DynamicView.test.tsx new file mode 100644 index 00000000..db3b83ea --- /dev/null +++ b/src/lib/core/components/View/__tests__/DynamicView.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import {render} from '@testing-library/react'; + +import {DynamicView} from '../'; +import {dynamicViewConfig} from '../../../../kit'; +import {SpecTypes} from '../../../constants'; +import type {ObjectSpec} from '../../../types'; + +const stringSpec = {type: SpecTypes.String, viewSpec: {type: 'base', layout: ''}} as const; + +describe('View/DynamicView', () => { + beforeAll(() => { + window.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } as unknown as typeof IntersectionObserver; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('warns about spec property keys containing dots', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const spec: ObjectSpec = { + type: SpecTypes.Object, + properties: { + 'agent.cluster': stringSpec, + namespace: stringSpec, + 'agent.resources': { + type: SpecTypes.Object, + properties: {'limits.memory': stringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + servers: { + type: SpecTypes.Array, + items: { + type: SpecTypes.Object, + properties: {'net.host': stringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + viewSpec: {type: 'base', layout: ''}, + }, + }, + viewSpec: {type: 'base', layout: ''}, + }; + + render(); + + expect(warn).toHaveBeenCalledTimes(1); + + const message = warn.mock.calls[0][0] as string; + + ['agent.cluster', 'agent.resources', 'limits.memory', 'net.host'].forEach((key) => { + expect(message).toContain(key); + }); + expect(message).not.toContain('namespace,'); + }); + + test('does not warn when spec property keys have no dots', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const spec: ObjectSpec = { + type: SpecTypes.Object, + properties: {namespace: stringSpec}, + viewSpec: {type: 'base', layout: ''}, + }; + + render(); + + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/core/helpers.ts b/src/lib/core/helpers.ts index ef65c4b4..0f74173e 100644 --- a/src/lib/core/helpers.ts +++ b/src/lib/core/helpers.ts @@ -2,7 +2,7 @@ import isObjectLike from 'lodash/isObjectLike'; import isString from 'lodash/isString'; import {SpecTypes} from './constants'; -import type {ArraySpec, BooleanSpec, NumberSpec, ObjectSpec, StringSpec} from './types'; +import type {ArraySpec, BooleanSpec, NumberSpec, ObjectSpec, Spec, StringSpec} from './types'; export const isCorrectSpec = (candidate: any) => isObjectLike(candidate) && @@ -28,3 +28,37 @@ export const isObjectSpec = (candidate: any): candidate is ObjectSpec => export const isStringSpec = (candidate: any): candidate is StringSpec => candidate?.type === SpecTypes.String; + +export const collectDottedPropertyKeys = (spec: Spec): string[] => { + const dottedKeys: string[] = []; + + if (isObjectSpec(spec) && isObjectLike(spec.properties)) { + Object.entries(spec.properties ?? {}).forEach(([key, childSpec]) => { + if (key.includes('.')) { + dottedKeys.push(key); + } + + dottedKeys.push(...collectDottedPropertyKeys(childSpec)); + }); + } + + if (isArraySpec(spec) && spec.items) { + dottedKeys.push(...collectDottedPropertyKeys(spec.items)); + } + + return dottedKeys; +}; + +export const warnAboutDottedPropertyKeys = (spec: Spec) => { + if (process.env.NODE_ENV !== 'production') { + const dottedKeys = collectDottedPropertyKeys(spec); + + if (dottedKeys.length) { + console.warn( + `[dynamic-forms] Spec property keys containing dots are not supported, their values will not be resolved: ${dottedKeys.join( + ', ', + )}. See docs/lib.md#dotted-property-keys`, + ); + } + } +};