import type { UiSchema } from '@rjsf/utils' /** Simplified JSON schema for improved suggestions */ import { JSONSchema7, JSONSchema7Type, JSONSchema7TypeName } from 'json-schema' import { toCamelCase, toKebabCase } from './utils.js' import { BYOCDataTypes } from './datatypes.js' export type JSONSchema7GenericKeys = Exclude< keyof JSONSchema7, // Type-specific properties | 'items' | 'additionalItems' | 'properties' | 'additionalProperties' | 'patternProperties' | 'dependencies' | 'required' | 'minProperties' | 'maxProperties' | 'minItems' | 'maxItems' | 'uniqueItems' | 'minLength' | 'maxLength' | 'pattern' | 'format' | 'minimum' | 'maximum' | 'multipleOf' | 'exclusiveMinimum' | 'exclusiveMaximum' | 'propertyNames' | 'contains' // Rarely used or more obscure properties | '$schema' | 'definitions' | '$defs' | '$comment' | 'const' | 'examples' | 'readOnly' | 'writeOnly' | 'contentMediaType' | 'contentEncoding' | 'if' | 'then' | 'else' | 'allOf' | 'anyOf' | 'oneOf' | 'not' // Advanced features | '$ref' | '$id' > /** Schema for common properties. */ export interface BaseSchema extends Pick { /** The type of data. */ type: T [key: string]: any } /** Schema for string type properties. */ export interface StringSchema extends BaseSchema<'string'> { /** Minimum length of the string. */ minLength?: number /** Maximum length of the string. */ maxLength?: number /** A regex pattern the string should match. */ pattern?: string /** Named format the string should adhere to. */ format?: string } /** Schema for number type properties. */ export interface NumberSchema extends BaseSchema<'number' | 'integer'> { /** Minimum value of the number. */ minimum?: number /** Maximum value of the number. */ maximum?: number /** The number should be a multiple of this value. */ multipleOf?: number } /** Schema for object type properties. */ export interface ObjectSchema extends BaseSchema<'object'> { /** Properties of the object. */ properties?: Record /** Additional properties not covered by 'properties'. */ additionalProperties?: JSONSchema | boolean /** Array of required properties. */ required?: string[] /** Minimum number of properties. */ minProperties?: number /** Maximum number of properties. */ maxProperties?: number /** Properties matching the patterns. */ patternProperties?: Record /** Property dependencies. */ dependencies?: Record } /** Schema for array type properties. */ export interface ArraySchema extends BaseSchema<'array'> { /** Items in the array. */ items?: JSONSchema | JSONSchema[] /** Additional items not covered by 'items'. */ additionalItems?: JSONSchema | boolean /** Minimum number of items. */ minItems?: number /** Maximum number of items. */ maxItems?: number /** All items should be unique. */ uniqueItems?: boolean } /** Schema for boolean type properties. */ export interface BooleanSchema extends BaseSchema<'boolean'> {} /** Schema for null type properties. */ export interface NullSchema extends BaseSchema<'null'> {} /** Definition of JSON Schema type. */ export type JSONSchema = StringSchema | NumberSchema | ObjectSchema | ArraySchema | BooleanSchema | NullSchema /** * JSON Schema is limited in describing how a given data type should be rendered as a form input component. That's why * this library introduces the concept of uiSchema. * * A UI schema is basically an object literal providing information on how the form should be rendered, while the JSON * schema tells what. * * @see https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema * @example * * { * // Present color choices as radio buttons * color: { 'ui:widget': 'radio' }, * // Text is a textarea with 5 rows * text: { 'ui:widget': 'textarea', 'ui:options': { rows: 5 } }, * // Count is a number field with spinner button * count: { 'ui:widget': 'updown' } * }, * */ export type UISchema = UiSchema export type JSONSchemaConstruct = Partial /** * Iterate JSON schema including nested constructs like oneOf, allOf, if, then, etc. creating a copy in process. * */ export function traverseSchema( schema: JSONSchemaConstruct, callback: (value: JSONSchemaConstruct) => JSONSchemaConstruct ): JSONSchemaConstruct { var traversed = {} as Partial for (var property in schema) { const value = schema[property] if (value && typeof value === 'object') { if (Array.isArray(value)) { traversed[property] = value.map((item) => typeof item === 'object' && item ? traverseSchema(item, callback) : item ) } else { traversed[property] = traverseSchema(callback(value), callback) } } else { traversed[property] = schema[property] } } return traversed } /** * Normalizes schema and assigns default values. It will traverse schema to normalize nested constructs. */ export function transformSchema(schema: Partial, defaults: Record = {}) { schema = transformSchemaObject({ /** 1. Assign schema type unless given */ type: 'object', /** 2. Ensure that properties is not null */ properties: {}, required: [], ...schema }) // Iterate nested constructs, without passing the defaults object return traverseSchema(schema, (construct) => construct.properties ? transformSchemaObject(construct, defaults) : construct ) } /** * Normalizes schema and assigns default values */ export function transformSchemaObject( schema: Partial, defaults: Record = {} ): ObjectSchema | ArraySchema { const transformed = { ...schema } if (transformed.properties) { transformed.required = schema.required?.slice() || [] transformed.properties = Object.keys(transformed.properties).reduce((acc: ObjectSchema['properties'], key) => { const given = transformed.properties[key] const custom = BYOCDataTypes[given?.type]?.(given) const property = { ...custom, ...given, type: custom?.type || given.type, /** 3. Assign default value from the explicit defaults object*/ default: defaults.hasOwnProperty(key) ? defaults[key] : given.default, /** 4. Generate fallback title */ title: given.title || toKebabCase(key) .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') } if (given?.properties || custom?.properties) { property.properties = { ...(custom?.properties || given?.properties) } // Auto-merge property definitions if their names match. Will bail out when expanding nested objects like Item and List Object.keys(custom?.properties || {}).forEach((subKey) => { property.properties[subKey] = { ...custom?.properties?.[subKey], ...given?.properties?.[subKey] } }) } if (given?.items || custom?.items) { property.items = custom?.items || given?.items } if (property.default === undefined) { delete property.default } if ('required' in property && typeof property.required == 'boolean') { if (transformed.required.indexOf(key) == -1) { transformed.required.push(key) } delete property.required } return Object.assign(acc as any, { [key]: property }) }, {}) as Partial } return { type: 'object', ...transformed } as ObjectSchema } /** Make UI schema assumptions to improve the ui */ export const transformUiSchema = (uiSchema: UISchema, properties: Record) => { let transformed: UISchema = { ...uiSchema } for (var property in properties) { if (typeof properties[property] != 'object' || !properties[property]) { continue } if (properties[property]?.type == 'integer' || properties[property]?.type == 'number') { transformed[property] = { ...transformed[property], 'ui:options': { widget: 'updown' } } } if (properties[property]?.ui) { transformed[property] = { ...transformed[property], 'ui:options': { ...transformed[property]?.['ui:options'], ...properties[property].ui } } } transformed = { ...transformed, [property]: properties[property].items ? { items: transformUiSchema(transformed[property], properties[property].items.properties) } : transformUiSchema(transformed[property], properties[property].properties || {}) } } return transformed } /** Parses a property value based on its property name and type defined in the JSON properties object. */ export function parseValue(value: any, type: JSONSchema['type']) { switch (type) { case 'string': return value case 'object': try { return typeof value == 'object' && value != null ? value : JSON.parse(value) } catch (e) { return null } case 'array': try { return Array.isArray(value) ? value : JSON.parse(value) } catch (e) { return null } case 'number': return parseFloat(value) case 'integer': return parseInt(value) case 'boolean': return value == 'true' || value == '1' default: return value } } /** Transform properties to match the schema types of a specified component. It will parse json for objects and arrays. */ export function parseSchemaProperties(schema: JSONSchema, props: Record): any { return Object.keys(props).reduce((prev, name) => { const value = props[name] const prop = toCamelCase(name) const definition = schema?.properties[prop] const type = definition?.type const parsed = parseValue(value, type) if (parsed != null && !name.startsWith('data-attribute') && !['class', 'id', 'contenteditable'].includes(name)) { return { ...prev, [prop]: parsed } } else { return prev } }, {}) } /** * 1. Transform properties to match the schema types of a specified component. It will parse json for objects and arrays. * 2. Combine it with default values as defined by the schema */ export function getSchemaProperties(schema: JSONSchema, props: Record): any { return { ...getSchemaDefaults(schema), ...parseSchemaProperties(schema, props) } } /** Get properties with their default non-null values*/ export function getSchemaDefaults(schema: JSONSchema): any { return Object.keys(schema.properties).reduce((prev, prop) => { if (schema.properties[prop]?.default != null) { return { ...prev, [prop]: schema.properties[prop]?.default } } else { return prev } }, {}) }