Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { JsonSchema } from '@jsonforms/core';
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';

import {
areDependenciesSatisfied,
sanitizeSchema,
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'], {
Expand Down Expand Up @@ -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);
});
91 changes: 87 additions & 4 deletions superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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>
);
}
Comment thread
betodealmeida marked this conversation as resolved.
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 names = (s as Record<string, unknown>)['x-enumNames'];
return Array.isArray(names) && (names as unknown[]).length > 0;
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: EnumNamesRenderer,
renderer: MultiEnumRenderer,
};

export const renderers = [
Expand All @@ -271,6 +353,7 @@ export const renderers = [
constEntry,
readOnlyEntry,
enumNamesEntry,
multiEnumEntry,
dynamicFieldEntry,
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Comment thread
betodealmeida marked this conversation as resolved.
setSelectedViewNames(prev => {
if (
prev.length === namesToAdd.length &&
prev.every((n, i) => n === namesToAdd[i])
) {
return prev;
}
return namesToAdd;
});
}, [singleViewMode, availableViews]);

return (
<StandardModal
show={show}
Expand Down Expand Up @@ -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')}
Expand Down
Loading