Skip to content

Commit 0fc8935

Browse files
os-zhuangclaude
andauthored
feat(plugin-list): 列表工具栏增加手动刷新按钮 (#2634) (#2645)
* feat(plugin-list): 列表工具栏增加手动刷新按钮 (#2634) 列表(ListView)工具栏此前没有主动刷新入口,后端数据变更后只能靠浏览器 整页 reload,导致筛选/排序/分页/滚动等页面状态全部丢失。 新增「刷新」按钮:点击后仅重新拉取当前视图数据(bump refreshKey 触发 re-fetch),筛选/排序/分页/搜索均为组件内 state,刷新时自动保留。按钮 在移动端与桌面端均常驻,加载时图标旋转。 开关采用 spec 规范的 userActions.refresh(@objectstack/spec ListView), 并向后兼容 legacy showRefresh 标志,默认可见(opt-out),与其它工具栏 开关(search/sort/filter/density)行为一致。 注:数据变更后的自动刷新(onMutation 订阅)与移动端下拉刷新此前已具备, 本次补齐的是缺失的桌面端手动刷新入口。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7Z2cbcb14wb99upTD6LNw * refactor(plugin-list): gate refresh button purely on spec userActions.refresh CI drift guard (list-view-spec-parity) flags new objectui-only ListView fields and asks to promote them into @objectstack/spec instead. `refresh` is already promoted (userActions.refresh), so drop the just-added legacy `showRefresh` zod flag and gate the toolbar button solely on `userActions.refresh` (read defensively; visible by default, opt-out via `userActions.refresh: false`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7Z2cbcb14wb99upTD6LNw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24d34a1 commit 0fc8935

4 files changed

Lines changed: 115 additions & 0 deletions

File tree

packages/i18n/src/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ const en = {
344344
},
345345
},
346346
showAll: 'Show all',
347+
refresh: 'Refresh',
347348
pullToRefresh: 'Pull to refresh',
348349
refreshing: 'Refreshing…',
349350
share: 'Share',

packages/i18n/src/locales/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ const zh = {
421421
},
422422
},
423423
showAll: '显示全部',
424+
refresh: '刷新',
424425
pullToRefresh: '下拉刷新',
425426
refreshing: '刷新中…',
426427
share: '分享',

packages/plugin-list/src/ListView.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const LIST_DEFAULT_TRANSLATIONS: Record<string, string> = {
245245
'list.hideFields': 'Hide fields',
246246
'list.showAll': 'Show all',
247247
'list.pullToRefresh': 'Pull to refresh',
248+
'list.refresh': 'Refresh',
248249
'list.refreshing': 'Refreshing…',
249250
'list.dataLimitReached': 'Showing first {{limit}} records. More data may be available.',
250251
'list.addRecord': 'Add record',
@@ -390,10 +391,15 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
390391
const toolbarFlags = React.useMemo(() => {
391392
const ua = schema.userActions;
392393
const addRecordEnabled = schema.addRecord?.enabled === true && ua?.addRecordForm !== false;
394+
// `refresh` is spec-canonical (`userActions.refresh`, @objectstack/spec). The
395+
// installed spec type may predate the field, so read it defensively. Visible by
396+
// default (opt-out via `userActions.refresh: false`), like the other toggles.
397+
const uaRefresh = (ua as { refresh?: boolean } | undefined)?.refresh;
393398
return {
394399
showSearch: ua?.search !== undefined ? ua.search : schema.showSearch !== false,
395400
showSort: ua?.sort !== undefined ? ua.sort : schema.showSort !== false,
396401
showFilters: ua?.filter !== undefined ? ua.filter : schema.showFilters !== false,
402+
showRefresh: uaRefresh !== undefined ? uaRefresh : true,
397403
showDensity: ua?.rowHeight !== undefined ? ua.rowHeight : schema.showDensity !== false,
398404
showHideFields: schema.showHideFields === true,
399405
showGroup: schema.showGroup !== false,
@@ -2147,6 +2153,25 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
21472153
<div className="h-5 w-px bg-border/50 mx-1 shrink-0" />
21482154
)}
21492155

2156+
{/* Refresh — re-fetch the current view from the backend without a full page
2157+
reload. Filters / sort / pagination / search all live in component state,
2158+
so bumping refreshKey re-queries while preserving the view. Always visible
2159+
(mobile + desktop) since reloading data is a primary list action. */}
2160+
{toolbarFlags.showRefresh && (
2161+
<Button
2162+
variant="ghost"
2163+
size="sm"
2164+
className="h-7 w-7 p-0 text-muted-foreground hover:text-primary transition-colors duration-150"
2165+
onClick={() => setRefreshKey(k => k + 1)}
2166+
disabled={loading}
2167+
title={t('list.refresh')}
2168+
aria-label={t('list.refresh')}
2169+
data-testid="refresh-button"
2170+
>
2171+
<RotateCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
2172+
</Button>
2173+
)}
2174+
21502175
{/* Export */}
21512176
{resolvedExportOptions && exportPermitted && (
21522177
<Popover open={showExport} onOpenChange={setShowExport}>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
* #2634 — the ListView toolbar must expose a manual "Refresh" button that
11+
* re-fetches the current view from the backend without a full page reload.
12+
* Clicking it re-queries the DataSource while the filter/sort/search state
13+
* (held in component state) is preserved.
14+
*
15+
* The toggle is spec-canonical (`userActions.refresh`, @objectstack/spec) and
16+
* defaults to visible (opt-out via `userActions.refresh: false`).
17+
*/
18+
import { describe, it, expect, vi, beforeAll } from 'vitest';
19+
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
20+
import '@testing-library/jest-dom';
21+
import React from 'react';
22+
import { ListView } from '../ListView';
23+
import type { ListViewSchema } from '@object-ui/types';
24+
import { SchemaRendererProvider } from '@object-ui/react';
25+
26+
beforeAll(() => {
27+
Object.defineProperty(window, 'localStorage', {
28+
value: (() => {
29+
let store: Record<string, string> = {};
30+
return {
31+
getItem: (k: string) => store[k] ?? null,
32+
setItem: (k: string, v: string) => { store[k] = v; },
33+
clear: () => { store = {}; },
34+
removeItem: (k: string) => { delete store[k]; },
35+
};
36+
})(),
37+
configurable: true,
38+
});
39+
});
40+
41+
function makeDataSource() {
42+
const find = vi.fn().mockResolvedValue({ data: [], total: 0 });
43+
return {
44+
find,
45+
findOne: vi.fn(),
46+
create: vi.fn(),
47+
update: vi.fn(),
48+
delete: vi.fn(),
49+
} as any;
50+
}
51+
52+
function renderList(schema: Partial<ListViewSchema>, ds: any) {
53+
return render(
54+
<SchemaRendererProvider dataSource={ds}>
55+
<ListView
56+
schema={{ type: 'list-view', objectName: 'proj', fields: ['name'], ...schema } as any}
57+
dataSource={ds}
58+
/>
59+
</SchemaRendererProvider>,
60+
);
61+
}
62+
63+
describe('ListView — manual refresh button (#2634)', () => {
64+
it('renders a refresh button by default and re-fetches on click', async () => {
65+
const ds = makeDataSource();
66+
renderList({}, ds);
67+
68+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
69+
const callsBefore = ds.find.mock.calls.length;
70+
71+
const btn = screen.getByTestId('refresh-button');
72+
expect(btn).toBeInTheDocument();
73+
74+
fireEvent.click(btn);
75+
76+
await waitFor(() => {
77+
expect(ds.find.mock.calls.length).toBeGreaterThan(callsBefore);
78+
});
79+
});
80+
81+
it('hides the refresh button when userActions.refresh is false', async () => {
82+
const ds = makeDataSource();
83+
renderList({ userActions: { refresh: false } as any }, ds);
84+
85+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
86+
expect(screen.queryByTestId('refresh-button')).not.toBeInTheDocument();
87+
});
88+
});

0 commit comments

Comments
 (0)