mirror of
https://github.com/apache/superset.git
synced 2026-05-08 01:15:46 +00:00
302 lines
8.9 KiB
TypeScript
302 lines
8.9 KiB
TypeScript
/**
|
|
* 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 } 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' },
|
|
};
|
|
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<string, unknown>).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<string, unknown>).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<string, unknown>).const ??
|
|
(schema as Record<string, unknown>).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<string, unknown>).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.
|
|
*/
|
|
export function areDependenciesSatisfied(
|
|
dependencies: string[],
|
|
data: Record<string, unknown>,
|
|
): boolean {
|
|
return dependencies.every(dep => {
|
|
const value = data[dep];
|
|
if (value === null || value === undefined || value === '') return false;
|
|
if (typeof value === 'object' && Object.keys(value).length === 0)
|
|
return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<string, unknown>)?.['x-dependsOn'];
|
|
const refreshing =
|
|
refreshingSchema &&
|
|
Array.isArray(deps) &&
|
|
areDependenciesSatisfied(deps as string[], (cfgData as Record<string, unknown>) ?? {});
|
|
|
|
if (!refreshing) {
|
|
return TextControl(props);
|
|
}
|
|
|
|
const uischema = {
|
|
...props.uischema,
|
|
options: {
|
|
...props.uischema.options,
|
|
placeholderText: t('Loading...'),
|
|
inputProps: { suffix: <Spin size="small" /> },
|
|
},
|
|
};
|
|
return TextControl({ ...props, uischema, enabled: false });
|
|
}
|
|
const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
|
|
const dynamicFieldEntry = {
|
|
tester: rankWith(
|
|
3,
|
|
and(
|
|
isStringControl,
|
|
schemaMatches(
|
|
s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
|
|
),
|
|
),
|
|
),
|
|
renderer: DynamicFieldRenderer,
|
|
};
|
|
|
|
export const renderers = [
|
|
...rendererRegistryEntries,
|
|
passwordEntry,
|
|
constEntry,
|
|
readOnlyEntry,
|
|
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<string, JsonSchema> = {};
|
|
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<string, unknown>)['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<string, unknown> = {
|
|
type: 'Control',
|
|
scope: `#/properties/${key}`,
|
|
};
|
|
if (typeof prop === 'object' && prop !== null) {
|
|
const options: Record<string, unknown> = {};
|
|
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<string, string[]> {
|
|
const deps: Record<string, string[]> = {};
|
|
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<string, unknown>)['x-dependsOn'])
|
|
) {
|
|
deps[key] = (prop as Record<string, unknown>)[
|
|
'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<string, string[]>,
|
|
data: Record<string, unknown>,
|
|
): string {
|
|
const allDepKeys = new Set<string>();
|
|
for (const deps of Object.values(dynamicDeps)) {
|
|
for (const dep of deps) {
|
|
allDepKeys.add(dep);
|
|
}
|
|
}
|
|
const snapshot: Record<string, unknown> = {};
|
|
for (const key of [...allDepKeys].sort()) {
|
|
snapshot[key] = data[key];
|
|
}
|
|
return JSON.stringify(snapshot);
|
|
}
|