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
50 changes: 50 additions & 0 deletions .changeset/listviews-refused-read-discrimination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

`listViews` no longer renders a refused metadata read as "this object has no saved views"
(objectui#8151).

`ObjectStackAdapter.listViews` degrades every failure to an empty list, and every consumer
reads only the return. So "the server served zero saved views" and "the server refused, or
broke" produced the identical UI — with a `console.warn` as the only discriminator, in the
browser console, with nothing pointing at it. It is the same defect objectui#7741 removed
from `listImportMappings` one method over, and the user-visible cost is the higher one: an
empty `listViews` is an object's **view switcher**, so a user whose token lapsed
mid-session could be shown an object that appears to have no saved views at all —
including views they created themselves.

**The empty-list return is unchanged.** `listViews` still answers `Promise<any[]>` and
still never throws, on every arm including the loud ones — this is a channel added
ALONGSIDE that contract, not a change to it. `listImportMappings` is likewise unchanged,
down to its wording.

- **`ObjectStackAdapter.listViews` now emits on `onMetadataReadWarning`** — the channel
objectui#7741 added — when the read failed in a way that is not the supported "this host
mounted no metadata door" shape. The event carries the object, whether the server
`refused` this caller or the answer was `unreadable`, and the server's own ADR-0112
code, HTTP status and message.
- **New: `classifyViewsFailure(err)`.** A SEPARATE reading, deliberately not a second
caller of `classifyImportMappingsFailure`: `view`'s quiet set is strictly smaller. The
arm the mapping classifier is built around — 400 `INVALID_REQUEST`, the metadata list
door's "this deployment carries no such kind" — is unreachable for `view`, which is in
the platform's static spelling contract, so reading it as kind-absence would swallow a
real refusal. On `view`, only a host with no `/meta` door at all stays quiet.
- **`MetadataReadWarningEvent`'s `operation` and `kind` gain their second members**
(`'listViews'` / `'view'`). This is the additive, reviewed widening the single-member
unions were designed for, and it worked as designed: the consumer that renders these
events had a `switch` naming one operation, so the widening turned "a views failure is
toasted as an import-mapping failure" into a compile error rather than a runtime lie.
- **New: `MetadataReadFailureKind`**, the neutral spelling of the three verdicts.
`ImportMappingsFailureKind` is now an alias of it — identical members, so existing
consumers are unaffected in both directions.
- **The console says which list it was.** `metadataReadWarningToast` picks its title and
its remedy by `operation`, so a failed view read reads *"Saved views for {{object}}
could not be loaded … not because this object has no saved views"*. Three new
`console.savedViews*` keys ship in all ten locale packs; the `console.importMappings*`
copy is untouched.

This applies framework #13906 decision 1 option A — *a thing that could not be READ is not
a thing that is ABSENT* — at the second seam that needed it.
111 changes: 111 additions & 0 deletions packages/app-shell/src/providers/metadataReadWarningToast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,114 @@ describe('emitMetadataReadWarning (objectui#7741)', () => {
expect(options.duration).toBe(10_000);
});
});

/**
* The SECOND emitter on this channel (objectui#8151).
*
* `listViews` carried the same swallow one adapter method over, and adding it
* to `MetadataReadWarningEvent`'s `operation` union is the widening
* objectui#7741 kept that union single-member FOR. This block is that widening
* arriving at its consumer: the pins below are about which SENTENCE a views
* failure gets, and — just as load-bearing — about the mapping sentences not
* moving while it happened.
*/
const VIEWS_REFUSED: MetadataReadWarningEvent = {
operation: 'listViews',
kind: 'view',
objectName: 'crm_lead',
reason: 'refused',
code: 'UNAUTHENTICATED',
status: 401,
message: 'authentication required',
};

describe('emitMetadataReadWarning — the listViews emitter (objectui#8151)', () => {
it('⭐ denies the reading the empty list invites: not "this object has no saved views"', () => {
// The user's actual question in front of a view switcher is "where did my
// views go?" — so the sentence has to answer THAT, not the import wizard's
// question about whether anything is registered.
const s = sink();

emitMetadataReadWarning(VIEWS_REFUSED, t, s);

const [title, options] = s.warning.mock.calls[0];
expect(title).toBe('Saved views for crm_lead could not be loaded');
expect(options.description).toContain('could not be read');
expect(options.description).toContain('no saved views');
expect(options.description).toContain('Sign in again');
});

it('⛔ never renders the import-mapping wording for a views failure', () => {
// The exact runtime lie the closed `operation` union existed to prevent:
// before this card the title was one hard-coded `importMappingsUnavailable`,
// so a second emitter would have toasted "Saved import mappings for
// crm_lead could not be loaded" with nothing failing to compile.
const s = sink();

emitMetadataReadWarning(VIEWS_REFUSED, t, s);

const [title, options] = s.warning.mock.calls[0];
expect(title).not.toContain('import');
expect(String(options.description)).not.toContain('registered');
});

it('says retry-and-report on an unreadable views answer', () => {
const s = sink();

emitMetadataReadWarning({ ...VIEWS_REFUSED, reason: 'unreadable', code: undefined, status: 500 }, t, s);

const [, options] = s.warning.mock.calls[0];
expect(options.description).toContain('no saved views');
expect(options.description).toContain('Try again');
expect(options.description).not.toContain('Sign in again');
});

it("carries the server's own words on this arm too", () => {
const s = sink();

emitMetadataReadWarning(VIEWS_REFUSED, t, s);

const [, options] = s.warning.mock.calls[0];
expect(options.description).toContain('UNAUTHENTICATED');
expect(options.description).toContain('HTTP 401');
});

it('refuses an unhandled operation rather than rendering another read’s sentence', () => {
// Same discipline as the unhandled-reason pin, one level up. Unreachable
// for a type-checked caller; reachable for a JS one.
const s = sink();

expect(() =>
emitMetadataReadWarning(
{
...VIEWS_REFUSED,
operation: 'listSomethingElse' as unknown as MetadataReadWarningEvent['operation'],
},
t,
s,
),
).toThrow(/no title for operation/);
});

it('⭐ THE LIT CONTROL — the import-mapping copy is byte-identical to what objectui#7741 shipped', () => {
// A widening that quietly reworded the sibling's toast would pass every
// pin above. These three strings are the ones objectui#7741 put in `en`,
// asserted whole rather than by substring.
const s = sink();

emitMetadataReadWarning(REFUSED, t, s);

const [title, options] = s.warning.mock.calls[0];
expect(title).toBe('Saved import mappings for crm_plant_cost could not be loaded');
expect(String(options.description).split('\n')[0]).toBe(
'The server refused this request, so this list is empty because it could not be read — not because nothing is registered. Sign in again, or ask an administrator for access.',
);

const s2 = sink();
emitMetadataReadWarning({ ...REFUSED, reason: 'unreadable', code: undefined, status: undefined, message: undefined }, t, s2);
const [, options2] = s2.warning.mock.calls[0];
expect(options2.description).toBe(
'This list is empty because it could not be read, not because nothing is registered. Try again, and report this if it keeps happening.',
);
});
});
138 changes: 123 additions & 15 deletions packages/app-shell/src/providers/metadataReadWarningToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,25 @@
* misreading. Promoting the log level would not have changed any of it. This
* module is the half that makes the failure visible where the decision is made.
*
* ## The same surface, a second read (objectui#8151)
*
* `listViews` carried the identical swallow, and its cost is the higher one: an
* empty view list is an object's VIEW SWITCHER, so a user whose token lapsed
* mid-session was shown an object that appears to have no saved views at all —
* including views they created. It is the second emitter on this channel, and
* every string below is chosen by WHICH read failed rather than shared, because
* a hedged sentence about "a list" would put the ambiguity back in the copy
* after the event removed it from the data.
*
* ## What it deliberately does NOT say
*
* Nothing about the supported case. A server that does not serve the `mapping`
* kind never reaches here — the adapter classifies that arm as `not-served` and
* emits no event — so an older deployment keeps its quiet, empty, selector-less
* wizard and earns no toast. Turning a real deployment shape into a visible
* fault is the failure this surface must not commit.
* Nothing about the supported case. The adapter classifies that arm as
* `not-served` and emits no event at all, so a deployment in a real, supported
* shape earns no toast — turning one into a visible fault is the failure this
* surface must not commit. ⚠️ WHICH failures are in that arm is decided per
* read and is not the same set twice: a server that does not serve the
* `mapping` kind is quiet, while on `view` only a host with no metadata door at
* all is (`classifyImportMappingsFailure` / `classifyViewsFailure`).
*
* ## Why the server's own words are appended untranslated
*
Expand Down Expand Up @@ -103,7 +115,8 @@ export interface MetadataReadWarningSink {
const READ_WARNING_TOAST_MS = 10_000;

/**
* The remedy sentence, chosen by WHICH loud verdict this was.
* The remedy sentence for a failed `listImportMappings`, chosen by WHICH loud
* verdict this was.
*
* An exhaustive `switch` with a `never` check rather than a ternary, for the
* reason `saveAdvisoryToast.advisoryTitle` records: a ternary answers "is it
Expand All @@ -117,8 +130,11 @@ const READ_WARNING_TOAST_MS = 10_000;
* caller wraps this in a try/catch that swallows: the failure mode is therefore
* "no toast", never "a toast naming the wrong remedy".
*/
function remedy(ev: MetadataReadWarningEvent, t: TranslateFn): string {
switch (ev.reason) {
function importMappingsRemedy(
reason: MetadataReadWarningEvent['reason'],
t: TranslateFn,
): string {
switch (reason) {
case 'refused':
return t('console.importMappingsRefused', {
defaultValue:
Expand All @@ -130,14 +146,109 @@ function remedy(ev: MetadataReadWarningEvent, t: TranslateFn): string {
'This list is empty because it could not be read, not because nothing is registered. Try again, and report this if it keeps happening.',
});
default: {
const unhandled: never = ev.reason;
const unhandled: never = reason;
throw new Error(
`metadataReadWarningToast: no remedy for reason ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* The remedy sentence for a failed `listViews` (objectui#8151).
*
* Same two verdicts, same `never` discipline — a DIFFERENT second clause. The
* whole point of the sentence is to deny the wrong reading the empty list
* invites, and the wrong reading differs per list: "nothing is registered" is
* what an absent saved-mapping selector says, while an empty `listViews` says
* *this object has no saved views* — including the ones the user created
* themselves, which is what makes it the sharper lie of the two.
*/
function savedViewsRemedy(
reason: MetadataReadWarningEvent['reason'],
t: TranslateFn,
): string {
switch (reason) {
case 'refused':
return t('console.savedViewsRefused', {
defaultValue:
'The server refused this request, so this list is empty because it could not be read — not because this object has no saved views. Sign in again, or ask an administrator for access.',
});
case 'unreadable':
return t('console.savedViewsUnreadable', {
defaultValue:
'This list is empty because it could not be read, not because this object has no saved views. Try again, and report this if it keeps happening.',
});
default: {
const unhandled: never = reason;
throw new Error(
`metadataReadWarningToast: no remedy for reason ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Which read failed decides BOTH strings (objectui#8151).
*
* `operation` — the adapter method — is the discriminant, not `kind`: it is
* what names the list the user is standing in front of, and the two fields are
* independent unions on the published event, so only one of them can be the
* authority here. Exhaustive with a `never` check for the reason the per-reason
* switches are: this file is the consumer objectui#7741 kept `operation` a
* closed union FOR, so a third emitter must fail to compile here rather than
* silently render some other read's sentence.
*
* ⛔ There is no shared "generic" wording either branch falls back to. A toast
* that hedges about WHICH list could not be read would re-introduce, in copy,
* exactly the ambiguity the event was added to remove.
*/
function remedy(ev: MetadataReadWarningEvent, t: TranslateFn): string {
switch (ev.operation) {
case 'listImportMappings':
return importMappingsRemedy(ev.reason, t);
case 'listViews':
return savedViewsRemedy(ev.reason, t);
default: {
const unhandled: never = ev.operation;
throw new Error(
`metadataReadWarningToast: no remedy for operation ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* The headline, chosen by the same discriminant and held to the same rule as
* {@link remedy} (objectui#8151).
*
* Before this card the title was one hard-coded `t('console.importMappingsUnavailable')`.
* That is the shape a second emitter would have turned into a runtime lie —
* *"Saved import mappings for account could not be loaded"* on a failed VIEW
* read — with nothing failing to compile, which is precisely what the closed
* `operation` union exists to prevent.
*/
function title(ev: MetadataReadWarningEvent, t: TranslateFn): string {
switch (ev.operation) {
case 'listImportMappings':
return t('console.importMappingsUnavailable', {
object: ev.objectName,
defaultValue: 'Saved import mappings for {{object}} could not be loaded',
});
case 'listViews':
return t('console.savedViewsUnavailable', {
object: ev.objectName,
defaultValue: 'Saved views for {{object}} could not be loaded',
});
default: {
const unhandled: never = ev.operation;
throw new Error(
`metadataReadWarningToast: no title for operation ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* The server's own words about its own answer, as one line — or nothing at all
* when it sent none.
Expand All @@ -160,19 +271,16 @@ function serverDetail(ev: MetadataReadWarningEvent): string | undefined {
* empty result anyway.
*
* The title names the object, because that is the scope the empty list is about
* and the wizard the user is standing in is open on exactly one object.
* and the surface the user is standing in — the import wizard, or one object's
* view switcher — is open on exactly one object.
*/
export function emitMetadataReadWarning(
ev: MetadataReadWarningEvent,
t: TranslateFn,
sink: MetadataReadWarningSink,
): void {
const title = t('console.importMappingsUnavailable', {
object: ev.objectName,
defaultValue: 'Saved import mappings for {{object}} could not be loaded',
});
const detail = serverDetail(ev);
sink.warning(title, {
sink.warning(title(ev, t), {
description: detail ? `${remedy(ev, t)}\n${detail}` : remedy(ev, t),
duration: READ_WARNING_TOAST_MS,
});
Expand Down
Loading
Loading