diff --git a/CHANGELOG_v6.md b/CHANGELOG_v6.md index 5e25b502af..5653da1c66 100644 --- a/CHANGELOG_v6.md +++ b/CHANGELOG_v6.md @@ -68,7 +68,22 @@ should change the heading of the (upcoming) version to include a major version b - BREAKING CHANGE: Added two the following new, required props to `TemplatesType`: - `ArrayFieldItemButtonsTemplate: ComponentType>;` - `GridTemplate: ComponentType` -- Added a new `buttonId(id: IdSchema | string, btn: 'add' | 'copy' | 'moveDown' | 'moveUp' | 'remove')` used to generate consistent ids for RJSF buttons +- BREAKING CHANGE: Updated the `SchemaUtilsType` to add new validator-based functions to the interface +- Added the following new non-validator utility functions: + - `buttonId(id: IdSchema | string, btn: 'add' | 'copy' | 'moveDown' | 'moveUp' | 'remove')`: used to generate consistent ids for RJSF buttons + - `getTestIds(): TestIdShape`: Returns an object of test IDs that can only be used in test mode, helpful for writing unit tests for React components + - `hashObject(object: unknown): string`: Stringifies an `object` and returns the hash of the resulting string + - `hashString(string: string): string`: Hashes a string into hex format + - `lookupFromFormContext(regOrFc: Registry | Registry['formContext'], toLookup: string, fallback?: unknown)`: Given a React JSON Schema Form registry or formContext object, return the value associated with `toLookup` +- Added the following new validator-based utility functions: + - `findFieldInSchema(validator: ValidatorType, rootSchema: S, path: string | string[], schema: S, formData?: T, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): FoundFieldType`: Finds the field specified by the `path` within the root or recursed `schema` + - `findSelectedOptionInXxxOf(validator: ValidatorType, rootSchema: S, schema: S, fallbackField: string,xxx: 'anyOf' | 'oneOf', formData?: T, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): S | undefined`: Finds the option that matches the selector field in the `schema` or undefined if nothing is selected + - `getFromSchema(validator: ValidatorType, rootSchema: S, schema: S, path: string | string[], defaultValue: T | S, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): T | S`: Helper that acts like lodash's `get` but additionally retrieves `$ref`s as needed to get the path for schemas + + +## @rjsf/validator-ajv6 + +- BREAKING CHANGE: This deprecated validator has been removed ## Dev / docs / playground diff --git a/packages/docs/docs/api-reference/utility-functions.md b/packages/docs/docs/api-reference/utility-functions.md index 3432a5aaf1..2764b3ca62 100644 --- a/packages/docs/docs/api-reference/utility-functions.md +++ b/packages/docs/docs/api-reference/utility-functions.md @@ -414,34 +414,51 @@ Extracts any `ui:submitButtonOptions` from the `uiSchema` and merges them onto t - UISchemaSubmitButtonOptions: The merging of the `DEFAULT_OPTIONS` with any custom ones -### getUiOptions() +### getTemplate, T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>() -Get all passed options from ui:options, and ui:<optionName>, returning them in an object with the `ui:` stripped off. -Any `globalOptions` will always be returned, unless they are overridden by options in the `uiSchema`. +Returns the template with the given `name` from either the `uiSchema` if it is defined or from the `registry` +otherwise. NOTE, since `ButtonTemplates` are not overridden in `uiSchema` only those in the `registry` are returned. #### Parameters -- [uiSchema={}]: UiSchema - The UI Schema from which to get any `ui:xxx` options -- [globalOptions={}]: GlobalUISchemaOptions - The optional Global UI Schema from which to get any fallback `xxx` options +- name: Name - The name of the template to fetch, restricted to the keys of `TemplatesType` +- registry: Registry - The `Registry` from which to read the template +- [uiOptions={}]: UIOptionsType - The `UIOptionsType` from which to read an alternate template #### Returns -- UIOptionsType An object containing all of the `ui:xxx` options with the `ui:` stripped off along with all `globalOptions` +- TemplatesType[Name] - The template from either the `uiSchema` or `registry` for the `name` -### getTemplate, T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>() +### getTestIds() -Returns the template with the given `name` from either the `uiSchema` if it is defined or from the `registry` -otherwise. NOTE, since `ButtonTemplates` are not overridden in `uiSchema` only those in the `registry` are returned. +Returns an object of test IDs that can only be used in test mode. +If the function is called in a test environment (`NODE_ENV === 'test'`, this is set by jest) then a Proxy object will be returned. +If a key within the returned object is accessed, if the value already exists the object will return that value, otherwise it will create that key +with a generated `uuid` value and return the generated ID. +If it is called outside of a test environment, the function will return an empty object, therefore returning `undefined` for any property within the object and excluding the prop from the rendered output of the component in which it is used. +To use this helper, you will want to generate a separate object for each component to avoid potential overlapping of ID names. +You will also want to export the object for use in tests, because the keys will be generated in the component file, and used in the test file. +Within the component file, add: `export const TEST_IDS = getTestIds();` +Then pass `TEST_IDS.examplePropertyName` as the value of the test ID attribute of the intended component. +This will allow you to use `TEST_IDS.examplePropertyName` within your tests, while keeping the test IDs out of your rendered output. + +#### Returns + +- TestIdShape: An object that auto-generates test ids upon request the first time and then returns the same value on subsequent calls + +### getUiOptions() + +Get all passed options from ui:options, and ui:<optionName>, returning them in an object with the `ui:` stripped off. +Any `globalOptions` will always be returned, unless they are overridden by options in the `uiSchema`. #### Parameters -- name: Name - The name of the template to fetch, restricted to the keys of `TemplatesType` -- registry: Registry - The `Registry` from which to read the template -- [uiOptions={}]: UIOptionsType - The `UIOptionsType` from which to read an alternate template +- [uiSchema={}]: UiSchema - The UI Schema from which to get any `ui:xxx` options +- [globalOptions={}]: GlobalUISchemaOptions - The optional Global UI Schema from which to get any fallback `xxx` options #### Returns -- TemplatesType[Name] - The template from either the `uiSchema` or `registry` for the `name` +- UIOptionsType An object containing all of the `ui:xxx` options with the `ui:` stripped off along with all `globalOptions` ### getWidget() @@ -464,6 +481,31 @@ on the schema type and `widget` name. If no widget component can be found an `Er - An error if there is no `Widget` component that can be returned +### hashObject() + +Stringifies an `object` and returns the hash of the resulting string. +Sorts object fields in consistent order before stringify to prevent different hash ids for the same object. + +#### Parameters + +- object: object - The object for which the hash is desired + +#### Returns + +- string: The string obtained from the hash of the stringified object + +### hashString() + +Hashes a string using the algorithm based on Java's hashing function. + +#### Parameters + +- string: string - The string for which to get the hash + +#### Returns + +- string: The resulting hash of the string in hex format + ### guessType() Given a specific `value` attempts to guess the type of a schema element. In the case where we have to implicitly @@ -595,7 +637,23 @@ Converts a local Date string into a UTC date string - string | undefined: A UTC date string if `dateString` is truthy, otherwise undefined -### mergeDefaultsWithFormData() +### lookupFromFormContext() + +Given a React JSON Schema Form registry or formContext object, return the value associated with `toLookup`. +This might be contained within the lookup map in the formContext. +If no such value exists, return the `fallback` value. + +#### Parameters + +- regOrFc: Registry | Registry['formContext'] - The @rjsf registry or form context in which the lookup will occur +- toLookup: string - The name of the field in the lookup map in the form context to get the value for +- [fallback]: unknown - The fallback value to use if the form context does not contain a value for `toLookup` + +#### Returns + +- any: The value associated with `toLookup` in the form context or `fallback` + +### mergeDefaultsWithFormData() Merges the `defaults` object of type `T` into the `formData` of type `T` @@ -924,6 +982,42 @@ This is used in isValid to make references to the rootSchema ## Validator-based utility functions +### findFieldInSchema() + +Returns the superset of `formData` that includes the given set updated to include any missing fields that have computed to have defaults provided in the `schema`. + +#### Parameters + +- validator: ValidatorType - An implementation of the `ValidatorType` interface that will be forwarded to all the APIs +- rootSchema: S | undefined - The root schema that will be forwarded to all the APIs +- schema: S - The node within the JSON schema in which to search +- path: string | string[] - The keys in the path to the desired field +- [formData={}]: T - The form data that is used to determine which anyOf/oneOf option to descend +- [experimental_customMergeAllOf]: Experimental_CustomMergeAllOf<S> - See `Form` documentation for the [experimental_customMergeAllOf](./form-props.md#experimental_custommergeallof) prop + +#### Returns + +- FoundFieldType<S>: An object that contains the field and its required state. If no field can be found then`{ field: undefined, isRequired: undefined }` is returned. + +### findSelectedOptionInXxxOf() + +Finds the option inside the `schema['any/oneOf']` list which has the `properties[selectorField].default` or `properties[selectorField].const` that matches the `formData[selectorField]` value. +For the purposes of this function, `selectorField` is either `schema.discriminator.propertyName` or `fallbackField`. + +#### Parameters + +- validator: ValidatorType - An implementation of the `ValidatorType` interface that will be forwarded to all the APIs +- rootSchema: S | undefined - The root schema that will be forwarded to all the APIs +- schema: S - The schema element in which to search for the selected anyOf/oneOf option +- fallbackField: string - The field to use as a backup selector field if the schema does not have a required field +- xxx: 'anyOf' | 'oneOf' - Either `anyOf` or `oneOf`, defines which value is being sought +- [formData={}]: T - The form data that is used to determine which anyOf/oneOf option to descend +- [experimental_customMergeAllOf]: Experimental_CustomMergeAllOf<S> - See `Form` documentation for the [experimental_customMergeAllOf](./form-props.md#experimental_custommergeallof) prop + +#### Returns + +- S | undefined: The anyOf/oneOf option that matches the selector field in the schema or undefined if nothing is selected + ### getDefaultFormState() Returns the superset of `formData` that includes the given set updated to include any missing fields that have computed to have defaults provided in the `schema`. @@ -942,6 +1036,27 @@ Returns the superset of `formData` that includes the given set updated to includ - T: The resulting `formData` with all the defaults provided +### getClosestMatchingOption() + +Determines which of the given `options` provided most closely matches the `formData`. +Returns the index of the option that is valid and is the closest match, or 0 if there is no match. + +The closest match is determined using the number of matching properties, and more heavily favors options with matching readOnly, default, or const values. + +#### Parameters + +- validator: ValidatorType - An implementation of the `ValidatorType` interface that will be used when necessary +- rootSchema: S - The root schema, used to primarily to look up `$ref`s +- [formData]: T | undefined - The current formData, if any, used to figure out a match +- options: S[] - The list of options to find a matching options from +- [selectedOption=-1]: number - The index of the currently selected option, defaulted to -1 if not specified +- [discriminatorField]: string | undefined - The optional name of the field within the options object whose value is used to determine which option is selected +- [experimental_customMergeAllOf]: Experimental_CustomMergeAllOf<S> - See `Form` documentation for the [experimental_customMergeAllOf](./form-props.md#experimental_custommergeallof) prop + +#### Returns + +- number: The index of the option that is the closest match to the `formData` or the `selectedOption` if no match + ### getDisplayLabel() Determines whether the combination of `schema` and `uiSchema` properties indicates that the label for the `schema` should be displayed in a UI. @@ -959,26 +1074,22 @@ Determines whether the combination of `schema` and `uiSchema` properties indicat - boolean: True if the label should be displayed or false if it should not -### getClosestMatchingOption() +### getFromSchema() -Determines which of the given `options` provided most closely matches the `formData`. -Returns the index of the option that is valid and is the closest match, or 0 if there is no match. - -The closest match is determined using the number of matching properties, and more heavily favors options with matching readOnly, default, or const values. +Helper that acts like lodash's `get` but additionally retrieves `$ref`s as needed to get the path for schemas containing potentially nested `$ref`s. #### Parameters -- validator: ValidatorType - An implementation of the `ValidatorType` interface that will be used when necessary -- rootSchema: S - The root schema, used to primarily to look up `$ref`s -- [formData]: T | undefined - The current formData, if any, used to figure out a match -- options: S[] - The list of options to find a matching options from -- [selectedOption=-1]: number - The index of the currently selected option, defaulted to -1 if not specified -- [discriminatorField]: string | undefined - The optional name of the field within the options object whose value is used to determine which option is selected +- validator: ValidatorType - An implementation of the `ValidatorType` interface that will be forwarded to all the APIs +- rootSchema: S - The root schema that will be forwarded to all the APIs +- schema: S - The current node within the JSON schema recursion +- path: string | string[] - The keys in the path to the desired field +- defaultValue: T | S - The value to return if a value is not found for the `pathList` path - [experimental_customMergeAllOf]: Experimental_CustomMergeAllOf<S> - See `Form` documentation for the [experimental_customMergeAllOf](./form-props.md#experimental_custommergeallof) prop #### Returns -- number: The index of the option that is the closest match to the `formData` or the `selectedOption` if no match +- T | S: The inner schema from the `schema` for the given `path` or the `defaultValue` if not found ### getFirstMatchingOption() diff --git a/packages/docs/docs/migration-guides/v6.x upgrade guide.md b/packages/docs/docs/migration-guides/v6.x upgrade guide.md index d597ec60ad..82cd358c10 100644 --- a/packages/docs/docs/migration-guides/v6.x upgrade guide.md +++ b/packages/docs/docs/migration-guides/v6.x upgrade guide.md @@ -22,6 +22,10 @@ The older `material-ui` theme has been removed in favor of the `mui` theme The `mui` theme no longer supports `@mui` version 5 due to the adoption of breaking changes in version 6 +### validator-ajv6 + +This deprecated validator has been removed. Use the `validator-ajv8`. + ### Node support Version 6 is dropping official support for Node 14, 16, and 18 as they are no longer a [maintained version](https://nodejs.org/en/about/releases/). @@ -70,3 +74,43 @@ If you have implemented your own `ArrayFieldItemTemplate` or `ArrayField` then y #### GridTemplate A new, theme-dependent template `GridTemplate` was added to support the new layout feature and must be provided if you are building your own `registry.templates` rather than overloading them via the `templates` props. + +#### SchemaUtilsType + +Five new functions were added to this type, so if you have your own implementation of this type, you will need to add them to yours. +The following new functions match the 5 new validator-based utility API functions mentioned below: + +- `findFieldInSchema(path: string | string[], schema: S, formData?: T): FoundFieldType` +- `findSelectedOptionInXxxOf(schema: S, fallbackField: string, xxx: 'anyOf' | `oneOf`, formData?: T): S | undefined;` +- `getFromSchema(schema: S, path: string | string[], defaultValue: T): T;` +- `getFromSchema(schema: S, path: string | string[], defaultValue: S): S;` +- `getFromSchema(schema: S, path: string | string[], defaultValue: T | S): S | T;` + +## New Features + +### New types + +The following new types were added to `@rjsf/utils`: + +- `ArrayFieldItemTemplateType`: The properties of each element in the ArrayFieldTemplateProps.items array. NOTE: `ArrayFieldTemplateItemType` is an alias to this type +- `FoundFieldType`: The interface for the return value of the `findFieldInSchema` function +- `GridTemplateProps`: The properties that are passed to a `GridTemplate` +- `TestIdShape`: The interface for the test ID proxy objects that are returned by the `getTestId` utility function + +### New non-validator utility functions + +Three new and two formerly internally private utility functions are available in `@rjsf/utils`: + +- `buttonId(id: IdSchema | string, btn: 'add' | 'copy' | 'moveDown' | 'moveUp' | 'remove')`: Generates consistent ids for RJSF buttons +- `getTestIds(): TestIdShape`: Returns an object of test IDs that can only be used in test mode, helpful for writing unit tests for React components +- `hashObject(object: unknown): string`: Stringifies an `object` and returns the hash of the resulting string +- `hashString(string: string): string`: Hashes a string into hex format +- `lookupFromFormContext(regOrFc: Registry | Registry['formContext'], toLookup: string, fallback?: unknown)`: Given a React JSON Schema Form registry or formContext object, return the value associated with `toLookup` + +### New validator-based utility functions + +Three new validator-based utility functions are available in `@rjsf/utils`: + +- `findFieldInSchema(validator: ValidatorType, rootSchema: S, path: string | string[], schema: S, formData?: T, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): FoundFieldType`: Finds the field specified by the `path` within the root or recursed `schema` +- `findSelectedOptionInXxxOf(validator: ValidatorType, rootSchema: S, schema: S, fallbackField: string,xxx: 'anyOf' | 'oneOf', formData?: T, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): S | undefined`: Finds the option that matches the selector field in the `schema` or undefined if nothing is selected +- `getFromSchema(validator: ValidatorType, rootSchema: S, schema: S, path: string | string[], defaultValue: T | S, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): T | S`: Helper that acts like lodash's `get` but additionally retrieves `$ref`s as needed to get the path for schemas diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts index 3221c7bb0e..b01f21851d 100644 --- a/packages/utils/src/constants.ts +++ b/packages/utils/src/constants.ts @@ -20,9 +20,21 @@ export const JUNK_OPTION_ID = '_$junk_option_schema_id$_'; export const NAME_KEY = '$name'; export const ONE_OF_KEY = 'oneOf'; export const PROPERTIES_KEY = 'properties'; +export const READONLY_KEY = 'readonly'; export const REQUIRED_KEY = 'required'; export const SUBMIT_BTN_OPTIONS_KEY = 'submitButtonOptions'; export const REF_KEY = '$ref'; +/** The path of the discriminator value returned by the schema endpoint. + * The discriminator is the value in a `oneOf` that determines which option is selected. + */ +export const DISCRIMINATOR_PATH = ['discriminator', 'propertyName']; +/** The name of the `formContext` attribute in the React JSON Schema Form Registry + */ +export const FORM_CONTEXT_NAME = 'formContext'; + +/** The name of the `layoutGridLookupMap` attribute in the form context + */ +export const LOOKUP_MAP_NAME = 'layoutGridLookupMap'; /** * @deprecated Replace with correctly spelled constant `RJSF_ADDITIONAL_PROPERTIES_FLAG` */ diff --git a/packages/utils/src/createSchemaUtils.ts b/packages/utils/src/createSchemaUtils.ts index 153dd9a000..efbb3d15c1 100644 --- a/packages/utils/src/createSchemaUtils.ts +++ b/packages/utils/src/createSchemaUtils.ts @@ -4,6 +4,7 @@ import { Experimental_CustomMergeAllOf, Experimental_DefaultFormStateBehavior, FormContextType, + FoundFieldType, GlobalUISchemaOptions, IdSchema, PathSchema, @@ -15,10 +16,13 @@ import { ValidatorType, } from './types'; import { + findFieldInSchema, + findSelectedOptionInXxxOf, getDefaultFormState, getDisplayLabel, getClosestMatchingOption, getFirstMatchingOption, + getFromSchema, getMatchingOption, isFilesArray, isMultiSelect, @@ -98,6 +102,49 @@ class SchemaUtils { + return findFieldInSchema( + this.validator, + this.rootSchema, + schema, + path, + formData, + this.experimental_customMergeAllOf + ); + } + + /** Finds the oneOf option inside the `schema['any/oneOf']` list which has the `properties[selectorField].default` that + * matches the `formData[selectorField]` value. For the purposes of this function, `selectorField` is either + * `schema.discriminator.propertyName` or `fallbackField`. + * + * @param schema - The schema element in which to search for the selected oneOf option + * @param fallbackField - The field to use as a backup selector field if the schema does not have a required field + * @param xxx - Either `oneOf` or `anyOf`, defines which value is being sought + * @param [formData={}] - The form data that is used to determine which oneOf option + * @returns - The anyOf/oneOf option that matches the selector field in the schema or undefined if nothing is selected + */ + findSelectedOptionInXxxOf(schema: S, fallbackField: string, xxx: 'anyOf' | `oneOf`, formData: T): S | undefined { + return findSelectedOptionInXxxOf( + this.validator, + this.rootSchema, + schema, + fallbackField, + xxx, + formData, + this.experimental_customMergeAllOf + ); + } + /** Returns the superset of `formData` that includes the given set updated to include any missing fields that have * computed to have defaults provided in the `schema`. * @@ -200,6 +247,28 @@ class SchemaUtils(this.validator, formData, options, this.rootSchema, discriminatorField); } + /** Helper that acts like lodash's `get` but additionally retrieves `$ref`s as needed to get the path for schemas + * containing potentially nested `$ref`s. + * + * @param schema - The current node within the JSON schema recursion + * @param path - The remaining keys in the path to the desired property + * @param defaultValue - The value to return if a value is not found for the `pathList` path + * @returns - The internal schema from the `schema` for the given `path` or the `defaultValue` if not found + */ + getFromSchema(schema: S, path: string | string[], defaultValue: T): T; + getFromSchema(schema: S, path: string | string[], defaultValue: S): S; + getFromSchema(schema: S, path: string | string[], defaultValue: T | S): T | S { + return getFromSchema( + this.validator, + this.rootSchema, + schema, + path, + // @ts-expect-error TS2769: No overload matches this call + defaultValue, + this.experimental_customMergeAllOf + ); + } + /** Checks to see if the `schema` and `uiSchema` combination represents an array of files * * @param schema - The schema for which check for array of files flag is desired diff --git a/packages/utils/src/getTestIds.ts b/packages/utils/src/getTestIds.ts new file mode 100644 index 0000000000..ecebeb3491 --- /dev/null +++ b/packages/utils/src/getTestIds.ts @@ -0,0 +1,37 @@ +import { nanoid } from 'nanoid'; + +import { TestIdShape } from './types'; + +/** Returns an object of test IDs that can only be used in test mode. If the function is called in a test environment + * (`NODE_ENV === 'test'`, this is set by jest) then a Proxy object will be returned. If a key within the returned + * object is accessed, if the value already exists the object will return that value, otherwise it will create that key + * with a generated `uuid` value and return the generated ID. If it is called outside of a test environment, the + * function will return an empty object, therefore returning `undefined` for any property within the object and + * excluding the prop from the rendered output of the component in which it is used. + * This implementation was adapted from the following blog post: https://www.matthewsessions.com/blog/react-test-id/ + * To use this helper, you will want to generate a separate object for each component to avoid potential overlapping of + * ID names. You will also want to export the object for use in tests, because the keys will be generated in the + * component file, and used in the test file. Within the component file, add: + * `export const TEST_IDS = getTestIds();` + * Then pass `TEST_IDS.examplePropertyName` as the value of the test ID attribute of the intended component. This will + * allow you to use `TEST_IDS.examplePropertyName` within your tests, while keeping the test IDs out of your rendered + * output. + */ +export default function getTestIds(): TestIdShape { + if (process.env.NODE_ENV !== 'test') { + return {}; + } + + const ids = new Map(); + return new Proxy( + {}, + { + get(_obj, prop) { + if (!ids.has(prop)) { + ids.set(prop, nanoid()); + } + return ids.get(prop); + }, + } + ); +} diff --git a/packages/utils/src/hashForSchema.ts b/packages/utils/src/hashForSchema.ts index 2e1b141cab..4ae93ef959 100644 --- a/packages/utils/src/hashForSchema.ts +++ b/packages/utils/src/hashForSchema.ts @@ -1,13 +1,14 @@ import { RJSFSchema, StrictRJSFSchema } from './types'; -/** JS has no built-in hashing function, so rolling our own +/** Hashes a string using the algorithm based on Java's hashing function. + * JS has no built-in hashing function, so rolling our own * based on Java's hashing fn: * http://www.java2s.com/example/nodejs-utility-method/string-hash/hashcode-4dc2b.html * * @param string - The string for which to get the hash * @returns - The resulting hash of the string in hex format */ -function hashString(string: string): string { +export function hashString(string: string): string { let hash = 0; for (let i = 0; i < string.length; i += 1) { const chr = string.charCodeAt(i); @@ -17,6 +18,19 @@ function hashString(string: string): string { return hash.toString(16); } +/** Stringifies an `object` and returns the hash of the resulting string. Sorts object fields + * in consistent order before stringify to prevent different hash ids for the same object. + * + * @param object - The object for which the hash is desired + * @returns - The string obtained from the hash of the stringified object + */ +export function hashObject(object: unknown): string { + const allKeys = new Set(); + // solution source: https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/53593328#53593328 + JSON.stringify(object, (key, value) => (allKeys.add(key), value)); + return hashString(JSON.stringify(object, Array.from(allKeys).sort())); +} + /** Stringifies the schema and returns the hash of the resulting string. Sorts schema fields * in consistent order before stringify to prevent different hash ids for the same schema. * @@ -24,8 +38,5 @@ function hashString(string: string): string { * @returns - The string obtained from the hash of the stringified schema */ export default function hashForSchema(schema: S) { - const allKeys = new Set(); - // solution source: https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/53593328#53593328 - JSON.stringify(schema, (key, value) => (allKeys.add(key), value)); - return hashString(JSON.stringify(schema, Array.from(allKeys).sort())); + return hashObject(schema); } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 66ec2f9100..9a13b829ae 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -20,10 +20,11 @@ import getInputProps from './getInputProps'; import getSchemaType from './getSchemaType'; import getSubmitButtonOptions from './getSubmitButtonOptions'; import getTemplate from './getTemplate'; +import getTestIds from './getTestIds'; import getUiOptions from './getUiOptions'; import getWidget from './getWidget'; import guessType from './guessType'; -import hashForSchema from './hashForSchema'; +import hashForSchema, { hashObject, hashString } from './hashForSchema'; import hasWidget from './hasWidget'; import { ariaDescribedByIds, @@ -41,6 +42,7 @@ import isFixedItems from './isFixedItems'; import isObject from './isObject'; import labelValue from './labelValue'; import localToUTC from './localToUTC'; +import lookupFromFormContext from './lookupFromFormContext'; import mergeDefaultsWithFormData from './mergeDefaultsWithFormData'; import mergeObjects from './mergeObjects'; import mergeSchemas from './mergeSchemas'; @@ -101,11 +103,14 @@ export { getSchemaType, getSubmitButtonOptions, getTemplate, + getTestIds, getUiOptions, getWidget, guessType, hasWidget, hashForSchema, + hashObject, + hashString, helpId, isConstant, isCustomWidget, @@ -113,6 +118,7 @@ export { isObject, labelValue, localToUTC, + lookupFromFormContext, mergeDefaultsWithFormData, mergeObjects, mergeSchemas, diff --git a/packages/utils/src/lookupFromFormContext.ts b/packages/utils/src/lookupFromFormContext.ts new file mode 100644 index 0000000000..48c26e757b --- /dev/null +++ b/packages/utils/src/lookupFromFormContext.ts @@ -0,0 +1,26 @@ +import get from 'lodash/get'; +import has from 'lodash/has'; + +import { FORM_CONTEXT_NAME, LOOKUP_MAP_NAME } from './constants'; +import { FormContextType, RJSFSchema, Registry, StrictRJSFSchema } from './types'; + +/** Given a React JSON Schema Form registry or formContext object, return the value associated with `toLookup`. This + * might be contained within the lookup map in the formContext. If no such value exists, return the `fallback` + * value. + * + * @param regOrFc - The @rjsf registry or form context in which the lookup will occur + * @param toLookup - The name of the field in the lookup map in the form context to get the value for + * @param [fallback] - The fallback value to use if the form context does not contain a value for `toLookup` + * @returns - The value associated with `toLookup` in the form context or `fallback` + */ +export default function lookupFromFormContext< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(regOrFc: Registry | Registry['formContext'], toLookup: string, fallback?: unknown) { + const lookupPath = [LOOKUP_MAP_NAME]; + if (has(regOrFc, FORM_CONTEXT_NAME)) { + lookupPath.unshift(FORM_CONTEXT_NAME); + } + return get(regOrFc, [...lookupPath, toLookup], fallback); +} diff --git a/packages/utils/src/schema/findFieldInSchema.ts b/packages/utils/src/schema/findFieldInSchema.ts new file mode 100644 index 0000000000..ab2a46fad3 --- /dev/null +++ b/packages/utils/src/schema/findFieldInSchema.ts @@ -0,0 +1,138 @@ +import get from 'lodash/get'; +import has from 'lodash/has'; + +import findSelectedOptionInXxxOf from './findSelectedOptionInXxxOf'; +import getFromSchema from './getFromSchema'; +import { ANY_OF_KEY, ONE_OF_KEY, PROPERTIES_KEY, REQUIRED_KEY } from '../constants'; +import { + Experimental_CustomMergeAllOf, + FormContextType, + FoundFieldType, + RJSFSchema, + StrictRJSFSchema, + ValidatorType, +} from '../types'; + +/** Unique schema that represents no schema was found, exported for testing purposes */ +export const NOT_FOUND_SCHEMA = { title: '!@#$_UNKNOWN_$#@!' }; + +/** Finds the field specified by the `path` within the root or recursed `schema`. If there is no field for the specified + * `path`, then the default `{ field: undefined, isRequired: undefined }` is returned. It determines whether a leaf + * field is in the `required` list for its parent and if so, it is marked as required on return. + * + * @param validator - An implementation of the `ValidatorType` interface that will be forwarded to all the APIs + * @param rootSchema - The root schema that will be forwarded to all the APIs + // * @param schema - The node within the JSON schema in which to search + * @param path - The keys in the path to the desired field + * @param [formData={}] - The form data that is used to determine which anyOf/oneOf option to descend + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas + * @returns - An object that contains the field and its required state. If no field can be found then + * `{ field: undefined, isRequired: undefined }` is returned. + */ +export default function findFieldInSchema< + T = undefined, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + rootSchema: S, + schema: S, + path: string | string[], + formData: T = {} as T, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf +): FoundFieldType { + const pathList = Array.isArray(path) ? [...path] : path.split('.'); + let parentField = schema; + + // store the desired field into a variable and removing it from the `pathList` + const fieldName = pathList.pop()!; + + if (pathList.length) { + // drilling into the schema for each sub-path and taking into account of the any/oneOfs + pathList.forEach((subPath) => { + parentField = getFromSchema( + validator, + rootSchema, + parentField, + [PROPERTIES_KEY, subPath], + {} as S, + experimental_customMergeAllOf + ); + if (has(parentField, ONE_OF_KEY)) { + // if this sub-path has a `oneOf` then use the formData to drill into the schema with the selected option + parentField = findSelectedOptionInXxxOf( + validator, + rootSchema, + parentField, + fieldName, + ONE_OF_KEY, + get(formData, subPath), + experimental_customMergeAllOf + )!; + } else if (has(parentField, ANY_OF_KEY)) { + // if this sub-path has a `anyOf` then use the formData to drill into the schema with the selected option + parentField = findSelectedOptionInXxxOf( + validator, + rootSchema, + parentField, + fieldName, + ANY_OF_KEY, + get(formData, subPath), + experimental_customMergeAllOf + )!; + } + }); + } + + if (has(parentField, ONE_OF_KEY)) { + // When oneOf is in the root schema, use the formData to drill into the schema with the selected option + parentField = findSelectedOptionInXxxOf( + validator, + rootSchema, + parentField, + fieldName, + ONE_OF_KEY, + formData, + experimental_customMergeAllOf + )!; + } else if (has(parentField, ANY_OF_KEY)) { + // When anyOf is in the root schema, use the formData to drill into the schema with the selected option + parentField = findSelectedOptionInXxxOf( + validator, + rootSchema, + parentField, + fieldName, + ANY_OF_KEY, + formData, + experimental_customMergeAllOf + )!; + } + + // taking the most updated `parentField`, get our desired field + let field: S | undefined = getFromSchema( + validator, + rootSchema, + parentField, + [PROPERTIES_KEY, fieldName], + NOT_FOUND_SCHEMA as S, + experimental_customMergeAllOf + ); + if (field === NOT_FOUND_SCHEMA) { + field = undefined; + } + // check to see if our desired field is in the `required` list for its parent + const requiredArray = getFromSchema( + validator, + rootSchema, + parentField, + REQUIRED_KEY, + [] as T, + experimental_customMergeAllOf + ); + let isRequired: boolean | undefined; + if (field && Array.isArray(requiredArray)) { + isRequired = requiredArray.includes(fieldName); + } + + return { field, isRequired }; +} diff --git a/packages/utils/src/schema/findSelectedOptionInXxxOf.ts b/packages/utils/src/schema/findSelectedOptionInXxxOf.ts new file mode 100644 index 0000000000..5654ac87d3 --- /dev/null +++ b/packages/utils/src/schema/findSelectedOptionInXxxOf.ts @@ -0,0 +1,52 @@ +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; + +import { CONST_KEY, DEFAULT_KEY, DISCRIMINATOR_PATH, PROPERTIES_KEY } from '../constants'; +import { Experimental_CustomMergeAllOf, FormContextType, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types'; +import retrieveSchema from './retrieveSchema'; + +/** Finds the option inside the `schema['any/oneOf']` list which has the `properties[selectorField].default` or + * `properties[selectorField].const` that matches the `formData[selectorField]` value. For the purposes of this + * function, `selectorField` is either `schema.discriminator.propertyName` or `fallbackField`. The `LayoutGridForm` + * works directly with schemas in a recursive manner, making this faster than `getFirstMatchingOption()`. + * + * @param validator - An implementation of the `ValidatorType` interface that will be forwarded to all the APIs + * @param rootSchema - The root schema that will be forwarded to all the APIs + * @param schema - The schema element in which to search for the selected anyOf/oneOf option + * @param fallbackField - The field to use as a backup selector field if the schema does not have a required field + * @param xxx - Either `anyOf` or `oneOf`, defines which value is being sought + * @param [formData={}] - The form data that is used to determine which anyOf/oneOf option to descend + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas + * @returns - The anyOf/oneOf option that matches the selector field in the schema or undefined if nothing is selected + */ +export default function findSelectedOptionInXxxOf< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + rootSchema: S, + schema: S, + fallbackField: string, + xxx: 'anyOf' | 'oneOf', + formData: T = {} as T, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf +): S | undefined { + if (Array.isArray(schema[xxx])) { + const discriminator = get(schema, DISCRIMINATOR_PATH); + const selectorField = discriminator || fallbackField; + const xxxOfs = schema[xxx]!.map((xxxOf) => + retrieveSchema(validator, xxxOf as S, rootSchema, formData, experimental_customMergeAllOf) + ); + const data = get(formData, selectorField); + if (data !== undefined) { + return xxxOfs.find((xxx) => { + return isEqual( + get(xxx, [PROPERTIES_KEY, selectorField, DEFAULT_KEY], get(xxx, [PROPERTIES_KEY, selectorField, CONST_KEY])), + data + ); + }); + } + } + return undefined; +} diff --git a/packages/utils/src/schema/getFromSchema.ts b/packages/utils/src/schema/getFromSchema.ts new file mode 100644 index 0000000000..599dda3d65 --- /dev/null +++ b/packages/utils/src/schema/getFromSchema.ts @@ -0,0 +1,100 @@ +import get from 'lodash/get'; +import has from 'lodash/has'; +import isEmpty from 'lodash/isEmpty'; + +import retrieveSchema from './retrieveSchema'; +import { Experimental_CustomMergeAllOf, FormContextType, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types'; +import { REF_KEY } from '../constants'; + +/** Internal helper function that acts like lodash's `get` but additionally retrieves `$ref`s as needed to get the path + * for schemas containing potentially nested `$ref`s. + * + * @param validator - An implementation of the `ValidatorType` interface that will be forwarded to all the APIs + * @param rootSchema - The root schema that will be forwarded to all the APIs + * @param schema - The current node within the JSON schema recursion + * @param path - The remaining keys in the path to the desired property + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas + * @returns - The internal schema from the `schema` for the given `path` or undefined if not found + */ +function getFromSchemaInternal( + validator: ValidatorType, + rootSchema: S, + schema: S, + path: string | string[], + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf +): T | S | undefined { + let fieldSchema = schema; + if (has(schema, REF_KEY)) { + fieldSchema = retrieveSchema(validator, schema, rootSchema, undefined, experimental_customMergeAllOf); + } + if (isEmpty(path)) { + return fieldSchema; + } + const pathList = Array.isArray(path) ? path : path.split('.'); + const [part, ...nestedPath] = pathList; + if (part && has(fieldSchema, part)) { + fieldSchema = get(fieldSchema, part) as S; + return getFromSchemaInternal( + validator, + rootSchema, + fieldSchema, + nestedPath, + experimental_customMergeAllOf + ); + } + return undefined; +} + +/** Helper that acts like lodash's `get` but additionally retrieves `$ref`s as needed to get the path for schemas + * containing potentially nested `$ref`s. + * + * @param validator - An implementation of the `ValidatorType` interface that will be forwarded to all the APIs + * @param rootSchema - The root schema that will be forwarded to all the APIs + * @param schema - The current node within the JSON schema recursion + * @param path - The keys in the path to the desired field + * @param defaultValue - The value to return if a value is not found for the `pathList` path + * @param [experimental_customMergeAllOf] - Optional function that allows for custom merging of `allOf` schemas + * @returns - The inner schema from the `schema` for the given `path` or the `defaultValue` if not found + */ +export default function getFromSchema< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + rootSchema: S, + schema: S, + path: string | string[], + defaultValue: T, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf +): T; +export default function getFromSchema< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + rootSchema: S, + schema: S, + path: string | string[], + defaultValue: S, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf +): S; +export default function getFromSchema< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + rootSchema: S, + schema: S, + path: string | string[], + defaultValue: T | S, + experimental_customMergeAllOf?: Experimental_CustomMergeAllOf +): T | S { + const result = getFromSchemaInternal(validator, rootSchema, schema, path, experimental_customMergeAllOf); + if (result === undefined) { + return defaultValue; + } + return result; +} diff --git a/packages/utils/src/schema/index.ts b/packages/utils/src/schema/index.ts index 1adbd67712..febb3ecad6 100644 --- a/packages/utils/src/schema/index.ts +++ b/packages/utils/src/schema/index.ts @@ -1,7 +1,10 @@ +import findFieldInSchema from './findFieldInSchema'; +import findSelectedOptionInXxxOf from './findSelectedOptionInXxxOf'; import getDefaultFormState from './getDefaultFormState'; import getDisplayLabel from './getDisplayLabel'; import getClosestMatchingOption from './getClosestMatchingOption'; import getFirstMatchingOption from './getFirstMatchingOption'; +import getFromSchema from './getFromSchema'; import getMatchingOption from './getMatchingOption'; import isFilesArray from './isFilesArray'; import isMultiSelect from './isMultiSelect'; @@ -13,10 +16,13 @@ import toIdSchema from './toIdSchema'; import toPathSchema from './toPathSchema'; export { + findFieldInSchema, + findSelectedOptionInXxxOf, getDefaultFormState, getDisplayLabel, getClosestMatchingOption, getFirstMatchingOption, + getFromSchema, getMatchingOption, isFilesArray, isMultiSelect, diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index cbdf2c38fd..0801c6c6c7 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -31,6 +31,10 @@ export type RJSFSchema = StrictRJSFSchema & GenericObjectType; */ export type FormContextType = GenericObjectType; +/** The interface for the test ID proxy objects that are returned by the `getTestId` utility function. + */ +export type TestIdShape = Record; + /** Experimental feature that specifies the Array `minItems` default form state behavior */ export type Experimental_ArrayMinItems = { @@ -1095,6 +1099,15 @@ export interface ValidatorType void; } +/** The interface for the return value of the `findFieldInSchema` function + */ +export interface FoundFieldType { + /** The field that was found, or undefined if it wasn't */ + field?: S; + /** The requiredness of the field found or undefined if it wasn't */ + isRequired?: boolean; +} + /** The `SchemaUtilsType` interface provides a wrapper around the publicly exported APIs in the `@rjsf/utils/schema` * directory such that one does not have to explicitly pass the `validator` or `rootSchema` to each method. Since both * the `validator` and `rootSchema` generally does not change across a `Form`, this allows for providing a simplified @@ -1122,6 +1135,28 @@ export interface SchemaUtilsType ): boolean; + /** Finds the field specified by the `path` within the root or recursed `schema`. If there is no field for the specified + * `path`, then the default `{ field: undefined, isRequired: undefined }` is returned. It determines whether a leaf + * field is in the `required` list for its parent and if so, it is marked as required on return. + * + * @param schema - The current node within the JSON schema + * @param path - The remaining keys in the path to the desired field + * @param [formData] - The form data that is used to determine which oneOf option + * @returns - An object that contains the field and its required state. If no field can be found then + * `{ field: undefined, isRequired: undefined }` is returned. + */ + findFieldInSchema(schema: S, path: string | string[], formData?: T): FoundFieldType; + /** Finds the oneOf option inside the `schema['any/oneOf']` list which has the `properties[selectorField].default` that + * matches the `formData[selectorField]` value. For the purposes of this function, `selectorField` is either + * `schema.discriminator.propertyName` or `fallbackField`. + * + * @param schema - The schema element in which to search for the selected oneOf option + * @param fallbackField - The field to use as a backup selector field if the schema does not have a required field + * @param xxx - Either `oneOf` or `anyOf`, defines which value is being sought + * @param [formData] - The form data that is used to determine which oneOf option + * @returns - The anyOf/oneOf option that matches the selector field in the schema or undefined if nothing is selected + */ + findSelectedOptionInXxxOf(schema: S, fallbackField: string, xxx: 'anyOf' | `oneOf`, formData?: T): S | undefined; /** Returns the superset of `formData` that includes the given set updated to include any missing fields that have * computed to have defaults provided in the `schema`. * @@ -1186,6 +1221,17 @@ export interface SchemaUtilsType TEST_UUID_BASE + nanoid()); + +describe('getTestIds', () => { + describe('process.env.NODE_ENV === "test"', () => { + let testIds: TestIdShape; + let fooTestId: string; + beforeAll(() => { + testIds = getTestIds(); + }); + afterAll(() => { + nanoidMock.nanoid.mockClear(); + }); + it('does not return an empty object', () => { + // it returns a Proxy object but since there isn't an easy way to test for it + // the following tests will check for the Proxy functionality + expect(testIds).not.toEqual({}); + nanoidMock.nanoid.mockClear(); // resetting the call count since the Proxy calls it during the initialization process + }); + it('returns a generated test id when getting a property value', () => { + fooTestId = testIds.foo; + expect(fooTestId).toEqual(expect.stringContaining(TEST_UUID_BASE)); + }); + it('called uuid once', () => { + expect(nanoidMock.nanoid).toHaveBeenCalledTimes(1); + }); + it('returns the same id when getting the same property value', () => { + expect(testIds.foo).toEqual(fooTestId); + }); + it('did not call uuid again', () => { + expect(nanoidMock.nanoid).toHaveBeenCalledTimes(1); + }); + it('returns a different id when getting a different property value', () => { + expect(testIds.bar).not.toEqual(fooTestId); + }); + it('called uuid again', () => { + expect(nanoidMock.nanoid).toHaveBeenCalledTimes(2); + }); + }); + describe('process.env.NODE_ENV !== "test"', () => { + let oldNodeEnv: string | undefined; + let testIds: TestIdShape; + beforeAll(() => { + oldNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'other'; + testIds = getTestIds(); + }); + afterAll(() => { + process.env.NODE_ENV = oldNodeEnv; + }); + it('returns an empty object', () => { + expect(testIds).toEqual({}); + }); + it('returns undefined when trying to access a property of the object', () => { + expect(testIds.foo).toBeUndefined(); + }); + it('did not call uuid', () => { + expect(nanoidMock.nanoid).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/utils/test/hashFromSchema.test.ts b/packages/utils/test/hashFromSchema.test.ts index 518473afda..ed1a51dd74 100644 --- a/packages/utils/test/hashFromSchema.test.ts +++ b/packages/utils/test/hashFromSchema.test.ts @@ -1,4 +1,4 @@ -import { hashForSchema, RJSFSchema } from '../src'; +import { hashForSchema, hashObject, hashString, RJSFSchema } from '../src'; import { RECURSIVE_REF } from './testUtils/testData'; const TINY_SCHEMA: RJSFSchema = { @@ -19,3 +19,63 @@ describe('hashForSchema', () => { expect(hashForSchema(schema1)).toBe(hashForSchema(schema2)); }); }); + +const TEST_OBJECT_1 = { foo: 'bar', yes: 'no', bool: false, nested: { one: '1', two: 2, three: 3.0 } }; +const TEST_OBJECT_2 = { bool: false, foo: 'bar', nested: { one: '1', three: 3.0, two: 2 }, yes: 'no' }; +const EMPTY_OBJECT_HASH = hashString('{}'); +const NON_PLAIN_OBJECT_HASH = hashString(JSON.stringify([null])); +const TEST_ARRAY_1 = ['foo', 'bar']; +const TEST_ARRAY_2 = ['foo', TEST_OBJECT_2]; +const TEST_ARRAY_3 = ['foo', TEST_ARRAY_2]; +const TEST_ARRAY_4 = [ + (name: string) => { + return { name }; + }, +]; + +describe('hashObject', () => { + it('returns hash of zero for an empty object', () => { + expect(hashObject({})).toEqual(EMPTY_OBJECT_HASH); + }); + it('returns the same hash for two objects with differently ordered keys', () => { + const hash1 = hashObject(TEST_OBJECT_1); + const hash2 = hashObject(TEST_OBJECT_2); + expect(hash1).toEqual(hash2); + }); + it('return the correct hash for non-plain objects', () => { + const hash3 = hashObject(TEST_ARRAY_4); + expect(hash3).toEqual(NON_PLAIN_OBJECT_HASH); + }); + describe('handles arrays', () => { + let expected1: string; + let expected2: string; + let expected3: string; + beforeAll(() => { + expected1 = hashString(JSON.stringify(TEST_ARRAY_1)); + expected2 = hashString(JSON.stringify([TEST_ARRAY_2[0], TEST_OBJECT_2])); + expected3 = hashString(JSON.stringify([TEST_ARRAY_3[0], [TEST_ARRAY_2[0], TEST_OBJECT_2]])); + }); + it('returns the hash of the json stringified array', () => { + expect(hashObject(TEST_ARRAY_1)).toEqual(expected1); + }); + it('hashes object within an array', () => { + expect(hashObject(TEST_ARRAY_2)).toEqual(expected2); + }); + it('hashes array within an array', () => { + expect(hashObject(TEST_ARRAY_3)).toEqual(expected3); + }); + }); +}); + +describe('hashString', () => { + it('should return 0 on empty string', () => { + expect(hashString('')).toEqual('0'); + }); + + it('should hash some strings', () => { + expect(hashString('1')).toEqual('31'); + expect(hashString('a')).toEqual('61'); + expect(hashString('hello good sir')).toEqual('-21f94979'); + expect(hashString('Benign Polyps')).toEqual('292648aa'); + }); +}); diff --git a/packages/utils/test/lookupFromFormContext.test.ts b/packages/utils/test/lookupFromFormContext.test.ts new file mode 100644 index 0000000000..d4caad81ed --- /dev/null +++ b/packages/utils/test/lookupFromFormContext.test.ts @@ -0,0 +1,35 @@ +import { FORM_CONTEXT_NAME, LOOKUP_MAP_NAME } from '../src/constants'; +import lookupFromFormContext from '../src/lookupFromFormContext'; +import { Registry } from '../src/types'; + +const PROP = 'exists'; + +const FORM_CONTEXT = { + [LOOKUP_MAP_NAME]: { + [PROP]: true, + }, +}; + +const EMPTY_REGISTRY = { + [FORM_CONTEXT_NAME]: {}, +}; + +/** Cast this as necessary for the tests */ +const REGISTRY = { + [FORM_CONTEXT_NAME]: FORM_CONTEXT, +} as unknown as Registry; + +describe('lookupFromFormContext', () => { + it('returns undefined when regOrFc is empty', () => { + expect(lookupFromFormContext({}, 'foo')).toBeUndefined(); + }); + it('returns undefined when registry.formContext is empty', () => { + expect(lookupFromFormContext(EMPTY_REGISTRY, 'foo')).toBeUndefined(); + }); + it('returns fallback when registry.formContext is empty', () => { + expect(lookupFromFormContext(EMPTY_REGISTRY, 'foo', PROP)).toBe(PROP); + }); + it('returns value when registry.formContext is contains field', () => { + expect(lookupFromFormContext(REGISTRY, PROP, 'foo')).toBe(FORM_CONTEXT[LOOKUP_MAP_NAME][PROP]); + }); +}); diff --git a/packages/utils/test/schema.test.ts b/packages/utils/test/schema.test.ts index cb2b33f55a..395ec74e3f 100644 --- a/packages/utils/test/schema.test.ts +++ b/packages/utils/test/schema.test.ts @@ -1,9 +1,12 @@ import getTestValidator from './testUtils/getTestValidator'; import { + findFieldInSchemaTest, + findSelectedOptionInXxxOfTest, getDefaultFormStateTest, getDisplayLabelTest, getClosestMatchingOptionTest, getFirstMatchingOptionTest, + getFromSchemaTest, isFilesArrayTest, isMultiSelectTest, isSelectTest, @@ -17,10 +20,13 @@ import { const testValidator = getTestValidator({}); // NOTE: to restrict which tests to run, you can temporarily comment out any tests you aren't needing +findFieldInSchemaTest(testValidator); +findSelectedOptionInXxxOfTest(testValidator); getDefaultFormStateTest(testValidator); getDisplayLabelTest(testValidator); getClosestMatchingOptionTest(testValidator); getFirstMatchingOptionTest(testValidator); +getFromSchemaTest(testValidator); isFilesArrayTest(testValidator); isMultiSelectTest(testValidator); isSelectTest(testValidator); diff --git a/packages/utils/test/schema/findFieldInSchemaTest.ts b/packages/utils/test/schema/findFieldInSchemaTest.ts new file mode 100644 index 0000000000..297f9c3cb1 --- /dev/null +++ b/packages/utils/test/schema/findFieldInSchemaTest.ts @@ -0,0 +1,119 @@ +import get from 'lodash/get'; + +import { createSchemaUtils, PROPERTIES_KEY, RJSFSchema } from '../../src'; +import { TestValidatorType } from './types'; +import { ANSWER_1, CHOICES, testAnyOfSchema, testOneOfSchema } from '../testUtils/testData'; + +const simpleSchema: RJSFSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + age: { + type: 'number', + }, + }, + required: ['name'], +}; + +const nestedSimpleSchema: RJSFSchema = { + type: 'object', + properties: { + nested: simpleSchema, + }, +}; + +const NOT_FOUND = { field: undefined, isRequired: undefined }; + +const nestedOneOf: RJSFSchema = { + type: 'object', + properties: { + nested: testOneOfSchema, + }, +}; + +const nestedAnyOf: RJSFSchema = { + type: 'object', + properties: { + nested: testAnyOfSchema, + }, +}; + +export default function findFieldInSchemaTest(testValidator: TestValidatorType) { + // Root schema is not needed for these tests + const schemaUtils = createSchemaUtils(testValidator, {}); + const expectedAnswerField = get(CHOICES[0], [PROPERTIES_KEY, 'answer']); + + describe('findFieldInSchema', () => { + it('returns NOT_FOUND when path is empty', () => { + expect(schemaUtils.findFieldInSchema({}, [])).toEqual(NOT_FOUND); + }); + it('returns NOT_FOUND when schema does not have properties', () => { + expect(schemaUtils.findFieldInSchema({}, 'foo')).toEqual(NOT_FOUND); + }); + it('return NOT_FOUND when field does not exist in the schema', () => { + expect(schemaUtils.findFieldInSchema(simpleSchema, 'foo')).toEqual(NOT_FOUND); + }); + it('returns field as required', () => { + const path = ['name']; + const expectedField = get(simpleSchema, [PROPERTIES_KEY, path[0]]); + expect(schemaUtils.findFieldInSchema(simpleSchema, path)).toEqual({ + field: expectedField, + isRequired: true, + }); + }); + it('returns field as not required', () => { + const path = ['age']; + const expectedField = get(simpleSchema, [PROPERTIES_KEY, path[0]]); + expect(schemaUtils.findFieldInSchema(simpleSchema, path)).toEqual({ + field: expectedField, + isRequired: false, + }); + }); + it('returns nested field as required', () => { + const path = ['nested', 'name']; + const expectedField = get(simpleSchema, [PROPERTIES_KEY, path[1]]); + expect(schemaUtils.findFieldInSchema(nestedSimpleSchema, path)).toEqual({ + field: expectedField, + isRequired: true, + }); + }); + it('returns nested field as not required', () => { + const path = ['nested', 'age']; + const expectedField = get(simpleSchema, [PROPERTIES_KEY, path[1]]); + expect(schemaUtils.findFieldInSchema(nestedSimpleSchema, path)).toEqual({ + field: expectedField, + isRequired: false, + }); + }); + it('schema has oneOf field in properties key and isRequired true', () => { + const path = 'answer'; + expect(schemaUtils.findFieldInSchema(testOneOfSchema, path, ANSWER_1)).toEqual({ + field: expectedAnswerField, + isRequired: true, + }); + }); + it('schema has anyOf field in properties key and isRequired false', () => { + const path = 'answer'; + expect(schemaUtils.findFieldInSchema(testAnyOfSchema, path, ANSWER_1)).toEqual({ + field: expectedAnswerField, + isRequired: true, + }); + }); + it('schema has oneOf in nested field in properties key and isRequired true', () => { + const path = 'nested.answer'; + expect(schemaUtils.findFieldInSchema(nestedOneOf, path, { nested: ANSWER_1 })).toEqual({ + field: expectedAnswerField, + isRequired: true, + }); + }); + it('schema has anyOf in nested field in properties key and isRequired false', () => { + const path = 'nested.answer'; + expect(schemaUtils.findFieldInSchema(nestedAnyOf, path, { nested: ANSWER_1 })).toEqual({ + field: expectedAnswerField, + isRequired: true, + }); + }); + }); +} diff --git a/packages/utils/test/schema/findSelectedOptionInXxxOfTest.ts b/packages/utils/test/schema/findSelectedOptionInXxxOfTest.ts new file mode 100644 index 0000000000..e31fc8a949 --- /dev/null +++ b/packages/utils/test/schema/findSelectedOptionInXxxOfTest.ts @@ -0,0 +1,77 @@ +import { ANY_OF_KEY, createSchemaUtils, ONE_OF_KEY, RJSFSchema } from '../../src'; +import { TestValidatorType } from './types'; +import { + ANSWER_1, + ANSWER_2, + CHOICES, + testAnyOfDiscriminatorSchema, + testAnyOfSchema, + testOneOfDiscriminatorSchema, + testOneOfSchema, +} from '../testUtils/testData'; + +export default function findSelectedOptionInXxxOfTest(testValidator: TestValidatorType) { + const schemaUtils = createSchemaUtils(testValidator, {} as RJSFSchema); + describe('findSelectedOptionInXxxOf', () => { + test('returns undefined when schema has no oneOfs', () => { + expect(schemaUtils.findSelectedOptionInXxxOf({}, 'foo', ONE_OF_KEY)).toBeUndefined(); + }); + test('returns undefined when schema has no anyOfs', () => { + expect(schemaUtils.findSelectedOptionInXxxOf({}, 'foo', ANY_OF_KEY)).toBeUndefined(); + }); + test('returns undefined when formData has no oneOf value via the fallbackField', () => { + expect(schemaUtils.findSelectedOptionInXxxOf(testOneOfSchema, 'name', ONE_OF_KEY)).toBeUndefined(); + }); + test('returns undefined when formData has no anyOf value via the fallbackField', () => { + expect(schemaUtils.findSelectedOptionInXxxOf(testAnyOfSchema, 'name', ANY_OF_KEY)).toBeUndefined(); + }); + test('returns undefined when formData has no oneOf value via the discriminator', () => { + expect(schemaUtils.findSelectedOptionInXxxOf(testOneOfDiscriminatorSchema, 'name', ONE_OF_KEY)).toBeUndefined(); + }); + test('returns undefined when formData has no oneOf value via the discriminator', () => { + expect(schemaUtils.findSelectedOptionInXxxOf(testAnyOfDiscriminatorSchema, 'name', ANY_OF_KEY)).toBeUndefined(); + }); + test('returns oneOf when formData has value via the fallbackField', () => { + const expectedResult = CHOICES[0]; + expect(schemaUtils.findSelectedOptionInXxxOf(testOneOfSchema, 'answer', ONE_OF_KEY, ANSWER_1)).toEqual( + expectedResult + ); + }); + test('returns anyOf when formData has value via the fallbackField', () => { + const expectedResult = CHOICES[0]; + expect(schemaUtils.findSelectedOptionInXxxOf(testAnyOfSchema, 'answer', ANY_OF_KEY, ANSWER_1)).toEqual( + expectedResult + ); + }); + test('returns oneOf when formData has value via the discriminator', () => { + expect( + schemaUtils.findSelectedOptionInXxxOf( + testOneOfDiscriminatorSchema, + 'ignored_in_this_test', + ONE_OF_KEY, + ANSWER_2 + ) + ).toEqual(CHOICES[1]); + }); + test('returns anyOf when formData has value via the discriminator', () => { + expect( + schemaUtils.findSelectedOptionInXxxOf( + testAnyOfDiscriminatorSchema, + 'ignored_in_this_test', + ANY_OF_KEY, + ANSWER_2 + ) + ).toEqual(CHOICES[1]); + }); + test('returns undefined when formData has non-existent oneOf value via the discriminator', () => { + expect( + schemaUtils.findSelectedOptionInXxxOf(testOneOfDiscriminatorSchema, 'ignored_in_this_test', ONE_OF_KEY, {}) + ).toBeUndefined(); + }); + test('returns undefined when formData has non-existent anyOf value via the discriminator', () => { + expect( + schemaUtils.findSelectedOptionInXxxOf(testAnyOfDiscriminatorSchema, 'ignored_in_this_test', ANY_OF_KEY, {}) + ).toBeUndefined(); + }); + }); +} diff --git a/packages/utils/test/schema/getFromSchemaTest.ts b/packages/utils/test/schema/getFromSchemaTest.ts new file mode 100644 index 0000000000..a3438594c3 --- /dev/null +++ b/packages/utils/test/schema/getFromSchemaTest.ts @@ -0,0 +1,99 @@ +import get from 'lodash/get'; + +import { DEFINITIONS_KEY, PROPERTIES_KEY, RJSFSchema, createSchemaUtils } from '../../src'; +import { TestValidatorType } from './types'; + +const rawSchema = { + title: 'Test Schema', + type: 'object', + properties: { + patient: { title: 'Patient', $ref: '#/definitions/Patient' }, + }, + definitions: { + Phone: { + type: 'object', + properties: { + number: { + title: 'Phone Number', + type: 'string', + }, + is_cell: { title: 'This is a mobile number', type: 'boolean' }, + }, + required: ['number', 'is_cell'], + }, + PatientTelecom: { + type: 'object', + properties: { + phone: { + title: 'Patient Phone Number', + $ref: '#/definitions/Phone', + }, + email: { + title: 'Email Address', + type: 'string', + format: 'email', + }, + }, + }, + Patient: { + type: 'object', + properties: { + birth_date: { + title: 'Date of Birth', + type: 'string', + format: 'date', + }, + telecom: { + title: 'Telecom', + $ref: '#/definitions/PatientTelecom', + }, + }, + required: ['name', 'birth_date', 'telecom'], + }, + }, + required: ['patient'], +}; +// Adding `& typeof rawSchema` allows us to directly access into the actual object for PATIENT_SCHEMA +const testSchema = rawSchema as RJSFSchema & typeof rawSchema; + +const SCHEMA_DEFINITIONS = rawSchema[DEFINITIONS_KEY]; + +const PATIENT_SCHEMA = SCHEMA_DEFINITIONS.Patient as RJSFSchema; + +export default function getFromSchemaTest(testValidator: TestValidatorType) { + const schemaUtils = createSchemaUtils(testValidator, testSchema); + describe('getFromSchema', () => { + it('performs a simple `get` for a path without $ref values', () => { + const fieldPath = [PROPERTIES_KEY, 'birth_date']; + const field = schemaUtils.getFromSchema(PATIENT_SCHEMA, fieldPath, undefined); + expect(field).toEqual(get(PATIENT_SCHEMA, fieldPath)); + }); + it('returns the expected field with the $refs retrieved for a path with a $ref', () => { + const field = schemaUtils.getFromSchema(testSchema, [PROPERTIES_KEY, 'patient'], undefined); + expect(field).toEqual({ + title: testSchema[PROPERTIES_KEY].patient.title, + ...schemaUtils.retrieveSchema(get(testSchema, [PROPERTIES_KEY, 'patient'])), + }); + }); + it('returns the expected field with the $refs retrieved for a path with nested $refs, string path', () => { + const field = schemaUtils.getFromSchema( + testSchema, + [PROPERTIES_KEY, 'patient', PROPERTIES_KEY, 'telecom', PROPERTIES_KEY, 'phone', PROPERTIES_KEY, 'number'].join( + '.' + ), + undefined + ); + expect(field).toEqual(SCHEMA_DEFINITIONS.Phone[PROPERTIES_KEY].number); + }); + it('returns the default data value when passed an invalid path', () => { + const defaultValue = 'DEFAULT'; + const field = schemaUtils.getFromSchema(testSchema, [PROPERTIES_KEY, 'patient', 'telecom'], defaultValue); + expect(field).toEqual(defaultValue); + }); + it('returns the default schema value when passed an invalid path', () => { + const defaultValue = { title: 'nothing' } as RJSFSchema; + const field = schemaUtils.getFromSchema(testSchema, [PROPERTIES_KEY, 'patient', 'telecom'], defaultValue); + expect(field).toEqual(defaultValue); + }); + }); +} diff --git a/packages/utils/test/schema/index.ts b/packages/utils/test/schema/index.ts index e2997b6abd..b2551c20fa 100644 --- a/packages/utils/test/schema/index.ts +++ b/packages/utils/test/schema/index.ts @@ -1,7 +1,10 @@ +import findFieldInSchemaTest from './findFieldInSchemaTest'; +import findSelectedOptionInXxxOfTest from './findSelectedOptionInXxxOfTest'; import getDefaultFormStateTest from './getDefaultFormStateTest'; import getDisplayLabelTest from './getDisplayLabelTest'; import getClosestMatchingOptionTest from './getClosestMatchingOptionTest'; import getFirstMatchingOptionTest from './getFirstMatchingOptionTest'; +import getFromSchemaTest from './getFromSchemaTest'; import isFilesArrayTest from './isFilesArrayTest'; import isMultiSelectTest from './isMultiSelectTest'; import isSelectTest from './isSelectTest'; @@ -14,10 +17,13 @@ import toPathSchemaTest from './toPathSchemaTest'; export * from './types'; export { + findFieldInSchemaTest, + findSelectedOptionInXxxOfTest, getDefaultFormStateTest, getDisplayLabelTest, getClosestMatchingOptionTest, getFirstMatchingOptionTest, + getFromSchemaTest, isFilesArrayTest, isMultiSelectTest, isSelectTest, diff --git a/packages/utils/test/testUtils/testData.ts b/packages/utils/test/testUtils/testData.ts index 9249a08e4f..d6b71c59f0 100644 --- a/packages/utils/test/testUtils/testData.ts +++ b/packages/utils/test/testUtils/testData.ts @@ -2,11 +2,12 @@ import reduce from 'lodash/reduce'; import deepFreeze from 'deep-freeze-es6'; import { + ANY_OF_KEY, EnumOptionsType, ErrorSchema, ErrorSchemaBuilder, - ONE_OF_KEY, ID_KEY, + ONE_OF_KEY, RJSFSchema, RJSFValidationError, } from '../../src'; @@ -898,3 +899,59 @@ export const SCHEMA_WITH_ALLOF_CANNOT_MERGE: RJSFSchema = deepFreeze }, ], }); + +export const CHOICES: RJSFSchema[] = [ + { + title: 'Choice 1', + type: 'object', + properties: { + answer: { + type: 'string', + default: '1', + readOnly: true, + }, + }, + required: ['answer'], + }, + { + title: 'Choice 2', + type: 'object', + properties: { + answer: { + type: 'string', + const: '2', + }, + }, + }, +]; + +export const testOneOfSchema: RJSFSchema = { + title: 'Simple OneOf', + type: 'object', + [ONE_OF_KEY]: CHOICES, + required: ['answer'], +}; + +export const testOneOfDiscriminatorSchema: RJSFSchema = { + ...testOneOfSchema, + discriminator: { + propertyName: 'answer', + }, +}; + +export const testAnyOfSchema: RJSFSchema = { + title: 'Simple AnyOf', + type: 'object', + [ANY_OF_KEY]: CHOICES, + required: [], +}; + +export const testAnyOfDiscriminatorSchema: RJSFSchema = { + ...testAnyOfSchema, + discriminator: { + propertyName: 'answer', + }, +}; + +export const ANSWER_1 = { answer: '1' }; +export const ANSWER_2 = { answer: '2' }; diff --git a/packages/validator-ajv8/jest.config.js b/packages/validator-ajv8/jest.config.js index ba654509b0..77b256a3a1 100644 --- a/packages/validator-ajv8/jest.config.js +++ b/packages/validator-ajv8/jest.config.js @@ -5,6 +5,7 @@ module.exports = { browsers: ['chrome', 'firefox', 'safari'], }, testMatch: ['**/test/**/*.test.ts?(x)'], + transformIgnorePatterns: [`/node_modules/(?!nanoid)`], coverageDirectory: '/coverage/', collectCoverage: true, coveragePathIgnorePatterns: ['/node_modules/', '/test'], diff --git a/packages/validator-ajv8/test/utilsTests/schema.test.ts b/packages/validator-ajv8/test/utilsTests/schema.test.ts index 893105141e..5a2e8fc840 100644 --- a/packages/validator-ajv8/test/utilsTests/schema.test.ts +++ b/packages/validator-ajv8/test/utilsTests/schema.test.ts @@ -2,10 +2,13 @@ import Ajv2019 from 'ajv/dist/2019'; import Ajv2020 from 'ajv/dist/2020'; import { + findFieldInSchemaTest, + findSelectedOptionInXxxOfTest, getDefaultFormStateTest, getDisplayLabelTest, getClosestMatchingOptionTest, getFirstMatchingOptionTest, + getFromSchemaTest, isFilesArrayTest, isMultiSelectTest, isSelectTest, @@ -20,10 +23,13 @@ import getTestValidator from './getTestValidator'; const testValidator = getTestValidator({}); // NOTE: to restrict which tests to run, you can temporarily comment out any tests you aren't needing +findFieldInSchemaTest(testValidator); +findSelectedOptionInXxxOfTest(testValidator); getDefaultFormStateTest(testValidator); getDisplayLabelTest(testValidator); getClosestMatchingOptionTest(testValidator); getFirstMatchingOptionTest(testValidator); +getFromSchemaTest(testValidator); isFilesArrayTest(testValidator); isMultiSelectTest(testValidator); isSelectTest(testValidator); @@ -36,10 +42,13 @@ toPathSchemaTest(testValidator); const testValidatorDiscriminated = getTestValidator({ ajvOptionsOverrides: { discriminator: true } }); // NOTE: to restrict which tests to run, you can temporarily comment out any tests you aren't needing +findFieldInSchemaTest(testValidatorDiscriminated); +findSelectedOptionInXxxOfTest(testValidatorDiscriminated); getDefaultFormStateTest(testValidatorDiscriminated); getDisplayLabelTest(testValidatorDiscriminated); getClosestMatchingOptionTest(testValidatorDiscriminated); getFirstMatchingOptionTest(testValidatorDiscriminated); +getFromSchemaTest(testValidatorDiscriminated); isFilesArrayTest(testValidatorDiscriminated); isMultiSelectTest(testValidatorDiscriminated); isSelectTest(testValidatorDiscriminated); @@ -52,10 +61,13 @@ toPathSchemaTest(testValidatorDiscriminated); const testValidator2019 = getTestValidator({ AjvClass: Ajv2019 }); // NOTE: to restrict which tests to run, you can temporarily comment out any tests you aren't needing +findFieldInSchemaTest(testValidator2019); +findSelectedOptionInXxxOfTest(testValidator2019); getDefaultFormStateTest(testValidator2019); getDisplayLabelTest(testValidator2019); getClosestMatchingOptionTest(testValidator2019); getFirstMatchingOptionTest(testValidator2019); +getFromSchemaTest(testValidator2019); isFilesArrayTest(testValidator2019); isMultiSelectTest(testValidator2019); isSelectTest(testValidator2019); @@ -68,10 +80,13 @@ toPathSchemaTest(testValidator2019); const testValidator2020 = getTestValidator({ AjvClass: Ajv2020 }); // NOTE: to restrict which tests to run, you can temporarily comment out any tests you aren't needing +findFieldInSchemaTest(testValidator2020); +findSelectedOptionInXxxOfTest(testValidator2020); getDefaultFormStateTest(testValidator2020); getDisplayLabelTest(testValidator2020); getClosestMatchingOptionTest(testValidator2020); getFirstMatchingOptionTest(testValidator2020); +getFromSchemaTest(testValidator2020); isFilesArrayTest(testValidator2020); isMultiSelectTest(testValidator2020); isSelectTest(testValidator2020);