@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.agents/rules/components.md +2 -2
  2. package/.agents/rules/shell.md +5 -1
  3. package/AGENTS.md +5 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/page-header.tsx +1 -1
  13. package/client/components/permission-editor.tsx +589 -0
  14. package/client/components/permission-filter.tsx +455 -0
  15. package/client/components/route-overlay.tsx +26 -13
  16. package/client/components/status-badge.tsx +7 -1
  17. package/client/components/ui/checkbox.tsx +25 -0
  18. package/client/components/ui/sidebar.tsx +1 -4
  19. package/client/components/ui/switch.tsx +30 -0
  20. package/client/components/ui/table.tsx +113 -0
  21. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  22. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  23. package/client/index.tsx +4 -0
  24. package/client/layouts/components/app-sidebar.tsx +6 -6
  25. package/client/layouts/components/layout-header.tsx +1 -1
  26. package/client/layouts/components/navigation-menu.tsx +223 -12
  27. package/client/layouts/components/sidebar-footer.tsx +1 -1
  28. package/client/layouts/components/user-menu.tsx +1 -1
  29. package/client/layouts/use-sidebar-preference.ts +62 -42
  30. package/client/lib/navigation-transform.ts +8 -0
  31. package/client/lib/scrollbar.ts +88 -0
  32. package/client/locales/system/en-US.ts +11 -0
  33. package/client/locales/system/settings-en-US.ts +141 -0
  34. package/client/locales/system/settings-zh-CN.ts +135 -0
  35. package/client/locales/system/zh-CN.ts +22 -12
  36. package/client/pages/auth/auth-page.css +52 -0
  37. package/client/pages/auth/login.tsx +1 -1
  38. package/client/pages/auth/reset-password.tsx +57 -14
  39. package/client/pages/auth/shared.tsx +26 -24
  40. package/client/pages/home/index.tsx +2 -2
  41. package/client/pages/settings/hooks.tsx +137 -0
  42. package/client/pages/settings/permission-filters.tsx +119 -0
  43. package/client/pages/settings/permissions-model.ts +260 -0
  44. package/client/pages/settings/role-form.tsx +426 -0
  45. package/client/pages/settings/roles.tsx +163 -0
  46. package/client/pages/settings/routes.ts +79 -0
  47. package/client/pages/settings/shared.tsx +119 -0
  48. package/client/pages/settings/user-detail.tsx +268 -0
  49. package/client/pages/settings/user-form.tsx +427 -0
  50. package/client/pages/settings/users.tsx +451 -0
  51. package/client/routing/app-router.tsx +4 -1
  52. package/client/routing/route-navigation.ts +10 -1
  53. package/client/routing/session-preparation.tsx +65 -0
  54. package/client/styles.css +257 -24
  55. package/client/theme/theme-settings.tsx +12 -7
  56. package/client/theme/themes/compact.css +9 -14
  57. package/client/theme/themes/default.css +7 -12
  58. package/package.json +26 -25
  59. package/server/app.ts +2 -0
  60. package/server/config/notification.ts +4 -7
  61. package/tests/components/app-layout-routes.test.tsx +14 -7
  62. package/tests/components/auth-i18n.test.tsx +57 -5
  63. package/tests/components/data-table.test.tsx +127 -0
  64. package/tests/components/layout-i18n.test.tsx +10 -3
  65. package/tests/components/navigation-menu.test.tsx +155 -16
  66. package/tests/components/permission-editor.test.tsx +477 -0
  67. package/tests/components/permission-filter-binding.test.tsx +150 -0
  68. package/tests/components/permission-filter.test.tsx +202 -0
  69. package/tests/components/settings.test.tsx +294 -0
  70. package/tests/components/sidebar-permissions.test.tsx +17 -19
  71. package/tests/components/sidebar-preference.test.tsx +209 -14
  72. package/tests/components/status-badge.test.tsx +22 -0
  73. package/tests/components/user-permissions.test.tsx +142 -0
  74. package/tests/fixtures/theme-tokens.tsx +9 -1
  75. package/tests/logic/asset-url-build.test.ts +0 -1
  76. package/tests/logic/client-routes.test.ts +40 -0
  77. package/tests/logic/client-shell.test.tsx +10 -2
  78. package/tests/logic/config-example.test.ts +30 -0
  79. package/tests/logic/dev-client-watch.test.ts +8 -1
  80. package/tests/logic/route-overlay.test.tsx +42 -0
  81. package/tests/logic/scrollbar.test.ts +88 -0
  82. package/tests/logic/session-preparation.test.tsx +223 -0
  83. package/tests/logic/settings-permissions.test.ts +168 -0
  84. package/tests/logic/theme-tokens.test.ts +1 -0
  85. package/vite.config.ts +0 -22
  86. package/vitest.config.ts +1 -0
  87. package/client/agent-annotations-host.ts +0 -25
@@ -21,7 +21,7 @@ Build your own components by composing these primitives, and put them in `client
21
21
 
22
22
  ## UI Library items
23
23
 
24
- `PageContainer`, `PageHeader`, `RouteDialog`, `RouteDrawer`, `RouteChildPage` and `useRouteOverlay` in `client/components/` come from the NocoBase UI Library, as do the authentication blocks in `client/extensions/nocobase-auth-forms/`, `nocobase-auth-methods/`, `nocobase-auth-split-layout/` and `nocobase-device-approval/` that `client/pages/auth/` composes, and the inbox block in `client/extensions/nocobase-inbox/` that `client/pages/inbox.tsx` renders on `/inbox`; like the rest of the source, they belong to the application. `yes n | pnpm exec shadcn add @nocobase/<item>` adds another item from the library: a single component lands in `client/components/`, a complete feature in `client/extensions/nocobase-<item>/`. A date field and a list's `DataTable` are the application's own components: compose `DatePicker` in `client/components/date-picker.tsx` from the `calendar` and `popover` primitives, following shadcn's Date Picker guide, and build `DataTable` in `client/components/` from the `table` primitive and `@tanstack/react-table`, following shadcn's Data Table guide. The CLI adds the packages an item or primitive needs to `dependencies`: move the new ones, such as `react-day-picker` for `calendar`, to `devDependencies`, as [where to declare a dependency](../skills/nocobase-app-development/references/dependencies.md) explains for everything client code imports, but leave `@nocobase/i18n` in `dependencies`, where the server needs it, and put back the `^` of its range, which the CLI pins.
24
+ `PageContainer`, `PageHeader`, `RouteDialog`, `RouteDrawer`, `RouteChildPage` and `useRouteOverlay` in `client/components/` come from the NocoBase UI Library, as do the authentication blocks in `client/extensions/nocobase-auth-forms/`, `nocobase-auth-methods/`, `nocobase-auth-split-layout/` and `nocobase-device-approval/` that `client/pages/auth/` composes, and the inbox block in `client/extensions/nocobase-inbox/` that `client/pages/inbox.tsx` renders on `/inbox`; like the rest of the source, they belong to the application. `yes n | pnpm exec shadcn add @nocobase/<item>` adds another item from the library: a single component lands in `client/components/`, a complete feature in `client/extensions/nocobase-<item>/`. A date field and a list's `DataTable` are the application's own components: compose `DatePicker` in `client/components/date-picker.tsx` from the `calendar` and `popover` primitives, following shadcn's Date Picker guide, and reuse the template's standard `DataTable` and companions from `#components/data-table` (`client/components/data-table/`). They use the template's declared TanStack Table v8 dependency; do not generate a separate table engine or substitute a hand-written HTML table for a business list. Server pagination uses the same component's controlled `manualPagination`/`rowCount` and sorting props. Verify the installed version and the `useReactTable`/`getCoreRowModel` exports before reporting incompatibility; repair an incompatible dependency to the supported v8 range instead of recording a custom implementation as an accepted deviation. The CLI adds the packages an item or primitive needs to `dependencies`: move the new ones, such as `react-day-picker` for `calendar`, to `devDependencies`, as [where to declare a dependency](../skills/nocobase-app-development/references/dependencies.md) explains for everything client code imports, but leave `@nocobase/i18n` in `dependencies`, where the server needs it, and put back the `^` of its range, which the CLI pins.
25
25
 
26
26
  ## Styling and visual consistency
27
27
 
@@ -29,6 +29,6 @@ Style with the semantic Tailwind tokens — `bg-background`, `text-foreground`,
29
29
 
30
30
  **Visual consistency is a whole-application property.** Match the surrounding code's spacing, typography, and component choices. If a change genuinely calls for a different look, change the application's design tokens in `client/theme/themes/*.css` so every page moves together. Never restyle only the part you are working on — a page that looks different from the rest is a defect, not a customization.
31
31
 
32
- The default brand palette and density live in `client/theme/themes/*.css`; shared hover/cursor and Badge geometry live in `client/styles.css`, outside generated shadcn primitives. For semantic status labels, use `StatusBadge` from `client/components/status-badge.tsx` with `neutral`, `info`, `warning`, or `success`; choose the tone by meaning and keep the visible status text. Reuse these shared components and tokens in new pages so template upgrades can carry the design consistently.
32
+ The default brand palette and density live in `client/theme/themes/*.css`; shared hover/cursor and Badge geometry live in `client/styles.css`, outside generated shadcn primitives. For semantic status labels, use `StatusBadge` from `client/components/status-badge.tsx` with `neutral`, `info`, `warning`, or `success`; choose the tone by meaning and keep the visible status text. A badge beside a title or on a dense card is the small one, `size='sm'` on `StatusBadge` or `data-size='sm'` on `Badge`. Reuse these shared components and tokens in new pages so template upgrades can carry the design consistently.
33
33
 
34
34
  **Follow the UI guidelines.** [The UI guidelines](../skills/nocobase-app-development/references/frontend/ui-guidelines.md) set the conventions every page follows, among them the page header and the single placement of the primary action, empty states, row actions behind one "…" menu, dates picked from a `Calendar` in a `Popover` rather than a native date input, and medium-width drawers; the frontend workflow says how much of it to read for a change.
@@ -8,10 +8,12 @@ Layouts own breadcrumb route context and render the current page's trail in the
8
8
 
9
9
  ## Header and sidebar
10
10
 
11
- `client/layouts/components/layout-header.tsx` is a presentation container accepting children. The sidebar is shadcn's Sidebar: `components/app-sidebar.tsx` wraps its provider, desktop icon mode and phone sheet, and `components/navigation-menu.tsx` draws the permission-filtered route tree with `SidebarMenu*` and controlled `Collapsible` sub-menus. Leave `client/components/ui/sidebar.tsx` as the shadcn CLI writes it and customize from outside: widths go through the provider's `style` in spacing units, the phone sheet is rendered in `app-sidebar.tsx` with a translated title, and the provider's Ctrl/Cmd+B shortcut is stopped at `document`. The App layout owns menus, branding, permissions and redirects. Desktop icon mode shows leaf labels as zero-delay tooltips and opens groups in hover and keyboard-focus popovers, preserving filtered navigation, parent-page links, nested groups and the selected group's highlight. Keep this behavior aligned across layouts. Desktop collapse state is shared through `useSidebarPreference` at `nocobase:sidebar:collapsed` across applications on the same origin; whether the phone sheet is open stays local to each layout.
11
+ `client/layouts/components/layout-header.tsx` is a presentation container accepting children. The sidebar is shadcn's Sidebar: `components/app-sidebar.tsx` wraps its provider, desktop icon mode and phone sheet, and `components/navigation-menu.tsx` draws the permission-filtered route tree with `SidebarMenu*`: a top-level group with no page of its own is a section, its entries always shown under a `SidebarGroupLabel` heading, while clickable parents and nested groups are controlled `Collapsible` sub-menus, and an item `client/lib/navigation-transform.ts` marks `section` deeper in the tree is a heading over its direct links inside that menu; a count beside an entry, such as unread items, is passed by the App layout in `NavigationMenu`'s `badges`, keyed by route name. Keep `client/components/ui/sidebar.tsx` as the shadcn CLI writes it except for removing its unused origin-wide `sidebar_state` cookie write; persistence belongs to the application-scoped preference. Customize from outside: widths go through the provider's `style` in spacing units, the phone sheet is rendered in `app-sidebar.tsx` with a translated title, and the provider's Ctrl/Cmd+B shortcut is stopped at `document`. The App layout owns menus, branding, permissions and redirects. Desktop icon mode hides top-level section headings and keeps their entries' icons, shows leaf labels as zero-delay tooltips and opens groups in hover and keyboard-focus popovers, preserving filtered navigation, parent-page links, nested groups and the selected group's highlight. Keep this behavior aligned across layouts. Desktop collapse state is shared through `useSidebarPreference` at `nocobase:sidebar:collapsed:app:<basename>` across layouts and tabs of the same application. `createApp(runtime)` provides a stable store using `runtime.basename`, with only empty/root and boundary slashes normalized; paths and case remain intact. Missing new keys default to expanded without migrating the old shared preference; whether the phone sheet is open stays local to each layout.
12
12
 
13
13
  The header carries no Settings or Inbox entry. An application has no separate settings area: a page that configures something is an ordinary route in `client/routes.ts` with `navigation`, placed in the sidebar like any other page, for example under a Settings navigation group. The `/inbox` page declares no navigation; give it an entry where the application wants one.
14
14
 
15
+ `client/lib/navigation-transform.ts` is the application-owned hook for customizing the permission-filtered navigation tree. The shared sidebar supports static section labels with direct links as well as collapsible groups.
16
+
15
17
  Navigation groups retain their expanded or collapsed state while the navigation tree stays mounted. Selecting a new page expands its ancestor groups without collapsing other groups; users can still collapse the active group manually.
16
18
 
17
19
  ## Account menu and session
@@ -27,3 +29,5 @@ The account menu language control in `client/layouts/components/language-switche
27
29
  Use `Loading` for page or panel waits and `Spinner` (the shadcn-compatible adapter to `BrandSpinner`) for inline pending actions, uploads, and refresh indicators. Both share the stable NocoBase mark and orbit animation; do not introduce independent Lucide spinners for application loading. Keep skeletons for content placeholders and progress bars for determinate progress. `index.html` has the matching pre-JavaScript mark and must stay visually consistent. All animations respect reduced motion.
28
30
 
29
31
  The shared list of shell behaviors to keep when customizing is in [customizing the shell](../skills/nocobase-app-development/references/frontend/references/shell.md).
32
+
33
+ The signed-in region waits in `client/routing/session-preparation.tsx` before mounting `AppLayout`: the authentication provider first reconnects active realtime subscriptions as the current session and waits for the open notification, including permission invalidation. A ten-second timeout shows a localized retry/sign-out screen. Keep this boundary inside `RequiredAuthentication`; guest login must remain reachable without realtime. Readiness is only for initial session preparation. Subsequent permission notifications and reconnects still use normal fail-closed route unmounting, including portal and effect cleanup.
package/AGENTS.md CHANGED
@@ -4,11 +4,9 @@ This is a NocoBase 3 application. Do not apply globally installed NocoBase 2 Ski
4
4
 
5
5
  Do not create a plugin to add a feature. Plugins are separately published packages for capabilities shared across several applications; building one for this application's own feature adds a package boundary, a version, and a release process to work that belongs in `client/` and `server/`. Create one only when the user explicitly asks for a reusable published package.
6
6
 
7
- When users refer to annotations, marks, or selections in the current application, first run `pnpm exec agent-annotations list --json` from the application root to find Agent Annotations records, ask for clarification only if none match or the target remains unclear, and do not implement annotation requests when the user only asks where they are.
8
-
9
7
  ## Default template scope
10
8
 
11
- Default is the clean application starting point. It registers product capabilities but no `app-plugin-*-example` plugins, example pages, application sample services, or sample APIs. Keep runnable demonstrations in `app-template-examples`. Application-owned server routes start empty; the only built-in application provider exposes Authorization Permission Sets as direct roles in the Users page. The only application page is a localized homepage.
9
+ Default is the clean application starting point. It registers product capabilities but no `app-plugin-*-example` plugins, example pages, application sample services, or sample APIs. Keep runnable demonstrations in `app-template-examples`. Application-owned business server routes start empty; Users supplies the standard Permission Set role scope. Application-owned pages include the localized homepage and inbox; prepared user/role administration modules are unregistered in the bare template and enabled as business capabilities are developed.
12
10
 
13
11
  `database/main/` contains required permission initialization only; application-owned business migrations and seeds start empty. Do not add article history, demo seeds, or compatibility copies from Examples to this template. Existing installations retain their own executed migration sources when upgrading; see the [upgrade migration rules](.agents/skills/nocobase-app-upgrade/references/edge-cases.md#migrations).
14
12
 
@@ -53,6 +51,10 @@ tests/ Tests; never beside the source
53
51
 
54
52
  A feature with a page and an API touches five places: a migration for the table, a route in `server/routes/`, a page in `client/pages/` declared in `client/routes.ts`, navigation on the page route, and strings in `client/locales/`. A page with children or page-local helpers uses a folder with `index.tsx`; child folders mirror route paths. [Application structure](.agents/skills/nocobase-app-development/references/app-structure.md) explains how the runtime composes these pieces, where configuration and its environment variables are declared, and the runtime paths.
55
53
 
54
+ ## System settings are part of feature delivery
55
+
56
+ A bare create-app stays Home-only with no settings routes or menu. As the application develops business capabilities, inspect its current state on every iteration and automatically register the prepared Users and Roles and permissions contribution from `client/pages/settings/routes.ts` once in `client/routes.ts`; preserve it and reconcile missing management surfaces on every iteration, without a first-build flag or one-time setup stage. Whenever an application adds lifecycles, workflows, approvals, notifications, files or other configurable capabilities, automatically include their real administration surfaces without requiring a second user request. Follow [system settings delivery](.agents/skills/nocobase-app-development/references/system-settings.md); in particular, every used process includes a definitions directory, standard diagram, supported parameters and authorized execution history. Do not add demo plugins or empty settings for unused capabilities.
57
+
56
58
  ## Rules that apply everywhere
57
59
 
58
60
  ### Pages and routes
@@ -25,7 +25,6 @@ ui-library/dist
25
25
  .git
26
26
  **/.agents
27
27
  **/.claude
28
- **/.agent-annotations
29
28
  **/coverage
30
29
  **/playwright-report
31
30
  **/test-results
package/README.MD CHANGED
@@ -6,10 +6,12 @@ You own everything in this directory. Add pages, API routes, database tables, an
6
6
 
7
7
  ## Default and Examples
8
8
 
9
- Default provides the application shell and product capabilities without example plugins or business demo pages. Application-owned routes start empty, and new databases receive no article sample data. Its built-in Users integration lists direct Authorization Permission Sets as application roles, while access assigned to all authenticated users remains separate. Use `app-template-examples` for article management and runnable plugin demonstrations.
9
+ Default provides the application shell and product capabilities without example plugins or business demo pages. Application-owned business API routes start empty, and new databases receive no article sample data. A bare create-app shows Home only, with no System settings routes or menu. As business capabilities are developed, the app development skill reconciles missing core administration on every iteration and registers the prepared Users and Roles and permissions contribution (`client/pages/settings/routes.ts`); each actually used configurable capability includes its administration as part of feature delivery. Its built-in Users integration lists direct Authorization Permission Sets as application roles, while access assigned to all authenticated users remains separate. Use `app-template-examples` for article management and runnable plugin demonstrations.
10
10
 
11
11
  Existing Default installations retain their data when example plugins are unregistered. If an installation already ran the article migration, retain its original migration sources as described in the [upgrade migration rules](.agents/skills/nocobase-app-upgrade/references/edge-cases.md#migrations) before upgrading. Do not erase migration history or delete existing data to make an upgrade start.
12
12
 
13
+ Business lists reuse the template's standard `DataTable` and companions from `#components/data-table`. The template declares the compatible TanStack Table v8 dependency and provides controlled server pagination/sorting; application agents should not generate an independent table engine. See the [table guide](.agents/skills/nocobase-app-development/references/frontend/references/table.md).
14
+
13
15
  ## Getting started
14
16
 
15
17
  Configure the application, then start the development server:
package/client/app.ts CHANGED
@@ -11,12 +11,26 @@ import {
11
11
  type ReactElement,
12
12
  } from 'react';
13
13
 
14
+ import {
15
+ createAppSidebarPreferenceStore,
16
+ SidebarPreferenceProvider,
17
+ } from './layouts/use-sidebar-preference.js';
14
18
  import { AppRouter } from './routing/app-router.js';
15
19
  export function createApp(runtime: AppRuntimeContext): ClientApplication {
16
20
  // Outermost, so every provider and page below can translate.
17
21
  const AppI18nProvider = ({ children }: PropsWithChildren): ReactElement =>
18
22
  createElement(I18nProvider, { runtime: runtime.i18n }, children);
19
23
 
24
+ const sidebarPreference = createAppSidebarPreferenceStore(runtime.basename);
25
+ const AppSidebarPreferenceProvider = ({
26
+ children,
27
+ }: PropsWithChildren): ReactElement =>
28
+ createElement(
29
+ SidebarPreferenceProvider,
30
+ { value: sidebarPreference },
31
+ children,
32
+ );
33
+
20
34
  const app = new ClientApplication({
21
35
  runtime,
22
36
  createRenderConfig: (): AppClientRenderConfig =>
@@ -24,6 +38,7 @@ export function createApp(runtime: AppRuntimeContext): ClientApplication {
24
38
  basename: runtime.basename,
25
39
  reactProviders: [
26
40
  AppI18nProvider,
41
+ AppSidebarPreferenceProvider,
27
42
  ...runtime.reactProviders.map((provider) => provider.component),
28
43
  ],
29
44
  routes: createElement(AppRouter, {
@@ -0,0 +1,91 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import type { Column, SortDirection } from '@tanstack/react-table';
3
+ import {
4
+ ArrowDownIcon,
5
+ ArrowUpIcon,
6
+ ChevronsUpDownIcon,
7
+ EyeOffIcon,
8
+ } from 'lucide-react';
9
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react';
10
+
11
+ import { Button } from '#components/ui/button';
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuSeparator,
17
+ DropdownMenuTrigger,
18
+ } from '#components/ui/dropdown-menu';
19
+ import { cn } from 'cn';
20
+
21
+ export interface DataTableColumnHeaderProps<TData, TValue> extends Omit<
22
+ ComponentPropsWithoutRef<'div'>,
23
+ 'title'
24
+ > {
25
+ readonly column: Column<TData, TValue>;
26
+ readonly title: ReactNode;
27
+ }
28
+
29
+ function SortIcon({
30
+ direction,
31
+ }: {
32
+ readonly direction: SortDirection | false;
33
+ }): ReactElement {
34
+ if (direction === 'desc') return <ArrowDownIcon />;
35
+ if (direction === 'asc') return <ArrowUpIcon />;
36
+ return <ChevronsUpDownIcon />;
37
+ }
38
+
39
+ /**
40
+ * A column header that opens a menu for sorting and hiding the column. Use it
41
+ * as the `header` of a column definition:
42
+ * `header: ({ column }) => <DataTableColumnHeader column={column} title='Email' />`.
43
+ */
44
+ export function DataTableColumnHeader<TData, TValue>({
45
+ column,
46
+ title,
47
+ className,
48
+ ...props
49
+ }: DataTableColumnHeaderProps<TData, TValue>): ReactElement {
50
+ const { t } = useTranslation();
51
+
52
+ if (!column.getCanSort()) {
53
+ return (
54
+ <div className={cn(className)} {...props}>
55
+ {title}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ return (
61
+ <div className={cn('flex items-center gap-2', className)} {...props}>
62
+ <DropdownMenu>
63
+ <DropdownMenuTrigger
64
+ render={<Button variant='ghost' size='sm' className='-ml-3 h-8' />}
65
+ >
66
+ <span>{title}</span>
67
+ <SortIcon direction={column.getIsSorted()} />
68
+ </DropdownMenuTrigger>
69
+ <DropdownMenuContent align='start' className='w-auto min-w-40'>
70
+ <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
71
+ <ArrowUpIcon />
72
+ {t('dataTable.sortAscending', { defaultValue: 'Asc' })}
73
+ </DropdownMenuItem>
74
+ <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
75
+ <ArrowDownIcon />
76
+ {t('dataTable.sortDescending', { defaultValue: 'Desc' })}
77
+ </DropdownMenuItem>
78
+ {column.getCanHide() ? (
79
+ <>
80
+ <DropdownMenuSeparator />
81
+ <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
82
+ <EyeOffIcon />
83
+ {t('dataTable.hideColumn', { defaultValue: 'Hide' })}
84
+ </DropdownMenuItem>
85
+ </>
86
+ ) : null}
87
+ </DropdownMenuContent>
88
+ </DropdownMenu>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,216 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import {
3
+ type ColumnDef,
4
+ type PaginationState,
5
+ type OnChangeFn,
6
+ type ColumnFiltersState,
7
+ type Row,
8
+ type RowSelectionState,
9
+ type SortingState,
10
+ type Table as TableInstance,
11
+ type VisibilityState,
12
+ flexRender,
13
+ getCoreRowModel,
14
+ getFilteredRowModel,
15
+ getPaginationRowModel,
16
+ getSortedRowModel,
17
+ useReactTable,
18
+ } from '@tanstack/react-table';
19
+ import { type ReactElement, type ReactNode, useMemo, useState } from 'react';
20
+
21
+ import { DataTablePagination } from './pagination.js';
22
+ import {
23
+ Table,
24
+ TableBody,
25
+ TableCell,
26
+ TableHead,
27
+ TableHeader,
28
+ TableRow,
29
+ } from '#components/ui/table';
30
+ import { cn } from 'cn';
31
+
32
+ export interface DataTableProps<TData, TValue = unknown> {
33
+ readonly columns: ColumnDef<TData, TValue>[];
34
+ readonly data: readonly TData[];
35
+ readonly manualPagination?: boolean;
36
+ readonly rowCount?: number;
37
+ readonly paginationState?: PaginationState;
38
+ readonly onPaginationChange?: OnChangeFn<PaginationState>;
39
+ readonly manualSorting?: boolean;
40
+ readonly sortingState?: SortingState;
41
+ readonly onSortingChange?: OnChangeFn<SortingState>;
42
+ readonly showSelectedCount?: boolean;
43
+ readonly loading?: boolean;
44
+ readonly className?: string;
45
+ /**
46
+ * Rendered above the table and handed the table instance, so filters and
47
+ * `DataTableViewOptions` can read and drive its state.
48
+ */
49
+ readonly toolbar?: (table: TableInstance<TData>) => ReactNode;
50
+ /** Shown in place of the body when no row survives filtering. */
51
+ readonly emptyMessage?: ReactNode;
52
+ /** Set to `false` to render every row and hide the pagination footer. */
53
+ readonly pagination?: boolean;
54
+ readonly pageSize?: number;
55
+ readonly pageSizeOptions?: readonly number[];
56
+ readonly getRowId?: (
57
+ row: TData,
58
+ index: number,
59
+ parent?: Row<TData>,
60
+ ) => string;
61
+ readonly onRowClick?: (row: Row<TData>) => void;
62
+ }
63
+
64
+ /**
65
+ * A table driven by TanStack Table with client-side sorting, filtering, column
66
+ * visibility, row selection and pagination, composed from the shadcn `Table`
67
+ * primitive the way the shadcn Data Table guide describes.
68
+ *
69
+ * Column definitions decide what each feature does: sort through
70
+ * `DataTableColumnHeader`, filter from the `toolbar` with
71
+ * `table.getColumn(id)?.setFilterValue(...)`, and select rows with a display
72
+ * column that renders a `Checkbox`.
73
+ */
74
+ export function DataTable<TData, TValue = unknown>({
75
+ columns,
76
+ data,
77
+ className,
78
+ toolbar,
79
+ emptyMessage,
80
+ pagination = true,
81
+ pageSize = 10,
82
+ pageSizeOptions,
83
+ getRowId,
84
+ onRowClick,
85
+ manualPagination = false,
86
+ rowCount,
87
+ paginationState,
88
+ onPaginationChange,
89
+ manualSorting = false,
90
+ sortingState,
91
+ onSortingChange,
92
+ showSelectedCount = true,
93
+ loading = false,
94
+ }: DataTableProps<TData, TValue>): ReactElement {
95
+ const { t } = useTranslation();
96
+ const tableData = useMemo(() => [...data], [data]);
97
+ const [sorting, setSorting] = useState<SortingState>([]);
98
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
99
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
100
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
101
+
102
+ // TanStack Table hands back one mutable instance that the React Compiler
103
+ // cannot memoize, so it skips compiling this component; the table is still
104
+ // correct without it.
105
+ // eslint-disable-next-line react-hooks/incompatible-library
106
+ const table = useReactTable({
107
+ data: tableData,
108
+ columns,
109
+ manualPagination,
110
+ rowCount,
111
+ manualSorting,
112
+ ...(onPaginationChange ? { onPaginationChange } : {}),
113
+ getRowId,
114
+ state: {
115
+ sorting: sortingState ?? sorting,
116
+ columnFilters,
117
+ columnVisibility,
118
+ rowSelection,
119
+ ...(paginationState ? { pagination: paginationState } : {}),
120
+ },
121
+ initialState: { pagination: { pageSize } },
122
+ onSortingChange: onSortingChange ?? setSorting,
123
+ onColumnFiltersChange: setColumnFilters,
124
+ onColumnVisibilityChange: setColumnVisibility,
125
+ onRowSelectionChange: setRowSelection,
126
+ getCoreRowModel: getCoreRowModel(),
127
+ ...(!manualSorting ? { getSortedRowModel: getSortedRowModel() } : {}),
128
+ getFilteredRowModel: getFilteredRowModel(),
129
+ ...(pagination && !manualPagination
130
+ ? { getPaginationRowModel: getPaginationRowModel() }
131
+ : {}),
132
+ });
133
+
134
+ const rows = table.getRowModel().rows;
135
+
136
+ return (
137
+ <div className={cn('flex flex-col gap-4', className)}>
138
+ {toolbar ? (
139
+ <div className='flex flex-wrap items-center gap-2'>
140
+ {toolbar(table)}
141
+ </div>
142
+ ) : null}
143
+ <div className='overflow-hidden rounded-lg border'>
144
+ <Table aria-busy={loading}>
145
+ <TableHeader>
146
+ {table.getHeaderGroups().map((headerGroup) => (
147
+ <TableRow key={headerGroup.id}>
148
+ {headerGroup.headers.map((header) => (
149
+ <TableHead
150
+ key={header.id}
151
+ colSpan={header.colSpan}
152
+ aria-sort={
153
+ header.column.getCanSort()
154
+ ? header.column.getIsSorted() === 'asc'
155
+ ? 'ascending'
156
+ : header.column.getIsSorted() === 'desc'
157
+ ? 'descending'
158
+ : 'none'
159
+ : undefined
160
+ }
161
+ >
162
+ {header.isPlaceholder
163
+ ? null
164
+ : flexRender(
165
+ header.column.columnDef.header,
166
+ header.getContext(),
167
+ )}
168
+ </TableHead>
169
+ ))}
170
+ </TableRow>
171
+ ))}
172
+ </TableHeader>
173
+ <TableBody>
174
+ {rows.length > 0 ? (
175
+ rows.map((row) => (
176
+ <TableRow
177
+ key={row.id}
178
+ data-state={row.getIsSelected() ? 'selected' : undefined}
179
+ className={onRowClick ? 'cursor-pointer' : undefined}
180
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
181
+ >
182
+ {row.getVisibleCells().map((cell) => (
183
+ <TableCell key={cell.id}>
184
+ {flexRender(
185
+ cell.column.columnDef.cell,
186
+ cell.getContext(),
187
+ )}
188
+ </TableCell>
189
+ ))}
190
+ </TableRow>
191
+ ))
192
+ ) : (
193
+ <TableRow>
194
+ <TableCell
195
+ colSpan={columns.length}
196
+ className='h-24 text-center text-muted-foreground'
197
+ >
198
+ {emptyMessage ??
199
+ t('dataTable.noResults', { defaultValue: 'No results.' })}
200
+ </TableCell>
201
+ </TableRow>
202
+ )}
203
+ </TableBody>
204
+ </Table>
205
+ </div>
206
+ {pagination ? (
207
+ <DataTablePagination
208
+ table={table}
209
+ pageSizeOptions={pageSizeOptions}
210
+ showSelectedCount={showSelectedCount}
211
+ disabled={loading}
212
+ />
213
+ ) : null}
214
+ </div>
215
+ );
216
+ }
@@ -0,0 +1,13 @@
1
+ export { DataTable, type DataTableProps } from './data-table.js';
2
+ export {
3
+ DataTablePagination,
4
+ type DataTablePaginationProps,
5
+ } from './pagination.js';
6
+ export {
7
+ DataTableColumnHeader,
8
+ type DataTableColumnHeaderProps,
9
+ } from './column-header.js';
10
+ export {
11
+ DataTableViewOptions,
12
+ type DataTableViewOptionsProps,
13
+ } from './view-options.js';
@@ -0,0 +1,165 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import type { Table as TableInstance } from '@tanstack/react-table';
3
+ import {
4
+ ChevronLeftIcon,
5
+ ChevronRightIcon,
6
+ ChevronsLeftIcon,
7
+ ChevronsRightIcon,
8
+ } from 'lucide-react';
9
+ import type { ComponentPropsWithoutRef, ReactElement } from 'react';
10
+
11
+ import { Button } from '#components/ui/button';
12
+ import {
13
+ Select,
14
+ SelectContent,
15
+ SelectItem,
16
+ SelectTrigger,
17
+ SelectValue,
18
+ } from '#components/ui/select';
19
+ import { cn } from 'cn';
20
+
21
+ export interface DataTablePaginationProps<
22
+ TData,
23
+ > extends ComponentPropsWithoutRef<'div'> {
24
+ readonly table: TableInstance<TData>;
25
+ readonly pageSizeOptions?: readonly number[];
26
+ /** Hide the "n of m selected" summary for tables without row selection. */
27
+ readonly showSelectedCount?: boolean;
28
+ readonly disabled?: boolean;
29
+ }
30
+
31
+ const DEFAULT_PAGE_SIZE_OPTIONS: readonly number[] = [10, 20, 30, 40, 50];
32
+
33
+ /**
34
+ * Pagination controls for a TanStack table: selected-row summary, page size,
35
+ * current page, and first/previous/next/last buttons. `DataTable` renders it
36
+ * for you; use it directly when composing a table by hand.
37
+ */
38
+ export function DataTablePagination<TData>({
39
+ table,
40
+ pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
41
+ showSelectedCount = true,
42
+ disabled = false,
43
+ className,
44
+ ...props
45
+ }: DataTablePaginationProps<TData>): ReactElement {
46
+ const { t } = useTranslation();
47
+ const { pageIndex, pageSize } = table.getState().pagination;
48
+ const pageCount = Math.max(table.getPageCount(), 1);
49
+ const selected = table.getFilteredSelectedRowModel().rows.length;
50
+ const total = table.getFilteredRowModel().rows.length;
51
+ const options = pageSizeOptions.includes(pageSize)
52
+ ? pageSizeOptions
53
+ : [...pageSizeOptions, pageSize].sort((a, b) => a - b);
54
+
55
+ return (
56
+ <div
57
+ className={cn(
58
+ 'flex flex-wrap items-center justify-between gap-4 px-2',
59
+ className,
60
+ )}
61
+ {...props}
62
+ >
63
+ <div className='flex-1 text-sm text-muted-foreground'>
64
+ {showSelectedCount
65
+ ? t('dataTable.selectedCount', {
66
+ defaultValue: '{{selected}} of {{total}} row(s) selected.',
67
+ selected,
68
+ total,
69
+ })
70
+ : null}
71
+ </div>
72
+ <div className='flex flex-wrap items-center gap-6 lg:gap-8'>
73
+ <div className='flex items-center gap-2'>
74
+ <p className='text-sm font-medium'>
75
+ {t('dataTable.rowsPerPage', { defaultValue: 'Rows per page' })}
76
+ </p>
77
+ <Select
78
+ disabled={disabled}
79
+ value={String(pageSize)}
80
+ onValueChange={(value) => {
81
+ if (value) table.setPageSize(Number(value));
82
+ }}
83
+ >
84
+ <SelectTrigger
85
+ size='sm'
86
+ className='w-[70px]'
87
+ aria-label={t('dataTable.rowsPerPage', {
88
+ defaultValue: 'Rows per page',
89
+ })}
90
+ >
91
+ <SelectValue />
92
+ </SelectTrigger>
93
+ <SelectContent
94
+ side='top'
95
+ className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'
96
+ >
97
+ {options.map((size) => (
98
+ <SelectItem key={size} value={String(size)}>
99
+ {size}
100
+ </SelectItem>
101
+ ))}
102
+ </SelectContent>
103
+ </Select>
104
+ </div>
105
+ <div className='flex w-[100px] items-center justify-center text-sm font-medium'>
106
+ {t('dataTable.pageOf', {
107
+ defaultValue: 'Page {{page}} of {{pageCount}}',
108
+ page: pageIndex + 1,
109
+ pageCount,
110
+ })}
111
+ </div>
112
+ <div className='flex items-center gap-2'>
113
+ <Button
114
+ variant='outline'
115
+ size='icon-sm'
116
+ className='hidden lg:flex'
117
+ onClick={() => table.setPageIndex(0)}
118
+ disabled={disabled || !table.getCanPreviousPage()}
119
+ >
120
+ <span className='sr-only'>
121
+ {t('dataTable.firstPage', { defaultValue: 'Go to first page' })}
122
+ </span>
123
+ <ChevronsLeftIcon />
124
+ </Button>
125
+ <Button
126
+ variant='outline'
127
+ size='icon-sm'
128
+ onClick={() => table.previousPage()}
129
+ disabled={disabled || !table.getCanPreviousPage()}
130
+ >
131
+ <span className='sr-only'>
132
+ {t('dataTable.previousPage', {
133
+ defaultValue: 'Go to previous page',
134
+ })}
135
+ </span>
136
+ <ChevronLeftIcon />
137
+ </Button>
138
+ <Button
139
+ variant='outline'
140
+ size='icon-sm'
141
+ onClick={() => table.nextPage()}
142
+ disabled={disabled || !table.getCanNextPage()}
143
+ >
144
+ <span className='sr-only'>
145
+ {t('dataTable.nextPage', { defaultValue: 'Go to next page' })}
146
+ </span>
147
+ <ChevronRightIcon />
148
+ </Button>
149
+ <Button
150
+ variant='outline'
151
+ size='icon-sm'
152
+ className='hidden lg:flex'
153
+ onClick={() => table.setPageIndex(pageCount - 1)}
154
+ disabled={disabled || !table.getCanNextPage()}
155
+ >
156
+ <span className='sr-only'>
157
+ {t('dataTable.lastPage', { defaultValue: 'Go to last page' })}
158
+ </span>
159
+ <ChevronsRightIcon />
160
+ </Button>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ );
165
+ }