From 19add642cbcc79407083ad7983b432932b470167 Mon Sep 17 00:00:00 2001 From: songwongtp <16089160+songwongtp@users.noreply.github.com> Date: Tue, 29 Aug 2023 11:57:47 +0700 Subject: [PATCH 1/4] feat: json schema nullable --- CHANGELOG.md | 1 + src/lib/components/fund/index.tsx | 4 +- .../json-schema/form/fields/ArrayField.tsx | 884 ++++++++++++++++++ .../json-schema/form/fields/BooleanField.tsx | 12 +- .../json-schema/form/fields/index.ts | 2 + .../form/templates/ArrayFieldTemplate.tsx | 5 +- .../form/templates/BaseInputTemplate.tsx | 101 +- .../form/templates/FieldTemplate.tsx | 13 - src/lib/components/json-schema/form/utils.ts | 7 + .../json-schema/form/widgets/SelectWidget.tsx | 10 +- .../components/schema-execute/ExecuteBox.tsx | 9 +- .../query/components/SchemaQueryComponent.tsx | 48 +- 12 files changed, 1025 insertions(+), 71 deletions(-) create mode 100644 src/lib/components/json-schema/form/fields/ArrayField.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a363368..f89fd5f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements +- [#510](https://github.com/alleslabs/celatone-frontend/pull/510) Support optional fields for array, boolean, and string - [#505](https://github.com/alleslabs/celatone-frontend/pull/505) Adjust attach funds form label and icon styling for schema section - [#501](https://github.com/alleslabs/celatone-frontend/pull/501) Add more JSON Schema state, e.g. empty object state, boolean field - [#502](https://github.com/alleslabs/celatone-frontend/pull/502) Display queried time and add json/schema output switch diff --git a/src/lib/components/fund/index.tsx b/src/lib/components/fund/index.tsx index be066af8f..9e273104c 100644 --- a/src/lib/components/fund/index.tsx +++ b/src/lib/components/fund/index.tsx @@ -72,6 +72,7 @@ interface AttachFundProps { attachFundsOption: AttachFundsType; labelBgColor?: string; setValue: UseFormSetValue; + showLabel?: boolean; } export const AttachFund = ({ @@ -79,11 +80,12 @@ export const AttachFund = ({ attachFundsOption, labelBgColor, setValue, + showLabel = true, }: AttachFundProps) => ( <> setValue(ATTACH_FUNDS_OPTION, value) diff --git a/src/lib/components/json-schema/form/fields/ArrayField.tsx b/src/lib/components/json-schema/form/fields/ArrayField.tsx new file mode 100644 index 000000000..35730c15d --- /dev/null +++ b/src/lib/components/json-schema/form/fields/ArrayField.tsx @@ -0,0 +1,884 @@ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable no-nested-ternary */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Checkbox, Flex, Text } from "@chakra-ui/react"; +import type { + ArrayFieldTemplateProps, + ErrorSchema, + Field, + FieldProps, + IdSchema, + RJSFSchema, + UiSchema, +} from "@rjsf/utils"; +import { + getTemplate, + getWidget, + getUiOptions, + isFixedItems, + allowAdditionalItems, + isCustomWidget, + optionsList, + ITEMS_KEY, +} from "@rjsf/utils"; +import isObject from "lodash/isObject"; +import set from "lodash/set"; +import { Component } from "react"; +import type React from "react"; +import * as uuid from "uuid"; + +import { isNullFormData } from "../utils"; + +/** Type used to represent the keyed form data used in the state */ +type KeyedFormDataType = { key: string; item: T }; + +/** Type used for the state of the `ArrayField` component */ +type ArrayFieldState = { + /** The keyed form data elements */ + keyedFormData: KeyedFormDataType[]; +}; + +/** Used to generate a unique ID for an element in a row */ +function generateRowId() { + return uuid.v4(); +} + +/** Converts the `formData` into `KeyedFormDataType` data, using the `generateRowId()` function to create the key + * + * @param formData - The data for the form + * @returns - The `formData` converted into a `KeyedFormDataType` element + */ +function generateKeyedFormData(formData: T[]): KeyedFormDataType[] { + return !Array.isArray(formData) + ? [] + : formData.map((item) => { + return { + key: generateRowId(), + item, + }; + }); +} + +/** Converts `KeyedFormDataType` data into the inner `formData` + * + * @param keyedFormData - The `KeyedFormDataType` to be converted + * @returns - The inner `formData` item(s) in the `keyedFormData` + */ +function keyedToPlainFormData( + keyedFormData: KeyedFormDataType | KeyedFormDataType[] +): T[] { + if (Array.isArray(keyedFormData)) { + return keyedFormData.map((keyedItem) => keyedItem.item); + } + return []; +} + +/** The `ArrayField` component is used to render a field in the schema that is of type `array`. It supports both normal + * and fixed array, allowing user to add and remove elements from the array data. + */ +class ArrayField extends Component< + FieldProps, + ArrayFieldState +> { + /** React lifecycle method that is called when the props are about to change allowing the state to be updated. It + * regenerates the keyed form data and returns it + * + * @param nextProps - The next set of props data + * @param prevState - The previous set of state data + */ + static getDerivedStateFromProps( + nextProps: Readonly>, + prevState: Readonly> + ) { + // Don't call getDerivedStateFromProps if keyed formdata was just updated. + // if (prevState.updatedKeyedFormData) { + // return { + // updatedKeyedFormData: false, + // }; + // } + const nextFormData = Array.isArray(nextProps.formData) + ? nextProps.formData + : []; + const previousKeyedFormData = prevState.keyedFormData || []; + const newKeyedFormData = + nextFormData.length === previousKeyedFormData.length + ? previousKeyedFormData.map((previousKeyedFormDatum, index) => { + return { + key: previousKeyedFormDatum.key, + item: nextFormData[index], + }; + }) + : generateKeyedFormData(nextFormData); + return { + keyedFormData: newKeyedFormData, + }; + } + + /** Determines whether the item described in the schema is always required, which is determined by whether any item + * may be null. + * + * @param itemSchema - The schema for the item + * @return - True if the item schema type does not contain the "null" type + */ + static isItemRequired(itemSchema: RJSFSchema) { + if (Array.isArray(itemSchema.type)) { + // While we don't yet support composite/nullable jsonschema types, it's + // future-proof to check for requirement against these. + return !itemSchema.type.includes("null"); + } + // All non-null array item types are inherently required by design + return itemSchema.type !== "null"; + } + + /** Constructs an `ArrayField` from the `props`, generating the initial keyed data from the `formData` + * + * @param props - The `FieldProps` for this template + */ + constructor(props: FieldProps) { + super(props); + const { formData = [] } = props; + const keyedFormData = generateKeyedFormData(formData); + this.state = { + keyedFormData, + }; + } + + componentDidMount(): void { + const { formData } = this.props; + if (formData === undefined) this.onSelectChange([]); + } + + /** Returns the default form information for an item based on the schema for that item. Deals with the possibility + * that the schema is fixed and allows additional items. + */ + getNewFormDataRow = (): T => { + const { schema, registry } = this.props; + const { schemaUtils } = registry; + let itemSchema = schema.items as RJSFSchema; + if (isFixedItems(schema) && allowAdditionalItems(schema)) { + itemSchema = schema.additionalItems as RJSFSchema; + } + // Cast this as a T to work around schema utils being for T[] caused by the FieldProps call on the class + return schemaUtils.getDefaultFormState(itemSchema) as unknown as T; + }; + + /** Callback handler for when the user clicks on the add button. Creates a new row of keyed form data at the end of + * the list, adding it into the state, and then returning `onChange()` with the plain form data converted from the + * keyed data + * + * @param event - The event for the click + */ + onAddClick = (event: MouseEvent) => { + if (event) { + event.preventDefault(); + } + + const { onChange } = this.props; + const { keyedFormData } = this.state; + const newKeyedFormDataRow: KeyedFormDataType = { + key: generateRowId(), + item: this.getNewFormDataRow(), + }; + const newKeyedFormData = [...keyedFormData, newKeyedFormDataRow]; + this.setState( + { + keyedFormData: newKeyedFormData, + }, + () => onChange(keyedToPlainFormData(newKeyedFormData)) + ); + }; + + /** Callback handler for when the user clicks on the add button on an existing array element. Creates a new row of + * keyed form data inserted at the `index`, adding it into the state, and then returning `onChange()` with the plain + * form data converted from the keyed data + * + * @param index - The index at which the add button is clicked + */ + onAddIndexClick = (index: number) => { + return (event: MouseEvent) => { + if (event) { + event.preventDefault(); + } + const { onChange } = this.props; + const { keyedFormData } = this.state; + const newKeyedFormDataRow: KeyedFormDataType = { + key: generateRowId(), + item: this.getNewFormDataRow(), + }; + const newKeyedFormData = [...keyedFormData]; + newKeyedFormData.splice(index, 0, newKeyedFormDataRow); + + this.setState( + { + keyedFormData: newKeyedFormData, + }, + () => onChange(keyedToPlainFormData(newKeyedFormData)) + ); + }; + }; + + /** Callback handler for when the user clicks on the remove button on an existing array element. Removes the row of + * keyed form data at the `index` in the state, and then returning `onChange()` with the plain form data converted + * from the keyed data + * + * @param index - The index at which the remove button is clicked + */ + onDropIndexClick = (index: number) => { + return (event: MouseEvent) => { + if (event) { + event.preventDefault(); + } + const { onChange, errorSchema } = this.props; + const { keyedFormData } = this.state; + // refs #195: revalidate to ensure properly reindexing errors + let newErrorSchema: ErrorSchema; + if (errorSchema) { + newErrorSchema = {}; + Object.keys(errorSchema).forEach((_, idx) => { + if (idx < index) { + set(newErrorSchema, [idx], errorSchema[idx]); + } else if (idx > index) { + set(newErrorSchema, [idx - 1], errorSchema[idx]); + } + }); + } + const newKeyedFormData = keyedFormData.filter((_, i) => i !== index); + this.setState( + { + keyedFormData: newKeyedFormData, + }, + () => + onChange( + keyedToPlainFormData(newKeyedFormData), + newErrorSchema as ErrorSchema + ) + ); + }; + }; + + /** Callback handler for when the user clicks on one of the move item buttons on an existing array element. Moves the + * row of keyed form data at the `index` to the `newIndex` in the state, and then returning `onChange()` with the + * plain form data converted from the keyed data + * + * @param index - The index of the item to move + * @param newIndex - The index to where the item is to be moved + */ + onReorderClick = (index: number, newIndex: number) => { + return (event: React.MouseEvent) => { + if (event) { + event.preventDefault(); + event.currentTarget.blur(); + } + const { onChange, errorSchema } = this.props; + let newErrorSchema: ErrorSchema; + if (errorSchema) { + newErrorSchema = {}; + Object.keys(errorSchema).forEach((_, idx) => { + if (idx === index) { + set(newErrorSchema, [newIndex], errorSchema[index]); + } else if (idx === newIndex) { + set(newErrorSchema, [index], errorSchema[newIndex]); + } else { + set(newErrorSchema, [idx], errorSchema[idx]); + } + }); + } + + const { keyedFormData } = this.state; + function reOrderArray() { + // Copy item + const newKeyedFormData = keyedFormData.slice(); + + // Moves item from index to newIndex + newKeyedFormData.splice(index, 1); + newKeyedFormData.splice(newIndex, 0, keyedFormData[index]); + + return newKeyedFormData; + } + const newKeyedFormData = reOrderArray(); + this.setState( + { + keyedFormData: newKeyedFormData, + }, + () => + onChange( + keyedToPlainFormData(newKeyedFormData), + newErrorSchema as ErrorSchema + ) + ); + }; + }; + + /** Callback handler used to deal with changing the value of the data in the array at the `index`. Calls the + * `onChange` callback with the updated form data + * + * @param index - The index of the item being changed + */ + onChangeForIndex = (index: number) => { + return (value: any, newErrorSchema?: ErrorSchema) => { + const { formData, onChange, errorSchema } = this.props; + const arrayData = Array.isArray(formData) ? formData : []; + const newFormData = arrayData.map((item: T, i: number) => { + // We need to treat undefined items as nulls to have validation. + // See https://github.com/tdegrunt/jsonschema/issues/206 + const jsonValue = typeof value === "undefined" ? null : value; + return index === i ? jsonValue : item; + }); + onChange( + newFormData, + errorSchema && { + ...errorSchema, + [index]: newErrorSchema, + } + ); + }; + }; + + /** Callback handler used to change the value for a checkbox */ + onSelectChange = (value: any) => { + const { onChange } = this.props; + onChange(value); + }; + + /** Determines whether more items can be added to the array. If the uiSchema indicates the array doesn't allow adding + * then false is returned. Otherwise, if the schema indicates that there are a maximum number of items and the + * `formData` matches that value, then false is returned, otherwise true is returned. + * + * @param formItems - The list of items in the form + * @returns - True if the item is addable otherwise false + */ + canAddItem(formItems: any[]) { + const { schema, uiSchema } = this.props; + let { addable } = getUiOptions(uiSchema); + if (addable !== false) { + // if ui:options.addable was not explicitly set to false, we can add + // another item if we have not exceeded maxItems yet + if (schema.maxItems !== undefined) { + addable = formItems.length < schema.maxItems; + } else { + addable = true; + } + } + return addable; + } + + /** Renders a normal array without any limitations of length + */ + renderNormalArray() { + const { + schema, + uiSchema = {}, + errorSchema, + idSchema, + name, + disabled = false, + readonly = false, + autofocus = false, + required = false, + registry, + onBlur, + onFocus, + idPrefix, + idSeparator = "_", + rawErrors, + formData: rawFormData, + } = this.props; + + const { keyedFormData } = this.state; + + const title = schema.title === undefined ? name : schema.title; + const { schemaUtils, formContext } = registry; + const uiOptions = getUiOptions(uiSchema); + const schemaItems = isObject(schema.items) + ? (schema.items as RJSFSchema) + : {}; + const itemsSchema = schemaUtils.retrieveSchema(schemaItems); + const formData = keyedToPlainFormData(keyedFormData); + const arrayProps: ArrayFieldTemplateProps = { + canAdd: this.canAddItem(formData), + items: keyedFormData.map((keyedItem, index) => { + const { key, item } = keyedItem; + // While we are actually dealing with a single item of type T, the types require a T[], so cast + const itemCast = item as unknown as T[]; + const itemSchema = schemaUtils.retrieveSchema(schemaItems, itemCast); + const itemErrorSchema = errorSchema + ? (errorSchema[index] as ErrorSchema) + : undefined; + const itemIdPrefix = idSchema.$id + idSeparator + index; + const itemIdSchema = schemaUtils.toIdSchema( + itemSchema, + itemIdPrefix, + itemCast, + idPrefix, + idSeparator + ); + return this.renderArrayFieldItem({ + key, + index, + name: name && `${name}-${index}`, + canMoveUp: index > 0, + canMoveDown: index < formData.length - 1, + itemSchema, + itemIdSchema, + itemErrorSchema, + itemData: itemCast, + itemUiSchema: uiSchema.items, + autofocus: autofocus && index === 0, + onBlur, + onFocus, + rawErrors, + }); + }), + className: `field field-array field-array-of-${itemsSchema.type}`, + disabled: disabled || isNullFormData(rawFormData), + idSchema, + uiSchema, + onAddClick: this.onAddClick, + readonly, + required, + schema, + title, + formContext, + formData: + isNullFormData(rawFormData) && formData.length === 0 + ? rawFormData + : formData, + rawErrors, + registry, + }; + + const Template = getTemplate<"ArrayFieldTemplate", T[], F>( + "ArrayFieldTemplate", + registry, + uiOptions + ); + return ( + +