@nocobase/app-template-default 1.0.0-beta.68 → 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.
- package/.agents/rules/components.md +1 -1
- package/.agents/rules/shell.md +3 -1
- package/AGENTS.md +5 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- package/client/layouts/components/app-sidebar.tsx +1 -1
- package/client/layouts/use-sidebar-preference.ts +62 -42
- package/client/locales/system/en-US.ts +7 -0
- package/client/locales/system/settings-en-US.ts +141 -0
- package/client/locales/system/settings-zh-CN.ts +135 -0
- package/client/locales/system/zh-CN.ts +7 -0
- package/client/pages/auth/auth-page.css +15 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/shared.tsx +3 -1
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +426 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +4 -7
- package/package.json +24 -23
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +8 -1
- package/tests/components/navigation-menu.test.tsx +24 -14
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +12 -4
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/vite.config.ts +0 -22
- 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
|
|
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
|
|
package/.agents/rules/shell.md
CHANGED
|
@@ -8,7 +8,7 @@ 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*`: 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.
|
|
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
|
|
|
@@ -29,3 +29,5 @@ The account menu language control in `client/layouts/components/language-switche
|
|
|
29
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.
|
|
30
30
|
|
|
31
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
|
|
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
|
package/Dockerfile.dockerignore
CHANGED
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
|
+
}
|