Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/refused-read-no-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/plugin-list': patch
'@object-ui/plugin-detail': patch
---

fix(ui): a refused list or related-list read says "no access" and offers no Retry

Clause-②: no
Metadata-protocol: none

- **List view.** A permission refusal (403) on a list read draws the no-access panel without the Retry button, the same way an object whose API is switched off already did: a retry re-reaches the same refusal. A network failure or a 5xx keeps Retry.
- **Related list.** A related list the reader may not read draws a quiet, neutral line (`role="status"`, no destructive styling) with the list's no-access sentence, and no Retry. It no longer shows the generic action refusal ("You do not have permission to perform this action.") in red to someone who only opened the record. Other read failures are drawn as before.
24 changes: 17 additions & 7 deletions ui/packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,31 +577,41 @@ function serverSentence(err: unknown): string | null {
* the shared fixed sentence and never the server's text (a refused read does
* not name what it cannot see), unless the producer marked a `userMessage` for
* the reader; every other failure says the server's sentence when it gave one.
* Retry for every kind but `api-disabled`, whose verdict a retry cannot change
* — the ListView precedent.
* Retry for every kind but `api-disabled` and `forbidden`, whose verdicts a
* retry cannot change — the ListView precedent.
*
* A `forbidden` read is not a failure of anything the reader did: it is drawn
* as a quiet neutral line (`role="status"`, no destructive styling) and always
* says the list's no-access sentence. A producer's `userMessage` on a 403 is
* the generic action refusal ("...perform this action"), which reads as a
* rebuke to someone who only opened the record to read it.
*/
const RelatedListLoadFailed: React.FC<{ error: unknown; onRetry: () => void }> = ({ error, onRetry }) => {
const { t } = useDetailTranslation();
const kind = classifyLoadError(error);
const copy = LOAD_FAILED_COPY[kind];
const refused = kind === 'forbidden';
const message =
declaredUserMessage(error)
(refused ? null : declaredUserMessage(error))
?? (copy.message
? t(copy.message[0], { defaultValue: copy.message[1] })
: serverSentence(error)
?? t('list.loadErrorMessage', { defaultValue: 'Something went wrong while loading this data. Check your connection and try again.' }));
const Icon = kind === 'network' || kind === 'rejected' ? AlertTriangle : ShieldAlert;
return (
<div
role="alert"
role={refused ? 'status' : 'alert'}
data-testid="related-list-load-failed"
data-error-kind={kind}
className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm"
className={cn(
'flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border px-3 py-2 text-sm',
refused ? 'border-border bg-muted/40' : 'border-destructive/30 bg-destructive/5',
)}
>
<Icon className="h-4 w-4 shrink-0 text-destructive/70" aria-hidden />
<Icon className={cn('h-4 w-4 shrink-0', refused ? 'text-muted-foreground' : 'text-destructive/70')} aria-hidden />
<span className="font-medium">{t(copy.title[0], { defaultValue: copy.title[1] })}</span>
<span className="min-w-0 text-muted-foreground">{message}</span>
{kind !== 'api-disabled' && (
{kind !== 'api-disabled' && !refused && (
<Button
variant="outline"
size="sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,58 @@ describe('record:related_list — a failed list keeps its own row in the stack f
expect(quotesTitle).toBeVisible();
});
});

/*
* A refused read is not a failure of anything the reader did. The line for a
* 403 is neutral (`role="status"`, no destructive styling), offers no Retry (a
* grant only an administrator can give does not change on a retry), and says
* the list's no-access sentence even when the producer marked the generic
* action refusal as the `userMessage`. The 5xx case above is the control for
* the destructive styling and the Retry.
*/
describe('RelatedList — a refused read is a neutral no-access line, not a failure', () => {
/** The 403 the server answers a refused read: the generic action refusal marked for the reader. */
const ACTION_REFUSAL =
'You do not have permission to perform this action. Contact your administrator if you need access.';
const refused = () =>
Object.assign(serverError(403, '[Security] Access denied on invoice_line.invoice', 'PERMISSION_DENIED'), {
userMessage: ACTION_REFUSAL,
});

it('draws a neutral status line with the no-access sentence and no Retry', async () => {
const find = vi.fn(async () => {
throw refused();
});
renderAdapterList(find);

const line = await screen.findByTestId('related-list-load-failed');
expect(line).toHaveAttribute('data-error-kind', 'forbidden');
expect(line).toHaveAttribute('role', 'status');
expect(line.className).not.toMatch(/destructive/);
expect(line.querySelector('svg')!.getAttribute('class')).not.toMatch(/destructive/);
expect(line).toHaveTextContent(FORBIDDEN_TITLE);
expect(line).toHaveTextContent(FORBIDDEN_MESSAGE);
expect(line).not.toHaveTextContent('perform this action');
expect(within(line).queryByRole('button', { name: /Retry/ })).toBeNull();
});

it('CONTROL: a 5xx keeps the alert styling and the Retry', async () => {
const find = vi.fn(async () => {
throw serverError(500, 'The database did not answer in time.');
});
renderAdapterList(find);

const line = await screen.findByTestId('related-list-load-failed');
expect(line).toHaveAttribute('role', 'alert');
expect(line.className).toMatch(/destructive/);
expect(within(line).getByRole('button', { name: /Retry/ })).toBeInTheDocument();
});

it('CONTROL: a readable related list draws its rows', async () => {
const find = vi.fn(async () => ({ data: [{ id: 'l1', name: 'Consulting hours' }] }));
renderAdapterList(find);

await screen.findByText('Consulting hours');
expect(failedLine()).toBeNull();
});
});
11 changes: 7 additions & 4 deletions ui/packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6431,11 +6431,14 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
the empty state. `DataErrorState` grew three icon props for this
migration and no fourth — an `action` prop would be a second
spelling of an affordance it already has. */}
{loadErrorKind === 'api-disabled' ? null : (
// No Retry for an `enable`-block denial. The verdict is a pure
// function of the object's metadata, so every retry re-fetches
// the identical refusal — offering the button is the same wrong
{loadErrorKind === 'api-disabled' || loadErrorKind === 'forbidden' ? null : (
// No Retry for an `enable`-block denial or a permission refusal
// (403). Neither verdict is one a retry can change (the first is
// a pure function of the object's metadata, the second a grant
// only an administrator can give), so every retry re-fetches the
// identical refusal — offering the button is the same wrong
// advice as "check your connection", just spelled as a control.
// A transient failure (network, 5xx) keeps it.
<Button
variant="outline"
size="sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe('ListView — the load failure is a DataErrorState (#7143)', () => {
});

it('UNCHANGED: the retry button is still rendered inside the panel', async () => {
const { container } = failing(forbidden());
const { container } = failing(Object.assign(new Error('Internal error'), { httpStatus: 500 }));
const box = await panel(container, 'list-error-state');
const retry = box.querySelector('[data-testid="list-error-retry"]');
expect(retry).not.toBeNull();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* A permission refusal (403) on a list read offers no Retry: the grant is one
* only an administrator can give, so a retry re-reaches the same refusal. A
* transient failure (5xx, network) keeps the Retry.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'invoice',
fields: ['name'],
};

function renderFailing(error: unknown) {
const ds = {
find: vi.fn().mockRejectedValue(error),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return render(
<SchemaRendererProvider dataSource={ds as any}>
<ListView schema={schema} dataSource={ds as any} />
</SchemaRendererProvider>,
);
}

async function errorState(container: HTMLElement): Promise<HTMLElement> {
await waitFor(() => {
expect(container.querySelector('[data-testid="list-error-state"]')).not.toBeNull();
});
return container.querySelector('[data-testid="list-error-state"]') as HTMLElement;
}

describe('ListView — a permission refusal offers no Retry', () => {
it('a 403 draws the no-access panel without Retry', async () => {
const err = Object.assign(new Error('Forbidden'), { httpStatus: 403, code: 'PERMISSION_DENIED' });
const { container } = renderFailing(err);
const panel = await errorState(container);
expect(panel.getAttribute('data-error-kind')).toBe('forbidden');
expect(panel.querySelector('[data-testid="list-error-retry"]')).toBeNull();
// "No Retry" is not reached by drawing nothing: the panel still says why.
expect(panel.textContent).toMatch(/permission/i);
});

it('CONTROL: a 500 still draws Retry', async () => {
const err = Object.assign(new Error('Internal error'), { httpStatus: 500 });
const { container } = renderFailing(err);
const panel = await errorState(container);
expect(panel.getAttribute('data-error-kind')).toBe('network');
expect(panel.querySelector('[data-testid="list-error-retry"]')).not.toBeNull();
});

it('CONTROL: a network failure still draws Retry', async () => {
const { container } = renderFailing(new TypeError('Failed to fetch'));
const panel = await errorState(container);
expect(panel.querySelector('[data-testid="list-error-retry"]')).not.toBeNull();
});
});
Loading