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
27 changes: 27 additions & 0 deletions .changeset/7199-listview-description-relay.md
Original file line numberDiff line numberDiff line change
@@ -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.<viewName>.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.
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>, view: Record<string, unknown>) {
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<unknown> {
captured = null;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
// `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);
});
});
31 changes: 28 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
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 (
<div
ref={pullRef}
Expand All@@ -2961,9 +2986,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
</div>
)}
{/* View Description (single line, no border duplication) */}
{schema.description && (schema.appearance?.showDescription !== false) && (
{viewDescription && (schema.appearance?.showDescription !== false) && (
<div className="px-4 pt-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
{typeof schema.description === 'string' ? schema.description : ''}
{viewDescription}
</div>
)}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
27 changes: 27 additions & 0 deletions .changeset/7199-listview-description-relay.md
Original file line numberDiff line numberDiff line change
@@ -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.<viewName>.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.
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>, view: Record<string, unknown>) {
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<unknown> {
captured = null;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
// `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);
});
});
31 changes: 28 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
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 (
<div
ref={pullRef}
Expand All@@ -2961,9 +2986,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
</div>
)}
{/* View Description (single line, no border duplication) */}
{schema.description && (schema.appearance?.showDescription !== false) && (
{viewDescription && (schema.appearance?.showDescription !== false) && (
<div className="px-4 pt-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
{typeof schema.description === 'string' ? schema.description : ''}
{viewDescription}
</div>
)}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
27 changes: 27 additions & 0 deletions .changeset/7199-listview-description-relay.md
Original file line numberDiff line numberDiff line change
@@ -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.<viewName>.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.
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>, view: Record<string, unknown>) {
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<unknown> {
captured = null;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
// `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);
});
});
31 changes: 28 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
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 (
<div
ref={pullRef}
Expand All@@ -2961,9 +2986,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
</div>
)}
{/* View Description (single line, no border duplication) */}
{schema.description && (schema.appearance?.showDescription !== false) && (
{viewDescription && (schema.appearance?.showDescription !== false) && (
<div className="px-4 pt-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
{typeof schema.description === 'string' ? schema.description : ''}
{viewDescription}
</div>
)}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
27 changes: 27 additions & 0 deletions .changeset/7199-listview-description-relay.md
Original file line numberDiff line numberDiff line change
@@ -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.<viewName>.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.
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>, view: Record<string, unknown>) {
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<unknown> {
captured = null;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
// `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);
});
});
31 changes: 28 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
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 (
<div
ref={pullRef}
Expand All@@ -2961,9 +2986,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
</div>
)}
{/* View Description (single line, no border duplication) */}
{schema.description && (schema.appearance?.showDescription !== false) && (
{viewDescription && (schema.appearance?.showDescription !== false) && (
<div className="px-4 pt-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
{typeof schema.description === 'string' ? schema.description : ''}
{viewDescription}
</div>
)}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
27 changes: 27 additions & 0 deletions .changeset/7199-listview-description-relay.md
Original file line numberDiff line numberDiff line change
@@ -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.<viewName>.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.
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>, view: Record<string, unknown>) {
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<unknown> {
captured = null;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
// `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);
});
});
31 changes: 28 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
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 (
<div
ref={pullRef}
Expand All@@ -2961,9 +2986,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
</div>
)}
{/* View Description (single line, no border duplication) */}
{schema.description && (schema.appearance?.showDescription !== false) && (
{viewDescription && (schema.appearance?.showDescription !== false) && (
<div className="px-4 pt-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
{typeof schema.description === 'string' ? schema.description : ''}
{viewDescription}
</div>
)}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
27 changes: 27 additions & 0 deletions .changeset/7199-listview-description-relay.md
Original file line numberDiff line numberDiff line change
@@ -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.<viewName>.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.
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>, view: Record<string, unknown>) {
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<unknown> {
captured = null;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
// `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);
});
});
31 changes: 28 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
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 (
<div
ref={pullRef}
Expand All@@ -2961,9 +2986,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
</div>
)}
{/* View Description (single line, no border duplication) */}
{schema.description && (schema.appearance?.showDescription !== false) && (
{viewDescription && (schema.appearance?.showDescription !== false) && (
<div className="px-4 pt-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
{typeof schema.description === 'string' ? schema.description : ''}
{viewDescription}
</div>
)}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
27 changes: 27 additions & 0 deletions .changeset/7199-listview-description-relay.md
Original file line numberDiff line numberDiff line change
@@ -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.<viewName>.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.
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>, view: Record<string, unknown>) {
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<unknown> {
captured = null;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
// `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);
});
});
31 changes: 28 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
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 (
<div
ref={pullRef}
Expand All@@ -2961,9 +2986,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
</div>
)}
{/* View Description (single line, no border duplication) */}
{schema.description && (schema.appearance?.showDescription !== false) && (
{viewDescription && (schema.appearance?.showDescription !== false) && (
<div className="px-4 pt-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
{typeof schema.description === 'string' ? schema.description : ''}
{viewDescription}
</div>
)}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
27 changes: 27 additions & 0 deletions .changeset/7199-listview-description-relay.md
Original file line numberDiff line numberDiff line change
@@ -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.<viewName>.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.
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>, view: Record<string, unknown>) {
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<unknown> {
captured = null;
render(
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
<Routes>
<Route
path="/apps/:appName/:objectName"
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
/>
</Routes>
</MemoryRouter>
</ExpressionProvider>,
);
// `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);
});
});
31 changes: 28 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -2941,6 +2941,31 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
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 (
<div
ref={pullRef}
Expand All@@ -2961,9 +2986,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
</div>
)}
{/* View Description (single line, no border duplication) */}
{schema.description && (schema.appearance?.showDescription !== false) && (
{viewDescription && (schema.appearance?.showDescription !== false) && (
<div className="px-4 pt-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
{typeof schema.description === 'string' ? schema.description : ''}
{viewDescription}
</div>
)}

Expand Down
Loading
Loading