diff --git a/docs/admin_docs/configuration/configuring-superset.mdx b/docs/admin_docs/configuration/configuring-superset.mdx index 97cfb3f343e7..db3bc45a4f8d 100644 --- a/docs/admin_docs/configuration/configuring-superset.mdx +++ b/docs/admin_docs/configuration/configuring-superset.mdx @@ -869,6 +869,19 @@ SUPERSET_DASHBOARD_POSITION_DATA_LIMIT = 131072 # double the default Alternatively, split a very large dashboard into several smaller ones. Note that this check is enforced when saving layout edits in the UI; a dashboard imported from a ZIP with an oversized layout will load and render, but cannot be edited and re-saved until the limit is raised. +## Semantic layer setup documentation + +Set `SEMANTIC_LAYER_DOCUMENTATION_URL` in `superset_config.py` to an absolute +HTTP or HTTPS URL for your deployment's semantic layer setup guide. The New +Semantic Layer and Add Semantic View dialogs show this optional link. It opens +in a new tab. Leave the setting as `None` (the default) to hide the link. Invalid +URLs and URLs with username/password userinfo are omitted from the page bootstrap +rather than partially sanitized; the server logs a warning without the URL value. + +This setting does not change the main documentation menu's `DOCUMENTATION_URL`, +install providers, grant permissions, or enable the `SEMANTIC_LAYERS` feature. +Provider setup descriptions come from each registered provider's metadata. + :::resources - [Blog: Feature Flags in Apache Superset](https://preset.io/blog/feature-flags-in-apache-superset-and-preset/) diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx index 67f278dbaa3f..4b25880739e9 100644 --- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.test.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import getBootstrapData from 'src/utils/getBootstrapData'; import { getClientErrorObject, SupersetClient } from '@superset-ui/core'; import type { JsonSchema } from '@jsonforms/core'; import { @@ -74,6 +75,13 @@ jest.mock('@superset-ui/core', () => ({ getClientErrorObject: jest.fn(() => Promise.resolve({ error: '' })), })); +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; @@ -86,6 +94,25 @@ const props = { }; beforeEach(() => { + props.addDangerToast.mockClear(); + props.addSuccessToast.mockClear(); + props.onHide.mockClear(); + 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, + }, + }, + }; + }); mockJsonFormsChangeTriggered = false; capturedOnChange = null; mockFormSchema = undefined; @@ -745,3 +772,531 @@ test('A pending clear prevents edits and a closed editor ignores its late respon await act(async () => finish({ json: {} })); expect(props.addSuccessToast).not.toHaveBeenCalled(); }); + +test('a successful edit after Cancel refreshes the parent without stale modal UI', async () => { + mockJsonFormsChangeTriggered = true; + const refreshFilterA = jest.fn(); + const refreshFilterB = jest.fn(); + let finishUpdate!: (value: { json: object }) => void; + const mockedPut = SupersetClient.put as jest.Mock; + mockedPut.mockReset().mockReturnValueOnce( + new Promise(resolve => { + finishUpdate = resolve; + }), + ); + const { rerender } = render( + , + ); + const save = await screen.findByRole('button', { name: 'Save' }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + await waitFor(() => expect(mockedPut).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onHide).toHaveBeenCalledTimes(1); + rerender( + , + ); + await act(async () => finishUpdate({ json: {} })); + + expect(refreshFilterB).toHaveBeenCalledTimes(1); + expect(refreshFilterA).not.toHaveBeenCalled(); + expect(props.onHide).toHaveBeenCalledTimes(1); + expect(props.addSuccessToast).not.toHaveBeenCalled(); +}); + +test('a successful create after Cancel refreshes with the latest list filter', async () => { + const refreshFilterA = jest.fn(); + const refreshFilterB = jest.fn(); + let finishCreate!: (value: { json: object }) => void; + mockedPost + .mockReset() + .mockResolvedValueOnce({ + json: { result: { type: 'object', properties: {} } }, + }) + .mockReturnValueOnce( + new Promise(resolve => { + finishCreate = resolve; + }), + ); + const { rerender } = render( + , + ); + const typePicker = await screen.findByRole('combobox', { + name: 'Semantic layer type', + }); + fireEvent.mouseDown(typePicker); + fireEvent.click(await screen.findByText('Example')); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + const create = await screen.findByRole('button', { name: 'Create' }); + await waitFor(() => expect(create).toBeEnabled()); + fireEvent.click(create); + await waitFor(() => expect(mockedPost).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + rerender( + , + ); + await act(async () => finishCreate({ json: {} })); + + expect(refreshFilterB).toHaveBeenCalledTimes(1); + expect(refreshFilterA).not.toHaveBeenCalled(); + expect(props.addSuccessToast).not.toHaveBeenCalled(); +}); + +test('a failed create after Cancel does not show a stale danger toast', async () => { + let failCreate!: (error: Error) => void; + mockedPost + .mockReset() + .mockResolvedValueOnce({ + json: { result: { type: 'object', properties: {} } }, + }) + .mockReturnValueOnce( + new Promise((_, reject) => { + failCreate = reject; + }), + ); + const { rerender } = render( + , + ); + const typePicker = await screen.findByRole('combobox', { + name: 'Semantic layer type', + }); + fireEvent.mouseDown(typePicker); + fireEvent.click(await screen.findByText('Example')); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + const create = await screen.findByRole('button', { name: 'Create' }); + await waitFor(() => expect(create).toBeEnabled()); + fireEvent.click(create); + await waitFor(() => expect(mockedPost).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + rerender( + , + ); + await act(async () => failCreate(new Error('late create failure'))); + + expect(props.addDangerToast).not.toHaveBeenCalled(); +}); + +test('a failed edit after Cancel does not show a stale danger toast', async () => { + mockJsonFormsChangeTriggered = true; + let failUpdate!: (error: Error) => void; + const mockedPut = SupersetClient.put as jest.Mock; + mockedPut.mockReset().mockReturnValueOnce( + new Promise((_, reject) => { + failUpdate = reject; + }), + ); + const { rerender } = render(); + const save = await screen.findByRole('button', { name: 'Save' }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + await waitFor(() => expect(mockedPut).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + rerender(); + await act(async () => failUpdate(new Error('late update failure'))); + + expect(props.addDangerToast).not.toHaveBeenCalled(); +}); + +test('cancel during error parsing suppresses a stale save danger toast', async () => { + mockJsonFormsChangeTriggered = true; + let finishError!: (value: { error: string }) => void; + jest.mocked(getClientErrorObject).mockReturnValueOnce( + new Promise(resolve => { + finishError = resolve; + }), + ); + const mockedPut = SupersetClient.put as jest.Mock; + mockedPut.mockReset().mockRejectedValueOnce(new Error('save failed')); + const { rerender } = render(); + const save = await screen.findByRole('button', { name: 'Save' }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + await waitFor(() => expect(getClientErrorObject).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + rerender(); + await act(async () => finishError({ error: 'save failed' })); + + expect(props.addDangerToast).not.toHaveBeenCalled(); +}); + +test('schema loading keeps an in-flow spinner in a reserved modal body', async () => { + let finishSchema!: (value: { json: { result: JsonSchema } }) => void; + mockedPost.mockReset().mockReturnValueOnce( + new Promise(resolve => { + finishSchema = resolve; + }), + ); + render(); + await waitFor(() => expect(mockedPost).toHaveBeenCalledTimes(1)); + + const spinner = screen.getByRole('status', { name: 'Loading' }); + expect(spinner).toHaveClass('normal'); + expect(spinner.parentElement).toHaveStyle({ minHeight: '200px' }); + await act(async () => + finishSchema({ json: { result: { type: 'object', properties: {} } } }), + ); +}); + +test('setup guidance distinguishes loading and empty provider types', async () => { + let resolveTypes!: (value: { json: { result: [] } }) => void; + mockedGet.mockReset().mockReturnValue( + new Promise(resolve => { + resolveTypes = resolve; + }), + ); + render(); + expect(screen.getByRole('status')).toHaveTextContent( + 'Loading semantic layer types', + ); + expect(screen.queryByText(/No semantic layer types/)).not.toBeInTheDocument(); + await act(async () => resolveTypes({ json: { result: [] } })); + expect( + await screen.findByText(/No semantic layer types are available/), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); +}); + +test.each([ + {}, + { result: null }, + { result: {} }, + { result: [{ name: 'missing id' }] }, +])('setup guidance rejects malformed provider lists: %j', async json => { + mockedGet.mockReset().mockResolvedValue({ json }); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Unable to load semantic layer types', + ); + expect(screen.queryByText(/No semantic layer types/)).not.toBeInTheDocument(); +}); + +test.each([403, 404, 500])( + 'setup guidance retries type failure %s without diagnosing installation', + async status => { + mockedGet + .mockReset() + .mockRejectedValueOnce({ status }) + .mockResolvedValueOnce({ json: { result: [] } }); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Unable to load semantic layer types', + ); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect( + await screen.findByText(/No semantic layer types are available/), + ).toBeInTheDocument(); + expect(mockedGet).toHaveBeenCalledTimes(2); + }, +); + +test('setup guidance displays provider-owned text before and during configuration', async () => { + mockedGet.mockReset().mockResolvedValue({ + json: { + result: [ + { + id: 'first', + name: 'First provider', + description: 'Prepare credentials for the first provider.', + }, + { + id: 'second', + name: 'Second provider', + description: '', + }, + ], + }, + }); + render(); + fireEvent.mouseDown( + await screen.findByRole('combobox', { name: 'Semantic layer type' }), + ); + fireEvent.click(await screen.findByText('First provider')); + expect( + screen.getByText('Prepare credentials for the first provider.'), + ).toBeInTheDocument(); + fireEvent.mouseDown( + screen.getByRole('combobox', { name: 'Semantic layer type' }), + ); + fireEvent.click(await screen.findByText('Second provider')); + expect( + screen.queryByText('Prepare credentials for the first provider.'), + ).not.toBeInTheDocument(); + expect( + screen.getByText(''), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect( + await screen.findByText('Configure Second provider'), + ).toBeInTheDocument(); + expect( + screen.getByText(''), + ).toBeInTheDocument(); +}); + +test.each([null, undefined, '', 42])( + 'setup guidance tolerates optional provider description %s', + async description => { + mockedGet.mockReset().mockResolvedValue({ + json: { + result: [{ id: 'plain', name: 'Plain provider', description }], + }, + }); + render(); + fireEvent.mouseDown( + await screen.findByRole('combobox', { name: 'Semantic layer type' }), + ); + fireEvent.click(await screen.findByText('Plain provider')); + expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled(); + expect( + screen.queryByTestId('semantic-provider-description'), + ).not.toBeInTheDocument(); + }, +); + +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(); + }, + ); +}); + +test.each(['resolve', 'reject'])( + 'setup guidance ignores stale type %s after reopening', + async settlement => { + let resolveOld!: (value: { json: { result: [] } }) => void; + let rejectOld!: (error: Error) => void; + mockedGet + .mockReset() + .mockReturnValueOnce( + new Promise((resolve, reject) => { + resolveOld = resolve; + rejectOld = reject; + }), + ) + .mockResolvedValueOnce({ + json: { + result: [ + { + id: 'current', + name: 'Current provider', + description: 'Current prerequisites', + }, + ], + }, + }); + 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')); + }); + fireEvent.mouseDown( + screen.getByRole('combobox', { name: 'Semantic layer type' }), + ); + fireEvent.click(await screen.findByText('Current provider')); + expect(screen.getByText('Current prerequisites')).toBeInTheDocument(); + expect( + screen.queryByText(/No semantic layer types are available/), + ).not.toBeInTheDocument(); + expect(props.addDangerToast).not.toHaveBeenCalled(); + }, +); + +test('closing a mounted layer dialog suppresses a pending type-fetch failure', async () => { + let rejectTypes!: (error: Error) => void; + mockedGet.mockReset().mockReturnValue( + new Promise((_, reject) => { + rejectTypes = reject; + }), + ); + const { rerender } = render( + , + ); + await waitFor(() => expect(mockedGet).toHaveBeenCalledTimes(1)); + + rerender( + , + ); + await act(async () => rejectTypes(new Error('late type failure'))); + + expect(props.addDangerToast).not.toHaveBeenCalled(); +}); + +test('closing a mounted layer dialog suppresses a pending schema-fetch failure', async () => { + let rejectSchema!: (error: Error) => void; + mockedPost.mockReset().mockReturnValue( + new Promise((_, reject) => { + rejectSchema = reject; + }), + ); + const { rerender } = render(); + await waitFor(() => expect(mockedPost).toHaveBeenCalledTimes(1)); + + rerender(); + await act(async () => rejectSchema(new Error('late schema failure'))); + + expect(props.addDangerToast).not.toHaveBeenCalled(); +}); + +test('setup documentation stays available during schema loading and configuration', async () => { + mockSemanticDocsUrl = 'https://docs.example.org/setup'; + mockedGet.mockReset().mockResolvedValue({ + json: { result: [{ id: 'current', name: 'Current provider' }] }, + }); + let resolveSchema!: (value: { json: { result: JsonSchema } }) => void; + mockedPost.mockReset().mockReturnValue( + new Promise(resolve => { + resolveSchema = resolve; + }), + ); + render(); + await waitFor(() => + expect( + screen.getByRole('combobox', { name: 'Semantic layer type' }), + ).toBeEnabled(), + ); + fireEvent.mouseDown( + screen.getByRole('combobox', { name: 'Semantic layer type' }), + ); + fireEvent.click(await screen.findByText('Current provider')); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect( + screen.getByRole('link', { name: 'Semantic layer setup guide' }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + await act(async () => + resolveSchema({ json: { result: { type: 'object', properties: {} } } }), + ); + expect( + await screen.findByRole('button', { name: 'Create' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Semantic layer setup guide' }), + ).toBeInTheDocument(); +}); + +test('canceling schema loading does not leave a reopened type picker spinning', async () => { + mockedGet.mockReset().mockResolvedValue({ + json: { result: [{ id: 'current', name: 'Current provider' }] }, + }); + let resolveSchema!: (value: { json: { result: JsonSchema } }) => void; + mockedPost.mockReset().mockReturnValue( + new Promise(resolve => { + resolveSchema = resolve; + }), + ); + const { rerender } = render( + , + ); + await waitFor(() => + expect( + screen.getByRole('combobox', { name: 'Semantic layer type' }), + ).toBeEnabled(), + ); + fireEvent.mouseDown( + screen.getByRole('combobox', { name: 'Semantic layer type' }), + ); + fireEvent.click(await screen.findByText('Current provider')); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + rerender( + , + ); + rerender(); + expect( + await screen.findByRole('combobox', { name: 'Semantic layer type' }), + ).toBeEnabled(); + await act(async () => + resolveSchema({ json: { result: { type: 'object', properties: {} } } }), + ); + expect( + screen.getByRole('combobox', { name: 'Semantic layer type' }), + ).toBeEnabled(); + expect( + screen.queryByRole('button', { name: 'Create' }), + ).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx index 301a2900c97f..9f30a64f1638 100644 --- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx @@ -16,11 +16,25 @@ * specific language governing permissions and limitations * under the License. */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { + useState, + useEffect, + useLayoutEffect, + useCallback, + useRef, +} from 'react'; import isEqual from 'lodash/isEqual'; import { t } from '@apache-superset/core/translation'; import { SupersetClient, getClientErrorObject } from '@superset-ui/core'; -import { Form, Input, Select, Button } from '@superset-ui/core/components'; +import { + Form, + Input, + Select, + Button, + Loading, + Flex, +} from '@superset-ui/core/components'; +import { Alert } from '@apache-superset/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { JsonForms } from '@jsonforms/react'; import type { JsonSchema, UISchemaElement } from '@jsonforms/core'; @@ -43,6 +57,9 @@ import { SCHEMA_REFRESH_DEBOUNCE_MS, } from './jsonFormsHelpers'; +import { isRecord } from '../semanticViews/creationResult'; +import SemanticLayerSetupHelp from './SemanticLayerSetupHelp'; + const ModalContent = styled.div` padding: ${({ theme }) => theme.sizeUnit * 4}px; @@ -76,12 +93,14 @@ const configurationErrorMessage = () => interface SemanticLayerType { id: string; name: string; - description: string; + description?: string | null; } interface SemanticLayerModalProps { show: boolean; onHide: () => void; + onCreated?: () => void; + onUpdated?: () => void; addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; semanticLayerUuid?: string; @@ -90,6 +109,8 @@ interface SemanticLayerModalProps { export default function SemanticLayerModal({ show, onHide, + onCreated, + onUpdated, addDangerToast, addSuccessToast, semanticLayerUuid, @@ -99,6 +120,17 @@ export default function SemanticLayerModal({ const [name, setName] = useState(''); const [selectedType, setSelectedType] = useState(null); const [types, setTypes] = useState([]); + const [typeStatus, setTypeStatus] = useState<'loading' | 'success' | 'error'>( + 'loading', + ); + const typeRequestRef = useRef(0); + const saveRequestRef = useRef(0); + const onCreatedRef = useRef(onCreated); + const onUpdatedRef = useRef(onUpdated); + useLayoutEffect(() => { + onCreatedRef.current = onCreated; + onUpdatedRef.current = onUpdated; + }, [onCreated, onUpdated]); const [loading, setLoading] = useState(false); const [configSchema, setConfigSchema] = useState(null); const [uiSchema, setUiSchema] = useState( @@ -141,20 +173,33 @@ export default function SemanticLayerModal({ const autoFilledNameRef = useRef(''); const fetchTypes = useCallback(async () => { - setLoading(true); + typeRequestRef.current += 1; + const request = typeRequestRef.current; + setTypeStatus('loading'); try { const { json } = await SupersetClient.get({ endpoint: '/api/v1/semantic_layer/types', }); - setTypes(json.result ?? []); - } catch (error) { - const clientError = await getClientErrorObject(error); + if (request !== typeRequestRef.current) return; + const result: unknown = json.result; + if ( + !Array.isArray(result) || + !result.every( + (type): type is SemanticLayerType => + isRecord(type) && + typeof type.id === 'string' && + typeof type.name === 'string', + ) + ) + throw new Error('Invalid semantic layer types'); + setTypes(result); + setTypeStatus('success'); + } catch { + if (request !== typeRequestRef.current) return; + setTypeStatus('error'); addDangerToast( - clientError.error || - t('An error occurred while fetching semantic layer types'), + t('An error occurred while fetching semantic layer types'), ); - } finally { - setLoading(false); } }, [addDangerToast]); @@ -249,10 +294,15 @@ export default function SemanticLayerModal({ fetchTypes(); } } else { + typeRequestRef.current += 1; + schemaRequestRef.current += 1; setStep('type'); setName(''); setSelectedType(null); setTypes([]); + setTypeStatus('loading'); + setLoading(false); + setSaving(false); setConfigSchema(null); setUiSchema(undefined); setFormData({}); @@ -271,6 +321,8 @@ export default function SemanticLayerModal({ useEffect( () => () => { + typeRequestRef.current += 1; + saveRequestRef.current += 1; clearRequestRef.current += 1; schemaRequestRef.current += 1; if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); @@ -333,6 +385,8 @@ export default function SemanticLayerModal({ }; const handleCreate = async () => { + saveRequestRef.current += 1; + const request = saveRequestRef.current; setSaving(true); try { if (isEditMode && semanticLayerUuid) { @@ -340,17 +394,28 @@ export default function SemanticLayerModal({ endpoint: `/api/v1/semantic_layer/${semanticLayerUuid}`, jsonPayload: { name, configuration: formData }, }); + if (request !== saveRequestRef.current) { + // Cancel refreshed the parent before the update committed. + onUpdatedRef.current?.(); + return; + } addSuccessToast(t('Semantic layer updated')); } else { await SupersetClient.post({ endpoint: '/api/v1/semantic_layer/', jsonPayload: { name, type: selectedType, configuration: formData }, }); + // The server created the layer even if this dialog was closed. Only + // the parent may refresh the picker; stale dialog UI remains ignored. + onCreatedRef.current?.(); + if (request !== saveRequestRef.current) return; addSuccessToast(t('Semantic layer created')); } onHide(); } catch (error) { + if (request !== saveRequestRef.current) return; const clientError = await getClientErrorObject(error); + if (request !== saveRequestRef.current) return; addDangerToast( clientError.error || (isEditMode @@ -358,7 +423,7 @@ export default function SemanticLayerModal({ : t('An error occurred while creating the semantic layer')), ); } finally { - setSaving(false); + if (request === saveRequestRef.current) setSaving(false); } }; @@ -460,8 +525,9 @@ export default function SemanticLayerModal({ [maybeRefreshSchema, formData], ); - const selectedTypeName = - types.find(type => type.id === selectedType)?.name ?? ''; + const selectedProvider = types.find(type => type.id === selectedType); + const selectedTypeName = selectedProvider?.name ?? ''; + const description = selectedProvider?.description; const title = isEditMode ? t('Edit %s', selectedTypeName || t('Semantic Layer')) @@ -479,72 +545,124 @@ export default function SemanticLayerModal({ width={isTypeStep ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH} saveDisabled={ isTypeStep - ? !selectedType - : saving || clearing || !name.trim() || hasErrors + ? !selectedType || typeStatus !== 'success' || loading + : loading || saving || clearing || !name.trim() || hasErrors } saveText={isTypeStep ? undefined : isEditMode ? t('Save') : t('Create')} saveLoading={saving} - contentLoading={loading} > - {isTypeStep ? ( - - 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.). -
- + 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' && ( + <> + + + + )} + {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 && ( + + )} + + )} {/* Semantic Layer */}