Skip to content

Commit bc95051

Browse files
Fix 4631: preserve additional property order for numeric keys (#5156)
* Fix 4631: preserve additional property order for numeric keys * Add pull request link to changelog * Reduce additional property order work during render * Address additional property order review * Test additional property dedupe after schema updates --------- Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com>
1 parent c67ed34 commit bc95051

3 files changed

Lines changed: 94 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ should change the heading of the (upcoming) version to include a major version b
3333

3434
- Updated `utility-functions.md` to document `getDecimalSeparator()`
3535

36+
# 6.7.1
37+
38+
## @rjsf/core
39+
40+
- Fixed `ObjectField` to preserve the insertion order of additional and pattern properties when keys are renamed to integers, fixing [#4631](https://github.com/rjsf-team/react-jsonschema-form/issues/4631) ([#5156](https://github.com/rjsf-team/react-jsonschema-form/pull/5156))
41+
3642
# 6.7.0
3743

3844
## @rjsf/antd

packages/core/src/components/fields/ObjectField.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ function getDefaultValue<T = any, S extends StrictRJSFSchema = RJSFSchema, F ext
7272
}
7373
}
7474

75+
function isAdditionalPropertySchema(schema: unknown) {
76+
return Boolean((schema as RJSFMarkedSchema)?.[ADDITIONAL_PROPERTY_FLAG]);
77+
}
78+
79+
function getAdditionalPropertyOrder<S extends StrictRJSFSchema = RJSFSchema>(
80+
schemaProperties: NonNullable<S['properties']>,
81+
) {
82+
return Object.keys(schemaProperties).filter((property) => isAdditionalPropertySchema(schemaProperties[property]));
83+
}
84+
7585
/** Props for the `ObjectFieldProperty` component */
7686
interface ObjectFieldPropertyProps<
7787
T = any,
@@ -234,10 +244,17 @@ export default function ObjectField<T = any, S extends StrictRJSFSchema = RJSFSc
234244
[schemaUtils, rawSchema, formData],
235245
);
236246
const uiOptions = useMemo(() => getUiOptions<T, S, F>(uiSchema, globalUiOptions), [uiSchema, globalUiOptions]);
237-
const { properties: schemaProperties = {} } = schema;
247+
const schemaProperties = useMemo(() => schema.properties ?? {}, [schema.properties]);
238248
// All the children will use childFieldPathId if present in the props, falling back to the fieldPathId
239249
const childFieldPathId = props.childFieldPathId ?? fieldPathId;
240250
const lastRenamedProperty = useRef({ previousKey: '', currentKey: undefined as string | undefined });
251+
const [additionalPropertyOrder, setAdditionalPropertyOrder] = useState(() =>
252+
getAdditionalPropertyOrder<S>(schemaProperties),
253+
);
254+
const definedPropertyOrder = useMemo(() => {
255+
const additionalPropertySet = new Set(getAdditionalPropertyOrder<S>(schemaProperties));
256+
return Object.keys(schemaProperties).filter((property) => !additionalPropertySet.has(property));
257+
}, [schemaProperties]);
241258

242259
const templateTitle = uiOptions.title ?? schema.title ?? title ?? name;
243260
const description = uiOptions.description ?? schema.description;
@@ -308,6 +325,7 @@ export default function ObjectField<T = any, S extends StrictRJSFSchema = RJSFSc
308325
lastRenamedProperty.current.currentKey = newKey;
309326
lastRenamedProperty.current.previousKey = getAvailableKey(newKey, newFormData);
310327
}
328+
setAdditionalPropertyOrder((order) => [...order, newKey]);
311329
onChange(newFormData, childFieldPathId.path);
312330
}, [formData, onChange, translateString, schemaUtils, childFieldPathId, getAvailableKey, schema]);
313331

@@ -339,6 +357,7 @@ export default function ObjectField<T = any, S extends StrictRJSFSchema = RJSFSc
339357
lastRenamedProperty.current.previousKey = oldKey;
340358
}
341359
lastRenamedProperty.current.currentKey = actualNewKey;
360+
setAdditionalPropertyOrder((order) => order.map((property) => (property === oldKey ? actualNewKey : property)));
342361
onChange(renamedObj, childFieldPathId.path);
343362
}
344363
},
@@ -350,6 +369,7 @@ export default function ObjectField<T = any, S extends StrictRJSFSchema = RJSFSc
350369
*/
351370
const handleRemoveProperty = useCallback(
352371
(key: string) => {
372+
setAdditionalPropertyOrder((order) => order.filter((property) => property !== key));
353373
onChange(ADDITIONAL_PROPERTY_KEY_REMOVE as T, [...childFieldPathId.path, key]);
354374
},
355375
[onChange, childFieldPathId],
@@ -369,8 +389,11 @@ export default function ObjectField<T = any, S extends StrictRJSFSchema = RJSFSc
369389

370390
if (!renderOptionalField || hasFormData) {
371391
try {
372-
const properties = Object.keys(schemaProperties);
373-
orderedProperties = orderProperties(properties, uiOptions.order);
392+
const definedPropertySet = new Set(definedPropertyOrder);
393+
const currentAdditionalProperties = additionalPropertyOrder.filter(
394+
(property) => Object.hasOwn(schemaProperties, property) && !definedPropertySet.has(property),
395+
);
396+
orderedProperties = orderProperties([...definedPropertyOrder, ...currentAdditionalProperties], uiOptions.order);
374397
} catch (err) {
375398
return (
376399
<div>
@@ -395,9 +418,7 @@ export default function ObjectField<T = any, S extends StrictRJSFSchema = RJSFSc
395418
title: uiOptions.label === false ? '' : templateTitle,
396419
description: uiOptions.label === false ? undefined : description,
397420
properties: orderedProperties.map((propertyName) => {
398-
const addedByAdditionalProperties = Boolean(
399-
(schema.properties?.[propertyName] as RJSFMarkedSchema)?.[ADDITIONAL_PROPERTY_FLAG],
400-
);
421+
const addedByAdditionalProperties = isAdditionalPropertySchema(schema.properties?.[propertyName]);
401422
const fieldUiSchema = addedByAdditionalProperties ? uiSchema.additionalProperties : uiSchema[propertyName];
402423
const hidden = getUiOptions<T, S, F>(fieldUiSchema).widget === 'hidden';
403424
const content = (

packages/core/test/ObjectField.test.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,67 @@ describe('ObjectField', () => {
992992
expectToHaveBeenCalledWithFormData(onChange, { first: 1, newSecond: 2, third: 3 }, 'root');
993993
});
994994

995+
it('should keep a numeric pattern property in place when renaming its key', async () => {
996+
const { node } = createFormComponent({
997+
schema: {
998+
type: 'object',
999+
patternProperties: {
1000+
'(.*?)': { type: 'string' },
1001+
},
1002+
},
1003+
formData: { first: 'one', second: 'two' },
1004+
});
1005+
1006+
const textNode = node.querySelector('#root_second-key')!;
1007+
await user.clear(textNode);
1008+
await user.type(textNode, '1');
1009+
await user.tab();
1010+
1011+
const propertyKeys = [...node.querySelectorAll<HTMLInputElement>('input[id$="-key"]')].map(
1012+
(input) => input.value,
1013+
);
1014+
expect(propertyKeys).toEqual(['first', '1']);
1015+
});
1016+
1017+
it('should not duplicate an additional property that becomes schema-defined after rerender', () => {
1018+
const initialSchema: RJSFSchema = {
1019+
type: 'object',
1020+
additionalProperties: {
1021+
type: 'string',
1022+
},
1023+
};
1024+
const updatedSchema: RJSFSchema = {
1025+
type: 'object',
1026+
properties: {
1027+
promoted: {
1028+
type: 'string',
1029+
},
1030+
},
1031+
additionalProperties: {
1032+
type: 'string',
1033+
},
1034+
};
1035+
const formData = { first: 'one', promoted: 'two' };
1036+
const { node, rerender } = createFormComponent({
1037+
schema: initialSchema,
1038+
formData,
1039+
});
1040+
1041+
rerender({
1042+
schema: updatedSchema,
1043+
formData,
1044+
});
1045+
1046+
const promotedInputs = node.querySelectorAll<HTMLInputElement>('input[id="root_promoted"]');
1047+
expect(promotedInputs).toHaveLength(1);
1048+
expect(promotedInputs[0]).toHaveValue('two');
1049+
1050+
const propertyKeys = [...node.querySelectorAll<HTMLInputElement>('input[id$="-key"]')].map(
1051+
(input) => input.value,
1052+
);
1053+
expect(propertyKeys).toEqual(['first']);
1054+
});
1055+
9951056
it('should rename nested additionalProperties key when key input is blurred', async () => {
9961057
const nestedSchema: RJSFSchema = {
9971058
type: 'object',

0 commit comments

Comments
 (0)