Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/lib.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 5 additions & 1 deletion src/lib/core/components/Form/DynamicField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,6 +65,10 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
const {store: searchStore, setField, removeField, isHiddenField} = useSearchStore();
const shared = useFormSharedStore(externalShared);

React.useEffect(() => {
warnAboutDottedPropertyKeys(spec);
}, [spec]);

const context = React.useMemo(
() => ({
config,
Expand Down
67 changes: 67 additions & 0 deletions src/lib/core/components/Form/__tests__/DynamicField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ beforeEach(() => {
});
});

afterEach(() => {
jest.restoreAllMocks();
});

test('Form/hooks/DynamicField', () => {
const mirror: WonderMirror = {field: {}, controller: {}};
let form = null as FormApi | null;
Expand Down Expand Up @@ -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(
<ThemeProvider>
<Form initialValues={{}} onSubmit={noop}>
{() => <DynamicField name={name} spec={dottedSpec} config={dynamicConfig} />}
</Form>
</ThemeProvider>,
);

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(
<ThemeProvider>
<Form initialValues={{}} onSubmit={noop}>
{() => <DynamicField name={name} spec={spec} config={dynamicConfig} />}
</Form>
</ThemeProvider>,
);

expect(warn).not.toHaveBeenCalled();
});
6 changes: 5 additions & 1 deletion src/lib/core/components/View/DynamicView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,6 +36,10 @@ export const DynamicView = ({
const DynamicFormsCtx = useCreateContext();
const shared = useViewSharedStore(externalShared);

React.useEffect(() => {
warnAboutDottedPropertyKeys(spec);
}, [spec]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule applies to both modes, so this log also needs to be added for DynamicField

@i-doshechnikow i-doshechnikow Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


const context = React.useMemo(
() => ({
config,
Expand Down
79 changes: 79 additions & 0 deletions src/lib/core/components/View/__tests__/DynamicView.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DynamicView value={{}} spec={spec} config={dynamicViewConfig} />);

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(<DynamicView value={{namespace: 'main'}} spec={spec} config={dynamicViewConfig} />);

expect(warn).not.toHaveBeenCalled();
});
});
36 changes: 35 additions & 1 deletion src/lib/core/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand All @@ -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`,
);
}
}
};
Loading