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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AddSemanticViewModal {...props} />);
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(<AddSemanticViewModal {...props} />);
await selectOption('Semantic layer', 'Test layer');
await selectOption('Semantic views', 'orders');
await userEvent.click(
screen.getByRole('button', { name: /add 1 view\(s\)/i }),
);
rerender(<AddSemanticViewModal {...props} show={false} />);
rerender(<AddSemanticViewModal {...props} />);
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)
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
SCHEMA_REFRESH_DEBOUNCE_MS,
} from 'src/features/semanticLayers/jsonFormsHelpers';
import {
batchRefusalMessage,
creationErrorMessage,
isRecord,
parseCreationResult,
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions superset-frontend/src/features/semanticViews/creationResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : {};
Expand Down
Loading