Skip to content

Commit 45bc17f

Browse files
committed
Add support for additional properties of objects
Create a simple field type selector
1 parent 78e9699 commit 45bc17f

File tree

6 files changed

+352
-14
lines changed

6 files changed

+352
-14
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/client/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"@parcel/reporter-bundle-analyzer": "^2.12.0",
3636
"@parcel/reporter-bundle-buddy": "^2.12.0",
3737
"@types/babel__core": "^7",
38-
"@types/node": "^22.8.4",
38+
"@types/node": "^22.9.0",
3939
"@types/react": "^18.3.4",
4040
"@types/react-dom": "^18.3.0",
4141
"buffer": "^6.0.3",
@@ -98,4 +98,4 @@
9898
"$pages": "~/src/pages",
9999
"$test": "~/test"
100100
}
101-
}
101+
}

packages/data-core/lib/schema/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,9 @@ export function schemaToFormDataStructure(
2929
return [];
3030
}
3131

32+
if (field.type === 'json') {
33+
return {};
34+
}
35+
3236
return '';
3337
}

packages/data-core/lib/schema/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface SchemaFieldObject {
3131
'ui:widget'?: string;
3232
label?: string | string[];
3333
properties: Record<string, SchemaField>;
34+
additionalProperties?: boolean;
3435
required?: string[];
3536
}
3637

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import React, { useCallback, useMemo, useState } from 'react';
2+
import {
3+
WidgetRenderer,
4+
SchemaField,
5+
schemaToFormDataStructure
6+
} from '@stac-manager/data-core';
7+
import {
8+
Fieldset,
9+
FieldsetBody,
10+
FieldsetDeleteBtn,
11+
FieldsetHeader
12+
} from './elements';
13+
import {
14+
Box,
15+
Flex,
16+
FormControl,
17+
FormErrorMessage,
18+
Input,
19+
InputGroup,
20+
InputLeftElement,
21+
Select
22+
} from '@chakra-ui/react';
23+
import { CollecticonTag } from '@devseed-ui/collecticons-chakra';
24+
import { useFormikContext } from 'formik';
25+
import get from 'lodash-es/get';
26+
import set from 'lodash-es/set';
27+
import unset from 'lodash-es/unset';
28+
import mapKeys from 'lodash-es/mapKeys';
29+
import toPath from 'lodash-es/toPath';
30+
import cloneDeep from 'lodash-es/cloneDeep';
31+
32+
const fieldTypes = [
33+
{ value: 'string', label: 'String' },
34+
{ value: 'number', label: 'Number' },
35+
{ value: 'string[]', label: 'List of String' },
36+
{ value: 'number[]', label: 'List of Number' },
37+
{ value: 'json', label: 'JSON' }
38+
] as const;
39+
40+
type FieldTypes = (typeof fieldTypes)[number]['value'];
41+
42+
/**
43+
* Infers the type of a given value and returns a corresponding FieldTypes
44+
* string.
45+
*
46+
* @param value - The value whose type is to be inferred.
47+
* @returns The inferred type as a FieldTypes string. Possible return values
48+
* are:
49+
* - 'number' for numeric values.
50+
* - 'number[]' for arrays where all elements are numbers.
51+
* - 'string[]' for arrays where all elements are strings.
52+
* - 'json' for arrays with mixed types or objects.
53+
* - 'string' for all other types.
54+
*/
55+
const inferFieldType = (value: any): FieldTypes => {
56+
if (typeof value === 'number') {
57+
return 'number';
58+
}
59+
60+
// if (typeof value === 'boolean') {
61+
// return 'boolean';
62+
// }
63+
64+
if (Array.isArray(value)) {
65+
if (value.every((v) => typeof v === 'number')) {
66+
return 'number[]';
67+
}
68+
// if (value.every((v) => typeof v === 'boolean')) {
69+
// return 'boolean[]';
70+
// }
71+
if (value.every((v) => typeof v === 'string')) {
72+
return 'string[]';
73+
}
74+
return 'json';
75+
}
76+
77+
if (typeof value === 'object') {
78+
return 'json';
79+
}
80+
81+
return 'string';
82+
};
83+
84+
/**
85+
* Generates a schema field based on the provided field type.
86+
*
87+
* @param type - The type of the field. Can be 'string', 'number', 'string[]',
88+
* 'number[]', or 'json'.
89+
* @returns A SchemaField object if the type is recognized, otherwise null.
90+
*/
91+
const getFieldSchema = (type: FieldTypes): SchemaField | null => {
92+
if (type === 'string' || type === 'number') {
93+
return {
94+
type: type,
95+
label: 'Value'
96+
};
97+
}
98+
99+
if (['string[]', 'number[]'].includes(type)) {
100+
return {
101+
type: 'array',
102+
label: 'Value',
103+
minItems: 1,
104+
items: {
105+
type: type.replace('[]', '')
106+
}
107+
} as SchemaField;
108+
}
109+
110+
if (type === 'json') {
111+
return {
112+
type: 'json',
113+
label: 'Value'
114+
};
115+
}
116+
117+
return null;
118+
};
119+
120+
/**
121+
* Replaces a key in an object at a specified path with a new key.
122+
* The order of the keys in the object is preserved.
123+
* The original object is not mutated.
124+
*
125+
* @param obj - The object in which the key replacement will occur.
126+
* @param path - The path to the key that needs to be replaced.
127+
* @param newKey - The new key that will replace the old key.
128+
* @returns A new object with the key replaced at the specified path.
129+
*/
130+
const replaceObjectKeyAt = (obj: any, path: string, newKey: string) => {
131+
const parts = toPath(path);
132+
const last = parts.pop()!;
133+
const isRoot = !parts.length;
134+
const valuesAtPath = isRoot ? obj : get(obj, parts);
135+
136+
const valuesWithNewKey = mapKeys(valuesAtPath, (_, key) => {
137+
return key === last ? newKey : key;
138+
});
139+
140+
valuesWithNewKey[newKey] = valuesAtPath[last];
141+
142+
return isRoot
143+
? valuesWithNewKey
144+
: set(cloneDeep(obj), parts, valuesWithNewKey);
145+
};
146+
147+
/*****************************************************************************
148+
* C O M P O N E N T *
149+
*****************************************************************************/
150+
151+
interface ObjectPropertyProps {
152+
pointer: string;
153+
property: string;
154+
existentProperties: string[];
155+
}
156+
157+
export function ObjectProperty(props: ObjectPropertyProps) {
158+
const { pointer, property, existentProperties } = props;
159+
160+
const ctx = useFormikContext();
161+
const value = pointer ? get(ctx.values, pointer) : ctx.values;
162+
163+
const [fieldType, setFieldType] = useState<FieldTypes>(inferFieldType(value));
164+
const [keyFieldValue, setKeyFieldValue] = useState(property);
165+
const [keyError, setKeyError] = useState<string>();
166+
167+
const onFieldKeyBlur = useCallback(
168+
(e: any) => {
169+
const newProp = e.target.value;
170+
const keyExists = existentProperties.includes(newProp);
171+
172+
// Revert to original value in case of error.
173+
setKeyError(undefined);
174+
if (keyExists || newProp === '') {
175+
setKeyFieldValue(property);
176+
return;
177+
}
178+
179+
// Update the form values with the new property name.
180+
if (newProp === property) return;
181+
182+
ctx.setValues(replaceObjectKeyAt(ctx.values, pointer, newProp));
183+
184+
const newPointer = pointer.replace(
185+
new RegExp(`${property}$`, 'g'),
186+
newProp
187+
);
188+
ctx.unregisterField(pointer);
189+
ctx.registerField(newPointer, {});
190+
},
191+
[pointer, property, value, ctx]
192+
);
193+
194+
const removeProperty = useCallback(
195+
(pointer: string) => {
196+
ctx.unregisterField(pointer);
197+
const valuesCopy = { ...ctx.values! };
198+
unset(valuesCopy, pointer);
199+
ctx.setValues(valuesCopy);
200+
},
201+
[ctx]
202+
);
203+
204+
const field = useMemo(() => getFieldSchema(fieldType), [fieldType]);
205+
206+
return (
207+
<Fieldset>
208+
<FieldsetHeader>
209+
<Box>
210+
<FormControl isInvalid={!!keyError}>
211+
<InputGroup size='sm' bg='surface.500' borderColor='base.200'>
212+
<InputLeftElement pointerEvents='none'>
213+
<CollecticonTag title='Value for the object property' />
214+
</InputLeftElement>
215+
<Input
216+
type='text'
217+
placeholder='Property name'
218+
value={keyFieldValue}
219+
onChange={(e) => {
220+
const value = e.target.value;
221+
setKeyFieldValue(value);
222+
if (existentProperties.includes(value)) {
223+
setKeyError('Property already exists');
224+
} else if (value === '') {
225+
setKeyError('Property name cannot be empty');
226+
}
227+
}}
228+
onBlur={onFieldKeyBlur}
229+
/>
230+
</InputGroup>
231+
<FormErrorMessage>{keyError}</FormErrorMessage>
232+
</FormControl>
233+
</Box>
234+
<Flex gap={4}>
235+
<Select
236+
size='sm'
237+
bg='surface.500'
238+
borderColor='base.200'
239+
borderRadius='md'
240+
value={fieldType}
241+
onChange={(e) => {
242+
const type = e.target.value as FieldTypes;
243+
setFieldType(type);
244+
245+
const schema = getFieldSchema(type);
246+
if (schema) {
247+
const valuesForSchema = schemaToFormDataStructure(schema);
248+
ctx.setFieldValue(pointer, valuesForSchema);
249+
}
250+
}}
251+
>
252+
{fieldTypes.map((type) => (
253+
<option key={type.value} value={type.value}>
254+
{type.label}
255+
</option>
256+
))}
257+
</Select>
258+
<FieldsetDeleteBtn
259+
aria-label='Remove item'
260+
onClick={() => removeProperty(pointer)}
261+
/>
262+
</Flex>
263+
</FieldsetHeader>
264+
<FieldsetBody>
265+
{field && <WidgetRenderer field={field} pointer={pointer} />}
266+
</FieldsetBody>
267+
</Fieldset>
268+
);
269+
}

0 commit comments

Comments
 (0)