diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx index eac84aaa3d0d..02deef69db50 100644 --- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx +++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.test.tsx @@ -527,6 +527,115 @@ test('shows toast when add semantic views fails', async () => { expect(props.onHide).not.toHaveBeenCalled(); }); +test.each([ + [ + 'the default cap', + { + message: + 'Too many views in one request: 101 provided, at most 100 allowed.', + }, + 'Too many views in one request: 101 provided, at most 100 allowed.', + ], + [ + 'a non-default cap', + { + message: + 'Too many views in one request: 26 provided, at most 25 allowed.', + }, + 'Too many views in one request: 26 provided, at most 25 allowed.', + ], + [ + 'a refusal without a message', + { message: { detail: 'private provider details' } }, + 'An error occurred while adding semantic views', + ], + [ + 'a refusal that is not an object', + 'private provider details', + 'An error occurred while adding semantic views', + ], +])( + 'a batch refused before any lookup explains %s and keeps the selection', + async (_label, body, expected) => { + mockedGet.mockResolvedValue({ + json: { result: [{ uuid: 'layer-1', name: 'Test layer' }] }, + }); + mockedPost.mockImplementation(({ endpoint }: { endpoint: string }) => { + if (endpoint.endsWith('/schema/runtime')) + return Promise.resolve({ json: { result: { properties: {} } } }); + if (endpoint.endsWith('/views')) + return Promise.resolve({ + json: { + result: [ + { name: 'orders', already_added: false }, + { name: 'customers', already_added: false }, + ], + }, + }); + return Promise.reject({ status: 400, json: async () => body }); + }); + const props = createProps(); + render(); + await selectOption('Semantic layer', 'Test layer'); + await selectOption('Semantic views', 'orders'); + await selectOption('Semantic views', 'customers'); + await userEvent.click( + screen.getByRole('button', { name: /add 2 view\(s\)/i }), + ); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(expected); + expect(alert).not.toHaveTextContent('private provider details'); + expect(props.addDangerToast).toHaveBeenCalledTimes(1); + expect(props.addDangerToast).toHaveBeenCalledWith(expected); + expect(props.addSuccessToast).not.toHaveBeenCalled(); + expect(props.onSuccess).not.toHaveBeenCalled(); + expect(props.onHide).not.toHaveBeenCalled(); + // The refused batch stays selected so the user can trim it and retry. + expect( + screen.getByRole('button', { name: /add 2 view\(s\)/i }), + ).toBeEnabled(); + }, +); + +test('a batch refusal cannot update a closed and reopened modal', async () => { + let refuseSave: (reason: unknown) => void = () => {}; + const pendingSave = new Promise((_resolve, reject) => { + refuseSave = reject; + }); + mockedGet.mockResolvedValue({ + json: { result: [{ uuid: 'layer-1', name: 'Test layer' }] }, + }); + mockedPost.mockImplementation(({ endpoint }: { endpoint: string }) => { + if (endpoint.endsWith('/schema/runtime')) + return Promise.resolve({ json: { result: { properties: {} } } }); + if (endpoint.endsWith('/views')) + return Promise.resolve({ + json: { result: [{ name: 'orders', already_added: false }] }, + }); + return pendingSave; + }); + const props = createProps(); + const { rerender } = render(); + await selectOption('Semantic layer', 'Test layer'); + await selectOption('Semantic views', 'orders'); + await userEvent.click( + screen.getByRole('button', { name: /add 1 view\(s\)/i }), + ); + rerender(); + rerender(); + await act(async () => { + refuseSave({ + status: 400, + json: async () => ({ + message: + 'Too many views in one request: 2 provided, at most 1 allowed.', + }), + }); + }); + expect(props.addDangerToast).not.toHaveBeenCalled(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + // --------------------------------------------------------------------------- // Large-catalog / refresh-stability regression tests (sc-107832) // --------------------------------------------------------------------------- diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx index 7db276d8f272..d58edfd56206 100644 --- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx +++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx @@ -45,6 +45,7 @@ import { SCHEMA_REFRESH_DEBOUNCE_MS, } from 'src/features/semanticLayers/jsonFormsHelpers'; import { + batchRefusalMessage, creationErrorMessage, isRecord, parseCreationResult, @@ -641,13 +642,21 @@ export default function AddSemanticViewModal({ body = json; } catch (error) { // SupersetClient rejects non-2xx responses before parsing their body. - if ( - !isRecord(error) || - error.status !== 422 || - typeof error.json !== 'function' - ) { + if (!isRecord(error) || typeof error.json !== 'function') { throw error; } + if (error.status === 400) { + // The whole batch was refused before any lookup (for example, more + // views than the configured limit): explain why and keep the + // selection so the user can trim it and retry. + const refusal = batchRefusalMessage(await error.json()); + if (!refusal) throw error; + if (gen !== fetchGenRef.current) return; + setCreationErrors([{ name: '', message: refusal }]); + addDangerToast(refusal); + return; + } + if (error.status !== 422) throw error; body = await error.json(); } const { created, errors, failedCount } = parseCreationResult(body); diff --git a/superset-frontend/src/features/semanticViews/creationResult.ts b/superset-frontend/src/features/semanticViews/creationResult.ts index 1348e909ceec..730178f269ca 100644 --- a/superset-frontend/src/features/semanticViews/creationResult.ts +++ b/superset-frontend/src/features/semanticViews/creationResult.ts @@ -53,6 +53,18 @@ function validationText(value: unknown, depth = 0): string { export const creationErrorMessage = (): string => t('An error occurred while adding semantic views'); +/** + * The host refuses an oversized or malformed batch with HTTP 400 before any + * provider lookup runs, so its ``message`` is host text that names the + * configured limit. Anything but a plain string falls back to the generic + * message rather than echoing an unexpected body. + */ +export function batchRefusalMessage(body: unknown): string | null { + if (!isRecord(body) || typeof body.message !== 'string') return null; + const message = body.message.trim(); + return message || null; +} + /** Only consume the documented batch result, not arbitrary transport errors. */ export function parseCreationResult(body: unknown): CreationResult { const result = isRecord(body) && isRecord(body.result) ? body.result : {};