diff --git a/.changeset/7199-listview-description-relay.md b/.changeset/7199-listview-description-relay.md new file mode 100644 index 000000000..2b080749c --- /dev/null +++ b/.changeset/7199-listview-description-relay.md @@ -0,0 +1,27 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/plugin-list': patch +--- + +fix(app-shell,plugin-list): a list view's own `description` now reaches the screen + +A `description` authored on a per-list-view entry (`listViews..description`) +was validated, built and served correctly, then silently never rendered. Two +independent cuts, both fixed here: + +- **app-shell** — `ObjectView`'s `renderListView` relay copied ~46 keys off the + active view onto the schema it hands `ListView` (`label`, `sort`, `filter`, + `hiddenFields`, `inlineEdit`, `color`, `allowExport`, …) but had no rung for + `description`, so the renderer could only ever see the object-level list's + description and a per-view one was unreachable. It is relayed now, with the + same two-rung shape as `label`. This is *not* the object's own + `objectDef.description`, which stays the page header's subtitle. +- **plugin-list** — `ListView` rendered `typeof description === 'string' ? … : ''`, + a type test rather than a resolution. `ListViewSchema.description` is + `I18nLabel`, so an inline locale map (`{ en, 'zh-CN' }`) — metadata the spec + entitles an author to write — rendered a blank strip in every locale. It now + resolves through the same shared helper the sibling `label` uses, and the + visibility guard reads the resolved text, so a map with no usable entry drops + the strip instead of reserving empty space for it. + +`appearance.showDescription: false` still suppresses the description in both arms. diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 52e0ae879..474030057 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -2151,6 +2151,26 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // The active view's display label (same string the ViewTabBar // shows) — ListView appends it to export download filenames. label: viewDef.label ?? listSchema.label, + /** + * The active view's own description — the sentence the author wrote + * to caveat THIS view (scope, staleness, "this lens is for + * browsing, the dashboard is authoritative"), which is exactly the + * text a per-view description is wanted for (objectui#7199). + * + * It was the one key of this relay's set with no rung, so + * `schema.description` at the `ListView` end could only ever be the + * object-level list's description and a per-view one was + * unreachable — authored, validated, built and served, then + * silently dropped here. Nothing errored: the value simply never + * arrived, and the only symptom was a sentence missing from the + * screen. + * + * ⚠️ NOT the object's own `objectDef.description`, which this page + * renders as the `PageHeader` subtitle further down. Crossing the + * two would put a view's caveat where the object's blurb belongs. + * Same two-rung shape as `label` above. + */ + description: viewDef.description ?? listSchema.description, // Propagate appearance/view-config properties for live preview rowHeight: viewDef.rowHeight ?? listSchema.rowHeight, densityMode: viewDef.densityMode ?? listSchema.densityMode, diff --git a/packages/app-shell/src/views/ObjectView.viewDescriptionRelay-7199.test.tsx b/packages/app-shell/src/views/ObjectView.viewDescriptionRelay-7199.test.tsx new file mode 100644 index 000000000..b1ecfb909 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.viewDescriptionRelay-7199.test.tsx @@ -0,0 +1,281 @@ +/** + * 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. + */ + +/** + * objectui#7199 — the object page relays a per-view `description`. + * + * ## The defect this pins + * + * `renderListView` builds `fullSchema` by spreading the OBJECT's `listSchema` + * and then relaying selected keys off the active `viewDef`. `label`, `sort`, + * `filter`, `hiddenFields`, `inlineEdit`, `color`, `allowExport` and ~40 more + * each have a rung. `description` had NONE, so `schema.description` at the + * `ListView` end could only ever be the object-level list's description, and a + * per-view one was unreachable — authored, validated, built and served + * correctly, then dropped here. + * + * It is the "declared and inert" shape: nothing errors, every authoring gate + * passes, the API serves the value, and the only symptom is that the sentence + * the author wrote for the user is not on the screen. It bites hardest where a + * view description is most wanted — disclosing a caveat about the view itself. + * + * ## The value DOES arrive here — the relay is where it dies + * + * Confirmed rather than assumed, because "the API serves it" traces the value + * only as far as the meta API, not as far as this component's props: + * `buildViewTabs` composes each entry through `viewEntry`, which is + * `Object.assign` over the authored body and stamps only `id` afterwards. No + * key whitelist runs between `objectDef.listViews` and `activeView`, so an + * authored `description` is present on `viewDef` and this relay is the single + * point of loss. The `objectDef.description` case below is what proves the fix + * did not simply reach for the object-level value instead. + * + * ## ⚠️ NOT the page header's subtitle + * + * This page also renders `subtitle={objectDef.description ? objectDesc(objectDef) : undefined}` + * on its `PageHeader`. That is the OBJECT's blurb — a different value with a + * different audience. Crossing the two would put a view's caveat where the + * object's description belongs, and would make the relay look fixed while + * showing the wrong sentence. The last case holds them apart. + * + * ## Direction and counts, written before the run (reverse verification) + * + * Deleting the `description:` rung from `fullSchema` was PREDICTED to turn the + * four view-authored cases RED (`captured.description` `undefined`, or the + * object-level value where the view's own was expected) and to leave the two + * fallback/absence controls GREEN — they resolve through the `...listSchema` + * spread, which the rung does not touch. Predicted 4 red / 2 passing. Measured + * outcome is recorded on the PR. + * + * ## Why the schema is captured rather than rendered + * + * The claim is about what THIS file hands down, so `ListView` is stubbed and + * its `schema` prop recorded — the same posture as + * `ObjectView.titleFieldConvergence.test.tsx`. Whether the captured value then + * reaches the DOM (and how a locale map resolves once it does) is the other + * half of objectui#7199 and is pinned in `plugin-list` by + * `ListView.descriptionInlineLocale-7199.test.tsx`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/permissions', () => ({ + usePermissions: () => ({ + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }), + useFieldPermissions: () => ({ canRead: () => true, canWrite: () => true, permissions: [] }), +})); + +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', () => ({ + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +/** The list schema this page hands down — captured, not rendered. */ +let captured: any = null; +vi.mock('@object-ui/plugin-list', () => ({ + ListView: (props: any) => { + captured = props.schema; + return null; + }, +})); + +/** + * What the HOST puts on the list schema before this page's relay runs — i.e. + * the `listSchema` the `...listSchema` spread carries in. + * + * The in-tree host (`plugin-view`'s `ObjectView`) sets no `description` of its + * own today, so this is `undefined` for every case except the object-level + * fallback control, where it stands in for an object-level list description. + * That is the rung's SECOND limb, and the only way to exercise it from here. + */ +let hostListDescription: unknown; + +vi.mock('@object-ui/plugin-view', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectView: (props: any) => + props.renderListView?.({ + schema: { + ...(props.schema ?? {}), + ...(hostListDescription === undefined ? {} : { description: hostListDescription }), + }, + dataSource: props.dataSource, + onEdit: props.onEdit, + className: '', + refreshKey: 0, + }) ?? null, + ViewTabBar: () => null, + ManageViewsDialog: () => null, +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'duly_task'; + +/** The per-view sentence — a caveat about THIS view, the text #7199 is about. */ +const VIEW_DESC = 'Open and in-progress work only. Counts cover the loaded page.'; +/** The object's own blurb. Distinct so a crossed wire fails instead of passing. */ +const OBJECT_DESC = 'Every task in the workspace.'; +/** An object-level LIST description — the relay rung's fallback limb. */ +const LIST_DESC = 'The default task list.'; + +function objectsWith(objectExtra: Record, view: Record) { + return [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + listViews: { + by_unit: { label: 'By business unit', type: 'grid', columns: ['name'], ...view }, + }, + ...objectExtra, + }, + ]; +} + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +/** Render the object list and return the `description` the relay handed down. */ +async function relayedDescription(objects: any[]): Promise { + captured = null; + render( + + + + {}} />} + /> + + + , + ); + // `options` is built unconditionally by the same object literal as the rung + // under test, so its arrival is the signal that the relay actually ran — + // waiting on `description` itself would hang rather than fail on a regression. + await waitFor(() => { + expect(captured?.options).toBeTruthy(); + }); + return captured.description; +} + +beforeEach(() => { + cleanup(); + captured = null; + hostListDescription = undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('ObjectView relays the active view\'s own description (objectui#7199)', () => { + it('THE FIX: a per-view `description` reaches the renderer', async () => { + // Before the rung existed this was `undefined` for every object, which is + // the whole of the reported defect. + expect(await relayedDescription(objectsWith({}, { description: VIEW_DESC }))).toBe(VIEW_DESC); + }); + + it('THE FIX: an inline locale map is relayed VERBATIM, not flattened here', async () => { + // `ListViewSchema.description` is `I18nLabel`. The relay's job is to carry + // the authored value; resolution belongs at the render site, which holds + // the audience locale. Flattening here would pick a locale on the wrong + // side of the boundary and is pinned against by this case. + const map = { en: 'Open work only.', 'zh-CN': '仅未完成的工作。' }; + expect(await relayedDescription(objectsWith({}, { description: map }))).toEqual(map); + }); + + it('THE FIX: the per-view value OVERRIDES an object-level list description', async () => { + hostListDescription = LIST_DESC; + expect(await relayedDescription(objectsWith({}, { description: VIEW_DESC }))).toBe(VIEW_DESC); + }); + + it('CONTROL: the object-level list description still shows when the view authors none', async () => { + // The control that the rung is a FALLBACK, not a replacement. Green in + // either world — it resolves through the `...listSchema` spread that the + // rung's second limb only restates — so a fix that stomped the object-level + // value with `undefined` fails here. + hostListDescription = LIST_DESC; + expect(await relayedDescription(objectsWith({}, {}))).toBe(LIST_DESC); + }); + + it('CONTROL: no description anywhere stays absent', async () => { + expect(await relayedDescription(objectsWith({}, {}))).toBeUndefined(); + }); + + it("the OBJECT's own description is never borrowed as the view's", async () => { + // `objectDef.description` is the PageHeader's subtitle — a different value + // with a different audience. A relay that reached for it would satisfy the + // "a description arrives" reading of this card while showing the object's + // blurb where the view's caveat belongs. + const relayed = await relayedDescription( + objectsWith({ description: OBJECT_DESC }, {}), + ); + expect(relayed).toBeUndefined(); + expect(relayed).not.toBe(OBJECT_DESC); + + // …and with BOTH authored, the view's own still wins. + cleanup(); + expect( + await relayedDescription(objectsWith({ description: OBJECT_DESC }, { description: VIEW_DESC })), + ).toBe(VIEW_DESC); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 8a7418a77..90cc7414e 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -22,7 +22,7 @@ import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; import { detectStatusField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveEffectiveCrudAffordances, isObjectInlineEditable, partitionRowsByPredicate, normalizeListViewSchema, rowHeightToDensityMode, mergeFilterNodes, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort } from '@object-ui/core'; -import { useObjectTranslation, useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale } from '@object-ui/i18n'; +import { useObjectTranslation, useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own // `resolveI18nLabel`: it resolves the INLINE per-locale map @@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef(({ */ const ariaLabel = resolveInlineI18nLabel(schema.aria?.ariaLabel, displayLocale); + /** + * The view's description, resolved — not type-tested (objectui#7199). + * + * `ListViewSchema.description` is `I18nLabel`, the same vocabulary as the + * sibling `label`: a plain string **or** an inline locale map + * (`{ en: 'Open work only', 'zh-CN': '仅未完成' }`). This read site used to + * be `typeof schema.description === 'string' ? schema.description : ''`, + * which is not a resolution — it is a type test that answers the empty + * string for every map an author is entitled to write. So a locale-map + * description rendered as a blank strip in EVERY locale, which is the same + * silent-blank symptom as the dropped relay one layer up, reached by a + * second route. + * + * `pickLocalized` is the spelling a TEXT NODE wants (`''` on a miss) — the + * same helper `TabBar.tsx` resolves the sibling `label` with, one component + * tree away. The attribute next door deliberately uses the spec's resolver + * instead, for its `undefined`; the two agree limb for limb, pinned by + * `i18nLabel-resolver-parity.test.ts` in this package. + * + * Guarding on the RESOLVED text rather than on `schema.description` is what + * keeps a map with no usable entry from rendering an empty strip: the raw + * value is a truthy object, its resolution is `''`. + */ + const viewDescription = pickLocalized(schema.description, displayLocale); + return (
(({
)} {/* View Description (single line, no border duplication) */} - {schema.description && (schema.appearance?.showDescription !== false) && ( + {viewDescription && (schema.appearance?.showDescription !== false) && (
- {typeof schema.description === 'string' ? schema.description : ''} + {viewDescription}
)} diff --git a/packages/plugin-list/src/__tests__/ListView.descriptionInlineLocale-7199.test.tsx b/packages/plugin-list/src/__tests__/ListView.descriptionInlineLocale-7199.test.tsx new file mode 100644 index 000000000..724cce7a7 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.descriptionInlineLocale-7199.test.tsx @@ -0,0 +1,218 @@ +/** + * 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. + */ + +/** + * The view description resolves the inline locale map — objectui#7199, half two. + * + * ## The defect this pins + * + * `ListViewSchema.description` is `I18nLabel` — a plain string **OR** an inline + * locale map (`{ en, 'zh-CN' }`), measured against the `@objectstack/spec@17.2.0` + * dist this repo installs: `ListViewSchema.safeParse` ACCEPTS both and rejects a + * number and a nested object at that key. The read site rendered + * + * typeof schema.description === 'string' ? schema.description : '' + * + * which is not a resolution but a type test, and its else-arm is the empty + * string. So a map — metadata the contract entitles an author to write — + * rendered a BLANK strip in every locale. + * + * ## Why it is worth its own file rather than a case in `ListView.test.tsx` + * + * It is the second route to objectui#7199's reported symptom, and the reason + * that card could not be closed by relaying the value alone: with only the + * relay fixed (`app-shell`'s `fullSchema`), a per-view locale-map description + * arrives correctly and still renders nothing — the identical silent blank, one + * layer down. A card that closes while its symptom still reproduces stops being + * findable, so both halves ship together and both are pinned. + * + * The blank is also invisible to the compiler: the else-arm is well-typed, so + * no type error existed before the fix and none appears if it is reverted. + * Nothing but an executed assertion holds this site. + * + * ## The guard moved from the RAW value to the RESOLVED one + * + * `{schema.description && …}` admitted `{}` — a truthy object — and rendered an + * empty strip. The guard now reads the resolved string, so a map with no usable + * entry drops the element entirely. That is asserted below, not just described: + * it is the one behaviour change beyond the map arm. + * + * ## Which resolver, and why + * + * `pickLocalized` (`@object-ui/i18n`) — the spelling a TEXT NODE wants, `''` on + * a miss. It is the same helper `TabBar.tsx` resolves the sibling `label` with + * (`ListViewSchema.label` is the same `I18nLabel` type), one component tree + * away. The nested `aria.ariaLabel` read site next door deliberately uses the + * spec's `resolveI18nLabel` instead, for its `undefined`, because an ATTRIBUTE + * wants omission rather than an empty name. The split is by DESTINATION, and + * the two agree limb for limb — pinned by `i18nLabel-resolver-parity.test.ts` + * in this package. ⛔ Neither is hand-rolled here. + * + * ## Direction and counts, written before the run (reverse verification) + * + * Restoring the `typeof` test at the read site was PREDICTED to turn the four + * map cases RED (the resolved text absent; for the empty map, the strip present + * rather than dropped) and to leave the five string-arm / suppression cases + * GREEN — the string arm never touched the union's second limb, so it is the + * negative control and cannot tell the two worlds apart. Predicted 4 red / 5 + * passing. Measured outcome is recorded on the PR. + * + * ## Locale channel + * + * `useDisplayLocale()` composes tenant locale → active UI language → `'en'`. + * `LocalizationProvider` drives its first limb, pinning the locale + * deterministically without registering a react-i18next global instance — + * the same channel `ListView.ariaLabelInlineLocale.test.tsx` uses. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { LocalizationProvider } from '@object-ui/i18n'; +import { SchemaRendererProvider } from '@object-ui/react'; +import type { ListViewSchema } from '@object-ui/types'; +import { ListView } from '../ListView'; + +const mockDataSource = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}; + +/** + * The map an author writes. Both entries are distinct sentences so a resolver + * that only ever answered one of them fails a case instead of passing two. + */ +const INLINE_MAP = { + en: 'Open and in-progress work only.', + 'zh-CN': '仅未完成的工作。', +} as const; + +function renderDescription( + description: unknown, + locale: string, + extra: Record = {}, +) { + const schema = { + type: 'list-view', + objectName: 'tasks', + viewType: 'grid', + columns: ['name'], + ...(description === undefined ? {} : { description }), + ...extra, + } as ListViewSchema; + + return render( + + + + + , + ); +} + +afterEach(() => cleanup()); + +describe('ListView view description resolves the inline locale map (objectui#7199)', () => { + /* ── The map arm: what was broken ─────────────────────────────────────── */ + + describe('the map arm', () => { + it('renders the sentence for the audience locale, not a blank strip', () => { + renderDescription(INLINE_MAP, 'zh-CN'); + + expect(screen.getByTestId('view-description')).toHaveTextContent('仅未完成的工作。'); + // Stated separately because the empty string is the exact thing the + // `typeof` else-arm produced: the element WAS in the DOM, carrying + // nothing, which is why the author had no way to notice. + expect(screen.getByTestId('view-description').textContent).not.toBe(''); + }); + + it('resolves the same map differently for a different locale', () => { + renderDescription(INLINE_MAP, 'en'); + + expect(screen.getByTestId('view-description')).toHaveTextContent( + 'Open and in-progress work only.', + ); + // A resolver that always answered one entry would pass the case above + // and fail here. + expect(screen.queryByText('仅未完成的工作。')).toBeNull(); + }); + + it('follows the base-language limb of the resolver rule', () => { + // Author wrote only `zh`; the audience is `zh-CN`. The six-limb rule is + // the shared resolver's own and is pinned limb-for-limb in this package + // by `i18nLabel-resolver-parity.test.ts` — asserted here only to show the + // rule genuinely reaches this read site. + renderDescription({ en: 'Open work only.', zh: '仅未完成。' }, 'zh-CN'); + + expect(screen.getByTestId('view-description')).toHaveTextContent('仅未完成。'); + }); + + it('drops the strip entirely when the map matches nothing', () => { + // `{}` is TRUTHY, so the pre-fix guard admitted it and rendered an empty + // grey strip. The guard now reads the RESOLVED text, so a miss removes + // the element rather than reserving blank space for it. + renderDescription({}, 'en'); + + expect(screen.queryByTestId('view-description')).not.toBeInTheDocument(); + }); + }); + + /* ── The string arm: the negative control ─────────────────────────────── */ + + describe('the string arm (negative control — unchanged by this fix)', () => { + it('passes a plain string through unchanged', () => { + renderDescription('Open and in-progress work only.', 'zh-CN'); + + expect(screen.getByTestId('view-description')).toHaveTextContent( + 'Open and in-progress work only.', + ); + }); + + it('passes a plain string through unchanged for every locale', () => { + renderDescription('Open and in-progress work only.', 'en'); + + expect(screen.getByTestId('view-description')).toHaveTextContent( + 'Open and in-progress work only.', + ); + }); + + it('renders nothing when no description is authored', () => { + renderDescription(undefined, 'en'); + + expect(screen.queryByTestId('view-description')).not.toBeInTheDocument(); + }); + }); + + /* ── The author's opt-out still wins over both arms ───────────────────── */ + + describe('appearance.showDescription: false still suppresses', () => { + it('suppresses a plain-string description', () => { + renderDescription('Open and in-progress work only.', 'en', { + appearance: { showDescription: false }, + }); + + expect(screen.queryByTestId('view-description')).not.toBeInTheDocument(); + }); + + it('suppresses a resolved locale-map description', () => { + // The case the fix could have broken: resolving the map first must not + // route around the opt-out. `showDescription` defaults to `true` + // (measured on `@objectstack/spec@17.2.0`: an `appearance: {}` parses to + // `{ showDescription: true }`), so only an explicit `false` suppresses — + // which is why objectui#7199 records no missing author opt-in. + renderDescription(INLINE_MAP, 'zh-CN', { + appearance: { showDescription: false }, + }); + + expect(screen.queryByTestId('view-description')).not.toBeInTheDocument(); + }); + }); +});