From 0028cd181fcf45d92a3603b197617c15fe79ca7a Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Wed, 13 May 2026 15:14:51 -0400 Subject: [PATCH] feat(semantic layers): form for semantic layer with single semantic view --- .../semanticLayers/MultiEnumControl.test.tsx | 96 +++++++++++++++++++ .../semanticLayers/jsonFormsHelpers.test.ts | 45 ++++++++- .../semanticLayers/jsonFormsHelpers.tsx | 91 +++++++++++++++++- .../semanticViews/AddSemanticViewModal.tsx | 38 +++++++- 4 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx diff --git a/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx b/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx new file mode 100644 index 000000000000..8154fc47f974 --- /dev/null +++ b/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx @@ -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 => + ({ + 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(); + 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(); + 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(); + 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(); + // 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( + , + ); + expect(container.querySelector('.ant-select-arrow-loading')).toBeTruthy(); +}); + +test('treats non-array data as an empty selection without crashing', () => { + render(); + // No tags rendered when data is missing + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); + expect(screen.queryByText('Banana')).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.test.ts b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.test.ts index 9f81bea4772a..67f1edde495b 100644 --- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.test.ts +++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.test.ts @@ -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); +}); diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx index 85c7b891dbc7..d995c4da4352 100644 --- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx +++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx @@ -252,17 +252,99 @@ 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, + and( + schemaMatches(s => { + const names = (s as Record)['x-enumNames']; + return Array.isArray(names) && (names as unknown[]).length > 0; + }), + schemaMatches(s => (s as Record)?.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; + const itemsSchema = + (arraySchema.items as Record) ?? + ({} as Record); + + 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) + ?.tooltip as string | undefined; + + return ( + +