mirror of
https://github.com/apache/superset.git
synced 2026-05-30 04:39:20 +00:00
feat(semantic layers): form for SL with a single SV (#40280)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 type { ControlProps } from '@jsonforms/core';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
|
||||
import { MultiEnumControl } from './jsonFormsHelpers';
|
||||
|
||||
const baseProps = (overrides: Partial<ControlProps> = {}): ControlProps =>
|
||||
({
|
||||
label: 'Tags',
|
||||
path: 'tags',
|
||||
enabled: true,
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
enum: ['a', 'b', 'c'],
|
||||
'x-enumNames': ['Apple', 'Banana', 'Cherry'],
|
||||
},
|
||||
},
|
||||
uischema: { type: 'Control', scope: '#/properties/tags', options: {} },
|
||||
data: [],
|
||||
handleChange: jest.fn(),
|
||||
config: {},
|
||||
...overrides,
|
||||
}) as unknown as ControlProps;
|
||||
|
||||
test('renders enum labels from items.x-enumNames', async () => {
|
||||
render(<MultiEnumControl {...baseProps()} />);
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
expect(await screen.findByText('Apple')).toBeInTheDocument();
|
||||
expect(screen.getByText('Banana')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cherry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('falls back to raw enum values when x-enumNames is absent', async () => {
|
||||
const props = baseProps({
|
||||
schema: { type: 'array', items: { enum: ['red', 'green'] } },
|
||||
});
|
||||
const { container } = render(<MultiEnumControl {...props} />);
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await screen.findAllByText('red');
|
||||
const options = container.ownerDocument.querySelectorAll(
|
||||
'.ant-select-item-option-content',
|
||||
);
|
||||
expect(Array.from(options).map(el => el.textContent)).toEqual([
|
||||
'red',
|
||||
'green',
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits the new array via handleChange when an option is picked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<MultiEnumControl {...baseProps({ handleChange })} />);
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByText('Banana'));
|
||||
expect(handleChange).toHaveBeenLastCalledWith('tags', ['b']);
|
||||
});
|
||||
|
||||
test('renders existing data as selected tags using x-enumNames labels', () => {
|
||||
render(<MultiEnumControl {...baseProps({ data: ['a', 'c'] })} />);
|
||||
// Selected items render in a hidden listbox with role=option,
|
||||
// but the tag text is the user-visible label.
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cherry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Banana')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows a loading state when config.refreshingSchema is true', () => {
|
||||
const { container } = render(
|
||||
<MultiEnumControl {...baseProps({ config: { refreshingSchema: true } })} />,
|
||||
);
|
||||
expect(container.querySelector('.ant-select-arrow-loading')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('treats non-array data as an empty selection without crashing', () => {
|
||||
render(<MultiEnumControl {...baseProps({ data: undefined })} />);
|
||||
// No tags rendered when data is missing
|
||||
expect(screen.queryByText('Apple')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Banana')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { JsonSchema } from '@jsonforms/core';
|
||||
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
|
||||
|
||||
import {
|
||||
areDependenciesSatisfied,
|
||||
@@ -24,8 +24,16 @@ import {
|
||||
buildUiSchema,
|
||||
getDynamicDependencies,
|
||||
serializeDependencyValues,
|
||||
multiEnumEntry,
|
||||
enumNamesEntry,
|
||||
} from './jsonFormsHelpers';
|
||||
|
||||
const control = {
|
||||
type: 'Control',
|
||||
scope: '#',
|
||||
} as unknown as UISchemaElement;
|
||||
const ctx = { rootSchema: {} as JsonSchema, config: {} };
|
||||
|
||||
test('areDependenciesSatisfied returns true for present dependency values', () => {
|
||||
expect(
|
||||
areDependenciesSatisfied(['database', 'schema'], {
|
||||
@@ -148,3 +156,38 @@ test('serializeDependencyValues is stable and sorted by key', () => {
|
||||
JSON.stringify({ database: 'analytics', warehouse: 'compute_wh' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('multiEnumEntry.tester matches array schemas with non-empty items.enum', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: { enum: ['a', 'b'] },
|
||||
} as unknown as JsonSchema;
|
||||
expect(multiEnumEntry.tester(control, schema, ctx)).toBe(35);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['empty items.enum', { type: 'array', items: { enum: [] } }],
|
||||
['items without enum', { type: 'array', items: { type: 'string' } }],
|
||||
['array without items', { type: 'array' }],
|
||||
['scalar enum', { type: 'string', enum: ['a', 'b'] }],
|
||||
])('multiEnumEntry.tester does not match %s', (_label, schema) => {
|
||||
expect(multiEnumEntry.tester(control, schema as JsonSchema, ctx)).toBe(-1);
|
||||
});
|
||||
|
||||
test('enumNamesEntry.tester matches scalar enum with x-enumNames', () => {
|
||||
const schema = {
|
||||
type: 'string',
|
||||
enum: ['a', 'b'],
|
||||
'x-enumNames': ['Alpha', 'Beta'],
|
||||
} as JsonSchema;
|
||||
expect(enumNamesEntry.tester(control, schema, ctx)).toBe(5);
|
||||
});
|
||||
|
||||
test('enumNamesEntry.tester does not match array schemas (multiEnum owns those)', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: { enum: ['a'], 'x-enumNames': ['Alpha'] },
|
||||
'x-enumNames': ['Alpha'],
|
||||
} as JsonSchema;
|
||||
expect(enumNamesEntry.tester(control, schema, ctx)).toBe(-1);
|
||||
});
|
||||
|
||||
@@ -252,25 +252,108 @@ function EnumNamesControl(props: ControlProps) {
|
||||
);
|
||||
}
|
||||
const EnumNamesRenderer = withJsonFormsControlProps(EnumNamesControl);
|
||||
const enumNamesEntry = {
|
||||
export const enumNamesEntry = {
|
||||
// Rank 5: higher than the default string renderer (2–3) so this fires
|
||||
// whenever x-enumNames is present, regardless of the underlying type.
|
||||
// Array-of-enum schemas are handled by ``multiEnumEntry`` below — this
|
||||
// renderer only targets scalar string/number controls.
|
||||
tester: rankWith(
|
||||
5,
|
||||
schemaMatches(s => {
|
||||
const names = (s as Record<string, unknown>)['x-enumNames'];
|
||||
return Array.isArray(names) && (names as unknown[]).length > 0;
|
||||
}),
|
||||
and(
|
||||
schemaMatches(s => {
|
||||
const names = (s as Record<string, unknown>)['x-enumNames'];
|
||||
return Array.isArray(names) && (names as unknown[]).length > 0;
|
||||
}),
|
||||
schemaMatches(s => (s as Record<string, unknown>)?.type !== 'array'),
|
||||
),
|
||||
),
|
||||
renderer: EnumNamesRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderer for ``{type: 'array', items: {enum: [...]}}`` schemas. Renders
|
||||
* a single Antd Select with ``mode="multiple"`` (tag-style multi-select),
|
||||
* matching the natural expectation of a "pick several from a list" control.
|
||||
*
|
||||
* Without this, the default ``PrimitiveArrayControl`` from the upstream
|
||||
* library renders an "Add …" button that creates one single-select per
|
||||
* element — visually wrong for an enum multi-select and unable to display
|
||||
* ``items.x-enumNames`` labels.
|
||||
*
|
||||
* The renderer is dynamic-aware: when the host form is refreshing the
|
||||
* schema (e.g. compatible options narrowing as the user picks), the Select
|
||||
* shows a loading indicator without becoming disabled, so the user can
|
||||
* continue editing while options refresh.
|
||||
*/
|
||||
export function MultiEnumControl(props: ControlProps) {
|
||||
const { refreshingSchema } = props.config ?? {};
|
||||
const arraySchema = props.schema as Record<string, unknown>;
|
||||
const itemsSchema =
|
||||
(arraySchema.items as Record<string, unknown>) ??
|
||||
({} as Record<string, unknown>);
|
||||
|
||||
const enumValues = (itemsSchema.enum as unknown[]) ?? [];
|
||||
const enumNames =
|
||||
(itemsSchema['x-enumNames'] as string[]) ?? enumValues.map(String);
|
||||
|
||||
const options = enumValues.map((value, index) => ({
|
||||
value: value as string | number,
|
||||
label: enumNames[index] ?? String(value),
|
||||
}));
|
||||
|
||||
const value = Array.isArray(props.data) ? (props.data as unknown[]) : [];
|
||||
|
||||
const tooltip = (props.uischema?.options as Record<string, unknown>)
|
||||
?.tooltip as string | undefined;
|
||||
|
||||
return (
|
||||
<Form.Item label={props.label} tooltip={tooltip}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={value as (string | number)[]}
|
||||
onChange={next => props.handleChange(props.path, next)}
|
||||
options={options}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!props.enabled}
|
||||
loading={!!refreshingSchema}
|
||||
allowClear
|
||||
optionFilterProp="label"
|
||||
placeholder={
|
||||
(props.uischema?.options as Record<string, unknown>)
|
||||
?.placeholderText as string | undefined
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
const MultiEnumRenderer = withJsonFormsControlProps(MultiEnumControl);
|
||||
export 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<string, unknown>;
|
||||
if (schema?.type !== 'array') return false;
|
||||
const items = schema.items as Record<string, unknown> | 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,
|
||||
];
|
||||
|
||||
|
||||
@@ -254,7 +254,10 @@ export default function AddSemanticViewModal({
|
||||
!schema?.properties ||
|
||||
Object.keys(schema.properties).length === 0
|
||||
) {
|
||||
// No runtime config needed — fetch views right away
|
||||
// Preserve top-level runtime metadata (e.g. x-singleView) even when
|
||||
// there are no form fields, then fetch views right away. Skip the
|
||||
// apply call entirely if the backend returned no schema at all.
|
||||
if (schema) applyRuntimeSchema(schema);
|
||||
fetchViews(uuid, {}, gen);
|
||||
} else {
|
||||
applyRuntimeSchema(schema);
|
||||
@@ -456,6 +459,31 @@ export default function AddSemanticViewModal({
|
||||
const viewsDisabled =
|
||||
loadingViews || (!loadingViews && availableViews.length === 0);
|
||||
|
||||
// When ``x-singleView: true`` the runtime form fully describes a single
|
||||
// semantic view (e.g. a MetricFlow cube). Hide the picker and auto-select
|
||||
// whatever ``get_semantic_views`` returned so the Add button can fire
|
||||
// without an extra user click.
|
||||
const singleViewMode =
|
||||
(runtimeSchema as Record<string, unknown> | null)?.['x-singleView'] ===
|
||||
true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!singleViewMode) return;
|
||||
const namesToAdd = availableViews
|
||||
.filter(v => !v.already_added)
|
||||
.map(v => v.name)
|
||||
.slice(0, 1);
|
||||
setSelectedViewNames(prev => {
|
||||
if (
|
||||
prev.length === namesToAdd.length &&
|
||||
prev.every((n, i) => n === namesToAdd[i])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return namesToAdd;
|
||||
});
|
||||
}, [singleViewMode, availableViews]);
|
||||
|
||||
return (
|
||||
<StandardModal
|
||||
show={show}
|
||||
@@ -511,8 +539,12 @@ export default function AddSemanticViewModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Semantic Views — always visible once a layer is selected */}
|
||||
{selectedLayerUuid && !loadingRuntime && (
|
||||
{/* Semantic Views — always visible once a layer is selected, unless
|
||||
the runtime schema declares ``x-singleView: true``: extensions
|
||||
(e.g. MetricFlow cubes) whose runtime form fully describes a
|
||||
single view set that flag so the picker disappears and the
|
||||
view is auto-selected when ``get_semantic_views`` returns it. */}
|
||||
{selectedLayerUuid && !loadingRuntime && !singleViewMode && (
|
||||
<ModalFormField label={t('Semantic Views')}>
|
||||
<Select
|
||||
ariaLabel={t('Semantic views')}
|
||||
|
||||
Reference in New Issue
Block a user