/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { useEffect } from 'react'; import { t } from '@apache-superset/core/translation'; import { Spin, Select, Form } from 'antd'; import { withJsonFormsControlProps } from '@jsonforms/react'; import type { JsonSchema, UISchemaElement, ControlProps, } from '@jsonforms/core'; import { rankWith, and, isStringControl, formatIs, schemaMatches, } from '@jsonforms/core'; import { rendererRegistryEntries, TextControl, } from '@great-expectations/jsonforms-antd-renderers'; export const SCHEMA_REFRESH_DEBOUNCE_MS = 500; /** * Custom renderer that renders `Input.Password` for fields with * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`). */ function PasswordControl(props: ControlProps) { const uischema = { ...props.uischema, options: { ...props.uischema.options, type: 'password', inputProps: { ...((props.uischema.options?.inputProps as Record) ?? {}), // Prevent browsers from autofilling stored login passwords into // service-token fields. 'new-password' is respected even when // 'off' is ignored (Chrome ≥ 34). autoComplete: 'new-password', }, }, }; return TextControl({ ...props, uischema }); } const PasswordRenderer = withJsonFormsControlProps(PasswordControl); const passwordEntry = { tester: rankWith(3, and(isStringControl, formatIs('password'))), renderer: PasswordRenderer, }; /** * Renderer for `const` properties (e.g. Pydantic discriminator fields). * Renders nothing visually but ensures the const value is set in form data, * so discriminated unions resolve correctly on the backend. */ function ConstControl({ data, handleChange, path, schema }: ControlProps) { const constValue = (schema as Record).const; useEffect(() => { if (constValue !== undefined && data !== constValue) { handleChange(path, constValue); } }, [constValue, data, handleChange, path]); return null; } const ConstRenderer = withJsonFormsControlProps(ConstControl); const constEntry = { tester: rankWith( 10, schemaMatches( s => s !== undefined && 'const' in s && !(s as Record).readOnly, ), ), renderer: ConstRenderer, }; /** * Renderer for read-only fields (e.g. a fixed database that the admin locked). * Renders a disabled input showing the current value. Also ensures the default * value is injected into form data (like ConstControl does for hidden fields). */ function ReadOnlyControl({ data, handleChange, path, schema, ...rest }: ControlProps) { const defaultValue = (schema as Record).const ?? (schema as Record).default; useEffect(() => { if (defaultValue !== undefined && data !== defaultValue) { handleChange(path, defaultValue); } }, [defaultValue, data, handleChange, path]); return TextControl({ ...rest, data, handleChange, path, schema, enabled: false, }); } const ReadOnlyRenderer = withJsonFormsControlProps(ReadOnlyControl); const readOnlyEntry = { tester: rankWith( 11, schemaMatches( s => s !== undefined && (s as Record).readOnly === true, ), ), renderer: ReadOnlyRenderer, }; /** * Checks whether all dependency values are filled (non-empty). * Handles nested objects (like auth) by checking they have at least one key. * * Fields that have a `default` in the schema are considered satisfied even * when the user has not explicitly touched them yet — JsonForms does not * write default values into `data` until a field is interacted with, so * without this fallback a field like `admin_host` (which ships with a * sensible default) would permanently block the refresh. */ export function areDependenciesSatisfied( dependencies: string[], data: Record, schema?: JsonSchema, ): boolean { return dependencies.every(dep => { const value = data[dep]; if (value !== null && value !== undefined && value !== '') { if (typeof value === 'object' && Object.keys(value).length === 0) return false; return true; } // Fall back to the schema default when the field hasn't been touched yet. const defaultValue = schema?.properties?.[dep]?.default; return ( defaultValue !== null && defaultValue !== undefined && defaultValue !== '' ); }); } /** * Renderer for fields marked `x-dynamic` in the JSON Schema. * Shows a loading spinner inside the input while the schema is being * refreshed with dynamic values from the backend. */ function DynamicFieldControl(props: ControlProps) { const { refreshingSchema, formData: cfgData } = props.config ?? {}; const deps = (props.schema as Record)?.['x-dependsOn']; const refreshing = refreshingSchema && Array.isArray(deps) && areDependenciesSatisfied( deps as string[], (cfgData as Record) ?? {}, props.rootSchema, ); if (!refreshing) { return TextControl(props); } const uischema = { ...props.uischema, options: { ...props.uischema.options, placeholderText: t('Loading...'), inputProps: { suffix: }, }, }; return TextControl({ ...props, uischema, enabled: false }); } const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl); const dynamicFieldEntry = { tester: rankWith( 3, and( isStringControl, schemaMatches( s => (s as Record)?.['x-dynamic'] === true, ), ), ), renderer: DynamicFieldRenderer, }; /** * Renderer for fields that carry an ``x-enumNames`` array alongside their * ``enum`` values. Renders as an Antd Select showing human-readable labels * (from ``x-enumNames``) while storing the underlying enum values in form * data. Used for MetricFlow's integer-ID fields (account, project, * environment) where the backend provides both IDs and display names. */ function EnumNamesControl(props: ControlProps) { const { refreshingSchema } = props.config ?? {}; const schema = props.schema as Record; const enumValues = (schema.enum as unknown[]) ?? []; const enumNames = (schema['x-enumNames'] as string[]) ?? enumValues.map(String); const options = enumValues.map((value, index) => ({ value, label: enumNames[index] ?? String(value), })); const tooltip = (props.uischema?.options as Record) ?.tooltip as string | undefined; return ( props.handleChange(props.path, next)} options={options} style={{ width: '100%' }} disabled={!props.enabled} loading={!!refreshingSchema} allowClear optionFilterProp="label" placeholder={ (props.uischema?.options as Record) ?.placeholderText as string | undefined } /> ); } const MultiEnumRenderer = withJsonFormsControlProps(MultiEnumControl); const multiEnumEntry = { // Rank 35: must beat upstream ``PrimitiveArrayRenderer`` (rank 30) so an // ``array``/``items.enum`` schema renders as one Antd multi-select tag // box instead of the "Add" repeater pattern that PrimitiveArray uses. tester: rankWith( 35, schemaMatches(s => { const schema = s as Record; if (schema?.type !== 'array') return false; const items = schema.items as Record | undefined; return ( !!items && Array.isArray(items.enum) && (items.enum as unknown[]).length > 0 ); }), ), renderer: MultiEnumRenderer, }; export const renderers = [ ...rendererRegistryEntries, passwordEntry, constEntry, readOnlyEntry, enumNamesEntry, multiEnumEntry, dynamicFieldEntry, ]; /** * Removes empty `enum` arrays from schema properties. The JSON Schema spec * requires `enum` to have at least one item, and AJV rejects empty arrays. * Fields with empty enums are rendered as plain text inputs instead. */ export function sanitizeSchema(schema: JsonSchema): JsonSchema { if (!schema.properties) return schema; const properties: Record = {}; for (const [key, prop] of Object.entries(schema.properties)) { if ( typeof prop === 'object' && prop !== null && 'enum' in prop && Array.isArray(prop.enum) && prop.enum.length === 0 ) { const { enum: _empty, ...rest } = prop; properties[key] = rest; } else { properties[key] = prop as JsonSchema; } } return { ...schema, properties } as JsonSchema; } /** * Builds a JSON Forms UI schema from a JSON Schema, using the first * `examples` entry as placeholder text for each string property. */ export function buildUiSchema(schema: JsonSchema): UISchemaElement | undefined { if (!schema.properties) return undefined; // Use explicit property order from backend if available, // otherwise fall back to the JSON object key order const propertyOrder: string[] = ((schema as Record)['x-propertyOrder'] as string[]) ?? Object.keys(schema.properties); const elements = propertyOrder .filter(key => key in (schema.properties ?? {})) .map(key => { const prop = schema.properties![key]; const control: Record = { type: 'Control', scope: `#/properties/${key}`, }; if (typeof prop === 'object' && prop !== null) { const options: Record = {}; if ( 'examples' in prop && Array.isArray(prop.examples) && prop.examples.length > 0 ) { options.placeholderText = String(prop.examples[0]); } if ('description' in prop && typeof prop.description === 'string') { options.tooltip = prop.description; } if (Object.keys(options).length > 0) { control.options = options; } } return control; }); return { type: 'VerticalLayout', elements } as UISchemaElement; } /** * Extracts dynamic field dependency mappings from the schema. * Returns a map of field name -> list of dependency field names. */ export function getDynamicDependencies( schema: JsonSchema, ): Record { const deps: Record = {}; if (!schema.properties) return deps; for (const [key, prop] of Object.entries(schema.properties)) { if ( typeof prop === 'object' && prop !== null && 'x-dynamic' in prop && 'x-dependsOn' in prop && Array.isArray((prop as Record)['x-dependsOn']) ) { deps[key] = (prop as Record)['x-dependsOn'] as string[]; } } return deps; } /** * Serializes the dependency values for a set of fields into a stable string * for comparison, so we only re-fetch when dependency values actually change. */ export function serializeDependencyValues( dynamicDeps: Record, data: Record, ): string { const allDepKeys = new Set(); for (const deps of Object.values(dynamicDeps)) { for (const dep of deps) { allDepKeys.add(dep); } } const snapshot: Record = {}; for (const key of [...allDepKeys].sort()) { snapshot[key] = data[key]; } return JSON.stringify(snapshot); }