You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/10/14 01:23:51 UTC
(superset) branch master updated: fix(semantic-views): hide stale dependent options while they refresh (#44995)
This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 8175e0ecc7b fix(semantic-views): hide stale dependent options while they refresh (#44995)
8175e0ecc7b is described below
commit 8175e0ecc7b25f4c54dac0c0810763b4f2c1ae4e
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Tue Oct 13 21:23:43 2026 -0400
fix(semantic-views): hide stale dependent options while they refresh (#44995)
Co-authored-by: Mike Bridge <mi...@ext.preset.io>
Co-authored-by: Claude Opus 5.5 (1M context) <no...@anthropic.com>
---
.../using-superset/semantic-layer-connections.mdx | 16 +
.../semanticLayers/MultiEnumControl.test.tsx | 36 +
.../semanticLayers/jsonFormsAntdRenderers.test.tsx | 17 +-
.../features/semanticLayers/jsonFormsHelpers.tsx | 16 +-
.../semanticViews/AddSemanticViewModal.test.tsx | 786 +++++++++++++++++++--
.../semanticViews/AddSemanticViewModal.tsx | 269 ++++---
6 files changed, 998 insertions(+), 142 deletions(-)
diff --git a/docs/docs/using-superset/semantic-layer-connections.mdx b/docs/docs/using-superset/semantic-layer-connections.mdx
index 1c50184cc08..b803725973a 100644
--- a/docs/docs/using-superset/semantic-layer-connections.mdx
+++ b/docs/docs/using-superset/semantic-layer-connections.mdx
@@ -45,3 +45,19 @@ generic fallback when no reason is available. Saved secrets remain masked while
editing; configuration changes may require re-entering masked values, as explained
by the server's validation message. Changing connection dependencies can clear
dependent selections, so check those fields before saving.
+
+## Dependent source selectors
+
+In **Add Semantic View**, changing a selected Database makes its dependent
+Schema choices unavailable while fresh options load, including the debounce
+interval. A matching response preserves selections that remain valid and clears
+those no longer offered. If loading fails, the form retains your selections but
+keeps stale choices unavailable; a later form edit can retry once for that
+dependency state. Changing the Database requests choices for the new state.
+After a failed refresh, saving stays blocked only while a single-choice dependent
+field has choices loaded.
+If no dependent field has its prerequisites filled, clearing a required source
+keeps dependent choices unavailable until that source is filled. Clearing an
+optional dependency can load choices for the empty state instead. Semantic
+views listed for an earlier dependency are discarded, and views cannot be added
+while dependent choices are loading.
diff --git a/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx b/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx
index 0983e1b4026..267e247c4da 100644
--- a/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx
+++ b/superset-frontend/src/features/semanticLayers/MultiEnumControl.test.tsx
@@ -222,6 +222,42 @@ test('DynamicFieldControl handles a 500-option enum via search (FR-006 sibling)'
expect(handleChange).toHaveBeenLastCalledWith('mode', 'metric_042');
});
+test.each([true, false])(
+ 'DynamicFieldControl closes stale options without losing the value (loading=%s)',
+ async refreshingSchema => {
+ const props = baseProps({
+ label: 'Schema',
+ path: 'schema',
+ schema: {
+ type: 'string',
+ enum: ['shared', 'old_only'],
+ 'x-dynamic': true,
+ 'x-dependsOn': ['database'],
+ } as ControlProps['schema'],
+ data: 'shared',
+ config: { formData: { database: 'first' } },
+ });
+ const { rerender } = render(<DynamicFieldControl {...props} />);
+ const box = screen.getByRole('combobox');
+ await userEvent.click(box);
+ await waitFor(() => expect(screen.getByText('old_only')).toBeVisible());
+ rerender(
+ <DynamicFieldControl
+ {...props}
+ config={{
+ formData: { database: 'second' },
+ refreshingSchema,
+ staleSchemaOptions: true,
+ }}
+ />,
+ );
+ expect(box).toBeDisabled();
+ await waitFor(() => expect(box).toHaveAttribute('aria-expanded', 'false'));
+ expect(box.closest('.ant-select')).toHaveTextContent('shared');
+ expect(props.handleChange).not.toHaveBeenCalled();
+ },
+);
+
test('renders options alphabetically for an unsorted backend enum', async () => {
// The renderers dropped their manual sort because the wrapped Select
// alphabetises by label; pin that inherited behaviour here so a future
diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsAntdRenderers.test.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsAntdRenderers.test.tsx
index be2c4243a87..8fa6f4d3695 100644
--- a/superset-frontend/src/features/semanticLayers/jsonFormsAntdRenderers.test.tsx
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsAntdRenderers.test.tsx
@@ -147,9 +147,14 @@ test('ordinary string controls retain the vendor default display', () => {
expect(screen.getByRole('textbox', { name: 'Schema' })).toHaveValue('PUBLIC');
});
-test.each([false, true])(
- 'manual fields remain typeable when discovery is pending: %s',
- refreshingSchema => {
+test.each([
+ [false, false],
+ [true, false],
+ [false, true],
+ [true, true],
+])(
+ 'manual fields remain typeable during discovery (refreshing=%s, stale=%s)',
+ (refreshingSchema, staleSchemaOptions) => {
const schema = {
type: 'object',
properties: {
@@ -168,7 +173,11 @@ test.each([false, true])(
data={{}}
renderers={renderers}
cells={cellRegistryEntries}
- config={{ refreshingSchema, formData: { account: 'synthetic' } }}
+ config={{
+ refreshingSchema,
+ staleSchemaOptions,
+ formData: { account: 'synthetic' },
+ }}
/>,
);
expect(screen.getByRole('textbox', { name: 'Database' })).toBeEnabled();
diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
index ac799b941fc..afa2bbcece5 100644
--- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -253,7 +253,11 @@ export function areDependenciesSatisfied(
* TextControl with ``inputProps.suffix`` doesn't reach the underlying Select.
*/
export function DynamicFieldControl(props: ControlProps) {
- const { refreshingSchema, formData: cfgData } = props.config ?? {};
+ const {
+ refreshingSchema,
+ staleSchemaOptions,
+ formData: cfgData,
+ } = props.config ?? {};
const schema = props.schema as Record<string, unknown>;
const deps = schema?.['x-dependsOn'];
const refreshing =
@@ -264,6 +268,13 @@ export function DynamicFieldControl(props: ControlProps) {
(cfgData as Record<string, unknown>) ?? {},
props.rootSchema,
);
+ // A field is never stale because of its own value: a self-dependent field
+ // (e.g. MetricFlow's mode picker) must stay selectable to satisfy itself.
+ const fieldName: string = props.path.split('.').pop() ?? props.path;
+ const stale =
+ !!staleSchemaOptions &&
+ Array.isArray(deps) &&
+ (deps as string[]).some(dep => dep !== fieldName);
const enumValues = Array.isArray(schema.enum)
? (schema.enum as unknown[])
@@ -303,7 +314,8 @@ export function DynamicFieldControl(props: ControlProps) {
value={(props.data as string | number | undefined) ?? undefined}
onChange={value => props.handleChange(props.path, value)}
options={options}
- disabled={!props.enabled}
+ // Disabling closes the menu without discarding a still-valid value.
+ disabled={!props.enabled || stale}
loading={refreshing}
allowClear
placeholder={refreshing ? t('Loading...') : placeholder}
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
index 1c85bb0ae78..0b7d4362bc5 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
@@ -17,6 +17,8 @@
* under the License.
*/
import {
+ act,
+ fireEvent,
render,
screen,
waitFor,
@@ -29,7 +31,10 @@ import {
largeRuntimeSchema,
} from 'src/features/semanticLayers/testFixtures';
-import AddSemanticViewModal from './AddSemanticViewModal';
+import AddSemanticViewModal, {
+ reconcileDynamicFieldValues,
+} from './AddSemanticViewModal';
+import { SCHEMA_REFRESH_DEBOUNCE_MS } from '../semanticLayers/jsonFormsHelpers';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
@@ -62,6 +67,10 @@ beforeEach(() => {
mockedPost.mockReset();
});
+afterEach(() => {
+ jest.useRealTimers();
+});
+
test('loads layers on open and adds selected semantic views', async () => {
mockedGet.mockResolvedValue({
json: {
@@ -409,15 +418,28 @@ test('a const array default does not loop updates', async () => {
}
});
-test('a failed schema refresh surfaces a toast and preserves selections', async () => {
+test('a failed array-only refresh keeps views and Add usable with a bounded retry', async () => {
let refreshCount = 0;
- mockLayerWithSchema(dynamicMetricsSchema, {
+ const schemaWithMode = {
+ ...dynamicMetricsSchema,
+ properties: {
+ ...dynamicMetricsSchema.properties,
+ mode: {
+ type: 'string',
+ title: 'Mode',
+ enum: ['full', 'cube'],
+ 'x-dynamic': true,
+ 'x-dependsOn': ['mode'],
+ },
+ },
+ };
+ mockLayerWithSchema(schemaWithMode, {
'/api/v1/semantic_layer/layer-1/schema/runtime': (payload: unknown) => {
if ((payload as { runtime_data?: unknown })?.runtime_data) {
refreshCount += 1;
return Promise.reject(new Error('refresh boom'));
}
- return Promise.resolve({ json: { result: dynamicMetricsSchema } });
+ return Promise.resolve({ json: { result: schemaWithMode } });
},
});
const props = createProps();
@@ -436,6 +458,18 @@ test('a failed schema refresh surfaces a toast and preserves selections', async
expect(refreshCount).toBeGreaterThanOrEqual(1);
// The user's selection is untouched by the failure (spec FR-005).
expect(selectedTag('m1')).toBeTruthy();
+ await waitFor(
+ () => {
+ expect(
+ screen.getByRole('combobox', { name: 'Semantic views' }),
+ ).toBeEnabled();
+ },
+ { timeout: 10000 },
+ );
+ await selectOption('Semantic views', 'orders');
+ expect(screen.getByRole('button', { name: /Add 1 view/ })).toBeEnabled();
+ await pickFromSelect(/metrics/i, 'm2');
+ await waitFor(() => expect(refreshCount).toBe(2), { timeout: 10000 });
});
test('the save payload carries every selected metric', async () => {
@@ -547,83 +581,721 @@ test('renders and selects within a 500-metric runtime schema', async () => {
).toBeUndefined();
});
-test('a persistently failing refresh retries once, not on every edit', async () => {
- let refreshCount = 0;
- mockLayerWithSchema(dynamicMetricsSchema, {
+const databaseSchema = (schemas: string[]) => ({
+ type: 'object',
+ required: ['database', 'schema'],
+ properties: {
+ database: {
+ type: 'string',
+ title: 'Database',
+ enum: ['first', 'second', 'third'],
+ 'x-enumNames': ['first', 'second', 'third'],
+ },
+ schema: {
+ type: 'string',
+ title: 'Schema',
+ enum: schemas,
+ 'x-dynamic': true,
+ 'x-dependsOn': ['database'],
+ },
+ note: { type: 'string', title: 'Note' },
+ },
+});
+
+type PendingSchema = {
+ resolve: (schema: Record<string, unknown> | null) => void;
+ reject: (error: Error) => void;
+};
+
+async function chooseDependentOption(name: string, value: string) {
+ fireEvent.mouseDown(await screen.findByRole('combobox', { name }));
+ const option = await waitFor(() => {
+ const item = document.querySelector(
+ `.ant-select-item-option[title="${value}"]`,
+ );
+ if (!item) throw new Error(`Missing option ${value}`);
+ return item;
+ });
+ fireEvent.click(option);
+ // JSON Forms emits onChange after its own 10 ms debounce; the modal's
+ // 500 ms schema-fetch debounce must not have elapsed at this point.
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+}
+
+async function openDependentSchemaForm(
+ makeSchema: typeof databaseSchema = databaseSchema,
+) {
+ jest.useFakeTimers();
+ const pending: PendingSchema[] = [];
+ const initial = makeSchema(['shared', 'old_only']);
+ mockLayerWithSchema(initial, {
'/api/v1/semantic_layer/layer-1/schema/runtime': (payload: unknown) => {
- if ((payload as { runtime_data?: unknown })?.runtime_data) {
- refreshCount += 1;
- return Promise.reject(new Error('refresh boom'));
+ if (!(payload as { runtime_data?: unknown })?.runtime_data) {
+ return Promise.resolve({ json: { result: initial } });
}
- return Promise.resolve({ json: { result: dynamicMetricsSchema } });
+ return new Promise((resolve, reject) => {
+ pending.push({
+ resolve: schema => resolve({ json: { result: schema } }),
+ reject,
+ });
+ });
},
});
const props = createProps();
- render(<AddSemanticViewModal {...props} />);
- await selectOption('Semantic layer', 'Snowflake SL');
+ const { rerender } = render(<AddSemanticViewModal {...props} />);
+ await chooseDependentOption('Semantic layer', 'Snowflake SL');
+ // Let JSON Forms emit its mount-time change before selecting a database.
+ // Otherwise that delayed event can reset the dependency snapshot mid-pick.
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ await chooseDependentOption('Database', 'first');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(1);
+ await act(async () => {
+ pending[0].resolve(initial);
+ });
+ return { pending, props, rerender };
+}
- await pickFromSelect(/metrics/i, 'm1');
- await waitFor(() => expect(refreshCount).toBe(1), { timeout: 10000 });
+const viewsRequests = () =>
+ mockedPost.mock.calls
+ .map(([request]) => request)
+ .filter(
+ ({ endpoint }: { endpoint: string }) =>
+ endpoint === '/api/v1/semantic_layer/layer-1/views',
+ );
- // One bounded retry for this dependency state, then no further attempts
- // however many more edits the user makes during the outage.
- await pickFromSelect(/dimensions/i, 'd1');
- await waitFor(() => expect(refreshCount).toBe(2), { timeout: 10000 });
- await pickFromSelect(/dimensions/i, 'd2');
- await new Promise(resolve => {
- setTimeout(resolve, 1200);
+test.each(['shared', 'old_only'])(
+ 'dependent options are unavailable through debounce/loading and validate selection %s',
+ async selection => {
+ const { pending } = await openDependentSchemaForm();
+ await chooseDependentOption('Schema', selection);
+ await chooseDependentOption('Database', 'second');
+ const schema = screen.getByRole('combobox', { name: 'Schema' });
+ expect(schema).toBeDisabled();
+ expect(pending).toHaveLength(1);
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS - 1);
+ });
+ expect(schema).toBeDisabled();
+ expect(pending).toHaveLength(1);
+ await act(async () => {
+ jest.advanceTimersByTime(1);
+ });
+ expect(pending).toHaveLength(2);
+ expect(schema).toBeDisabled();
+ await act(async () => {
+ pending[1].resolve(databaseSchema(['shared', 'new_only']));
+ });
+ await waitFor(() => expect(schema).toBeEnabled());
+ if (selection === 'shared') {
+ expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+ } else {
+ expect(selectedTag('old_only')).toBeNull();
+ }
+ fireEvent.mouseDown(schema);
+ expect(await screen.findByText('new_only')).toBeInTheDocument();
+ expect(
+ document.querySelector('.ant-select-item-option[title="old_only"]'),
+ ).toBeNull();
+ },
+);
+
+test('dependent options ignore an older response while the latest refresh is pending', async () => {
+ const { pending } = await openDependentSchemaForm();
+ await chooseDependentOption('Schema', 'shared');
+ await chooseDependentOption('Database', 'second');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await chooseDependentOption('Database', 'third');
+ const schema = screen.getByRole('combobox', { name: 'Schema' });
+ await act(async () => {
+ pending[1].resolve(databaseSchema(['wrong_database']));
+ });
+ expect(schema).toBeDisabled();
+ expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(3);
+ await act(async () => {
+ pending[2].resolve(databaseSchema(['shared', 'third_only']));
+ });
+ await waitFor(() => expect(schema).toBeEnabled());
+ fireEvent.mouseDown(schema);
+ expect(await screen.findByText('third_only')).toBeInTheDocument();
+ expect(screen.queryByText('wrong_database')).not.toBeInTheDocument();
+ expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+});
+
+test('dependent options stay unavailable after an error and recover on the bounded retry', async () => {
+ const { pending, props } = await openDependentSchemaForm();
+ await chooseDependentOption('Schema', 'shared');
+ await chooseDependentOption('Database', 'second');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await act(async () => {
+ pending[1].reject(new Error('offline'));
+ });
+ expect(props.addDangerToast).toHaveBeenCalledWith(
+ 'An error occurred while refreshing the runtime schema',
+ );
+ const schema = screen.getByRole('combobox', { name: 'Schema' });
+ expect(schema).toBeDisabled();
+ expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+ fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), {
+ target: { value: 'retry' },
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(3);
+ expect(schema).toBeDisabled();
+ await act(async () => {
+ pending[2].resolve(databaseSchema(['shared', 'recovered']));
+ });
+ await waitFor(() => expect(schema).toBeEnabled());
+ expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+ fireEvent.mouseDown(schema);
+ expect(await screen.findByText('recovered')).toBeInTheDocument();
+ expect(props.addDangerToast).toHaveBeenCalledTimes(1);
+});
+
+test('failed scalar narrowing keeps Add and views unavailable until a matching response', async () => {
+ const { pending } = await openDependentSchemaForm();
+ await chooseDependentOption('Schema', 'shared');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await chooseDependentOption('Semantic views', 'orders');
+ expect(screen.getByRole('button', { name: /Add 1 view/ })).toBeEnabled();
+ await chooseDependentOption('Database', 'second');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await act(async () => {
+ pending[1].reject(new Error('offline'));
});
- expect(refreshCount).toBe(2);
- // And the user was told once, not once per attempt.
expect(
- props.addDangerToast.mock.calls.filter(([msg]) =>
- String(msg).includes('refreshing the runtime schema'),
+ screen.getByRole('combobox', { name: 'Semantic views' }),
+ ).toBeDisabled();
+ const viewsBefore = viewsRequests().length;
+ fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), {
+ target: { value: 'retry' },
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(10 + SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(viewsRequests()).toHaveLength(viewsBefore);
+ expect(
+ screen.getByRole('combobox', { name: 'Semantic views' }),
+ ).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+ await act(async () => {
+ pending[2].resolve(databaseSchema(['shared', 'new_only']));
+ });
+ expect(screen.getByRole('combobox', { name: 'Schema' })).toBeEnabled();
+});
+
+test('manual text entered while options are stale survives an empty-enum refresh', async () => {
+ const { pending } = await openDependentSchemaForm(() => ({
+ ...databaseSchema([]),
+ required: ['database'],
+ }));
+ await chooseDependentOption('Database', 'second');
+ const schema = screen.getByRole('textbox', { name: 'Schema' });
+ expect(schema).toBeEnabled();
+ fireEvent.change(schema, { target: { value: 'manual_schema' } });
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(2);
+ expect(schema).toHaveValue('manual_schema');
+ await act(async () => {
+ pending[1].resolve({ ...databaseSchema([]), required: ['database'] });
+ });
+ expect(screen.getByRole('textbox', { name: 'Schema' })).toHaveValue(
+ 'manual_schema',
+ );
+});
+
+test('reconciliation retains manual text for empty enums and clears old selections', () => {
+ const emptyEnum = databaseSchema([]);
+ expect(
+ reconcileDynamicFieldValues(
+ { database: 'second', schema: 'manual_schema' },
+ emptyEnum,
+ emptyEnum,
),
- ).toHaveLength(1);
+ ).toEqual({ database: 'second', schema: 'manual_schema' });
+ expect(
+ reconcileDynamicFieldValues(
+ { database: 'second', schema: 'old_only' },
+ databaseSchema(['old_only']),
+ emptyEnum,
+ ),
+ ).toEqual({ database: 'second' });
+ expect(
+ reconcileDynamicFieldValues(
+ { database: 'third', schema: 'manual_schema' },
+ emptyEnum,
+ databaseSchema(['third_only']),
+ ),
+ ).toEqual({ database: 'third' });
});
-test('a superseded refresh response cannot toast or clobber a newer one', async () => {
- // Exercises schemaRefreshGenRef: when two refreshes overlap and the older
- // one settles last, its failure must be ignored — no error toast, and no
- // rollback of the dependency snapshot the newer request committed.
- let call = 0;
- const resolvers: Array<() => void> = [];
- mockLayerWithSchema(dynamicMetricsSchema, {
+test('a refresh response without a schema reports failure and permits one retry', async () => {
+ const { pending, props } = await openDependentSchemaForm();
+ await chooseDependentOption('Database', 'second');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await act(async () => {
+ pending[1].resolve(null);
+ });
+ expect(props.addDangerToast).toHaveBeenCalledWith(
+ 'An error occurred while refreshing the runtime schema',
+ );
+ expect(screen.getByRole('combobox', { name: 'Schema' })).toBeDisabled();
+ fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), {
+ target: { value: 'retry' },
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(10 + SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(3);
+});
+
+test.each([{ values: ['new_only'] }, { values: [] }])(
+ 'dependent selection is cleared against matching enum $values',
+ async ({ values }) => {
+ const consoleError = jest.spyOn(console, 'error');
+ const { pending } = await openDependentSchemaForm();
+ await chooseDependentOption('Schema', 'old_only');
+ await chooseDependentOption('Database', 'second');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await act(async () => {
+ pending[1].resolve(databaseSchema(values));
+ });
+ expect(selectedTag('old_only')).toBeNull();
+ if (values.length === 0)
+ expect(screen.getByRole('textbox', { name: 'Schema' })).toHaveValue('');
+ const hookOrderChanged = consoleError.mock.calls.some(args =>
+ String(args[0]).includes('change in the order of Hooks'),
+ );
+ consoleError.mockRestore();
+ expect(hookOrderChanged).toBe(false);
+ },
+);
+
+test('dependent options are unavailable when a dependency is cleared', async () => {
+ const { pending } = await openDependentSchemaForm();
+ const database = screen.getByRole('combobox', { name: 'Database' });
+ const clear = database
+ .closest('.ant-select')
+ ?.querySelector('.ant-select-clear');
+ expect(clear).toBeTruthy();
+ if (clear) fireEvent.click(clear);
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ expect(database.closest('.ant-select')).not.toHaveTextContent('first');
+ const schema = screen.getByRole('combobox', { name: 'Schema' });
+ expect(schema).toBeDisabled();
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(1);
+ expect(schema).toBeDisabled();
+ await chooseDependentOption('Database', 'third');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await act(async () => {
+ pending[1].resolve(databaseSchema(['third_only']));
+ });
+ await waitFor(() => expect(schema).toBeEnabled());
+});
+
+const optionalDatabaseSchema = (schemas: string[]) => ({
+ ...databaseSchema(schemas),
+ required: ['schema'],
+});
+
+test('clearing an optional dependency on a valid form refreshes options and fetches views', async () => {
+ const { pending } = await openDependentSchemaForm(optionalDatabaseSchema);
+ await chooseDependentOption('Schema', 'shared');
+ const database = screen.getByRole('combobox', { name: 'Database' });
+ const clear = database
+ .closest('.ant-select')
+ ?.querySelector('.ant-select-clear');
+ expect(clear).toBeTruthy();
+ if (clear) fireEvent.click(clear);
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ const schema = screen.getByRole('combobox', { name: 'Schema' });
+ expect(schema).toBeDisabled();
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ // The optional dependency is empty but the form is valid, so the options
+ // must be refreshed for that state rather than stay unavailable forever.
+ expect(pending).toHaveLength(2);
+ await act(async () => {
+ pending[1].resolve(optionalDatabaseSchema(['shared']));
+ });
+ await waitFor(() => expect(schema).toBeEnabled());
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await waitFor(() =>
+ expect(
+ viewsRequests().some(
+ ({ jsonPayload }: { jsonPayload: { runtime_data?: unknown } }) =>
+ (jsonPayload.runtime_data as Record<string, unknown> | undefined)
+ ?.database == null,
+ ),
+ ).toBe(true),
+ );
+});
+
+test('a dependency satisfied by its schema default refreshes dependent options', async () => {
+ jest.useFakeTimers();
+ const pending: PendingSchema[] = [];
+ const base = databaseSchema(['shared']);
+ const initial = {
+ ...base,
+ properties: {
+ ...base.properties,
+ database: { ...base.properties.database, default: 'first' },
+ },
+ };
+ mockLayerWithSchema(initial, {
'/api/v1/semantic_layer/layer-1/schema/runtime': (payload: unknown) => {
if (!(payload as { runtime_data?: unknown })?.runtime_data) {
- return Promise.resolve({ json: { result: dynamicMetricsSchema } });
+ return Promise.resolve({ json: { result: initial } });
}
- call += 1;
- if (call === 1) {
- // First (soon-to-be superseded) refresh: fails, but only after the
- // second one has already been issued.
- return new Promise((_resolve, reject) => {
- resolvers.push(() => reject(new Error('stale refresh boom')));
+ return new Promise((resolve, reject) => {
+ pending.push({
+ resolve: schema => resolve({ json: { result: schema } }),
+ reject,
});
+ });
+ },
+ });
+ render(<AddSemanticViewModal {...createProps()} />);
+ await chooseDependentOption('Semantic layer', 'Snowflake SL');
+ await chooseDependentOption('Schema', 'shared');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(1);
+ // The default is not sent; the provider applies it for the absent key.
+ const refreshPayloads = mockedPost.mock.calls
+ .map(([request]) => request)
+ .filter(
+ ({ endpoint, jsonPayload }) =>
+ endpoint === '/api/v1/semantic_layer/layer-1/schema/runtime' &&
+ jsonPayload?.runtime_data,
+ );
+ expect(refreshPayloads.at(-1).jsonPayload.runtime_data).toEqual({
+ schema: 'shared',
+ });
+ await act(async () => {
+ pending[0].resolve(initial);
+ });
+ await waitFor(() =>
+ expect(screen.getByRole('combobox', { name: 'Schema' })).toBeEnabled(),
+ );
+});
+
+test('an empty optional dependency does not block a required dependent field', async () => {
+ jest.useFakeTimers();
+ const pending: PendingSchema[] = [];
+ const initial = optionalDatabaseSchema(['shared']);
+ mockLayerWithSchema(initial, {
+ '/api/v1/semantic_layer/layer-1/schema/runtime': (payload: unknown) => {
+ if (!(payload as { runtime_data?: unknown })?.runtime_data) {
+ return Promise.resolve({ json: { result: initial } });
}
- return Promise.resolve({ json: { result: dynamicMetricsSchema } });
+ return new Promise((resolve, reject) => {
+ pending.push({
+ resolve: schema => resolve({ json: { result: schema } }),
+ reject,
+ });
+ });
},
});
- const props = createProps();
- render(<AddSemanticViewModal {...props} />);
- await selectOption('Semantic layer', 'Snowflake SL');
+ render(<AddSemanticViewModal {...createProps()} />);
+ await chooseDependentOption('Semantic layer', 'Snowflake SL');
+ // The only error is the required dependent field; the optional dependency
+ // is empty, so its options must still load instead of staying disabled.
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(1);
+ await act(async () => {
+ pending[0].resolve(optionalDatabaseSchema(['no_database']));
+ });
+ const schema = screen.getByRole('combobox', { name: 'Schema' });
+ await waitFor(() => expect(schema).toBeEnabled());
+ fireEvent.mouseDown(schema);
+ expect(await screen.findByText('no_database')).toBeInTheDocument();
+});
- await pickFromSelect(/metrics/i, 'm1');
- await waitFor(() => expect(call).toBe(1), { timeout: 10000 });
- // Second selection supersedes the in-flight refresh.
- await pickFromSelect(/metrics/i, 'm2');
- await waitFor(() => expect(call).toBe(2), { timeout: 10000 });
- // Now let the stale first request fail, last.
- resolvers.forEach(fn => fn());
- await new Promise(resolve => {
- setTimeout(resolve, 1000);
+test('a refresh that drops the dependent value keeps the new options available', async () => {
+ const { pending } = await openDependentSchemaForm(optionalDatabaseSchema);
+ await chooseDependentOption('Schema', 'old_only');
+ const database = screen.getByRole('combobox', { name: 'Database' });
+ const clear = database
+ .closest('.ant-select')
+ ?.querySelector('.ant-select-clear');
+ if (clear) fireEvent.click(clear);
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(2);
+ await act(async () => {
+ pending[1].resolve(optionalDatabaseSchema(['no_database']));
+ });
+ // Reconcile drops old_only, leaving the required dependent field empty;
+ // that error must not hide the options the refresh just loaded.
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ const schema = screen.getByRole('combobox', { name: 'Schema' });
+ await waitFor(() => expect(schema).toBeEnabled());
+ expect(selectedTag('old_only')).toBeNull();
+});
+
+test('a self-dependent required field stays selectable when the form opens', async () => {
+ jest.useFakeTimers();
+ // MetricFlow's mode picker depends on its own value and is required.
+ const modeSchema = {
+ type: 'object',
+ required: ['mode'],
+ properties: {
+ mode: {
+ type: 'string',
+ title: 'Mode',
+ enum: ['full', 'cube'],
+ 'x-enumNames': ['Full semantic layer', 'Cube'],
+ 'x-dynamic': true,
+ 'x-dependsOn': ['mode'],
+ },
+ },
+ };
+ mockLayerWithSchema(modeSchema);
+ render(<AddSemanticViewModal {...createProps()} />);
+ await chooseDependentOption('Semantic layer', 'Snowflake SL');
+ await act(async () => {
+ jest.advanceTimersByTime(10);
});
+ const mode = await screen.findByRole('combobox', { name: 'Mode' });
+ expect(mode).toBeEnabled();
+ await chooseDependentOption('Mode', 'Full semantic layer');
+ expect(mode.closest('.ant-select')).toHaveTextContent('Full semantic layer');
+ expect(mode).toBeEnabled();
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(mode).toBeEnabled();
+});
+test('a dependency change cancels views queued for the previous dependency state', async () => {
+ const { pending } = await openDependentSchemaForm();
+ await chooseDependentOption('Schema', 'shared');
+ // Views for {database: first, schema: shared} are queued but not yet sent.
+ await chooseDependentOption('Database', 'second');
+ const sentBefore = viewsRequests().length;
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ const staleViewRequests = viewsRequests()
+ .slice(sentBefore)
+ .filter(
+ ({ jsonPayload }: { jsonPayload: { runtime_data?: unknown } }) =>
+ (jsonPayload.runtime_data as Record<string, unknown>)?.database ===
+ 'first',
+ );
+ expect(staleViewRequests).toHaveLength(0);
+ expect(pending).toHaveLength(2);
+});
+
+test('a dependency change discards an in-flight views response', async () => {
+ const { pending } = await openDependentSchemaForm();
+ const originalPost = mockedPost.getMockImplementation();
+ let finishViews: (() => void) | undefined;
+ let viewsCall = 0;
+ mockedPost.mockImplementation((request: { endpoint: string }) => {
+ if (request.endpoint === '/api/v1/semantic_layer/layer-1/views') {
+ viewsCall += 1;
+ if (viewsCall === 1) {
+ return Promise.resolve({
+ json: { result: [{ name: 'old_view', already_added: false }] },
+ });
+ }
+ return new Promise(resolve => {
+ finishViews = () =>
+ resolve({
+ json: { result: [{ name: 'old_view', already_added: false }] },
+ });
+ });
+ }
+ return originalPost?.(request);
+ });
+ await chooseDependentOption('Schema', 'shared');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await chooseDependentOption('Semantic views', 'old_view');
+ expect(screen.getByRole('button', { name: /Add 1 view/ })).toBeEnabled();
+ fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), {
+ target: { value: 'new request' },
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(10);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(viewsCall).toBe(2);
+ expect(finishViews).toBeDefined();
+ await chooseDependentOption('Database', 'second');
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+ await act(async () => {
+ finishViews?.();
+ });
+ expect(selectedTag('old_view')).toBeNull();
+ expect(
+ screen.getByRole('combobox', { name: 'Semantic views' }),
+ ).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(2);
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+});
+
+test('closing and reopening clears stale dependent-option state', async () => {
+ const { props, rerender } = await openDependentSchemaForm();
+ await chooseDependentOption('Database', 'second');
+ expect(screen.getByRole('combobox', { name: 'Schema' })).toBeDisabled();
+ rerender(<AddSemanticViewModal {...props} show={false} />);
+ rerender(<AddSemanticViewModal {...props} />);
+ await chooseDependentOption('Semantic layer', 'Snowflake SL');
+ expect(screen.getByRole('combobox', { name: 'Schema' })).toBeEnabled();
+});
+
+test('switching layers clears stale dependent-option state', async () => {
+ jest.useFakeTimers();
+ const schema = databaseSchema(['shared']);
+ mockLayerWithSchema(schema, {
+ '/api/v1/semantic_layer/layer-2/schema/runtime': () =>
+ Promise.resolve({ json: { result: schema } }),
+ });
+ mockedGet.mockResolvedValue({
+ json: {
+ result: [
+ { uuid: 'layer-1', name: 'Snowflake SL' },
+ { uuid: 'layer-2', name: 'Second SL' },
+ ],
+ },
+ });
+ render(<AddSemanticViewModal {...createProps()} />);
+ await chooseDependentOption('Semantic layer', 'Snowflake SL');
+ await chooseDependentOption('Database', 'first');
+ expect(screen.getByRole('combobox', { name: 'Schema' })).toBeDisabled();
+ await chooseDependentOption('Semantic layer', 'Second SL');
+ expect(screen.getByRole('combobox', { name: 'Schema' })).toBeEnabled();
+});
+
+test('a persistently failing refresh retries once, not on every edit', async () => {
+ const { pending, props } = await openDependentSchemaForm();
+ await chooseDependentOption('Schema', 'shared');
+ await chooseDependentOption('Database', 'second');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(2);
+ await act(async () => {
+ pending[1].reject(new Error('refresh boom'));
+ });
+
+ const note = screen.getByRole('textbox', { name: 'Note' });
+ fireEvent.change(note, { target: { value: 'retry' } });
+ await act(async () => {
+ jest.advanceTimersByTime(10 + SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(3);
+ await act(async () => {
+ pending[2].reject(new Error('refresh boom'));
+ });
+
+ // More edits in the same dependency state must not schedule a third attempt
+ // or fetch views for the stale options.
+ const viewsBefore = viewsRequests().length;
+ fireEvent.change(note, { target: { value: 'another edit' } });
+ await act(async () => {
+ jest.advanceTimersByTime(10 + SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(3);
+ expect(viewsRequests()).toHaveLength(viewsBefore);
expect(
props.addDangerToast.mock.calls.filter(([msg]) =>
String(msg).includes('refreshing the runtime schema'),
),
- ).toHaveLength(0);
- expect(selectedTag('m1')).toBeTruthy();
- expect(selectedTag('m2')).toBeTruthy();
+ ).toHaveLength(1);
+});
+
+test('a superseded refresh failure cannot toast or clobber a newer response', async () => {
+ const { pending, props } = await openDependentSchemaForm();
+ await chooseDependentOption('Schema', 'shared');
+ await chooseDependentOption('Database', 'second');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ await chooseDependentOption('Database', 'third');
+ await act(async () => {
+ jest.advanceTimersByTime(SCHEMA_REFRESH_DEBOUNCE_MS);
+ });
+ expect(pending).toHaveLength(3);
+ await act(async () => {
+ pending[2].resolve(databaseSchema(['shared', 'third_only']));
+ });
+ const schema = screen.getByRole('combobox', { name: 'Schema' });
+ expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+ await act(async () => {
+ pending[1].reject(new Error('stale refresh boom'));
+ });
+ expect(props.addDangerToast).not.toHaveBeenCalled();
+ expect(schema.closest('.ant-select')).toHaveTextContent('shared');
+ fireEvent.mouseDown(schema);
+ expect(await screen.findByText('third_only')).toBeInTheDocument();
});
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
index 63e5cdd8d88..a22f2a88010 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -54,6 +54,31 @@ interface AvailableView {
already_added: boolean;
}
+/** Keep manual text when discovery has no choices; drop invalid enum selections. */
+export function reconcileDynamicFieldValues(
+ previous: Record<string, unknown>,
+ previousSchema: JsonSchema | null,
+ rawSchema: JsonSchema,
+): Record<string, unknown> {
+ const invalidFields = Object.entries(rawSchema.properties ?? {})
+ .filter(
+ ([key, field]) =>
+ (field as Record<string, unknown>)['x-dynamic'] === true &&
+ Array.isArray(field.enum) &&
+ (field.enum.length > 0 ||
+ (previousSchema?.properties?.[key]?.enum?.length ?? 0) > 0) &&
+ key in previous &&
+ !field.enum.includes(previous[key]),
+ )
+ .map(([key]) => key);
+ if (invalidFields.length === 0) return previous;
+ const next = { ...previous };
+ invalidFields.forEach(key => {
+ delete next[key];
+ });
+ return next;
+}
+
const ModalContent = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 4}px;
`;
@@ -135,12 +160,16 @@ export default function AddSemanticViewModal({
// --- Runtime config ---
const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
+ // Read by the change handler without re-creating it on every schema load.
+ const runtimeSchemaRef = useRef<JsonSchema | null>(null);
+ runtimeSchemaRef.current = runtimeSchema;
const [runtimeUiSchema, setRuntimeUiSchema] = useState<
UISchemaElement | undefined
>();
const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
const [loadingRuntime, setLoadingRuntime] = useState(false);
const [refreshingSchema, setRefreshingSchema] = useState(false);
+ const [staleSchemaOptions, setStaleSchemaOptions] = useState(false);
const errorsRef = useRef<ErrorObject[]>([]);
const dynamicDepsRef = useRef<Record<string, string[]>>({});
const lastDepSnapshotRef = useRef('');
@@ -156,6 +185,9 @@ export default function AddSemanticViewModal({
// --- Misc ---
const [saving, setSaving] = useState(false);
const fetchGenRef = useRef(0);
+ // Bumped whenever dependent options go stale, so views queued or in
+ // flight for a previous dependency state never land.
+ const viewsGenRef = useRef(0);
// =========================================================================
// Fetch helpers
@@ -182,6 +214,9 @@ export default function AddSemanticViewModal({
const fetchViews = useCallback(
async (uuid: string, rData: Record<string, unknown>, gen: number) => {
+ const viewsGen: number = viewsGenRef.current;
+ const isCurrent = (): boolean =>
+ gen === fetchGenRef.current && viewsGen === viewsGenRef.current;
setLoadingViews(true);
setAvailableViews([]);
setSelectedViewNames([]);
@@ -190,13 +225,13 @@ export default function AddSemanticViewModal({
endpoint: `/api/v1/semantic_layer/${uuid}/views`,
jsonPayload: { runtime_data: rData },
});
- if (gen !== fetchGenRef.current) return;
+ if (!isCurrent()) return;
setAvailableViews(json.result ?? []);
} catch {
- if (gen !== fetchGenRef.current) return;
+ if (!isCurrent()) return;
addDangerToast(t('An error occurred while fetching available views'));
} finally {
- if (gen === fetchGenRef.current) setLoadingViews(false);
+ if (isCurrent()) setLoadingViews(false);
}
},
[addDangerToast],
@@ -213,6 +248,12 @@ export default function AddSemanticViewModal({
const retriedDepSnapshotRef = useRef('');
const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
dynamicDepsRef.current = getDynamicDependencies(rawSchema);
+ const previousSchema = runtimeSchemaRef.current;
+ // Reconcile against the matching response, not an intermediate empty
+ // option list. Retain selections that are valid in both dependency states.
+ setRuntimeData(previous =>
+ reconcileDynamicFieldValues(previous, previousSchema, rawSchema),
+ );
const schema = sanitizeSchema(rawSchema);
const uiSchema = buildUiSchema(schema);
// Dedupe key = key-order-canonical schema + the derived ui schema.
@@ -246,6 +287,16 @@ export default function AddSemanticViewModal({
[fetchViews],
);
+ // Drop views queued or in flight for the previous dependency state.
+ const invalidateViews = useCallback(() => {
+ viewsGenRef.current += 1;
+ if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
+ setLoadingViews(false);
+ setAvailableViews([]);
+ setSelectedViewNames([]);
+ lastViewsKeyRef.current = '';
+ }, []);
+
// =========================================================================
// Layer change — fetch runtime schema, clear downstream state
// =========================================================================
@@ -261,6 +312,8 @@ export default function AddSemanticViewModal({
setRuntimeSchema(null);
setRuntimeUiSchema(undefined);
setRuntimeData({});
+ setRefreshingSchema(false);
+ setStaleSchemaOptions(false);
errorsRef.current = [];
dynamicDepsRef.current = {};
lastDepSnapshotRef.current = '';
@@ -325,87 +378,131 @@ export default function AddSemanticViewModal({
// Dynamic deps changed → refresh schema (e.g. database → schema)
const dynamicDeps = dynamicDepsRef.current;
if (Object.keys(dynamicDeps).length > 0) {
+ // Count schema defaults as DynamicFieldControl does, so a dependency
+ // satisfied only by its default still refreshes dependent options.
+ const schema: JsonSchema | undefined =
+ runtimeSchemaRef.current ?? undefined;
const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
- areDependenciesSatisfied(deps, data),
+ areDependenciesSatisfied(deps, data, schema),
);
- if (hasSatisfiedDeps) {
- const snapshot = serializeDependencyValues(dynamicDeps, data);
- if (snapshot !== lastDepSnapshotRef.current) {
- lastDepSnapshotRef.current = snapshot;
- // Config is changing — clear views
- setAvailableViews([]);
- setSelectedViewNames([]);
- lastViewsKeyRef.current = '';
- if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
- const uuid = selectedLayerUuid;
- schemaRefreshGenRef.current += 1;
- const refreshGen = schemaRefreshGenRef.current;
- schemaTimerRef.current = setTimeout(async () => {
- setRefreshingSchema(true);
- try {
- const { json } = await SupersetClient.post({
- endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
- jsonPayload: { runtime_data: data },
- });
- if (
- gen !== fetchGenRef.current ||
- refreshGen !== schemaRefreshGenRef.current
- )
- return;
- // Reset before applying: the request itself succeeded, so a
- // throw inside applyRuntimeSchema must not be reported (or
- // retried) as a transient network failure.
- refreshErrorToastShownRef.current = false;
- retriedDepSnapshotRef.current = '';
- if (json.result) applyRuntimeSchema(json.result);
- } catch (error) {
- if (
- gen !== fetchGenRef.current ||
- refreshGen !== schemaRefreshGenRef.current
- )
- return;
- logging.error('Runtime schema refresh failed', error);
- // Allow exactly one retry per dependency state: clearing the
- // committed snapshot lets the next change event re-attempt
- // the refresh, but only if this state hasn't been retried
- // already, so a persistent outage doesn't re-fire on every
- // edit. Only clear a snapshot this attempt still owns — a
- // newer change may have committed its own since.
- if (
- lastDepSnapshotRef.current === snapshot &&
- retriedDepSnapshotRef.current !== snapshot
- ) {
- retriedDepSnapshotRef.current = snapshot;
- lastDepSnapshotRef.current = '';
- }
- // The form (and the user's selections) stay intact; only the
- // dynamic narrowing is stale, so surface it without blocking
- // — once per outage, not once per debounced attempt.
- if (!refreshErrorToastShownRef.current) {
- refreshErrorToastShownRef.current = true;
- addDangerToast(
- t('An error occurred while refreshing the runtime schema'),
- );
- }
- } finally {
- if (
- gen === fetchGenRef.current &&
- refreshGen === schemaRefreshGenRef.current
- )
- setRefreshingSchema(false);
+ // Wait for input only while a missing dependency is required: a
+ // refresh is the only way to clear stale options, so an empty
+ // optional dependency must still refresh for that state.
+ const required: string[] = Array.isArray(schema?.required)
+ ? schema.required
+ : [];
+ const awaitsRequiredDependency = Object.values(dynamicDeps).some(deps =>
+ deps.some(
+ dep =>
+ required.includes(dep) &&
+ !areDependenciesSatisfied([dep], data, schema),
+ ),
+ );
+ if (!hasSatisfiedDeps && awaitsRequiredDependency) {
+ // Not a committed state: the next valid change must refresh.
+ lastDepSnapshotRef.current = '';
+ schemaRefreshGenRef.current += 1;
+ if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
+ setRefreshingSchema(false);
+ setStaleSchemaOptions(true);
+ invalidateViews();
+ return;
+ }
+ const snapshot = serializeDependencyValues(dynamicDeps, data);
+ if (snapshot !== lastDepSnapshotRef.current) {
+ lastDepSnapshotRef.current = snapshot;
+ setRefreshingSchema(true);
+ setStaleSchemaOptions(true);
+ // Config is changing — clear views
+ invalidateViews();
+ if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
+ const uuid = selectedLayerUuid;
+ schemaRefreshGenRef.current += 1;
+ const refreshGen = schemaRefreshGenRef.current;
+ schemaTimerRef.current = setTimeout(async () => {
+ try {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
+ jsonPayload: { runtime_data: data },
+ });
+ if (
+ gen !== fetchGenRef.current ||
+ refreshGen !== schemaRefreshGenRef.current
+ )
+ return;
+ if (!json.result)
+ throw new Error('Runtime schema refresh returned no schema');
+ // The request succeeded, so end the current outage; a later
+ // failure can toast and retry again.
+ refreshErrorToastShownRef.current = false;
+ retriedDepSnapshotRef.current = '';
+ applyRuntimeSchema(json.result);
+ setStaleSchemaOptions(false);
+ } catch (error) {
+ if (
+ gen !== fetchGenRef.current ||
+ refreshGen !== schemaRefreshGenRef.current
+ )
+ return;
+ logging.error('Runtime schema refresh failed', error);
+ // Allow exactly one retry per dependency state: clearing the
+ // committed snapshot lets the next change event re-attempt
+ // the refresh, but only if this state hasn't been retried
+ // already, so a persistent outage doesn't re-fire on every
+ // edit. Only clear a snapshot this attempt still owns — a
+ // newer change may have committed its own since.
+ if (
+ lastDepSnapshotRef.current === snapshot &&
+ retriedDepSnapshotRef.current !== snapshot
+ ) {
+ retriedDepSnapshotRef.current = snapshot;
+ lastDepSnapshotRef.current = '';
}
- }, SCHEMA_REFRESH_DEBOUNCE_MS);
- return;
- }
+ // Only scalar choices can remain unsafe after a failed refresh;
+ // array choices are not reconciled and should not block views.
+ const hasStaleScalarChoices = Object.entries(
+ schema?.properties ?? {},
+ ).some(
+ ([key, field]) =>
+ (dynamicDeps[key] ?? []).some(dep => dep !== key) &&
+ field.type !== 'array' &&
+ field.type !== 'object' &&
+ Array.isArray(field.enum) &&
+ field.enum.length > 0,
+ );
+ setStaleSchemaOptions(hasStaleScalarChoices);
+ // Retain selections and toast once per outage.
+ if (!refreshErrorToastShownRef.current) {
+ refreshErrorToastShownRef.current = true;
+ addDangerToast(
+ t('An error occurred while refreshing the runtime schema'),
+ );
+ }
+ } finally {
+ if (
+ gen === fetchGenRef.current &&
+ refreshGen === schemaRefreshGenRef.current
+ )
+ setRefreshingSchema(false);
+ }
+ }, SCHEMA_REFRESH_DEBOUNCE_MS);
+ return;
}
}
// No schema refresh needed — fetch views if form is valid
- if (errorsRef.current.length === 0) {
+ if (!staleSchemaOptions && errorsRef.current.length === 0) {
scheduleFetchViews(selectedLayerUuid, data);
}
},
- [selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews, addDangerToast],
+ [
+ selectedLayerUuid,
+ applyRuntimeSchema,
+ scheduleFetchViews,
+ invalidateViews,
+ addDangerToast,
+ staleSchemaOptions,
+ ],
);
// After a schema refresh settles, JSON Forms re-validates and fires
@@ -413,7 +510,12 @@ export default function AddSemanticViewModal({
// (in case onChange doesn't fire), try once refreshingSchema flips false.
const prevRefreshingRef = useRef(false);
useEffect(() => {
- if (prevRefreshingRef.current && !refreshingSchema && selectedLayerUuid) {
+ if (
+ prevRefreshingRef.current &&
+ !refreshingSchema &&
+ !staleSchemaOptions &&
+ selectedLayerUuid
+ ) {
const timer = setTimeout(() => {
if (errorsRef.current.length === 0) {
scheduleFetchViews(selectedLayerUuid, runtimeData);
@@ -424,7 +526,13 @@ export default function AddSemanticViewModal({
}
prevRefreshingRef.current = refreshingSchema;
return undefined;
- }, [refreshingSchema, selectedLayerUuid, runtimeData, scheduleFetchViews]);
+ }, [
+ refreshingSchema,
+ staleSchemaOptions,
+ selectedLayerUuid,
+ runtimeData,
+ scheduleFetchViews,
+ ]);
// =========================================================================
// Modal open / close
@@ -445,6 +553,7 @@ export default function AddSemanticViewModal({
setRuntimeData({});
setLoadingRuntime(false);
setRefreshingSchema(false);
+ setStaleSchemaOptions(false);
errorsRef.current = [];
dynamicDepsRef.current = {};
lastDepSnapshotRef.current = '';
@@ -536,8 +645,8 @@ export default function AddSemanticViewModal({
// every control, so an inline literal would re-render all of them on each
// modal render.
const jsonFormsConfig = useMemo(
- () => ({ refreshingSchema, formData: runtimeData }),
- [refreshingSchema, runtimeData],
+ () => ({ refreshingSchema, staleSchemaOptions, formData: runtimeData }),
+ [refreshingSchema, staleSchemaOptions, runtimeData],
);
// Sorted copy — sorting in place would mutate React state during render.
@@ -589,7 +698,9 @@ export default function AddSemanticViewModal({
title={t('Add Semantic View')}
icon={<Icons.PlusOutlined />}
width={MODAL_STANDARD_WIDTH}
- saveDisabled={newViewCount === 0 || saving}
+ saveDisabled={
+ newViewCount === 0 || saving || staleSchemaOptions || refreshingSchema
+ }
saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')}
saveLoading={saving}
>