- {isTypeStep ? (
-
- setSelectedType(value as string)}
- options={types.map(type => ({
- value: type.id,
- label: type.name,
- }))}
- getPopupContainer={() => document.body}
- />
-
+
+ {loading ? (
+
+
+
) : (
<>
- {isEditMode && (
-
- {t('Reload metadata')}
-
+ {/* COPY-TBD: semantic setup guidance, pending product copy. */}
+ {isTypeStep && typeStatus === 'loading' && (
+
+ {t('Loading semantic layer types…')}
+
+ )}
+ {isTypeStep && typeStatus === 'error' && (
+ <>
+
+ {t('Try again')}
+ >
+ )}
+ {isTypeStep && typeStatus === 'success' && types.length === 0 && (
+
+
+ {t(
+ 'No semantic layer types are available. Ask your administrator to check the semantic layer setup for this deployment.',
+ )}
+
+
)}
- {!isEditMode && (
- }
- onClick={handleBack}
- >
- {t('Back')}
-
+ {isTypeStep &&
+ typeStatus === 'success' &&
+ types.length > 0 &&
+ !selectedType && (
+
+ {t(
+ 'Choose a semantic layer type to see its setup information.',
+ )}
+
+ )}
+ {typeof description === 'string' && description.trim() && (
+ {t(description)}
)}
-
- setName(e.target.value)}
- placeholder={t('Name of the semantic layer')}
- />
-
- {configSchema && (
- // Wrap in a form with autocomplete="off" so browsers do not
- // autofill credential fields (service token, account, etc.).
-
+
+ ) : (
+ <>
+ {isEditMode && (
+
+ {t('Reload metadata')}
+
+ )}
+ {!isEditMode && (
+ }
+ onClick={handleBack}
+ >
+ {t('Back')}
+
+ )}
+
+ setName(e.target.value)}
+ placeholder={t('Name of the semantic layer')}
+ />
+
+ {configSchema && (
+ // Wrap in a form with autocomplete="off" so browsers do not
+ // autofill credential fields (service token, account, etc.).
+
+ )}
+ >
)}
>
)}
diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerSetupHelp.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerSetupHelp.tsx
new file mode 100644
index 000000000000..5018fa5d3129
--- /dev/null
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerSetupHelp.tsx
@@ -0,0 +1,53 @@
+/**
+ * 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 { t } from '@apache-superset/core/translation';
+import getBootstrapData from 'src/utils/getBootstrapData';
+
+export default function SemanticLayerSetupHelp() {
+ const configuredUrl: unknown =
+ getBootstrapData().common.conf.SEMANTIC_LAYER_DOCUMENTATION_URL;
+ if (
+ typeof configuredUrl !== 'string' ||
+ !/^https?:\/\//i.test(configuredUrl.trim())
+ ) {
+ return null;
+ }
+ let url: URL;
+ try {
+ url = new URL(configuredUrl.trim());
+ } catch {
+ return null;
+ }
+ if (
+ !['http:', 'https:'].includes(url.protocol) ||
+ !url.hostname ||
+ url.username ||
+ url.password
+ ) {
+ return null;
+ }
+ return (
+
+
+ {/* COPY-TBD-10: pending product copy. */}
+ {t('Semantic layer setup guide')}
+
+
+ );
+}
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
index eac84aaa3d0d..a972b384a2ff 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+import getBootstrapData from 'src/utils/getBootstrapData';
import {
act,
fireEvent,
@@ -46,6 +47,13 @@ jest.mock('@superset-ui/core', () => ({
},
}));
+let mockSemanticDocsUrl: unknown;
+jest.mock('src/utils/getBootstrapData', () => ({
+ __esModule: true,
+ ...jest.requireActual('src/utils/getBootstrapData'),
+ default: jest.fn(jest.requireActual('src/utils/getBootstrapData').default),
+}));
+
const mockedGet = SupersetClient.get as jest.Mock;
const mockedPost = SupersetClient.post as jest.Mock;
@@ -64,6 +72,22 @@ const selectOption = async (name: string, optionLabel: string) => {
};
beforeEach(() => {
+ mockSemanticDocsUrl = undefined;
+ jest.mocked(getBootstrapData).mockImplementation(() => {
+ const bootstrap = jest
+ .requireActual('src/utils/getBootstrapData')
+ .default();
+ return {
+ ...bootstrap,
+ common: {
+ ...bootstrap.common,
+ conf: {
+ ...bootstrap.common.conf,
+ SEMANTIC_LAYER_DOCUMENTATION_URL: mockSemanticDocsUrl,
+ },
+ },
+ };
+ });
mockedGet.mockReset();
mockedPost.mockReset();
});
@@ -1677,3 +1701,168 @@ test('a superseded refresh failure cannot toast or clobber a newer response', as
fireEvent.mouseDown(schema);
expect(await screen.findByText('third_only')).toBeInTheDocument();
});
+
+test('setup guidance distinguishes pending and empty layers for readers', async () => {
+ let resolveLayers!: (value: { json: { result: [] } }) => void;
+ mockedGet.mockReturnValue(
+ new Promise(resolve => {
+ resolveLayers = resolve;
+ }),
+ );
+ render( );
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Loading semantic layers',
+ );
+ expect(screen.queryByText(/Ask your administrator/)).not.toBeInTheDocument();
+ await act(async () => resolveLayers({ json: { result: [] } }));
+ expect(
+ await screen.findByText(
+ /Ask your administrator to set up a semantic layer or grant you access/,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Create semantic layer' }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+});
+
+test('setup guidance offers direct creation only when a caller supplies the action', async () => {
+ mockedGet.mockResolvedValue({ json: { result: [] } });
+ const onCreateLayer = jest.fn();
+ render(
+ ,
+ );
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Create semantic layer' }),
+ );
+ expect(onCreateLayer).toHaveBeenCalledTimes(1);
+ expect(mockedPost).not.toHaveBeenCalled();
+});
+
+test.each([
+ {},
+ { result: null },
+ { result: {} },
+ { result: [{ name: 'missing id' }] },
+])('setup guidance treats malformed layer data as an error: %j', async json => {
+ mockedGet.mockResolvedValue({ json });
+ render( );
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Unable to load semantic layers',
+ );
+ expect(
+ screen.queryByText(/No semantic layers are available/),
+ ).not.toBeInTheDocument();
+});
+
+test.each([403, 404, 500])(
+ 'setup guidance retries a failed %s read without diagnosing installation',
+ async status => {
+ mockedGet
+ .mockRejectedValueOnce({ status })
+ .mockResolvedValueOnce({ json: { result: [] } });
+ render( );
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Unable to load semantic layers',
+ );
+ expect(
+ screen.queryByText(/No semantic layers are available/),
+ ).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
+ expect(
+ await screen.findByText(/No semantic layers are available to you/),
+ ).toBeInTheDocument();
+ expect(mockedGet).toHaveBeenCalledTimes(2);
+ },
+);
+
+test.each(['resolve', 'reject'])(
+ 'setup guidance ignores stale layer %s after reopening',
+ async settlement => {
+ let resolveOld!: (value: { json: { result: [] } }) => void;
+ let rejectOld!: (error: Error) => void;
+ mockedGet
+ .mockReturnValueOnce(
+ new Promise((resolve, reject) => {
+ resolveOld = resolve;
+ rejectOld = reject;
+ }),
+ )
+ .mockResolvedValueOnce({
+ json: { result: [{ uuid: 'current', name: 'Current layer' }] },
+ });
+ const props = createProps();
+ const { rerender } = render( );
+ rerender( );
+ rerender( );
+ await waitFor(() => expect(mockedGet).toHaveBeenCalledTimes(2));
+ await act(async () => {
+ if (settlement === 'resolve') resolveOld({ json: { result: [] } });
+ else rejectOld(new Error('stale request'));
+ });
+ await userEvent.click(
+ screen.getByRole('combobox', { name: 'Semantic layer' }),
+ );
+ expect(await screen.findByText('Current layer')).toBeInTheDocument();
+ expect(
+ screen.queryByText(/No semantic layers are available/),
+ ).not.toBeInTheDocument();
+ expect(props.addDangerToast).not.toHaveBeenCalled();
+ },
+);
+
+describe('setup documentation', () => {
+ test.each([
+ undefined,
+ null,
+ '',
+ ' ',
+ '/relative',
+ 'https://',
+ 'https:example.org',
+ '//example.org/docs',
+ // Deliberately unsafe input: the link must reject executable URLs.
+ // eslint-disable-next-line no-script-url
+ 'javascript:alert(1)',
+ 'data:text/html,test',
+ 'https://user:secret@example.org/docs',
+ 123,
+ ])('hides an unset or unsafe URL: %p', async url => {
+ mockSemanticDocsUrl = url;
+ mockedGet.mockReset().mockResolvedValue({ json: { result: [] } });
+ render( );
+ await screen.findByText(/No semantic layer/);
+ expect(
+ screen.queryByRole('link', { name: 'Semantic layer setup guide' }),
+ ).not.toBeInTheDocument();
+ });
+
+ test.each([
+ 'https://docs.example.org/setup',
+ 'http://docs.example.org/setup',
+ ])(
+ 'keeps the configured help link available while loading and after failure: %s',
+ async url => {
+ mockSemanticDocsUrl = url;
+ let reject!: (reason: Error) => void;
+ mockedGet.mockReset().mockReturnValue(
+ new Promise((_, rejectRequest) => {
+ reject = rejectRequest;
+ }),
+ );
+ render( );
+ const link = screen.getByRole('link', {
+ name: 'Semantic layer setup guide',
+ });
+ expect(link).toHaveAttribute('href', url);
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ await act(async () => reject(new Error('private detail')));
+ expect(screen.getByRole('alert')).toHaveTextContent('Unable to load');
+ expect(
+ screen.getByRole('link', { name: 'Semantic layer setup guide' }),
+ ).toHaveAttribute('href', url);
+ expect(screen.queryByText('private detail')).not.toBeInTheDocument();
+ },
+ );
+});
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
index 7db276d8f272..314460b7e073 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -21,7 +21,7 @@ import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { styled } from '@apache-superset/core/theme';
import { SupersetClient } from '@superset-ui/core';
-import { List, Select } from '@superset-ui/core/components';
+import { Button, List, Select } from '@superset-ui/core/components';
import { Alert } from '@apache-superset/core/components';
import { Spin } from '@superset-ui/core/components/Spin';
import { Icons } from '@superset-ui/core/components/Icons';
@@ -50,6 +50,8 @@ import {
parseCreationResult,
} from './creationResult';
+import SemanticLayerSetupHelp from '../semanticLayers/SemanticLayerSetupHelp';
+
interface SemanticLayerOption {
uuid: string;
name: string;
@@ -151,6 +153,8 @@ interface AddSemanticViewModalProps {
show: boolean;
onHide: () => void;
onSuccess: () => void;
+ onCreateLayer?: () => void;
+ layerCreatedSignal?: number;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
@@ -159,6 +163,8 @@ export default function AddSemanticViewModal({
show,
onHide,
onSuccess,
+ onCreateLayer,
+ layerCreatedSignal,
addDangerToast,
addSuccessToast,
}: AddSemanticViewModalProps) {
@@ -167,7 +173,11 @@ export default function AddSemanticViewModal({
const [selectedLayerUuid, setSelectedLayerUuid] = useState(
null,
);
- const [loadingLayers, setLoadingLayers] = useState(false);
+ const [layerStatus, setLayerStatus] = useState<
+ 'loading' | 'success' | 'error'
+ >('loading');
+ const layerRequestRef = useRef(0);
+ const loadingLayers = layerStatus === 'loading';
// --- Runtime config ---
const [runtimeSchema, setRuntimeSchema] = useState(null);
@@ -210,21 +220,31 @@ export default function AddSemanticViewModal({
// =========================================================================
const fetchLayers = async () => {
- setLoadingLayers(true);
+ layerRequestRef.current += 1;
+ const request = layerRequestRef.current;
+ setLayerStatus('loading');
try {
const { json } = await SupersetClient.get({
endpoint: '/api/v1/semantic_layer/',
});
- setLayers(
- (json.result ?? []).map((l: { uuid: string; name: string }) => ({
- uuid: l.uuid,
- name: l.name,
- })),
- );
+ if (request !== layerRequestRef.current) return;
+ const result: unknown = json.result;
+ if (
+ !Array.isArray(result) ||
+ !result.every(
+ (layer): layer is SemanticLayerOption =>
+ isRecord(layer) &&
+ typeof layer.uuid === 'string' &&
+ typeof layer.name === 'string',
+ )
+ )
+ throw new Error('Invalid semantic layer list');
+ setLayers(result);
+ setLayerStatus('success');
} catch {
+ if (request !== layerRequestRef.current) return;
+ setLayerStatus('error');
addDangerToast(t('An error occurred while fetching semantic layers'));
- } finally {
- setLoadingLayers(false);
}
};
@@ -567,6 +587,7 @@ export default function AddSemanticViewModal({
unmountedRef.current = false;
return () => {
unmountedRef.current = true;
+ layerRequestRef.current += 1;
// Drop in-flight continuations, as closing the modal does.
fetchGenRef.current += 1;
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
@@ -587,7 +608,8 @@ export default function AddSemanticViewModal({
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
setLayers([]);
setSelectedLayerUuid(null);
- setLoadingLayers(false);
+ layerRequestRef.current += 1;
+ setLayerStatus('loading');
setRuntimeSchema(null);
setRuntimeUiSchema(undefined);
setRuntimeData({});
@@ -608,7 +630,7 @@ export default function AddSemanticViewModal({
setCreationErrors([]);
lastViewsKeyRef.current = '';
}
- }, [show]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [show, layerCreatedSignal]); // eslint-disable-line react-hooks/exhaustive-deps
// =========================================================================
// Save
@@ -765,12 +787,17 @@ export default function AddSemanticViewModal({
icon={ }
width={MODAL_STANDARD_WIDTH}
saveDisabled={
- newViewCount === 0 || saving || staleSchemaOptions || refreshingSchema
+ layerStatus !== 'success' ||
+ newViewCount === 0 ||
+ saving ||
+ staleSchemaOptions ||
+ refreshingSchema
}
saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')}
saveLoading={saving}
>
+
{creationErrors.length > 0 && (
)}
+ {/* COPY-TBD: semantic setup guidance, pending product copy. */}
+ {loadingLayers && (
+
+ {t('Loading semantic layers…')}
+
+ )}
+ {layerStatus === 'error' && (
+ <>
+
+ {t('Try again')}
+ >
+ )}
+ {layerStatus === 'success' && layers.length === 0 && (
+ <>
+
+
+ {onCreateLayer
+ ? t(
+ 'No semantic layers are available to you. A semantic layer connects your data to its business definitions. Create a semantic layer before adding a semantic view.',
+ )
+ : t(
+ 'No semantic layers are available to you. Ask your administrator to set up a semantic layer or grant you access to one.',
+ )}
+
+
+ {onCreateLayer && (
+
+ {t('Create semantic layer')}
+
+ )}
+ >
+ )}
{/* Semantic Layer */}
handleLayerChange(value as string)}
options={layers.map(l => ({
diff --git a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
index 540f9c7b3706..64fbf3ae9cf5 100644
--- a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
+++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
@@ -608,3 +608,131 @@ test('failed authorized upload lookup leaves uploads disabled', async () => {
fetchMock.callHistory.calls('glob:*/api/v1/database/?q=*'),
).toHaveLength(1);
});
+
+test('a layer created after Cancel refreshes the Connections list', async () => {
+ setupMocks({ dependents: [], rows: [] });
+ fetchMock.get('glob:*/api/v1/semantic_layer/types', {
+ result: [{ id: 'example', name: 'Example provider' }],
+ });
+ fetchMock.post('glob:*/api/v1/semantic_layer/schema/configuration', {
+ result: { type: 'object', properties: {} },
+ });
+ let finishCreate!: (value: { result: { uuid: string } }) => void;
+ fetchMock.post(
+ 'glob:*/api/v1/semantic_layer/',
+ () =>
+ new Promise(resolve => {
+ finishCreate = resolve;
+ }),
+ );
+ renderDatabaseList();
+ await waitFor(() =>
+ expect(
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length,
+ ).toBeGreaterThan(0),
+ );
+ const initialLoads = fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length;
+
+ await userEvent.click(screen.getByTestId('btn-create-new'));
+ await userEvent.click(
+ await screen.findByRole('menuitem', { name: 'Semantic Layer' }),
+ );
+ const typePicker = await screen.findByRole('combobox', {
+ name: 'Semantic layer type',
+ });
+ await waitFor(() => expect(typePicker).toBeEnabled());
+ fireEvent.mouseDown(typePicker);
+ await userEvent.click(
+ await screen.findByText('Example provider', {
+ selector: '.ant-select-item-option-content',
+ }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Add' }));
+ const create = await screen.findByRole('button', { name: 'Create' });
+ await waitFor(() => expect(create).toBeEnabled());
+ await userEvent.click(create);
+ await waitFor(() =>
+ expect(
+ fetchMock.callHistory.calls('glob:*/api/v1/semantic_layer/'),
+ ).toHaveLength(1),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await waitFor(() =>
+ expect(
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length,
+ ).toBeGreaterThan(initialLoads),
+ );
+ const loadsBeforeCreate =
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length;
+
+ await act(async () => finishCreate({ result: { uuid: 'new-layer' } }));
+ await waitFor(() =>
+ expect(
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length,
+ ).toBeGreaterThan(loadsBeforeCreate),
+ );
+});
+
+test('a layer updated after Cancel refreshes the Connections list again', async () => {
+ setupMocks({ dependents: [] });
+ fetchMock.get('glob:*/api/v1/semantic_layer/types', {
+ result: [{ id: 'demo', name: 'Demo provider' }],
+ });
+ fetchMock.get(`glob:*/api/v1/semantic_layer/${SL_UUID}`, {
+ result: {
+ name: semanticLayerRow.database_name,
+ type: 'demo',
+ configuration: {},
+ },
+ });
+ fetchMock.post('glob:*/api/v1/semantic_layer/schema/configuration', {
+ result: { type: 'object', properties: {} },
+ });
+ let finishUpdate!: (value: { result: object }) => void;
+ const updateRoute = `glob:*/api/v1/semantic_layer/${SL_UUID}`;
+ fetchMock.put(
+ updateRoute,
+ () =>
+ new Promise(resolve => {
+ finishUpdate = resolve;
+ }),
+ );
+
+ renderDatabaseList();
+ await screen.findByText(semanticLayerRow.database_name);
+ await waitFor(() =>
+ expect(
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length,
+ ).toBeGreaterThan(0),
+ );
+ await userEvent.click(await screen.findByTestId('Edit'));
+ const name = await screen.findByPlaceholderText('Name of the semantic layer');
+ await userEvent.clear(name);
+ await userEvent.type(name, 'Updated Semantic Layer');
+ const save = screen.getByRole('button', { name: 'Save' });
+ await waitFor(() => expect(save).toBeEnabled());
+ await userEvent.click(save);
+ await waitFor(() =>
+ expect(
+ fetchMock.callHistory.calls(updateRoute, { method: 'PUT' }),
+ ).toHaveLength(1),
+ );
+
+ const loadsBeforeCancel =
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length;
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await waitFor(() =>
+ expect(
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length,
+ ).toBeGreaterThan(loadsBeforeCancel),
+ );
+ const loadsAfterCancel =
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length;
+
+ await act(async () => finishUpdate({ result: {} }));
+ await waitFor(() =>
+ expect(
+ fetchMock.callHistory.calls(CONNECTIONS_ROUTE).length,
+ ).toBeGreaterThan(loadsAfterCancel),
+ );
+});
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index 921ed517161b..5b37d5a3e3ae 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -1221,6 +1221,7 @@ function DatabaseList({
/>
{
setSemanticLayerModalOpen(false);
refreshData();
@@ -1230,6 +1231,7 @@ function DatabaseList({
/>
{
setSlCurrentlyEditing(null);
refreshData();
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx
index edfbab5342a2..1540069e2d08 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx
@@ -16,8 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { act, screen, waitFor, within } from '@testing-library/react';
+import {
+ act,
+ fireEvent,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
import fetchMock from 'fetch-mock';
+import userEvent from '@testing-library/user-event';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import {
setupMocks,
@@ -29,6 +36,7 @@ import {
mockExportOnlyUser,
mockDatasets,
mockDatasetListEndpoints,
+ API_ENDPOINTS,
} from './DatasetList.testHelpers';
jest.mock('@superset-ui/core', () => ({
@@ -425,3 +433,332 @@ test('user with can_duplicate sees duplicate button only for virtual datasets',
const virtualDuplicateButton = within(virtualRow!).getByTestId('copy');
expect(virtualDuplicateButton).toBeInTheDocument();
});
+
+describe('semantic layer setup transition', () => {
+ const configure = (canWriteLayer: boolean) => {
+ jest
+ .mocked(isFeatureEnabled)
+ .mockImplementation(flag => flag === 'SEMANTIC_LAYERS');
+ setupApiPermissions(['can_read', 'can_write']);
+ fetchMock.get(
+ 'glob:*/api/v1/semantic_layer/',
+ { result: [] },
+ { name: 'setup-layers' },
+ );
+ fetchMock.get(
+ 'glob:*/api/v1/semantic_layer/types',
+ { result: [] },
+ { name: 'setup-types' },
+ );
+ return {
+ ...mockAdminUser,
+ roles: {
+ Custom: [
+ ['can_read', 'SemanticLayer'],
+ ...(canWriteLayer ? [['can_write', 'SemanticLayer']] : []),
+ ],
+ },
+ };
+ };
+
+ const openView = async () => {
+ await userEvent.click(await screen.findByTestId('btn-create-new'));
+ await userEvent.click(
+ await screen.findByText('Semantic View', {
+ selector: '.ant-dropdown-menu-title-content',
+ }),
+ );
+ };
+
+ test('layer writers can switch dialogs and cancel back to a fresh layer list', async () => {
+ renderDatasetList(configure(true));
+ await openView();
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Create semantic layer' }),
+ );
+ expect(
+ await screen.findByText(
+ 'No semantic layer types are available. Ask your administrator to check the semantic layer setup for this deployment.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
+ fireEvent.keyDown(screen.getByRole('dialog'), {
+ key: 'Escape',
+ keyCode: 27,
+ });
+ expect(
+ await screen.findByRole('button', { name: 'Create semantic layer' }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
+ expect(fetchMock.callHistory.calls('setup-layers')).toHaveLength(2);
+ expect(
+ fetchMock.callHistory
+ .calls()
+ .filter(call => call.options.method === 'POST'),
+ ).toHaveLength(0);
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await waitFor(() =>
+ expect(screen.getByTestId('btn-create-new')).toHaveFocus(),
+ );
+ });
+
+ test('dataset writers without layer-write permission get administrator guidance', async () => {
+ renderDatasetList(configure(false));
+ await openView();
+ expect(
+ await screen.findByText(
+ /Ask your administrator to set up a semantic layer or grant you access/,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Create semantic layer' }),
+ ).not.toBeInTheDocument();
+ });
+
+ test.each([true, false])(
+ 'creation success=%s returns only after a successful save',
+ async succeeds => {
+ const user = configure(true);
+ fetchMock.removeRoutes({ names: ['setup-types'] });
+ fetchMock.get('glob:*/api/v1/semantic_layer/types', {
+ result: [{ id: 'example', name: 'Example provider' }],
+ });
+ fetchMock.post('glob:*/api/v1/semantic_layer/schema/configuration', {
+ result: { type: 'object', properties: {} },
+ });
+ fetchMock.post(
+ 'glob:*/api/v1/semantic_layer/',
+ succeeds ? { result: { uuid: 'new-layer' } } : 500,
+ { name: 'create-layer' },
+ );
+ renderDatasetList(user);
+ await openView();
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Create semantic layer' }),
+ );
+ const typePicker = await screen.findByRole('combobox', {
+ name: 'Semantic layer type',
+ });
+ await waitFor(() => expect(typePicker).toBeEnabled());
+ fireEvent.mouseDown(typePicker);
+ await userEvent.click(
+ await screen.findByText('Example provider', {
+ selector: '.ant-select-item-option-content',
+ }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Add' }));
+ const create = await screen.findByRole('button', { name: 'Create' });
+ await waitFor(() => expect(create).toBeEnabled());
+ fetchMock.removeRoutes({ names: ['setup-layers'] });
+ fetchMock.get(
+ 'glob:*/api/v1/semantic_layer/',
+ { result: [{ uuid: 'new-layer', name: 'Example provider' }] },
+ { name: 'refreshed-layers' },
+ );
+ await userEvent.click(create);
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('create-layer')).toHaveLength(1),
+ );
+ if (succeeds) {
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('refreshed-layers')).toHaveLength(
+ 1,
+ ),
+ );
+ const layerPicker = await screen.findByRole('combobox', {
+ name: 'Semantic layer',
+ });
+ const layerSelect = layerPicker.closest('.ant-select');
+ expect(layerSelect).toHaveTextContent('Select a semantic layer');
+ expect(
+ layerSelect?.querySelector('.ant-select-selection-item'),
+ ).toBeNull();
+ expect(
+ fetchMock.callHistory
+ .calls()
+ .filter(call =>
+ /\/api\/v1\/semantic_layer\/[^/]+\/(schema\/runtime|views)/.test(
+ String(call.url),
+ ),
+ ),
+ ).toHaveLength(0);
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+ } else {
+ await waitFor(() => expect(create).toBeEnabled());
+ expect(
+ screen.queryByRole('combobox', { name: 'Semantic layer' }),
+ ).not.toBeInTheDocument();
+ expect(fetchMock.callHistory.calls('refreshed-layers')).toHaveLength(0);
+ }
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
+ expect(
+ fetchMock.callHistory
+ .calls()
+ .filter(
+ call =>
+ String(call.url).includes('/semantic_view/') &&
+ call.options.method === 'POST',
+ ),
+ ).toHaveLength(0);
+ },
+ );
+
+ test('late layer creation cannot replace a newer setup session', async () => {
+ const user = configure(true);
+ fetchMock.removeRoutes({ names: ['setup-types'] });
+ fetchMock.get('glob:*/api/v1/semantic_layer/types', {
+ result: [{ id: 'example', name: 'Example provider' }],
+ });
+ fetchMock.post('glob:*/api/v1/semantic_layer/schema/configuration', {
+ result: { type: 'object', properties: {} },
+ });
+ let finishSave!: (value: { result: { uuid: string } }) => void;
+ fetchMock.post(
+ 'glob:*/api/v1/semantic_layer/',
+ () =>
+ new Promise(resolve => {
+ finishSave = resolve;
+ }),
+ { name: 'pending-create' },
+ );
+ renderDatasetList(user);
+ await openView();
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Create semantic layer' }),
+ );
+ const typePicker = await screen.findByRole('combobox', {
+ name: 'Semantic layer type',
+ });
+ await waitFor(() => expect(typePicker).toBeEnabled());
+ fireEvent.mouseDown(typePicker);
+ await userEvent.click(
+ await screen.findByText('Example provider', {
+ selector: '.ant-select-item-option-content',
+ }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Add' }));
+ const create = await screen.findByRole('button', { name: 'Create' });
+ await waitFor(() => expect(create).toBeEnabled());
+ await userEvent.click(create);
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('pending-create')).toHaveLength(1),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Create semantic layer' }),
+ );
+ expect(
+ await screen.findByRole('combobox', { name: 'Semantic layer type' }),
+ ).toBeInTheDocument();
+ await act(async () => {
+ finishSave({ result: { uuid: 'old-layer' } });
+ await fetchMock.callHistory.flush(true);
+ });
+ expect(
+ screen.getByRole('combobox', { name: 'Semantic layer type' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('combobox', { name: 'Semantic layer' }),
+ ).not.toBeInTheDocument();
+ expect(fetchMock.callHistory.calls('setup-layers')).toHaveLength(2);
+ });
+
+ test('late layer creation refreshes the view picker after cancel without stale UI', async () => {
+ const user = configure(true);
+ const addSuccessToast = jest.fn();
+ fetchMock.removeRoutes({ names: ['setup-types'] });
+ fetchMock.get('glob:*/api/v1/semantic_layer/types', {
+ result: [{ id: 'example', name: 'Example provider' }],
+ });
+ fetchMock.post('glob:*/api/v1/semantic_layer/schema/configuration', {
+ result: { type: 'object', properties: {} },
+ });
+ let finishSave!: (value: { result: { uuid: string } }) => void;
+ fetchMock.post(
+ 'glob:*/api/v1/semantic_layer/',
+ () =>
+ new Promise(resolve => {
+ finishSave = resolve;
+ }),
+ { name: 'pending-create' },
+ );
+ renderDatasetList(user, { addSuccessToast });
+ await openView();
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Create semantic layer' }),
+ );
+ const typePicker = await screen.findByRole('combobox', {
+ name: 'Semantic layer type',
+ });
+ await waitFor(() => expect(typePicker).toBeEnabled());
+ fireEvent.mouseDown(typePicker);
+ await userEvent.click(
+ await screen.findByText('Example provider', {
+ selector: '.ant-select-item-option-content',
+ }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Add' }));
+ const create = await screen.findByRole('button', { name: 'Create' });
+ await waitFor(() => expect(create).toBeEnabled());
+ await userEvent.click(create);
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('pending-create')).toHaveLength(1),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(
+ await screen.findByText(/No semantic layers are available to you/),
+ ).toBeInTheDocument();
+ expect(fetchMock.callHistory.calls('setup-layers')).toHaveLength(2);
+ fetchMock.removeRoutes({ names: ['setup-layers'] });
+ fetchMock.get(
+ 'glob:*/api/v1/semantic_layer/',
+ { result: [{ uuid: 'new-layer', name: 'Example provider' }] },
+ { name: 'refreshed-layers' },
+ );
+ await act(async () => {
+ finishSave({ result: { uuid: 'new-layer' } });
+ await fetchMock.callHistory.flush(true);
+ });
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls('refreshed-layers')).toHaveLength(1),
+ );
+ const layerPicker = screen.getByRole('combobox', {
+ name: 'Semantic layer',
+ });
+ expect(layerPicker).toBeEnabled();
+ fireEvent.mouseDown(layerPicker);
+ expect(
+ await screen.findByText('Example provider', {
+ selector: '.ant-select-item-option-content',
+ }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
+ expect(addSuccessToast).not.toHaveBeenCalled();
+ });
+
+ test('flag off keeps the setup entry unavailable', async () => {
+ const user = configure(true);
+ jest.mocked(isFeatureEnabled).mockReturnValue(false);
+ fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS_INFO] });
+ let respondPermissions!: (value: { permissions: string[] }) => void;
+ fetchMock.get(
+ API_ENDPOINTS.DATASETS_INFO,
+ () =>
+ new Promise(resolve => {
+ respondPermissions = resolve;
+ }),
+ { name: API_ENDPOINTS.DATASETS_INFO },
+ );
+ renderDatasetList(user);
+ expect(await screen.findByText('Datasets')).toBeInTheDocument();
+ await act(async () =>
+ respondPermissions({ permissions: ['can_read', 'can_write'] }),
+ );
+ expect(await screen.findByTestId('import-button')).toBeInTheDocument();
+ expect(screen.queryByTestId('btn-create-new')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /(?:plus\s*)?Dataset$/i }),
+ ).toBeInTheDocument();
+ expect(fetchMock.callHistory.calls('setup-layers')).toHaveLength(0);
+ });
+});
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index ffc15ca1b43d..a810b418772e 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -105,6 +105,7 @@ import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal';
import type DatasetType from 'src/types/Dataset';
import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
import AddSemanticViewModal from 'src/features/semanticViews/AddSemanticViewModal';
+import SemanticLayerModal from 'src/features/semanticLayers/SemanticLayerModal';
import {
datasetLabel,
datasetLabelLower,
@@ -645,8 +646,14 @@ const DatasetList: FunctionComponent = ({
const [svCurrentlyDeleting, setSvCurrentlyDeleting] =
useState(null);
- const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
- useState(false);
+ const [semanticSetup, setSemanticSetup] = useState<
+ 'closed' | 'view' | 'layer'
+ >('closed');
+ const [layerCreatedSignal, setLayerCreatedSignal] = useState(0);
+ const newDatasourceButtonRef = useRef(null);
+ const canCreateLayer =
+ isFeatureEnabled(SEMANTIC_LAYERS_FLAG) &&
+ findPermission('can_write', 'SemanticLayer', user?.roles);
const [importingDataset, showImportModal] = useState(false);
const [passwordFields, setPasswordFields] = useState([]);
const [preparingExport, setPreparingExport] = useState(false);
@@ -1394,13 +1401,14 @@ const DatasetList: FunctionComponent = ({
{
key: 'semantic-view',
label: t('Semantic View'),
- onClick: () => setShowAddSemanticViewModal(true),
+ onClick: () => setSemanticSetup('view'),
},
],
}}
trigger={['click']}
>
}
@@ -1708,13 +1716,40 @@ const DatasetList: FunctionComponent = ({
addSuccessToast={addSuccessToast}
semanticView={svCurrentlyEditing}
/>
- setShowAddSemanticViewModal(false)}
- onSuccess={refreshData}
- addDangerToast={addDangerToast}
- addSuccessToast={addSuccessToast}
- />
+ {semanticSetup === 'view' && (
+ {
+ setSemanticSetup('closed');
+ newDatasourceButtonRef.current?.focus();
+ }}
+ onCreateLayer={
+ canCreateLayer
+ ? () => {
+ if (
+ isFeatureEnabled(SEMANTIC_LAYERS_FLAG) &&
+ findPermission('can_write', 'SemanticLayer', user?.roles)
+ ) {
+ setSemanticSetup('layer');
+ }
+ }
+ : undefined
+ }
+ onSuccess={refreshData}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ )}
+ {semanticSetup === 'layer' && (
+ setSemanticSetup('view')}
+ onCreated={() => setLayerCreatedSignal(signal => signal + 1)}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ )}
int | None:
def _get_frontend_config_value(key: str) -> Any:
"""Get frontend config value, converting sets to lists for JSON compatibility."""
val = app.config.get(key)
+ if key == "SEMANTIC_LAYER_DOCUMENTATION_URL":
+ return _semantic_layer_documentation_url(val)
if isinstance(val, set):
return list(val)
return val
+def _semantic_layer_documentation_url(value: object) -> str | None:
+ """Expose only absolute HTTP(S) setup links without URL credentials."""
+ if value is None:
+ return None
+ if isinstance(value, str) and value and not re.search(r"[\s\x00-\x1f\x7f]", value):
+ try:
+ parsed: SplitResult = urlsplit(value)
+ port: int | None = parsed.port
+ if (
+ parsed.scheme.lower() in ("http", "https")
+ and parsed.hostname
+ and parsed.username is None
+ and parsed.password is None
+ and (port is None or port > 0)
+ ):
+ return value
+ except ValueError:
+ pass
+ logger.warning("Dropped invalid SEMANTIC_LAYER_DOCUMENTATION_URL")
+ return None
+
+
def _soft_delete_conf() -> dict[str, Any]:
"""Return the retention window to advertise to the client, if any.
diff --git a/tests/unit_tests/views/test_bootstrap_auth.py b/tests/unit_tests/views/test_bootstrap_auth.py
index ae7421de7f37..d7147441de80 100644
--- a/tests/unit_tests/views/test_bootstrap_auth.py
+++ b/tests/unit_tests/views/test_bootstrap_auth.py
@@ -18,7 +18,7 @@
from unittest.mock import MagicMock, patch
import pytest
-from flask import g
+from flask import current_app, g
from flask_appbuilder.const import (
AUTH_DB,
AUTH_LDAP,
@@ -27,6 +27,7 @@
AUTH_SAML,
)
+from superset import config
from superset.views.base import cached_common_bootstrap_data, common_bootstrap_payload
@@ -316,3 +317,70 @@ def test_language_pack_template_context_inline_for_override_pack(
"fr", {"language_pack": {"domain": "superset"}}
)
assert context == {"language_pack_src": None, "language_pack_inline": True}
+
+
+@pytest.mark.parametrize(
+ "url",
+ [
+ None,
+ "http://docs.example.org/semantic-setup",
+ "https://docs.example.org:8443/semantic-setup",
+ ],
+)
+def test_semantic_setup_documentation_config_in_bootstrap(
+ app_context: None,
+ url: str | None,
+) -> None:
+ with (
+ patch.dict(
+ current_app.config,
+ {
+ "SEMANTIC_LAYER_DOCUMENTATION_URL": url,
+ "DOCUMENTATION_URL": "https://docs.example.org/main",
+ },
+ ),
+ patch("superset.views.base.menu_data", return_value={}),
+ ):
+ payload: dict[str, Any] = cached_common_bootstrap_data.uncached(
+ user_id=1, locale=None
+ )
+ assert payload["conf"]["SEMANTIC_LAYER_DOCUMENTATION_URL"] == url
+
+
+def test_semantic_setup_documentation_is_opt_in() -> None:
+ assert config.SEMANTIC_LAYER_DOCUMENTATION_URL is None
+
+
+@pytest.mark.parametrize(
+ "url",
+ [
+ "https://user:secret@docs.example.org/semantic-setup",
+ "https://@docs.example.org/semantic-setup",
+ "ftp://docs.example.org/semantic-setup",
+ "/semantic-setup",
+ "https://[invalid",
+ "https://docs.example.org:invalid/semantic-setup",
+ "https://docs.example.org/has space",
+ ],
+)
+def test_semantic_setup_documentation_drops_unsafe_url(
+ app_context: None,
+ caplog: pytest.LogCaptureFixture,
+ url: str,
+) -> None:
+ with (
+ patch.dict(current_app.config, {"SEMANTIC_LAYER_DOCUMENTATION_URL": url}),
+ patch("superset.views.base.menu_data", return_value={}),
+ caplog.at_level("WARNING", logger="superset.views.base"),
+ ):
+ payload: dict[str, Any] = cached_common_bootstrap_data.uncached(
+ user_id=1, locale=None
+ )
+ assert payload["conf"]["SEMANTIC_LAYER_DOCUMENTATION_URL"] is None
+ assert [
+ record.getMessage()
+ for record in caplog.records
+ if record.name == "superset.views.base"
+ and "SEMANTIC_LAYER_DOCUMENTATION_URL" in record.getMessage()
+ ] == ["Dropped invalid SEMANTIC_LAYER_DOCUMENTATION_URL"]
+ assert "secret" not in caplog.text