Skip to content

Commit 02c9f98

Browse files
fix(ui): a tree list view draws the view's authored empty state, and the tree's load failure is translated (#29191)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbNy46ZS2ZhE1irAPWp5FY5P)_ Fixes #28966 Clause-②: no Metadata-protocol: none The two lines are copied from the claim (comment 6572298995). ## 维护者速读 - 改了什么:列表视图以树形显示且没有记录时,现在和表格、画廊一样显示视图作者写的空状态(标题、说明、图标、链接),没写就显示本地化的"这里还没有数据";树加载失败时显示翻译后的"无法加载记录"加上错误原文,不再是英文的 "Failed to load tree: …"。 - 为什么改:管理员给视图写的空状态说明,在表格和画廊上能看到,切到树形(例如 Setup › 业务单元默认就是树)就丢了,只剩通用的"暂无数据";中文页面上树加载失败还冒出一句英文。 - 风险与回滚:只影响树形视图的空结果和加载失败两种情况;有记录的树、其他视图类型不变。没有新增元数据键或选项。回滚即撤销本 PR。 - 先看:`ListView.tsx` 空状态分支的条件(加了 `tree`);`ObjectTree.tsx` 加载失败分支改为 `DataErrorState`;新测试 `ListView.treeEmptyState-28966.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-list/src/__tests__/ListView.treeEmptyState-28966.test.tsx packages/plugin-tree/src/ObjectTree.loadErrorI18n-28966.test.tsx`,预期 2 个文件 8 个用例通过。 - 需要你做的:无,席位落地。 ## What changes - **Empty tree views (`ui/packages/plugin-list/src/ListView.tsx`).** The empty-state branch now covers `tree` as well as `grid` and `gallery`, so an empty tree list view draws `ListEmptyState`: the authored `emptyState` first, resolved against the display locale, otherwise the localized `list.*` copy, with the same Create, Clear search and Clear filters actions. Before, the empty set went to `ObjectTree`, which reads no `emptyState` and drew `common.noData`. The edit is the condition plus its comment; nothing else in the branch moved. - **Which of the two routes, measured.** The card offered widening the `ListView` condition or handing the resolved empty state to `ObjectTree`. Widening was taken. `ObjectTree` has no toolbar of its own (its only chrome is the refresh bar over drawn rows), and the list toolbar sits outside the branch, so it stays on screen above the empty state; a test pins its Filter and Refresh buttons there. Handing the state down would have needed a new host prop on `ObjectTree`, against "no new option". `ListView` already fetches a tree view with the same effective filter the tree's own query is handed (`selfQueryFilter`) and with no search term (`searchlessViewOnScreen`), so its empty `data` is the tree's empty answer too. - **Tree load failures (`ui/packages/plugin-tree/src/ObjectTree.tsx`).** The English literal "Failed to load tree: MESSAGE" is now a `DataErrorState` (`role="alert"`, `data-testid="tree-load-error"`) titled by `list.loadErrorTitle` ("Couldn't load records", the list failure panel's own title) with the error's message under it, the shape `ObjectGrid` gives its failure. The key is borrowed, not minted: no pack gains a key, so `@object-ui/i18n` is untouched and `Clause-②: no` holds. The key is added to the tree's provider-less defaults table, as `grid.refreshing` and `common.noData` already are. - **Repointed pin.** `ObjectTree.inlineQueryKeys-9136.test.tsx` asserted the absence of the old English text, which would have passed vacuously after the change; it now asserts the absence of `tree-load-error`. ## Tests - New `ListView.treeEmptyState-28966.test.tsx` (tree is a stand-in registered as `object-tree`; this package does not depend on `plugin-tree`, and the pinned decision is whether the tree is mounted at all): - CONTROL: with a row, the tree stand-in is mounted and no empty state; - an authored plain-string `emptyState` is drawn as written, in place of the tree; - an authored inline locale map paints its zh-CN entries; - zh with nothing authored: the pack's first-run copy, not `暂无数据`; - the wide toolbar's Filter and Refresh stay on screen above the empty tree; - the tree and the grid draw the same empty state, node for node. - New `ObjectTree.loadErrorI18n-28966.test.tsx`: under the zh pack a rejected read shows an alert titled `无法加载记录` with the error message, and no English line; with no provider, the English title from the defaults table and the message. - Ablation (one-shot, through `scripts/ablation-replace.mjs`, restored to HEAD's blob): with the condition put back to grid/gallery only, 5 of 6 cases in the new ListView file go red and the CONTROL stays green. ## Acceptance notes - A standalone `object-tree` node (not under a list view) still draws `common.noData` when empty: the tree node declares no empty-text key, and the card asks for no new key. Unchanged by this PR. ## Verification Readings on head `62040c75` (the fix and test commits), re-checked on the merge head `3f8de5f9` (`origin/main` merged; none of the 14 incoming commits touch `plugin-list`, `plugin-tree` or `i18n`). <details><summary>Tests</summary> - New and neighbouring files on `3f8de5f9`: `ListView.treeEmptyState-28966`, `ObjectGallery.emptyState-12217`, `ObjectTree.loadErrorI18n-28966`, `ObjectTree.inlineQueryKeys-9136`, `ObjectTree.placeholderI18n`: 5 files, 28 tests passed. - `packages/plugin-tree/` plus the first half of `plugin-list`'s test files: 119 files, 832 tests passed. The second half of `plugin-list`: 91 files, 1097 tests passed (all 183 `plugin-list` test files in the two batches). - `packages/app-shell/src/views/InterfaceListPage.treeChartRelay-11572.test.tsx` (the one test outside these packages authoring a `tree` list view): 9 passed. - `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped; 4900 tests passed. - Ablation of the condition: 5 failed, 1 passed (CONTROL); restored blob equals HEAD, `git diff HEAD` empty. </details> <details><summary>Type-check and gates</summary> - `turbo run type-check --filter=@object-ui/plugin-list --filter=@object-ui/plugin-tree`: 51 of 51 tasks successful (both packages' `type-check` covers `tsconfig.test.json`). - `eslint` on the five touched files under `ui/`: 0 errors (warnings only, all pre-existing). - `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families for this diff; all 34 ran with exit 0, and `--ran` reconciled 34 derived, 34 run, 0 NOT MEASURED. The derived set is unchanged at the merge head, where the changeset, ADR-0087, empty-changeset, sdui-manifest, nul-bytes and published-files gates were re-run with exit 0. The changeset and closing-keyword gates ran against this body. - `node scripts/check-sdui-manifest.mjs`: exit 0 (no registry change). - `pnpm check:new-line-citations` (under `ui/`): 0 new citations. - The CI-only type-check lanes and the whole-tree families are CI's; their check-runs on this PR are the reading. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUCYVF58JVbiXdjXuX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a221ac commit 02c9f98

6 files changed

Lines changed: 305 additions & 5 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/plugin-tree': patch
4+
---
5+
6+
fix(ui): a tree list view draws the view's authored empty state, and the tree's load failure is translated
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Empty tree views.** A list view shown as a tree now draws the same empty state as the grid and the gallery: the view's authored `emptyState` (title, message, icon, link) first, otherwise the localized "Nothing here yet" copy, with the same Create, Clear search and Clear filters actions. It used to draw the tree's generic "No data" line, so guidance authored on the view was lost on that layout. The list toolbar stays above it.
12+
- **Tree load failures.** A tree whose read fails shows the translated "Couldn't load records" title with the error's own message under it, in the alert panel the list uses, instead of the English line "Failed to load tree: …" on every page.
13+
14+
No metadata key or option is added.

‎ui/packages/plugin-list/src/ListView.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6776,12 +6776,17 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
67766776
/>
67776777
))}
67786778
</div>
6779-
) : !loading && data.length === 0 && (currentView === 'grid' || currentView === 'gallery') ? (
6779+
) : !loading && data.length === 0 && (currentView === 'grid' || currentView === 'gallery' || currentView === 'tree') ? (
67806780
// objectui#12217 — the gallery draws the grid's empty state, through
67816781
// the one shared component: the authored `emptyState`, resolved
67826782
// against the display locale, otherwise the localized `list.*` copy.
67836783
// It used to hand the empty list to `ObjectGallery`, which drew a
67846784
// hard-coded English line and never read the view's `emptyState`.
6785+
// The tree takes the same branch: `ObjectTree` reads no `emptyState`
6786+
// and drew the generic `common.noData`. This component's fetch runs
6787+
// with the very filter the tree's own query is handed
6788+
// (`selfQueryFilter`) and no search term (`searchlessViewOnScreen`),
6789+
// so an empty `data` is the tree's empty answer too.
67856790
<ListEmptyState
67866791
title={schema.emptyState?.title}
67876792
message={schema.emptyState?.message}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* An empty `tree` list view draws the view's empty state (objectstack#28966).
11+
*
12+
* ## The defect this pins
13+
*
14+
* `ListView` drew `ListEmptyState` (the authored `emptyState`, otherwise the
15+
* localized `list.*` copy) only for the `grid` and `gallery` visualizations. A
16+
* tree view was handed the empty set and `ObjectTree`, which reads no
17+
* `emptyState`, drew the generic `common.noData` line instead. An administrator
18+
* who authored guidance on a view saw it on the grid and lost it the moment the
19+
* same view was a tree, as Setup's business units are by default.
20+
*
21+
* ## Which rows discriminate
22+
*
23+
* RED against the unfixed condition: every row that waits for
24+
* `[data-testid="empty-state"]` on a tree view (the tree stand-in was mounted
25+
* instead). GREEN in both worlds: the CONTROL with a row, which proves the
26+
* tree visualization is the one mounted, so an empty state drawn for some
27+
* other reason (a view that silently fell back to the grid) cannot pass the
28+
* other rows vacuously.
29+
*
30+
* The tree is a stand-in registered under `object-tree`: this package does not
31+
* depend on `plugin-tree`, and what is pinned is `ListView`'s branch, which
32+
* decides whether the tree is mounted at all.
33+
*/
34+
35+
import * as React from 'react';
36+
import { describe, it, expect, vi, afterEach } from 'vitest';
37+
import { render, cleanup, waitFor, screen } from '@testing-library/react';
38+
import { ComponentRegistry } from '@object-ui/core';
39+
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
40+
import { SchemaRendererProvider } from '@object-ui/react';
41+
import type { DataSource, ListViewSchema } from '@object-ui/types';
42+
import { ListView } from '../ListView';
43+
44+
ComponentRegistry.register(
45+
'object-tree',
46+
() => <div data-testid="object-tree-standin" />,
47+
{ namespace: 'test', label: 'object-tree stand-in', category: 'view' },
48+
);
49+
50+
const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const;
51+
52+
/** The built-in `zh` pack's `list.*` first-run rows. */
53+
const ZH_FIRST_RUN_TITLE = '这里还没有数据';
54+
const ZH_FIRST_RUN_MESSAGE = '创建第一条记录即可开始。';
55+
56+
const OBJECT_DEF = {
57+
name: 'business_unit',
58+
fields: {
59+
name: { type: 'text', label: 'Name' },
60+
parent: { type: 'lookup', reference: 'business_unit', label: 'Parent' },
61+
},
62+
};
63+
64+
const dataSourceAnswering = (rows: Record<string, unknown>[]) => ({
65+
find: vi.fn().mockResolvedValue(rows),
66+
findOne: vi.fn(),
67+
create: vi.fn(),
68+
update: vi.fn(),
69+
delete: vi.fn(),
70+
getObjectSchema: vi.fn().mockResolvedValue(OBJECT_DEF),
71+
});
72+
73+
const EMPTY_STATE = '[data-testid="empty-state"]';
74+
const TREE = '[data-testid="object-tree-standin"]';
75+
const headingOf = (state: Element) => state.querySelector('h3')?.textContent ?? null;
76+
const messageOf = (state: Element) => state.querySelector('p')?.textContent ?? null;
77+
78+
type Locale = 'zh-pack' | { inline: string } | 'none';
79+
80+
function withLocale(ui: React.ReactElement, locale: Locale) {
81+
if (locale === 'zh-pack') {
82+
return <I18nProvider config={ZH} persistLanguage={false}>{ui}</I18nProvider>;
83+
}
84+
if (locale === 'none') return ui;
85+
return <LocalizationProvider value={{ locale: locale.inline }}>{ui}</LocalizationProvider>;
86+
}
87+
88+
function mountListView(
89+
viewType: 'grid' | 'tree',
90+
extra: Partial<ListViewSchema>,
91+
locale: Locale,
92+
rows: Record<string, unknown>[] = [],
93+
) {
94+
const ds = dataSourceAnswering(rows);
95+
const schema = {
96+
type: 'list-view',
97+
objectName: 'business_unit',
98+
viewType,
99+
columns: ['name'],
100+
...(viewType === 'tree' ? { tree: { parentField: 'parent' } } : {}),
101+
...extra,
102+
} as ListViewSchema;
103+
return render(
104+
withLocale(
105+
<SchemaRendererProvider dataSource={ds as unknown as DataSource}>
106+
<ListView schema={schema} dataSource={ds as unknown as DataSource} />
107+
</SchemaRendererProvider>,
108+
locale,
109+
),
110+
);
111+
}
112+
113+
async function emptyStateOf(
114+
viewType: 'grid' | 'tree',
115+
extra: Partial<ListViewSchema>,
116+
locale: Locale,
117+
): Promise<HTMLElement> {
118+
const { container } = mountListView(viewType, extra, locale);
119+
await waitFor(() => expect(container.querySelector(EMPTY_STATE)).not.toBeNull());
120+
return container.querySelector(EMPTY_STATE) as HTMLElement;
121+
}
122+
123+
afterEach(() => cleanup());
124+
125+
describe('an empty tree list view draws the view empty state (objectstack#28966)', () => {
126+
it('CONTROL: with a row, the tree visualization is the one mounted, and no empty state', async () => {
127+
const { container } = mountListView('tree', {}, 'none', [{ id: 'b1', name: 'Head office' }]);
128+
await waitFor(() => expect(container.querySelector(TREE)).not.toBeNull());
129+
expect(container.querySelector(EMPTY_STATE)).toBeNull();
130+
});
131+
132+
it('an authored plain-string `emptyState` is drawn as written, in place of the tree', async () => {
133+
const state = await emptyStateOf(
134+
'tree',
135+
{ emptyState: { title: 'No business units yet', message: 'Add the head office first.' } },
136+
'none',
137+
);
138+
expect(headingOf(state)).toBe('No business units yet');
139+
expect(messageOf(state)).toBe('Add the head office first.');
140+
expect(state.ownerDocument.querySelector(TREE)).toBeNull();
141+
});
142+
143+
it('an authored `emptyState` inline locale map paints its zh-CN entries', async () => {
144+
const state = await emptyStateOf(
145+
'tree',
146+
{
147+
emptyState: {
148+
title: { en: 'No business units yet', 'zh-CN': '还没有业务单元' },
149+
message: { en: 'Add the head office first.', 'zh-CN': '请先添加总部。' },
150+
},
151+
},
152+
{ inline: 'zh-CN' },
153+
);
154+
expect(headingOf(state)).toBe('还没有业务单元');
155+
expect(messageOf(state)).toBe('请先添加总部。');
156+
});
157+
158+
it('zh, nothing authored: the localized first-run copy, not the tree generic line', async () => {
159+
const state = await emptyStateOf('tree', {}, 'zh-pack');
160+
expect(headingOf(state)).toBe(ZH_FIRST_RUN_TITLE);
161+
expect(messageOf(state)).toBe(ZH_FIRST_RUN_MESSAGE);
162+
expect(state.ownerDocument.body.textContent).not.toContain('暂无数据');
163+
});
164+
165+
it('the list toolbar stays on screen above the empty tree', async () => {
166+
// The wide toolbar (objectui#12423: the compact form is the default), so
167+
// its Filter and Refresh buttons are named controls a test can find. The
168+
// empty state replaces the visualization below the toolbar, never the
169+
// toolbar: the reader keeps Filter to widen the query and Refresh.
170+
const state = await emptyStateOf(
171+
'tree',
172+
{ compactToolbar: false, emptyState: { title: 'No business units yet' } } as Partial<ListViewSchema>,
173+
'none',
174+
);
175+
expect(screen.getByTestId('toolbar-filter-trigger')).toBeTruthy();
176+
expect(screen.getByTestId('refresh-button')).toBeTruthy();
177+
expect(headingOf(state)).toBe('No business units yet');
178+
});
179+
180+
it('the tree and the grid draw the SAME empty state, node for node', async () => {
181+
const view: Partial<ListViewSchema> = {
182+
emptyState: { title: { en: 'Nothing here', 'zh-CN': '这里什么都没有' }, message: 'Start with one.' },
183+
};
184+
const grid = (await emptyStateOf('grid', view, { inline: 'zh-CN' })).outerHTML;
185+
cleanup();
186+
const tree = (await emptyStateOf('tree', view, { inline: 'zh-CN' })).outerHTML;
187+
expect(tree).toBe(grid);
188+
// Non-vacuity: what both drew is the authored, resolved copy.
189+
expect(grid).toContain('这里什么都没有');
190+
});
191+
});

‎ui/packages/plugin-tree/src/ObjectTree.inlineQueryKeys-9136.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ describe('objectui#9136 — the tree honours filter and the row ceiling on inlin
212212
render(<ObjectTree schema={{ ...base, data: { provider: 'value', items: [root, child] } }} />);
213213
await waitFor(() => expect(drawn().count).toBe(2));
214214
expect(drawn().names).toBe('Acme,Engineering');
215-
expect(screen.queryByText(/Failed to load tree/)).toBeNull();
215+
// The load-failure panel (objectstack#28966 moved it off an English literal).
216+
expect(screen.queryByTestId('tree-load-error')).toBeNull();
216217
});
217218

218219
it('providerBackedControl: the OBJECT provider is untouched by this repair', async () => {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `ObjectTree`'s load failure speaks the session locale (objectstack#28966).
11+
*
12+
* It printed the English literal "Failed to load tree: MESSAGE" on every page.
13+
* It is now the failure panel (`DataErrorState`, `role="alert"`) titled by the
14+
* list's own `list.loadErrorTitle`, with the error's message under it, the
15+
* shape `ObjectGrid` gives its failure. The zh row is the one that was red; the
16+
* provider-less row pins the English the factory's defaults table supplies.
17+
*/
18+
19+
import React from 'react';
20+
import { describe, it, expect, vi, afterEach } from 'vitest';
21+
import { render, screen, cleanup } from '@testing-library/react';
22+
import '@testing-library/jest-dom';
23+
import { I18nProvider } from '@object-ui/i18n';
24+
import { ObjectTree } from './ObjectTree';
25+
26+
const SERVER_SENTENCE = 'upstream refused the read';
27+
28+
function failingDataSource() {
29+
return {
30+
find: vi.fn(() => Promise.reject(new Error(SERVER_SENTENCE))),
31+
findOne: vi.fn(),
32+
create: vi.fn(),
33+
update: vi.fn(),
34+
delete: vi.fn(),
35+
getObjectSchema: vi.fn(async (object: string) => ({
36+
name: object,
37+
fields: { name: { type: 'text' }, parent_id: { type: 'text' } },
38+
})),
39+
};
40+
}
41+
42+
const tree = (ds: ReturnType<typeof failingDataSource>) => (
43+
<ObjectTree
44+
schema={{
45+
type: 'object-tree',
46+
objectName: 'category',
47+
parentField: 'parent_id',
48+
labelField: 'name',
49+
} as never}
50+
dataSource={ds as never}
51+
/>
52+
);
53+
54+
afterEach(() => cleanup());
55+
56+
describe('ObjectTree load failure is translated', () => {
57+
it('zh: the translated title and the error message, in an alert, no English literal', async () => {
58+
render(
59+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
60+
{tree(failingDataSource())}
61+
</I18nProvider>,
62+
);
63+
const panel = await screen.findByRole('alert');
64+
expect(panel).toHaveAttribute('data-testid', 'tree-load-error');
65+
expect(panel.querySelector('h3')?.textContent).toBe('无法加载记录');
66+
expect(panel.querySelector('p')?.textContent).toBe(SERVER_SENTENCE);
67+
expect(document.body.textContent).not.toMatch(/Failed to load tree/);
68+
});
69+
70+
it('no provider: the English title from the defaults table, and the error message', async () => {
71+
render(tree(failingDataSource()));
72+
const panel = await screen.findByRole('alert');
73+
expect(panel.querySelector('h3')?.textContent).toBe('Couldn’t load records');
74+
expect(panel.querySelector('p')?.textContent).toBe(SERVER_SENTENCE);
75+
});
76+
});

‎ui/packages/plugin-tree/src/ObjectTree.tsx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
usePredicateScope,
5454
} from '@object-ui/react';
5555
import {
56+
DataErrorState,
5657
NavigationOverlay,
5758
RefreshIndicator,
5859
cn,
@@ -115,6 +116,12 @@ const TREE_DEFAULT_TRANSLATIONS: Record<string, string> = {
115116
// contract declares no empty-text key, so nothing authored overrides them.
116117
'common.loading': 'Loading…',
117118
'common.noData': 'No data',
119+
// The load-failure title, borrowed from the list's own failure panel: a
120+
// tree is a list layout (`ListView` mounts it for a `tree` view), and a
121+
// failed read here is the same "Couldn't load records" a reader sees on the
122+
// grid, so one failure gets one translation. The error's own message is
123+
// drawn under it, as `ObjectGrid` draws it under `grid.errorLoading`.
124+
'list.loadErrorTitle': 'Couldn\u2019t load records',
118125
};
119126

120127
const useTreeTranslation = createSafeTranslation(
@@ -1168,11 +1175,17 @@ export const ObjectTree: React.FC<ObjectTreeProps> = ({
11681175
// rules-of-hooks.
11691176
const { t } = useTreeTranslation();
11701177

1178+
// The load-failure panel: a translated title and the error's own message,
1179+
// drawn by the component named for a failure (`role="alert"`), never an
1180+
// English literal on a translated page.
11711181
if (error) {
11721182
return (
1173-
<div className={cn('flex items-center justify-center h-40 text-destructive', className)}>
1174-
<p>Failed to load tree: {error.message}</p>
1175-
</div>
1183+
<DataErrorState
1184+
data-testid="tree-load-error"
1185+
className={cn('h-40', className)}
1186+
title={t('list.loadErrorTitle')}
1187+
message={error.message}
1188+
/>
11761189
);
11771190
}
11781191

0 commit comments

Comments
 (0)