From 23e87018edc87676919b55bfd66eec7a70ad1804 Mon Sep 17 00:00:00 2001 From: doscjen Date: Wed, 12 Aug 2026 17:39:14 +0300 Subject: [PATCH 1/4] fix: resolve values of spec keys containing dots --- docs/config.md | 2 + docs/lib.md | 17 +- .../core/components/View/ViewController.tsx | 22 +- .../View/__tests__/DynamicView.test.tsx | 104 +++++++ .../View/hooks/__tests__/useRender.test.tsx | 65 +++- .../core/components/View/hooks/useRender.tsx | 6 +- .../Views/ArrayBaseView/ArrayBaseView.tsx | 8 +- .../kit/components/Views/CardOneOfView.tsx | 1 + .../Views/MultiOneOfView/MultiOneOfView.tsx | 19 +- .../Views/ObjectBaseView/ObjectBaseView.tsx | 6 +- .../ObjectValueInputView.tsx | 11 +- .../components/Views/OneOfView/OneOfView.tsx | 1 + .../Views/TableArrayView/TableArrayView.tsx | 7 +- .../Views/TextLinkView/TextLinkView.tsx | 1 + .../TimeRangeSelectorView.tsx | 12 +- .../__tests__/dottedKeysContract.test.tsx | 282 ++++++++++++++++++ src/stories/ObjectDottedKeys.stories.tsx | 101 +++++++ test-utils/setup-tests-after.ts | 25 ++ 18 files changed, 655 insertions(+), 35 deletions(-) create mode 100644 src/lib/core/components/View/__tests__/DynamicView.test.tsx create mode 100644 src/lib/kit/components/Views/__tests__/dottedKeysContract.test.tsx create mode 100644 src/stories/ObjectDottedKeys.stories.tsx diff --git a/docs/config.md b/docs/config.md index 7c26c41e..14e31914 100644 --- a/docs/config.md +++ b/docs/config.md @@ -64,6 +64,8 @@ type LayoutType = React.ComponentType< `Views` are the components responsible for rendering the entity value. There can be two types of views: simple and independent. A `simple` `View` [ViewController](./lib.md#viewcontroller) wraps in [ViewLayout](#viewlayouts), whereas an `independent` `View` [ViewController](./lib.md#viewcontroller) is passed [ViewLayout](#viewlayouts) into the props, allowing the component to wrap itself. +If your `View` renders nested entities through [ViewController](./lib.md#viewcontroller), always pass the child value in the `resolvedValue` prop. Otherwise the value is looked up by `name` as a path and properties whose keys contain dots resolve to `undefined` — see [Dotted property keys](./lib.md#dotted-property-keys). + ```typescript type ViewEntity = { Component: React.ComponentType<{ diff --git a/docs/lib.md b/docs/lib.md index cd17127d..0c4b8abd 100644 --- a/docs/lib.md +++ b/docs/lib.md @@ -53,7 +53,16 @@ This component serves as the primary entry point for creating an overview of for This component searches for all required rendering elements and renders the entity. -| Property | Type | Required | Description | -| :------- | :------- | :------: | :----------------------------------------------- | -| name | `string` | yes | View name | -| spec | `Spec` | yes | An [spec](./spec.md#specs) describing the entity | +| Property | Type | Required | Description | +| :------------ | :---------- | :------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | `string` | yes | View name | +| spec | `Spec` | yes | An [spec](./spec.md#specs) describing the entity | +| resolvedValue | `FormValue` | | Already resolved value of the entity. Pass it when rendering child entities, so the value does not depend on parsing `name` as a path (see [Dotted property keys](#dotted-property-keys)) | + +Passing `resolvedValue` — including `resolvedValue={undefined}` — disables reading the value from the form value by `name`. Omit the prop entirely to keep that behaviour. + +### Dotted property keys + +`spec.properties` keys are treated as literal keys of the value object, not as paths. A spec with the property `agent.cluster` describes the value `{'agent.cluster': 'main'}`, not `{agent: {cluster: 'main'}}`. + +Every `View` that renders nested entities must therefore pass the already resolved child value into [ViewController](#viewcontroller) via `resolvedValue`. All views shipped with the library do this. If you render `ViewController` from a custom [View](./config.md#views) without `resolvedValue`, the value is looked up by `name` as a path and properties whose keys contain dots resolve to `undefined`. diff --git a/src/lib/core/components/View/ViewController.tsx b/src/lib/core/components/View/ViewController.tsx index 192699f5..9e50307a 100644 --- a/src/lib/core/components/View/ViewController.tsx +++ b/src/lib/core/components/View/ViewController.tsx @@ -1,21 +1,29 @@ import React from 'react'; -import type {Spec} from '../../types'; +import type {FormValue, Spec} from '../../types'; import {useComponents, useDynamicFormsCtx, useRender} from './hooks'; export interface ViewControllerProps { spec: SpecType; name: string; + resolvedValue?: FormValue; } -export const ViewController = ({ - spec, - name, -}: ViewControllerProps) => { - const {config, value, Link} = useDynamicFormsCtx(); +export const ViewController = (props: ViewControllerProps) => { + const {spec, name} = props; + const {config, value: contextValue, Link} = useDynamicFormsCtx(); const {viewEntity, Layout} = useComponents(spec, config); - const render = useRender({name, value, spec, viewEntity, Layout, Link}); + const resolved = 'resolvedValue' in props; + const render = useRender({ + name, + value: resolved ? props.resolvedValue : contextValue, + resolved, + spec, + viewEntity, + Layout, + Link, + }); return {render}; }; 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..1f429381 --- /dev/null +++ b/src/lib/core/components/View/__tests__/DynamicView.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; + +import {render, screen} from '@testing-library/react'; + +import type {DynamicViewConfig, ObjectIndependentView} from '../'; +import {DynamicView, ViewController} 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; + +const spec: ObjectSpec = { + type: SpecTypes.Object, + properties: { + 'agent.cluster': stringSpec, + 'agent.auth.serviceAccountId': stringSpec, + namespace: stringSpec, + 'agent.namespaces': { + type: SpecTypes.Array, + items: stringSpec, + viewSpec: {type: 'base', layout: ''}, + }, + 'agent.resources': { + type: SpecTypes.Object, + properties: {cpu: stringSpec, 'limits.memory': stringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + applicationName: stringSpec, + }, + viewSpec: {type: 'base', layout: ''}, +}; + +const value = { + 'agent.cluster': 'main-cluster', + 'agent.auth.serviceAccountId': 'service-account-1', + namespace: 'default-namespace', + 'agent.namespaces': ['namespace-one', 'namespace-two', 'namespace-three'], + 'agent.resources': {cpu: 'cpu-limit-2', 'limits.memory': 'memory-limit-4'}, + applicationName: 'log-agent', +}; + +describe('View/DynamicView', () => { + test('renders values of properties with dots in keys', () => { + render(); + + expect(screen.getByText('main-cluster')).toBeInTheDocument(); + expect(screen.getByText('service-account-1')).toBeInTheDocument(); + expect(screen.getByText('default-namespace')).toBeInTheDocument(); + expect(screen.getByText('namespace-one')).toBeInTheDocument(); + expect(screen.getByText('namespace-two')).toBeInTheDocument(); + expect(screen.getByText('namespace-three')).toBeInTheDocument(); + expect(screen.getByText('cpu-limit-2')).toBeInTheDocument(); + expect(screen.getByText('memory-limit-4')).toBeInTheDocument(); + expect(screen.getByText('log-agent')).toBeInTheDocument(); + }); + + test('reads the value by name for views that do not pass resolvedValue', () => { + const LegacyObjectView: ObjectIndependentView = ({spec, name}) => ( + + {Object.keys(spec.properties || {}).map((property) => ( + + ))} + + ); + + const legacyConfig: DynamicViewConfig = { + ...dynamicViewConfig, + object: { + ...dynamicViewConfig.object, + views: { + ...dynamicViewConfig.object.views, + base: {Component: LegacyObjectView, independent: true}, + }, + }, + }; + + const legacySpec: ObjectSpec = { + type: SpecTypes.Object, + properties: { + agent: { + type: SpecTypes.Object, + properties: {cluster: stringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + }, + viewSpec: {type: 'base', layout: ''}, + }; + + render( + , + ); + + expect(screen.getByText('legacy-cluster')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/core/components/View/hooks/__tests__/useRender.test.tsx b/src/lib/core/components/View/hooks/__tests__/useRender.test.tsx index 225d70bf..ed1a65dd 100644 --- a/src/lib/core/components/View/hooks/__tests__/useRender.test.tsx +++ b/src/lib/core/components/View/hooks/__tests__/useRender.test.tsx @@ -8,7 +8,7 @@ import {useComponents, useRender} from '../'; import type {FormValue, Spec} from '../../../../../core'; import {BaseView, ObjectBaseView, ViewRow, dynamicViewConfig} from '../../../../../kit'; import {SpecTypes} from '../../../../constants'; -import type {AnyObject, ObjectSpec, StringSpec} from '../../../../types'; +import type {ObjectSpec, StringSpec} from '../../../../types'; import type {DynamicViewConfig} from '../../types'; const name = 'name'; @@ -20,7 +20,8 @@ interface UseRenderProps { useRender?: ReturnType; }; name: string; - value: AnyObject; + value: FormValue; + resolved?: boolean; spec: Spec; config: DynamicViewConfig; Link?: React.ComponentType<{ @@ -190,4 +191,64 @@ describe('View/hooks/useRender', () => { expect(mirror.useRender?.props.linkValue.props.value).toBe(value[name]); expect(mirror.useRender?.props.linkValue.props.link).toBe(_spec.viewSpec.link); }); + + describe('resolved', () => { + const baseSpec = (): StringSpec => { + const _spec = cloneDeep(spec); + + _spec.viewSpec.type = 'base'; + + return _spec; + }; + + test('reads value by name as a path when not resolved', () => { + const mirror: UseRenderProps['mirror'] = {}; + + render( + , + ); + + expect(mirror.useRender?.props.value).toBe('nested'); + }); + + test('uses value as is when resolved', () => { + const mirror: UseRenderProps['mirror'] = {}; + + render( + , + ); + + expect(mirror.useRender?.props.value).toBe('resolved'); + }); + + test('does not fall back to the path when resolved value is undefined', () => { + const mirror: UseRenderProps['mirror'] = {}; + + render( + , + ); + + expect(mirror.useRender?.props.value).toBe(undefined); + }); + }); }); diff --git a/src/lib/core/components/View/hooks/useRender.tsx b/src/lib/core/components/View/hooks/useRender.tsx index 9606e9e7..a4420839 100644 --- a/src/lib/core/components/View/hooks/useRender.tsx +++ b/src/lib/core/components/View/hooks/useRender.tsx @@ -10,6 +10,7 @@ import type {IndependentViewEntity, ViewEntity, ViewLayoutType} from '../types'; export interface UseRenderParams { value: Value; + resolved?: boolean; name: string; spec: SpecType; viewEntity?: ViewEntity | IndependentViewEntity; @@ -19,6 +20,7 @@ export interface UseRenderParams export const useRender = ({ value, + resolved, name, spec, viewEntity, @@ -28,7 +30,7 @@ export const useRender = ({ const render = React.useMemo(() => { if (viewEntity && isCorrectSpec(spec) && isString(name)) { if (!spec.viewSpec.hidden) { - const currentValue = name ? get(value, name) : value; + const currentValue = !resolved && name ? get(value, name) : value; const linkValue = isValidElementType(Link) && spec?.viewSpec?.link ? ( @@ -71,7 +73,7 @@ export const useRender = ({ } return null; - }, [spec, name, value, viewEntity, Layout, Link]); + }, [spec, name, value, resolved, viewEntity, Layout, Link]); return render; }; diff --git a/src/lib/kit/components/Views/ArrayBaseView/ArrayBaseView.tsx b/src/lib/kit/components/Views/ArrayBaseView/ArrayBaseView.tsx index 4f1f1f8a..606bb878 100644 --- a/src/lib/kit/components/Views/ArrayBaseView/ArrayBaseView.tsx +++ b/src/lib/kit/components/Views/ArrayBaseView/ArrayBaseView.tsx @@ -52,11 +52,15 @@ export const ArrayBaseView: ArrayView = ({spec, name, value = []}) => { {spec.viewSpec.itemPrefix} ) : null} - + ); }), - [value.length, name, getItemSpec, spec.viewSpec.itemPrefix], + [value, name, getItemSpec, spec.viewSpec.itemPrefix], ); if (!itemSpecCorrect) { diff --git a/src/lib/kit/components/Views/CardOneOfView.tsx b/src/lib/kit/components/Views/CardOneOfView.tsx index cf16e4ed..a8a65376 100644 --- a/src/lib/kit/components/Views/CardOneOfView.tsx +++ b/src/lib/kit/components/Views/CardOneOfView.tsx @@ -61,6 +61,7 @@ export const CardOneOfView: ObjectIndependentView = (props) => { spec={specProperties[valueKey]} name={`${name ? name + '.' : ''}${valueKey}`} key={`${name ? name + '.' : ''}${valueKey}`} + resolvedValue={value[valueKey]} /> ) : null} diff --git a/src/lib/kit/components/Views/MultiOneOfView/MultiOneOfView.tsx b/src/lib/kit/components/Views/MultiOneOfView/MultiOneOfView.tsx index 4c7086e8..41cd5bfa 100644 --- a/src/lib/kit/components/Views/MultiOneOfView/MultiOneOfView.tsx +++ b/src/lib/kit/components/Views/MultiOneOfView/MultiOneOfView.tsx @@ -29,11 +29,11 @@ export const MultiOneOfView: React.FC = (props) => { const items = React.useMemo( () => - values.map((value) => { + values.map((valueKey) => { const title = - spec.description?.[value] || - specProperties[value]?.viewSpec.layoutTitle || - value || + spec.description?.[valueKey] || + specProperties[valueKey]?.viewSpec.layoutTitle || + valueKey || ''; return title; @@ -83,12 +83,13 @@ export const MultiOneOfView: React.FC = (props) => { className={b('content', {flat: withoutIndent, 'multiple-values': items.length > 1})} > - {values.map((value) => ( - - {specProperties && specProperties[value] ? ( + {values.map((valueKey) => ( + + {specProperties && specProperties[valueKey] ? ( ) : null} diff --git a/src/lib/kit/components/Views/ObjectBaseView/ObjectBaseView.tsx b/src/lib/kit/components/Views/ObjectBaseView/ObjectBaseView.tsx index f4c7ed9b..43f011c0 100644 --- a/src/lib/kit/components/Views/ObjectBaseView/ObjectBaseView.tsx +++ b/src/lib/kit/components/Views/ObjectBaseView/ObjectBaseView.tsx @@ -20,6 +20,7 @@ export const ObjectBaseView: React.FC = ({ spec, name, Layout, + value, ...restProps }) => { const content = React.useMemo(() => { @@ -44,6 +45,7 @@ export const ObjectBaseView: React.FC = ({ {delimiter && delimiter[property] ? ( {delimiter[property]} @@ -53,14 +55,14 @@ export const ObjectBaseView: React.FC = ({ )} ); - }, [inline, name, spec.properties, spec.viewSpec.delimiter, spec.viewSpec.order]); + }, [inline, name, value, spec.properties, spec.viewSpec.delimiter, spec.viewSpec.order]); if (!Layout || !content) { return content; } return ( - + {content} ); diff --git a/src/lib/kit/components/Views/ObjectValueInputView/ObjectValueInputView.tsx b/src/lib/kit/components/Views/ObjectValueInputView/ObjectValueInputView.tsx index bebb6544..1348dc0c 100644 --- a/src/lib/kit/components/Views/ObjectValueInputView/ObjectValueInputView.tsx +++ b/src/lib/kit/components/Views/ObjectValueInputView/ObjectValueInputView.tsx @@ -6,7 +6,13 @@ import type {ObjectIndependentView} from '../../../../core'; import {ViewController} from '../../../../core'; import {OBJECT_VALUE_PROPERTY_NAME} from '../../../constants/common'; -export const ObjectValueInputView: ObjectIndependentView = ({spec, name, Layout, ...restProps}) => { +export const ObjectValueInputView: ObjectIndependentView = ({ + spec, + name, + Layout, + value, + ...restProps +}) => { const childSpec = React.useMemo(() => { if (spec.properties?.[OBJECT_VALUE_PROPERTY_NAME]) { const childSpec = cloneDeep(spec.properties[OBJECT_VALUE_PROPERTY_NAME]); @@ -27,12 +33,13 @@ export const ObjectValueInputView: ObjectIndependentView = ({spec, name, Layout, ); if (Layout) { return ( - + {content} ); diff --git a/src/lib/kit/components/Views/OneOfView/OneOfView.tsx b/src/lib/kit/components/Views/OneOfView/OneOfView.tsx index 797b2f44..fa10d859 100644 --- a/src/lib/kit/components/Views/OneOfView/OneOfView.tsx +++ b/src/lib/kit/components/Views/OneOfView/OneOfView.tsx @@ -77,6 +77,7 @@ const OneOfViewComponent: React.FC = (props) => { spec={specProperties[valueKey]} name={`${name ? name + '.' : ''}${valueKey}`} key={`${name ? name + '.' : ''}${valueKey}`} + resolvedValue={value[valueKey]} /> ) : null} diff --git a/src/lib/kit/components/Views/TableArrayView/TableArrayView.tsx b/src/lib/kit/components/Views/TableArrayView/TableArrayView.tsx index 871aa121..ecd4430e 100644 --- a/src/lib/kit/components/Views/TableArrayView/TableArrayView.tsx +++ b/src/lib/kit/components/Views/TableArrayView/TableArrayView.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {Flex, HelpMark, Table, type TableColumnConfig} from '@gravity-ui/uikit'; -import type {ArrayView, FormValue, ObjectValue} from '../../../../core'; +import type {ArrayView, ObjectValue} from '../../../../core'; import { ViewController, isArraySpec, @@ -35,7 +35,7 @@ export const TableArrayView: ArrayView = ({value = [], spec, name}) => { id: 'idx', name: '', sticky: 'left', - template: (__: FormValue, idx: number) => ( + template: (__: ObjectValue, idx: number) => (
{idx + 1}
@@ -71,7 +71,7 @@ export const TableArrayView: ArrayView = ({value = [], spec, name}) => { {label} ), - template: (_: FormValue, idx: number) => { + template: (item: ObjectValue, idx: number) => { const entitySpec = items?.properties?.[property]; if (!entitySpec) { @@ -91,6 +91,7 @@ export const TableArrayView: ArrayView = ({value = [], spec, name}) => { ); diff --git a/src/lib/kit/components/Views/TextLinkView/TextLinkView.tsx b/src/lib/kit/components/Views/TextLinkView/TextLinkView.tsx index 5ceebc24..ee13ea71 100644 --- a/src/lib/kit/components/Views/TextLinkView/TextLinkView.tsx +++ b/src/lib/kit/components/Views/TextLinkView/TextLinkView.tsx @@ -32,6 +32,7 @@ export const TextLinkView: ObjectIndependentView = ({value, spec, name, Layout, ); diff --git a/src/lib/kit/components/Views/TimeRangeSelectorView/TimeRangeSelectorView.tsx b/src/lib/kit/components/Views/TimeRangeSelectorView/TimeRangeSelectorView.tsx index 7006fd0c..a98ec107 100644 --- a/src/lib/kit/components/Views/TimeRangeSelectorView/TimeRangeSelectorView.tsx +++ b/src/lib/kit/components/Views/TimeRangeSelectorView/TimeRangeSelectorView.tsx @@ -35,8 +35,16 @@ export const TimeRangeSelectorView: ObjectIndependentView = ({ const content = ( - - + + ); diff --git a/src/lib/kit/components/Views/__tests__/dottedKeysContract.test.tsx b/src/lib/kit/components/Views/__tests__/dottedKeysContract.test.tsx new file mode 100644 index 00000000..c576c20c --- /dev/null +++ b/src/lib/kit/components/Views/__tests__/dottedKeysContract.test.tsx @@ -0,0 +1,282 @@ +import React from 'react'; + +import {ThemeProvider} from '@gravity-ui/uikit'; +import {fireEvent, render, screen} from '@testing-library/react'; + +import {DynamicView} from '../../../../core'; +import {SpecTypes} from '../../../../core/constants'; +import type {FormValue, ObjectSpec, Spec} from '../../../../core/types'; +import {dynamicViewConfig} from '../../../constants/config'; + +const DOTTED_KEY = 'dotted.key'; + +interface ViewFixture { + spec: Spec; + value: FormValue; + expectTexts: (string | RegExp)[]; + prepare?: () => void; +} + +const baseStringSpec: Spec = {type: SpecTypes.String, viewSpec: {type: 'base', layout: ''}}; + +const fixtures: Record> = { + string: { + base: { + spec: baseStringSpec, + value: 'string-base-value', + expectTexts: ['string-base-value'], + }, + select: { + spec: baseStringSpec, + value: 'string-select-value', + expectTexts: ['string-select-value'], + }, + radio_group: { + spec: baseStringSpec, + value: 'string-radio-value', + expectTexts: ['string-radio-value'], + }, + textarea: {spec: baseStringSpec, value: 'textarea-value', expectTexts: ['textarea-value']}, + date_input: {spec: baseStringSpec, value: '2024-05-10', expectTexts: ['10.05.2024 00:00']}, + color_picker: {spec: baseStringSpec, value: '#ff0000', expectTexts: ['#ff0000']}, + file_input: { + spec: baseStringSpec, + value: 'file-input-value', + expectTexts: ['file-input-value'], + }, + text_content: { + spec: { + type: SpecTypes.String, + viewSpec: { + type: 'base', + layout: '', + textContentParams: {text: 'text-content-static'}, + }, + }, + value: 'text-content-value', + expectTexts: ['text-content-static'], + }, + number_with_scale: { + spec: { + type: SpecTypes.String, + viewSpec: { + type: 'base', + layout: '', + sizeParams: {defaultType: 'b', scale: {b: {factor: '1', title: 'B'}}}, + }, + }, + value: '2048', + expectTexts: ['2048', 'B'], + }, + monaco_input: { + spec: baseStringSpec, + value: 'monaco-value', + expectTexts: ['monaco-value'], + prepare: () => fireEvent.click(screen.getByRole('button')), + }, + }, + number: { + base: { + spec: {type: SpecTypes.Number, viewSpec: {type: 'base', layout: ''}}, + value: 42, + expectTexts: ['42'], + }, + range_input_picker: { + spec: {type: SpecTypes.Number, viewSpec: {type: 'base', layout: ''}}, + value: 42, + expectTexts: ['42'], + }, + }, + boolean: { + base: { + spec: {type: SpecTypes.Boolean, viewSpec: {type: 'base', layout: ''}}, + value: true, + expectTexts: ['true'], + }, + switch: { + spec: {type: SpecTypes.Boolean, viewSpec: {type: 'base', layout: ''}}, + value: true, + expectTexts: ['true'], + }, + }, + array: { + base: { + spec: { + type: SpecTypes.Array, + items: baseStringSpec, + viewSpec: {type: 'base', layout: ''}, + }, + value: ['array-item-1', 'array-item-2'], + expectTexts: ['array-item-1', 'array-item-2'], + }, + select: { + spec: {type: SpecTypes.Array, viewSpec: {type: 'base', layout: ''}}, + value: ['select-item-1', 'select-item-2'], + expectTexts: ['select-item-1', 'select-item-2'], + }, + checkbox_group: { + spec: {type: SpecTypes.Array, viewSpec: {type: 'base', layout: ''}}, + value: ['checkbox-item-1', 'checkbox-item-2'], + expectTexts: [/checkbox-item-1/, /checkbox-item-2/], + }, + table: { + spec: { + type: SpecTypes.Array, + items: { + type: SpecTypes.Object, + properties: {col: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + viewSpec: {type: 'base', layout: '', table: [{property: 'col', label: 'Col'}]}, + }, + value: [{col: 'table-cell-1'}, {col: 'table-cell-2'}], + expectTexts: ['table-cell-1', 'table-cell-2'], + }, + }, + object: { + base: { + spec: { + type: SpecTypes.Object, + properties: {inner: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {inner: 'object-inner-value'}, + expectTexts: ['object-inner-value'], + }, + inline: { + spec: { + type: SpecTypes.Object, + properties: {inner: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {inner: 'inline-inner-value'}, + expectTexts: ['inline-inner-value'], + }, + oneof: { + spec: { + type: SpecTypes.Object, + properties: {branch: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {branch: 'oneof-branch-value'}, + expectTexts: ['oneof-branch-value'], + }, + oneof_flat: { + spec: { + type: SpecTypes.Object, + properties: {branch: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {branch: 'oneof-flat-value'}, + expectTexts: ['oneof-flat-value'], + }, + card_oneof: { + spec: { + type: SpecTypes.Object, + properties: {branch: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {branch: 'card-oneof-value'}, + expectTexts: ['card-oneof-value'], + }, + multi_oneof: { + spec: { + type: SpecTypes.Object, + properties: {opt: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {opt: 'multi-oneof-value'}, + expectTexts: ['multi-oneof-value'], + }, + multi_oneof_flat: { + spec: { + type: SpecTypes.Object, + properties: {opt: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {opt: 'multi-oneof-flat-value'}, + expectTexts: ['multi-oneof-flat-value'], + }, + object_value: { + spec: { + type: SpecTypes.Object, + properties: {value: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {value: 'object-value-inner'}, + expectTexts: ['object-value-inner'], + }, + text_link: { + spec: { + type: SpecTypes.Object, + properties: {text: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {text: 'text-link-value', link: 'https://example.com'}, + expectTexts: ['text-link-value'], + }, + time_range_selector: { + spec: { + type: SpecTypes.Object, + properties: {start: baseStringSpec, end: baseStringSpec}, + viewSpec: {type: 'base', layout: ''}, + }, + value: {start: '10:15', end: '11:45'}, + expectTexts: ['10:15', '11:45'], + }, + range_input_picker: { + spec: {type: SpecTypes.Object, viewSpec: {type: 'base', layout: ''}}, + value: {from: 11, to: 55}, + expectTexts: [/11/, /55/], + }, + }, +}; + +const FakeMonaco: React.FC<{value?: string}> = ({value}) =>
{value}
; + +describe('Views/dotted keys contract', () => { + (['array', 'boolean', 'number', 'object', 'string'] as const).forEach((specType) => { + const views = dynamicViewConfig[specType].views as Record; + + describe(specType, () => { + test.each(Object.keys(views))('%s', (viewType) => { + if (!views[viewType]) { + expect(fixtures[specType][viewType]).toBeUndefined(); + return; + } + + const fixture = fixtures[specType][viewType]; + + expect(fixture).toBeDefined(); + + const spec = { + ...fixture.spec, + viewSpec: {...fixture.spec.viewSpec, type: viewType}, + } as Spec; + + render( + + + , + ); + + fixture.prepare?.(); + + fixture.expectTexts.forEach((text) => { + expect(screen.getAllByText(text).length).toBeGreaterThan(0); + }); + }); + }); + }); +}); diff --git a/src/stories/ObjectDottedKeys.stories.tsx b/src/stories/ObjectDottedKeys.stories.tsx new file mode 100644 index 00000000..a15d6738 --- /dev/null +++ b/src/stories/ObjectDottedKeys.stories.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +import type {StoryFn} from '@storybook/react'; + +import type {ObjectSpec} from '../lib'; +import {ObjectBase, SpecTypes} from '../lib'; + +import {InputPreview} from './components'; + +export default { + title: 'Object/DottedKeys', + component: ObjectBase, +}; + +const baseSpec: ObjectSpec = { + type: SpecTypes.Object, + properties: { + 'agent.cluster': { + type: SpecTypes.String, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Agent cluster'}, + }, + 'agent.auth.serviceAccountId': { + type: SpecTypes.String, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Service account ID'}, + }, + namespace: { + type: SpecTypes.String, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Namespace'}, + }, + 'agent.namespaces': { + type: SpecTypes.Array, + items: { + type: SpecTypes.String, + viewSpec: {type: 'base', layout: 'row'}, + }, + viewSpec: { + type: 'base', + layout: 'accordeon', + layoutTitle: 'Namespaces to collect logs from', + layoutOpen: true, + }, + }, + 'agent.resources': { + type: SpecTypes.Object, + properties: { + cpu: { + type: SpecTypes.String, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'CPU limit'}, + }, + }, + viewSpec: { + type: 'base', + layout: 'accordeon', + layoutTitle: 'Resource limits', + layoutOpen: true, + }, + }, + applicationName: { + type: SpecTypes.String, + viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Application name'}, + }, + }, + viewSpec: { + type: 'base', + layout: 'accordeon', + layoutTitle: 'Helm values with dots in keys', + layoutOpen: true, + }, +}; + +const value = { + 'agent.cluster': 'main-cluster', + 'agent.auth.serviceAccountId': 'service-account-1', + namespace: 'default-namespace', + 'agent.namespaces': ['namespace-one', 'namespace-two', 'namespace-three'], + 'agent.resources': {cpu: 'cpu-limit-2'}, + applicationName: 'log-agent', +}; + +const excludeOptions = [ + 'description', + 'viewSpec.type', + 'viewSpec.oneOfParams', + 'viewSpec.delimiter', + 'viewSpec.inputProps', +]; + +const template = (spec: ObjectSpec = baseSpec) => { + const Template: StoryFn = (__, {viewMode}) => ( + + ); + + return Template; +}; + +export const DottedKeys = template(); diff --git a/test-utils/setup-tests-after.ts b/test-utils/setup-tests-after.ts index 7b0828bf..69367ee0 100644 --- a/test-utils/setup-tests-after.ts +++ b/test-utils/setup-tests-after.ts @@ -1 +1,26 @@ import '@testing-library/jest-dom'; + +window.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} as unknown as typeof IntersectionObserver; + +window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; From c752329c0fbf6c85d951a81e76daf8db153f9392 Mon Sep 17 00:00:00 2001 From: doscjen Date: Tue, 18 Aug 2026 11:54:33 +0300 Subject: [PATCH 2/4] revert: resolve values of spec keys containing dots --- docs/config.md | 2 - docs/lib.md | 17 +- .../core/components/View/ViewController.tsx | 22 +- .../View/__tests__/DynamicView.test.tsx | 104 ------- .../View/hooks/__tests__/useRender.test.tsx | 65 +--- .../core/components/View/hooks/useRender.tsx | 6 +- .../Views/ArrayBaseView/ArrayBaseView.tsx | 8 +- .../kit/components/Views/CardOneOfView.tsx | 1 - .../Views/MultiOneOfView/MultiOneOfView.tsx | 19 +- .../Views/ObjectBaseView/ObjectBaseView.tsx | 6 +- .../ObjectValueInputView.tsx | 11 +- .../components/Views/OneOfView/OneOfView.tsx | 1 - .../Views/TableArrayView/TableArrayView.tsx | 7 +- .../Views/TextLinkView/TextLinkView.tsx | 1 - .../TimeRangeSelectorView.tsx | 12 +- .../__tests__/dottedKeysContract.test.tsx | 282 ------------------ src/stories/ObjectDottedKeys.stories.tsx | 101 ------- test-utils/setup-tests-after.ts | 25 -- 18 files changed, 35 insertions(+), 655 deletions(-) delete mode 100644 src/lib/core/components/View/__tests__/DynamicView.test.tsx delete mode 100644 src/lib/kit/components/Views/__tests__/dottedKeysContract.test.tsx delete mode 100644 src/stories/ObjectDottedKeys.stories.tsx diff --git a/docs/config.md b/docs/config.md index 14e31914..7c26c41e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -64,8 +64,6 @@ type LayoutType = React.ComponentType< `Views` are the components responsible for rendering the entity value. There can be two types of views: simple and independent. A `simple` `View` [ViewController](./lib.md#viewcontroller) wraps in [ViewLayout](#viewlayouts), whereas an `independent` `View` [ViewController](./lib.md#viewcontroller) is passed [ViewLayout](#viewlayouts) into the props, allowing the component to wrap itself. -If your `View` renders nested entities through [ViewController](./lib.md#viewcontroller), always pass the child value in the `resolvedValue` prop. Otherwise the value is looked up by `name` as a path and properties whose keys contain dots resolve to `undefined` — see [Dotted property keys](./lib.md#dotted-property-keys). - ```typescript type ViewEntity = { Component: React.ComponentType<{ diff --git a/docs/lib.md b/docs/lib.md index 0c4b8abd..cd17127d 100644 --- a/docs/lib.md +++ b/docs/lib.md @@ -53,16 +53,7 @@ This component serves as the primary entry point for creating an overview of for This component searches for all required rendering elements and renders the entity. -| Property | Type | Required | Description | -| :------------ | :---------- | :------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | `string` | yes | View name | -| spec | `Spec` | yes | An [spec](./spec.md#specs) describing the entity | -| resolvedValue | `FormValue` | | Already resolved value of the entity. Pass it when rendering child entities, so the value does not depend on parsing `name` as a path (see [Dotted property keys](#dotted-property-keys)) | - -Passing `resolvedValue` — including `resolvedValue={undefined}` — disables reading the value from the form value by `name`. Omit the prop entirely to keep that behaviour. - -### Dotted property keys - -`spec.properties` keys are treated as literal keys of the value object, not as paths. A spec with the property `agent.cluster` describes the value `{'agent.cluster': 'main'}`, not `{agent: {cluster: 'main'}}`. - -Every `View` that renders nested entities must therefore pass the already resolved child value into [ViewController](#viewcontroller) via `resolvedValue`. All views shipped with the library do this. If you render `ViewController` from a custom [View](./config.md#views) without `resolvedValue`, the value is looked up by `name` as a path and properties whose keys contain dots resolve to `undefined`. +| Property | Type | Required | Description | +| :------- | :------- | :------: | :----------------------------------------------- | +| name | `string` | yes | View name | +| spec | `Spec` | yes | An [spec](./spec.md#specs) describing the entity | diff --git a/src/lib/core/components/View/ViewController.tsx b/src/lib/core/components/View/ViewController.tsx index 9e50307a..192699f5 100644 --- a/src/lib/core/components/View/ViewController.tsx +++ b/src/lib/core/components/View/ViewController.tsx @@ -1,29 +1,21 @@ import React from 'react'; -import type {FormValue, Spec} from '../../types'; +import type {Spec} from '../../types'; import {useComponents, useDynamicFormsCtx, useRender} from './hooks'; export interface ViewControllerProps { spec: SpecType; name: string; - resolvedValue?: FormValue; } -export const ViewController = (props: ViewControllerProps) => { - const {spec, name} = props; - const {config, value: contextValue, Link} = useDynamicFormsCtx(); +export const ViewController = ({ + spec, + name, +}: ViewControllerProps) => { + const {config, value, Link} = useDynamicFormsCtx(); const {viewEntity, Layout} = useComponents(spec, config); - const resolved = 'resolvedValue' in props; - const render = useRender({ - name, - value: resolved ? props.resolvedValue : contextValue, - resolved, - spec, - viewEntity, - Layout, - Link, - }); + const render = useRender({name, value, spec, viewEntity, Layout, Link}); return {render}; }; diff --git a/src/lib/core/components/View/__tests__/DynamicView.test.tsx b/src/lib/core/components/View/__tests__/DynamicView.test.tsx deleted file mode 100644 index 1f429381..00000000 --- a/src/lib/core/components/View/__tests__/DynamicView.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react'; - -import {render, screen} from '@testing-library/react'; - -import type {DynamicViewConfig, ObjectIndependentView} from '../'; -import {DynamicView, ViewController} 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; - -const spec: ObjectSpec = { - type: SpecTypes.Object, - properties: { - 'agent.cluster': stringSpec, - 'agent.auth.serviceAccountId': stringSpec, - namespace: stringSpec, - 'agent.namespaces': { - type: SpecTypes.Array, - items: stringSpec, - viewSpec: {type: 'base', layout: ''}, - }, - 'agent.resources': { - type: SpecTypes.Object, - properties: {cpu: stringSpec, 'limits.memory': stringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - applicationName: stringSpec, - }, - viewSpec: {type: 'base', layout: ''}, -}; - -const value = { - 'agent.cluster': 'main-cluster', - 'agent.auth.serviceAccountId': 'service-account-1', - namespace: 'default-namespace', - 'agent.namespaces': ['namespace-one', 'namespace-two', 'namespace-three'], - 'agent.resources': {cpu: 'cpu-limit-2', 'limits.memory': 'memory-limit-4'}, - applicationName: 'log-agent', -}; - -describe('View/DynamicView', () => { - test('renders values of properties with dots in keys', () => { - render(); - - expect(screen.getByText('main-cluster')).toBeInTheDocument(); - expect(screen.getByText('service-account-1')).toBeInTheDocument(); - expect(screen.getByText('default-namespace')).toBeInTheDocument(); - expect(screen.getByText('namespace-one')).toBeInTheDocument(); - expect(screen.getByText('namespace-two')).toBeInTheDocument(); - expect(screen.getByText('namespace-three')).toBeInTheDocument(); - expect(screen.getByText('cpu-limit-2')).toBeInTheDocument(); - expect(screen.getByText('memory-limit-4')).toBeInTheDocument(); - expect(screen.getByText('log-agent')).toBeInTheDocument(); - }); - - test('reads the value by name for views that do not pass resolvedValue', () => { - const LegacyObjectView: ObjectIndependentView = ({spec, name}) => ( - - {Object.keys(spec.properties || {}).map((property) => ( - - ))} - - ); - - const legacyConfig: DynamicViewConfig = { - ...dynamicViewConfig, - object: { - ...dynamicViewConfig.object, - views: { - ...dynamicViewConfig.object.views, - base: {Component: LegacyObjectView, independent: true}, - }, - }, - }; - - const legacySpec: ObjectSpec = { - type: SpecTypes.Object, - properties: { - agent: { - type: SpecTypes.Object, - properties: {cluster: stringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - }, - viewSpec: {type: 'base', layout: ''}, - }; - - render( - , - ); - - expect(screen.getByText('legacy-cluster')).toBeInTheDocument(); - }); -}); diff --git a/src/lib/core/components/View/hooks/__tests__/useRender.test.tsx b/src/lib/core/components/View/hooks/__tests__/useRender.test.tsx index ed1a65dd..225d70bf 100644 --- a/src/lib/core/components/View/hooks/__tests__/useRender.test.tsx +++ b/src/lib/core/components/View/hooks/__tests__/useRender.test.tsx @@ -8,7 +8,7 @@ import {useComponents, useRender} from '../'; import type {FormValue, Spec} from '../../../../../core'; import {BaseView, ObjectBaseView, ViewRow, dynamicViewConfig} from '../../../../../kit'; import {SpecTypes} from '../../../../constants'; -import type {ObjectSpec, StringSpec} from '../../../../types'; +import type {AnyObject, ObjectSpec, StringSpec} from '../../../../types'; import type {DynamicViewConfig} from '../../types'; const name = 'name'; @@ -20,8 +20,7 @@ interface UseRenderProps { useRender?: ReturnType; }; name: string; - value: FormValue; - resolved?: boolean; + value: AnyObject; spec: Spec; config: DynamicViewConfig; Link?: React.ComponentType<{ @@ -191,64 +190,4 @@ describe('View/hooks/useRender', () => { expect(mirror.useRender?.props.linkValue.props.value).toBe(value[name]); expect(mirror.useRender?.props.linkValue.props.link).toBe(_spec.viewSpec.link); }); - - describe('resolved', () => { - const baseSpec = (): StringSpec => { - const _spec = cloneDeep(spec); - - _spec.viewSpec.type = 'base'; - - return _spec; - }; - - test('reads value by name as a path when not resolved', () => { - const mirror: UseRenderProps['mirror'] = {}; - - render( - , - ); - - expect(mirror.useRender?.props.value).toBe('nested'); - }); - - test('uses value as is when resolved', () => { - const mirror: UseRenderProps['mirror'] = {}; - - render( - , - ); - - expect(mirror.useRender?.props.value).toBe('resolved'); - }); - - test('does not fall back to the path when resolved value is undefined', () => { - const mirror: UseRenderProps['mirror'] = {}; - - render( - , - ); - - expect(mirror.useRender?.props.value).toBe(undefined); - }); - }); }); diff --git a/src/lib/core/components/View/hooks/useRender.tsx b/src/lib/core/components/View/hooks/useRender.tsx index a4420839..9606e9e7 100644 --- a/src/lib/core/components/View/hooks/useRender.tsx +++ b/src/lib/core/components/View/hooks/useRender.tsx @@ -10,7 +10,6 @@ import type {IndependentViewEntity, ViewEntity, ViewLayoutType} from '../types'; export interface UseRenderParams { value: Value; - resolved?: boolean; name: string; spec: SpecType; viewEntity?: ViewEntity | IndependentViewEntity; @@ -20,7 +19,6 @@ export interface UseRenderParams export const useRender = ({ value, - resolved, name, spec, viewEntity, @@ -30,7 +28,7 @@ export const useRender = ({ const render = React.useMemo(() => { if (viewEntity && isCorrectSpec(spec) && isString(name)) { if (!spec.viewSpec.hidden) { - const currentValue = !resolved && name ? get(value, name) : value; + const currentValue = name ? get(value, name) : value; const linkValue = isValidElementType(Link) && spec?.viewSpec?.link ? ( @@ -73,7 +71,7 @@ export const useRender = ({ } return null; - }, [spec, name, value, resolved, viewEntity, Layout, Link]); + }, [spec, name, value, viewEntity, Layout, Link]); return render; }; diff --git a/src/lib/kit/components/Views/ArrayBaseView/ArrayBaseView.tsx b/src/lib/kit/components/Views/ArrayBaseView/ArrayBaseView.tsx index 606bb878..4f1f1f8a 100644 --- a/src/lib/kit/components/Views/ArrayBaseView/ArrayBaseView.tsx +++ b/src/lib/kit/components/Views/ArrayBaseView/ArrayBaseView.tsx @@ -52,15 +52,11 @@ export const ArrayBaseView: ArrayView = ({spec, name, value = []}) => { {spec.viewSpec.itemPrefix} ) : null} - + ); }), - [value, name, getItemSpec, spec.viewSpec.itemPrefix], + [value.length, name, getItemSpec, spec.viewSpec.itemPrefix], ); if (!itemSpecCorrect) { diff --git a/src/lib/kit/components/Views/CardOneOfView.tsx b/src/lib/kit/components/Views/CardOneOfView.tsx index a8a65376..cf16e4ed 100644 --- a/src/lib/kit/components/Views/CardOneOfView.tsx +++ b/src/lib/kit/components/Views/CardOneOfView.tsx @@ -61,7 +61,6 @@ export const CardOneOfView: ObjectIndependentView = (props) => { spec={specProperties[valueKey]} name={`${name ? name + '.' : ''}${valueKey}`} key={`${name ? name + '.' : ''}${valueKey}`} - resolvedValue={value[valueKey]} /> ) : null} diff --git a/src/lib/kit/components/Views/MultiOneOfView/MultiOneOfView.tsx b/src/lib/kit/components/Views/MultiOneOfView/MultiOneOfView.tsx index 41cd5bfa..4c7086e8 100644 --- a/src/lib/kit/components/Views/MultiOneOfView/MultiOneOfView.tsx +++ b/src/lib/kit/components/Views/MultiOneOfView/MultiOneOfView.tsx @@ -29,11 +29,11 @@ export const MultiOneOfView: React.FC = (props) => { const items = React.useMemo( () => - values.map((valueKey) => { + values.map((value) => { const title = - spec.description?.[valueKey] || - specProperties[valueKey]?.viewSpec.layoutTitle || - valueKey || + spec.description?.[value] || + specProperties[value]?.viewSpec.layoutTitle || + value || ''; return title; @@ -83,13 +83,12 @@ export const MultiOneOfView: React.FC = (props) => { className={b('content', {flat: withoutIndent, 'multiple-values': items.length > 1})} > - {values.map((valueKey) => ( - - {specProperties && specProperties[valueKey] ? ( + {values.map((value) => ( + + {specProperties && specProperties[value] ? ( ) : null} diff --git a/src/lib/kit/components/Views/ObjectBaseView/ObjectBaseView.tsx b/src/lib/kit/components/Views/ObjectBaseView/ObjectBaseView.tsx index 43f011c0..f4c7ed9b 100644 --- a/src/lib/kit/components/Views/ObjectBaseView/ObjectBaseView.tsx +++ b/src/lib/kit/components/Views/ObjectBaseView/ObjectBaseView.tsx @@ -20,7 +20,6 @@ export const ObjectBaseView: React.FC = ({ spec, name, Layout, - value, ...restProps }) => { const content = React.useMemo(() => { @@ -45,7 +44,6 @@ export const ObjectBaseView: React.FC = ({ {delimiter && delimiter[property] ? ( {delimiter[property]} @@ -55,14 +53,14 @@ export const ObjectBaseView: React.FC = ({ )} ); - }, [inline, name, value, spec.properties, spec.viewSpec.delimiter, spec.viewSpec.order]); + }, [inline, name, spec.properties, spec.viewSpec.delimiter, spec.viewSpec.order]); if (!Layout || !content) { return content; } return ( - + {content} ); diff --git a/src/lib/kit/components/Views/ObjectValueInputView/ObjectValueInputView.tsx b/src/lib/kit/components/Views/ObjectValueInputView/ObjectValueInputView.tsx index 1348dc0c..bebb6544 100644 --- a/src/lib/kit/components/Views/ObjectValueInputView/ObjectValueInputView.tsx +++ b/src/lib/kit/components/Views/ObjectValueInputView/ObjectValueInputView.tsx @@ -6,13 +6,7 @@ import type {ObjectIndependentView} from '../../../../core'; import {ViewController} from '../../../../core'; import {OBJECT_VALUE_PROPERTY_NAME} from '../../../constants/common'; -export const ObjectValueInputView: ObjectIndependentView = ({ - spec, - name, - Layout, - value, - ...restProps -}) => { +export const ObjectValueInputView: ObjectIndependentView = ({spec, name, Layout, ...restProps}) => { const childSpec = React.useMemo(() => { if (spec.properties?.[OBJECT_VALUE_PROPERTY_NAME]) { const childSpec = cloneDeep(spec.properties[OBJECT_VALUE_PROPERTY_NAME]); @@ -33,13 +27,12 @@ export const ObjectValueInputView: ObjectIndependentView = ({ ); if (Layout) { return ( - + {content} ); diff --git a/src/lib/kit/components/Views/OneOfView/OneOfView.tsx b/src/lib/kit/components/Views/OneOfView/OneOfView.tsx index fa10d859..797b2f44 100644 --- a/src/lib/kit/components/Views/OneOfView/OneOfView.tsx +++ b/src/lib/kit/components/Views/OneOfView/OneOfView.tsx @@ -77,7 +77,6 @@ const OneOfViewComponent: React.FC = (props) => { spec={specProperties[valueKey]} name={`${name ? name + '.' : ''}${valueKey}`} key={`${name ? name + '.' : ''}${valueKey}`} - resolvedValue={value[valueKey]} /> ) : null} diff --git a/src/lib/kit/components/Views/TableArrayView/TableArrayView.tsx b/src/lib/kit/components/Views/TableArrayView/TableArrayView.tsx index ecd4430e..871aa121 100644 --- a/src/lib/kit/components/Views/TableArrayView/TableArrayView.tsx +++ b/src/lib/kit/components/Views/TableArrayView/TableArrayView.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {Flex, HelpMark, Table, type TableColumnConfig} from '@gravity-ui/uikit'; -import type {ArrayView, ObjectValue} from '../../../../core'; +import type {ArrayView, FormValue, ObjectValue} from '../../../../core'; import { ViewController, isArraySpec, @@ -35,7 +35,7 @@ export const TableArrayView: ArrayView = ({value = [], spec, name}) => { id: 'idx', name: '', sticky: 'left', - template: (__: ObjectValue, idx: number) => ( + template: (__: FormValue, idx: number) => (
{idx + 1}
@@ -71,7 +71,7 @@ export const TableArrayView: ArrayView = ({value = [], spec, name}) => { {label} ), - template: (item: ObjectValue, idx: number) => { + template: (_: FormValue, idx: number) => { const entitySpec = items?.properties?.[property]; if (!entitySpec) { @@ -91,7 +91,6 @@ export const TableArrayView: ArrayView = ({value = [], spec, name}) => { ); diff --git a/src/lib/kit/components/Views/TextLinkView/TextLinkView.tsx b/src/lib/kit/components/Views/TextLinkView/TextLinkView.tsx index ee13ea71..5ceebc24 100644 --- a/src/lib/kit/components/Views/TextLinkView/TextLinkView.tsx +++ b/src/lib/kit/components/Views/TextLinkView/TextLinkView.tsx @@ -32,7 +32,6 @@ export const TextLinkView: ObjectIndependentView = ({value, spec, name, Layout, ); diff --git a/src/lib/kit/components/Views/TimeRangeSelectorView/TimeRangeSelectorView.tsx b/src/lib/kit/components/Views/TimeRangeSelectorView/TimeRangeSelectorView.tsx index a98ec107..7006fd0c 100644 --- a/src/lib/kit/components/Views/TimeRangeSelectorView/TimeRangeSelectorView.tsx +++ b/src/lib/kit/components/Views/TimeRangeSelectorView/TimeRangeSelectorView.tsx @@ -35,16 +35,8 @@ export const TimeRangeSelectorView: ObjectIndependentView = ({ const content = ( - - + + ); diff --git a/src/lib/kit/components/Views/__tests__/dottedKeysContract.test.tsx b/src/lib/kit/components/Views/__tests__/dottedKeysContract.test.tsx deleted file mode 100644 index c576c20c..00000000 --- a/src/lib/kit/components/Views/__tests__/dottedKeysContract.test.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import React from 'react'; - -import {ThemeProvider} from '@gravity-ui/uikit'; -import {fireEvent, render, screen} from '@testing-library/react'; - -import {DynamicView} from '../../../../core'; -import {SpecTypes} from '../../../../core/constants'; -import type {FormValue, ObjectSpec, Spec} from '../../../../core/types'; -import {dynamicViewConfig} from '../../../constants/config'; - -const DOTTED_KEY = 'dotted.key'; - -interface ViewFixture { - spec: Spec; - value: FormValue; - expectTexts: (string | RegExp)[]; - prepare?: () => void; -} - -const baseStringSpec: Spec = {type: SpecTypes.String, viewSpec: {type: 'base', layout: ''}}; - -const fixtures: Record> = { - string: { - base: { - spec: baseStringSpec, - value: 'string-base-value', - expectTexts: ['string-base-value'], - }, - select: { - spec: baseStringSpec, - value: 'string-select-value', - expectTexts: ['string-select-value'], - }, - radio_group: { - spec: baseStringSpec, - value: 'string-radio-value', - expectTexts: ['string-radio-value'], - }, - textarea: {spec: baseStringSpec, value: 'textarea-value', expectTexts: ['textarea-value']}, - date_input: {spec: baseStringSpec, value: '2024-05-10', expectTexts: ['10.05.2024 00:00']}, - color_picker: {spec: baseStringSpec, value: '#ff0000', expectTexts: ['#ff0000']}, - file_input: { - spec: baseStringSpec, - value: 'file-input-value', - expectTexts: ['file-input-value'], - }, - text_content: { - spec: { - type: SpecTypes.String, - viewSpec: { - type: 'base', - layout: '', - textContentParams: {text: 'text-content-static'}, - }, - }, - value: 'text-content-value', - expectTexts: ['text-content-static'], - }, - number_with_scale: { - spec: { - type: SpecTypes.String, - viewSpec: { - type: 'base', - layout: '', - sizeParams: {defaultType: 'b', scale: {b: {factor: '1', title: 'B'}}}, - }, - }, - value: '2048', - expectTexts: ['2048', 'B'], - }, - monaco_input: { - spec: baseStringSpec, - value: 'monaco-value', - expectTexts: ['monaco-value'], - prepare: () => fireEvent.click(screen.getByRole('button')), - }, - }, - number: { - base: { - spec: {type: SpecTypes.Number, viewSpec: {type: 'base', layout: ''}}, - value: 42, - expectTexts: ['42'], - }, - range_input_picker: { - spec: {type: SpecTypes.Number, viewSpec: {type: 'base', layout: ''}}, - value: 42, - expectTexts: ['42'], - }, - }, - boolean: { - base: { - spec: {type: SpecTypes.Boolean, viewSpec: {type: 'base', layout: ''}}, - value: true, - expectTexts: ['true'], - }, - switch: { - spec: {type: SpecTypes.Boolean, viewSpec: {type: 'base', layout: ''}}, - value: true, - expectTexts: ['true'], - }, - }, - array: { - base: { - spec: { - type: SpecTypes.Array, - items: baseStringSpec, - viewSpec: {type: 'base', layout: ''}, - }, - value: ['array-item-1', 'array-item-2'], - expectTexts: ['array-item-1', 'array-item-2'], - }, - select: { - spec: {type: SpecTypes.Array, viewSpec: {type: 'base', layout: ''}}, - value: ['select-item-1', 'select-item-2'], - expectTexts: ['select-item-1', 'select-item-2'], - }, - checkbox_group: { - spec: {type: SpecTypes.Array, viewSpec: {type: 'base', layout: ''}}, - value: ['checkbox-item-1', 'checkbox-item-2'], - expectTexts: [/checkbox-item-1/, /checkbox-item-2/], - }, - table: { - spec: { - type: SpecTypes.Array, - items: { - type: SpecTypes.Object, - properties: {col: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - viewSpec: {type: 'base', layout: '', table: [{property: 'col', label: 'Col'}]}, - }, - value: [{col: 'table-cell-1'}, {col: 'table-cell-2'}], - expectTexts: ['table-cell-1', 'table-cell-2'], - }, - }, - object: { - base: { - spec: { - type: SpecTypes.Object, - properties: {inner: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {inner: 'object-inner-value'}, - expectTexts: ['object-inner-value'], - }, - inline: { - spec: { - type: SpecTypes.Object, - properties: {inner: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {inner: 'inline-inner-value'}, - expectTexts: ['inline-inner-value'], - }, - oneof: { - spec: { - type: SpecTypes.Object, - properties: {branch: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {branch: 'oneof-branch-value'}, - expectTexts: ['oneof-branch-value'], - }, - oneof_flat: { - spec: { - type: SpecTypes.Object, - properties: {branch: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {branch: 'oneof-flat-value'}, - expectTexts: ['oneof-flat-value'], - }, - card_oneof: { - spec: { - type: SpecTypes.Object, - properties: {branch: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {branch: 'card-oneof-value'}, - expectTexts: ['card-oneof-value'], - }, - multi_oneof: { - spec: { - type: SpecTypes.Object, - properties: {opt: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {opt: 'multi-oneof-value'}, - expectTexts: ['multi-oneof-value'], - }, - multi_oneof_flat: { - spec: { - type: SpecTypes.Object, - properties: {opt: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {opt: 'multi-oneof-flat-value'}, - expectTexts: ['multi-oneof-flat-value'], - }, - object_value: { - spec: { - type: SpecTypes.Object, - properties: {value: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {value: 'object-value-inner'}, - expectTexts: ['object-value-inner'], - }, - text_link: { - spec: { - type: SpecTypes.Object, - properties: {text: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {text: 'text-link-value', link: 'https://example.com'}, - expectTexts: ['text-link-value'], - }, - time_range_selector: { - spec: { - type: SpecTypes.Object, - properties: {start: baseStringSpec, end: baseStringSpec}, - viewSpec: {type: 'base', layout: ''}, - }, - value: {start: '10:15', end: '11:45'}, - expectTexts: ['10:15', '11:45'], - }, - range_input_picker: { - spec: {type: SpecTypes.Object, viewSpec: {type: 'base', layout: ''}}, - value: {from: 11, to: 55}, - expectTexts: [/11/, /55/], - }, - }, -}; - -const FakeMonaco: React.FC<{value?: string}> = ({value}) =>
{value}
; - -describe('Views/dotted keys contract', () => { - (['array', 'boolean', 'number', 'object', 'string'] as const).forEach((specType) => { - const views = dynamicViewConfig[specType].views as Record; - - describe(specType, () => { - test.each(Object.keys(views))('%s', (viewType) => { - if (!views[viewType]) { - expect(fixtures[specType][viewType]).toBeUndefined(); - return; - } - - const fixture = fixtures[specType][viewType]; - - expect(fixture).toBeDefined(); - - const spec = { - ...fixture.spec, - viewSpec: {...fixture.spec.viewSpec, type: viewType}, - } as Spec; - - render( - - - , - ); - - fixture.prepare?.(); - - fixture.expectTexts.forEach((text) => { - expect(screen.getAllByText(text).length).toBeGreaterThan(0); - }); - }); - }); - }); -}); diff --git a/src/stories/ObjectDottedKeys.stories.tsx b/src/stories/ObjectDottedKeys.stories.tsx deleted file mode 100644 index a15d6738..00000000 --- a/src/stories/ObjectDottedKeys.stories.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from 'react'; - -import type {StoryFn} from '@storybook/react'; - -import type {ObjectSpec} from '../lib'; -import {ObjectBase, SpecTypes} from '../lib'; - -import {InputPreview} from './components'; - -export default { - title: 'Object/DottedKeys', - component: ObjectBase, -}; - -const baseSpec: ObjectSpec = { - type: SpecTypes.Object, - properties: { - 'agent.cluster': { - type: SpecTypes.String, - viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Agent cluster'}, - }, - 'agent.auth.serviceAccountId': { - type: SpecTypes.String, - viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Service account ID'}, - }, - namespace: { - type: SpecTypes.String, - viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Namespace'}, - }, - 'agent.namespaces': { - type: SpecTypes.Array, - items: { - type: SpecTypes.String, - viewSpec: {type: 'base', layout: 'row'}, - }, - viewSpec: { - type: 'base', - layout: 'accordeon', - layoutTitle: 'Namespaces to collect logs from', - layoutOpen: true, - }, - }, - 'agent.resources': { - type: SpecTypes.Object, - properties: { - cpu: { - type: SpecTypes.String, - viewSpec: {type: 'base', layout: 'row', layoutTitle: 'CPU limit'}, - }, - }, - viewSpec: { - type: 'base', - layout: 'accordeon', - layoutTitle: 'Resource limits', - layoutOpen: true, - }, - }, - applicationName: { - type: SpecTypes.String, - viewSpec: {type: 'base', layout: 'row', layoutTitle: 'Application name'}, - }, - }, - viewSpec: { - type: 'base', - layout: 'accordeon', - layoutTitle: 'Helm values with dots in keys', - layoutOpen: true, - }, -}; - -const value = { - 'agent.cluster': 'main-cluster', - 'agent.auth.serviceAccountId': 'service-account-1', - namespace: 'default-namespace', - 'agent.namespaces': ['namespace-one', 'namespace-two', 'namespace-three'], - 'agent.resources': {cpu: 'cpu-limit-2'}, - applicationName: 'log-agent', -}; - -const excludeOptions = [ - 'description', - 'viewSpec.type', - 'viewSpec.oneOfParams', - 'viewSpec.delimiter', - 'viewSpec.inputProps', -]; - -const template = (spec: ObjectSpec = baseSpec) => { - const Template: StoryFn = (__, {viewMode}) => ( - - ); - - return Template; -}; - -export const DottedKeys = template(); diff --git a/test-utils/setup-tests-after.ts b/test-utils/setup-tests-after.ts index 69367ee0..7b0828bf 100644 --- a/test-utils/setup-tests-after.ts +++ b/test-utils/setup-tests-after.ts @@ -1,26 +1 @@ import '@testing-library/jest-dom'; - -window.IntersectionObserver = class { - observe() {} - unobserve() {} - disconnect() {} - takeRecords() { - return []; - } -} as unknown as typeof IntersectionObserver; - -window.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} -} as unknown as typeof ResizeObserver; - -window.matchMedia = ((query: string) => ({ - matches: false, - media: query, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, -})) as unknown as typeof window.matchMedia; From 92c92a34569a6fda783dd81325ac1ea58bddb9bd Mon Sep 17 00:00:00 2001 From: doscjen Date: Tue, 18 Aug 2026 11:55:07 +0300 Subject: [PATCH 3/4] feat: warn about unsupported dots in spec property keys --- docs/lib.md | 6 ++ src/lib/core/components/View/DynamicView.tsx | 16 +++- .../View/__tests__/DynamicView.test.tsx | 79 +++++++++++++++++++ src/lib/core/helpers.ts | 22 +++++- 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/lib/core/components/View/__tests__/DynamicView.test.tsx diff --git a/docs/lib.md b/docs/lib.md index cd17127d..ae17b7af 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 `DynamicView` warns 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/View/DynamicView.tsx b/src/lib/core/components/View/DynamicView.tsx index 4096fd1a..542e8f68 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 {collectDottedPropertyKeys, isCorrectSpec} from '../../helpers'; import type {FormValue, Spec} from '../../types'; import {ViewController} from './ViewController'; @@ -36,6 +36,20 @@ export const DynamicView = ({ const DynamicFormsCtx = useCreateContext(); const shared = useViewSharedStore(externalShared); + React.useEffect(() => { + 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`, + ); + } + } + }, [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..b529e8e5 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,23 @@ 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; +}; From 78819f3fa8803110d3fc2aaa2b2c7876c69fb0a8 Mon Sep 17 00:00:00 2001 From: doscjen Date: Mon, 24 Aug 2026 11:49:45 +0300 Subject: [PATCH 4/4] fix: warn about dotted keys in DynamicField too --- docs/lib.md | 2 +- src/lib/core/components/Form/DynamicField.tsx | 6 +- .../Form/__tests__/DynamicField.test.tsx | 67 +++++++++++++++++++ src/lib/core/components/View/DynamicView.tsx | 14 +--- src/lib/core/helpers.ts | 14 ++++ 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/docs/lib.md b/docs/lib.md index ae17b7af..9a0a2ee5 100644 --- a/docs/lib.md +++ b/docs/lib.md @@ -62,4 +62,4 @@ This component searches for all required rendering elements and renders the enti 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 `DynamicView` warns 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. +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 542e8f68..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 {collectDottedPropertyKeys, isCorrectSpec} from '../../helpers'; +import {isCorrectSpec, warnAboutDottedPropertyKeys} from '../../helpers'; import type {FormValue, Spec} from '../../types'; import {ViewController} from './ViewController'; @@ -37,17 +37,7 @@ export const DynamicView = ({ const shared = useViewSharedStore(externalShared); React.useEffect(() => { - 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`, - ); - } - } + warnAboutDottedPropertyKeys(spec); }, [spec]); const context = React.useMemo( diff --git a/src/lib/core/helpers.ts b/src/lib/core/helpers.ts index b529e8e5..0f74173e 100644 --- a/src/lib/core/helpers.ts +++ b/src/lib/core/helpers.ts @@ -48,3 +48,17 @@ export const collectDottedPropertyKeys = (spec: Spec): string[] => { 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`, + ); + } + } +};