|
| 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