@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.89
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 +3 -1
- package/.agents/rules/shell.md +8 -2
- package/AGENTS.md +1 -1
- package/client/components/back-button.tsx +37 -12
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +47 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +0 -4
- package/client/locales/system/en-US.ts +19 -0
- package/client/locales/system/zh-CN.ts +19 -0
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +15 -7
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/routing/client-route.tsx +199 -37
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-leave-boundary.tsx +13 -1
- package/client/styles.css +18 -1
- package/package.json +8 -8
- package/tests/client/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +48 -12
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +4 -2
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +70 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
|
@@ -15,7 +15,7 @@ yes n | pnpm exec shadcn add card
|
|
|
15
15
|
pnpm exec prettier --write client/components/ui/card.tsx
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Build your own components by composing these primitives, and put them in `client/components/`. A
|
|
18
|
+
Build your own components by composing these primitives, and put them in `client/components/`. A covering child page, independent detail or edit declares its complete navigation with `breadcrumb` or `usePageBreadcrumb`; the shell renders desktop breadcrumbs and the mobile parent arrow. Declare dynamic/logic parents before loading succeeds and keep them on failure. Use `BackButton` only without that shell navigation, with route/access context for target validation; never show duplicate return entries.
|
|
19
19
|
|
|
20
20
|
**Look components up in the shadcn skill.** The development Skill carries the shadcn/ui skill, unchanged from shadcn 4.21.0, in `.agents/skills/nocobase-app-development/references/frontend/shadcn/`. Read [the shadcn guide](../skills/nocobase-app-development/references/frontend/references/shadcn.md) first: it routes to the skill's rules and states where this application departs from them, among others `pnpm exec shadcn` instead of `npx shadcn@latest`, no `shadcn apply` or `--preset`, and create, edit and detail as `RouteDialog` or `RouteDrawer` child routes rather than a `Sheet`. `pnpm exec shadcn docs <name>` gives one primitive's documentation and examples for the Base UI version. For a whole page, start from the worked example that [the example index](../skills/nocobase-app-development/references/frontend/references/example.md) indexes.
|
|
21
21
|
|
|
@@ -31,6 +31,8 @@ Style with the semantic Tailwind tokens — `bg-background`, `text-foreground`,
|
|
|
31
31
|
|
|
32
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
|
+
Dialog and AlertDialog footer actions use the shared semantic-slot sizing rule in `client/styles.css`, including linked Buttons and RouteOverlay's composed DialogFooter. Keep route links and pending guards; avoid page height/padding patches. The [footer contract](../skills/nocobase-app-development/references/frontend/references/overlay.md#footer-ownership) explains precedence, wrapping and the source upgrade path for existing applications.
|
|
35
|
+
|
|
34
36
|
**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.
|
|
35
37
|
|
|
36
38
|
## Standard process management
|
package/.agents/rules/shell.md
CHANGED
|
@@ -4,6 +4,8 @@ Read this before changing the layouts, header, sidebar, breadcrumbs, account men
|
|
|
4
4
|
|
|
5
5
|
## Breadcrumbs and layouts
|
|
6
6
|
|
|
7
|
+
Both automatic and explicit parents pass through `client/routing/navigation-destination.ts`, using the shell's existing route/access context. Keep permission loading fail-closed, check every target ancestor, and remove denied parent names. Desktop breadcrumbs and the mobile parent arrow use the same resolved levels; mobile keeps the current title and omits the duplicate brand. Follow the [detail/edit generation contract](../skills/nocobase-app-development/references/frontend/references/navigation.md), including stable parents during record failures, source-tab state and direct-entry defaults. Do not add a second page BackButton under the shell.
|
|
8
|
+
|
|
7
9
|
Layouts own breadcrumb route context and render the current page's trail in the header after the sidebar toggle (`client/components/breadcrumbs.tsx`): the route trail by default, or the trail a page declares with `usePageBreadcrumb` from `@nocobase/app-client` when it names records. Pages do not render breadcrumbs themselves. `AppRouter` selects routes and layouts. See [pages and routes](../skills/nocobase-app-development/references/frontend/references/page.md) for each layout's scope.
|
|
8
10
|
|
|
9
11
|
The route-tree provider remembers each visited ancestor's query while its descendants are open. Default breadcrumbs and back links restore that query, including an empty one, instead of applying detail-only parameters to the list. Keep this provider mounted above page navigation. Explicit breadcrumb/back destinations remain application-owned. See the linked pages-and-routes reference for deep-link and reload fallbacks.
|
|
@@ -24,13 +26,13 @@ Theme lives in the account menu as a submenu beside Language, rather than a sepa
|
|
|
24
26
|
|
|
25
27
|
The account menu checks Better Auth sign-out results before refreshing the session and shows a localized error toast for API or network failures. Preserve this behavior when upgrading the shell; navigation alone does not revoke a session.
|
|
26
28
|
|
|
27
|
-
The authorization provider clears the permission snapshot before rendering a new session. Route navigation and page guards subscribe to the authorization revision; preserve these checks when customizing the shell so account changes and permission updates take effect without a reload. Keep `RouteLeaveBoundary` in `AppLayout`, outside all page Activities including nested routes, so `useSettingsLeave` navigation guards, confirmation UI and `beforeunload` survive permission checks. Clear retained guards on denial and session changes. Restored Activity Effects re-run existing data requests. Pending checks hide protected content with React Activity while retaining drafts; failed checks unmount it and deny access. Body portals must detach in an Effect cleanup so they also disappear while the page is hidden; use `BodyPortal` from `#components/body-portal` for application-owned content mounted on `document.body`.
|
|
29
|
+
The authorization provider clears the permission snapshot before rendering a new session. Route navigation and page guards subscribe to the authorization revision; preserve these checks when customizing the shell so account changes and permission updates take effect without a reload. Keep `RouteLeaveBoundary` in `AppLayout`, outside all page Activities including nested routes, so `useSettingsLeave` navigation guards, confirmation UI and `beforeunload` survive permission checks. Clear retained guards on denial and session changes. Restored Activity Effects re-run existing data requests. Keep each resolved route module for the same loader and retry attempt when Activity reconnects; namespace-wrapping loaders may return a new component type on every invocation and discard form state if invoked again. Pending checks hide protected content with React Activity while retaining drafts; failed checks unmount it and deny access. Body portals must detach in an Effect cleanup so they also disappear while the page is hidden; use `BodyPortal` from `#components/body-portal` for application-owned content mounted on `document.body`.
|
|
28
30
|
|
|
29
31
|
The account menu language control in `client/layouts/components/language-switcher.tsx` uses a shadcn submenu with radio items. Render it inside `DropdownMenuContent` to preserve menu keyboard navigation and selection semantics.
|
|
30
32
|
|
|
31
33
|
## Branded loading
|
|
32
34
|
|
|
33
|
-
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.
|
|
35
|
+
Follow [pending feedback](../skills/nocobase-app-development/references/pending-feedback.md) for shared 300ms display and 10s long-wait guidance. Use `PendingButton` with real `submitting` for native form actions; preserve the shared submission lock. 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.
|
|
34
36
|
|
|
35
37
|
The shared list of shell behaviors to keep when customizing is in [customizing the shell](../skills/nocobase-app-development/references/frontend/references/shell.md).
|
|
36
38
|
|
|
@@ -39,3 +41,7 @@ The signed-in region waits in `client/routing/session-preparation.tsx` before mo
|
|
|
39
41
|
## List context
|
|
40
42
|
|
|
41
43
|
Keep the identity-keyed `SessionPreparation` boundary: mounted private list conditions and ancestor query memory must be discarded when the signed-in user changes. Public list state uses `useTableUrlState`; tabbed datasets use `useListTabs`, with complete owned parameter keys and permission-filtered tabs. Follow the synchronized [list navigation rules](../skills/nocobase-app-development/references/frontend/references/child-routes.md#list-tab-breadcrumbs-and-private-conditions). Default back links use an empty ancestor query when no source visit exists; explicit return destinations remain application-owned.
|
|
44
|
+
|
|
45
|
+
## Page data freshness
|
|
46
|
+
|
|
47
|
+
Follow the synchronized [page data freshness rules](../skills/nocobase-app-development/references/frontend/references/page-freshness.md): ordinary pages have no persistent Refresh action. Preserve contextual read retries, write invalidation and live progress updates. Dependency upgrades do not rewrite application-owned pages or installed Registry source; reconcile them through the template upgrade workflow.
|
package/AGENTS.md
CHANGED
|
@@ -63,7 +63,7 @@ A bare create-app shows Home to signed-in users and, only to unrestricted admini
|
|
|
63
63
|
|
|
64
64
|
### Pages and routes
|
|
65
65
|
|
|
66
|
-
Declare every page in `client/routes.ts` with `defineAppRoutes()` and keep its component behind a lazy `componentLoader` whose module default-exports it. Never write the deployment base path such as `/main` into a route — the runtime restores it. `auth` controls browser navigation only and is not server security. Declare `navigation` on the route to put it in a menu, and always declare `authz` on the first page of every path; the page id identifies stored page grants, so changing it requires migrating the grants that reference it. Read [pages and routes](.agents/skills/nocobase-app-development/references/frontend/references/page.md), and [overlays](.agents/skills/nocobase-app-development/references/frontend/references/overlay.md) before implementing dialogs, drawers or close guards.
|
|
66
|
+
Declare every page in `client/routes.ts` with `defineAppRoutes()` and keep its component behind a lazy `componentLoader` whose module default-exports it. Never write the deployment base path such as `/main` into a route — the runtime restores it. `auth` controls browser navigation only and is not server security. Declare `navigation` on the route to put it in a menu, and always declare `authz` on the first page of every path; the page id identifies stored page grants, so changing it requires migrating the grants that reference it. Every new independent detail/edit page follows the [navigation pattern](.agents/skills/nocobase-app-development/references/frontend/references/navigation.md), including a parent during loading/errors and mobile return; omit duplicate page back links under the shell. Read [pages and routes](.agents/skills/nocobase-app-development/references/frontend/references/page.md), and [overlays](.agents/skills/nocobase-app-development/references/frontend/references/overlay.md) before implementing dialogs, drawers or close guards.
|
|
67
67
|
|
|
68
68
|
Every route listed as a page permission needs a localized display name: use its existing navigation/breadcrumb title, or add its application route name to `client/pages/settings/page-permission-titles.ts` for an overlay or other page with no title metadata. Do not add breadcrumbs/menus just to label an overlay. Run `tests/client/page-permission-titles.test.ts`; it requires non-empty English/Chinese labels and rejects missing or stale mappings. Never rename route/permission ids or change `authz` to fix display text.
|
|
69
69
|
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
2
|
import { ArrowLeftIcon } from 'lucide-react';
|
|
3
3
|
import type { ReactElement, ReactNode } from 'react';
|
|
4
|
-
import { Link, type To, useResolvedPath } from 'react-router';
|
|
4
|
+
import { Link, type To, parsePath, useResolvedPath } from 'react-router';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { navigationDestination } from '../routing/navigation-destination.js';
|
|
7
|
+
import {
|
|
8
|
+
useRouteTree,
|
|
9
|
+
useRouteAccess,
|
|
10
|
+
useRouteSearches,
|
|
11
|
+
} from '../routing/route-context.js';
|
|
7
12
|
|
|
8
13
|
import { cn } from 'cn';
|
|
9
14
|
|
|
10
15
|
export interface BackButtonProps {
|
|
11
16
|
/**
|
|
12
17
|
* Where it leads. Defaults to the parent route with its last visited query; a directly opened detail falls back
|
|
13
|
-
* to the default parent list. An explicit destination
|
|
18
|
+
* to the default parent list. An explicit destination must match an authorized application page.
|
|
14
19
|
*/
|
|
15
20
|
readonly to?: To;
|
|
16
21
|
/** The label, "Back" by default. */
|
|
@@ -31,11 +36,36 @@ export function BackButton({
|
|
|
31
36
|
children,
|
|
32
37
|
className,
|
|
33
38
|
to,
|
|
34
|
-
}: BackButtonProps = {}): ReactElement {
|
|
39
|
+
}: BackButtonProps = {}): ReactElement | null {
|
|
35
40
|
const { t } = useTranslation();
|
|
36
41
|
const parent = useResolvedPath('..');
|
|
42
|
+
const resolved = useResolvedPath(to ?? '..');
|
|
43
|
+
const routes = useRouteTree();
|
|
44
|
+
const access = useRouteAccess();
|
|
37
45
|
const searches = useRouteSearches();
|
|
38
46
|
|
|
47
|
+
const fallback = {
|
|
48
|
+
...parent,
|
|
49
|
+
search: searches.get(parent.pathname.replace(/\/+$/u, '') || '/') ?? '',
|
|
50
|
+
};
|
|
51
|
+
const rawPath =
|
|
52
|
+
typeof to === 'string' ? parsePath(to).pathname : to?.pathname;
|
|
53
|
+
const unsafe =
|
|
54
|
+
rawPath &&
|
|
55
|
+
(/^(?:[a-z][a-z\d+.-]*:|\/\/)/iu.test(rawPath) ||
|
|
56
|
+
rawPath.includes('\\') ||
|
|
57
|
+
[...rawPath].some((character) => character.charCodeAt(0) <= 32));
|
|
58
|
+
const primary = unsafe
|
|
59
|
+
? undefined
|
|
60
|
+
: navigationDestination(
|
|
61
|
+
to && rawPath?.startsWith('/') ? to : to ? resolved : fallback,
|
|
62
|
+
routes,
|
|
63
|
+
access,
|
|
64
|
+
);
|
|
65
|
+
const destination =
|
|
66
|
+
primary ?? navigationDestination(fallback, routes, access);
|
|
67
|
+
if (!destination) return null;
|
|
68
|
+
|
|
39
69
|
return (
|
|
40
70
|
<Link
|
|
41
71
|
replace
|
|
@@ -43,16 +73,11 @@ export function BackButton({
|
|
|
43
73
|
'inline-flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground',
|
|
44
74
|
className,
|
|
45
75
|
)}
|
|
46
|
-
to={
|
|
47
|
-
to ?? {
|
|
48
|
-
pathname: '..',
|
|
49
|
-
search:
|
|
50
|
-
searches.get(parent.pathname.replace(/\/+$/u, '') || '/') ?? '',
|
|
51
|
-
}
|
|
52
|
-
}
|
|
76
|
+
to={destination}
|
|
53
77
|
>
|
|
54
78
|
<ArrowLeftIcon className='size-4' />
|
|
55
|
-
{
|
|
79
|
+
{(primary ? children : undefined) ??
|
|
80
|
+
t('navigation.back', { defaultValue: 'Back' })}
|
|
56
81
|
</Link>
|
|
57
82
|
);
|
|
58
83
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef, ReactElement } from 'react';
|
|
2
2
|
import { cn } from 'cn';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
BRAND_MARK_PATH,
|
|
6
|
+
BRAND_MARK_TRANSLATE,
|
|
7
|
+
} from './brand-mark-geometry.js';
|
|
5
8
|
|
|
6
9
|
/** Keep the brand recognizable; the orbit, rather than the bricks, conveys progress. */
|
|
7
10
|
export function BrandLoadingMark({
|
|
@@ -25,7 +28,7 @@ export function BrandLoadingMark({
|
|
|
25
28
|
strokeWidth='2'
|
|
26
29
|
opacity='.12'
|
|
27
30
|
/>
|
|
28
|
-
<g className='
|
|
31
|
+
<g className='origin-center motion-safe:animate-spin'>
|
|
29
32
|
<path
|
|
30
33
|
d='M32 4a28 28 0 0 1 28 28'
|
|
31
34
|
stroke='currentColor'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Official Logomark vector from the supplied logos/Transparent/Vectors asset.
|
|
2
2
|
* Keep geometry unchanged; color, size and decorative animation belong to consumers.
|
|
3
3
|
*/
|
|
4
|
-
export const BRAND_MARK_VIEW_BOX = '0 0 100 87.19720458984375';
|
|
5
|
-
export const BRAND_MARK_TRANSLATE = 'translate(0 -6.401886463165283)';
|
|
6
|
-
export const BRAND_MARK_PATH =
|
|
4
|
+
export const BRAND_MARK_VIEW_BOX: string = '0 0 100 87.19720458984375';
|
|
5
|
+
export const BRAND_MARK_TRANSLATE: string = 'translate(0 -6.401886463165283)';
|
|
6
|
+
export const BRAND_MARK_PATH: string =
|
|
7
7
|
'M99.876 55.369c-.025-.045-.076-.072-.109-.111-.066-.078-.125-.168-.217-.221l-.014-.008-.001-.002h-.001L80.368 43.648v-24.67a.92.92 0 0 0-.124-.459c-.026-.045-.075-.072-.108-.111-.066-.08-.126-.168-.218-.221l-.014-.008-.001-.002h-.002L60.286 6.531a.92.92 0 0 0-.94 0L20.099 29.834h-.002l-.001.002-.014.006c-.092.055-.151.143-.218.221-.033.041-.082.068-.108.113a.907.907 0 0 0-.124.457v1.361L.466 43.373H.465l-.001.002c-.098.055-.162.143-.23.227-.034.041-.084.068-.109.113a.898.898 0 0 0-.125.457v25.195c0 .324.171.625.45.791l39.264 23.311c.002.002.006 0 .008.002l.007.008a.924.924 0 0 0 .911 0c.003-.002.004-.006.008-.008.001-.002.005 0 .007-.002l19.161-11.377 19.162 11.377c.002.002.006 0 .008.002.004.002.004.006.007.008a.924.924 0 0 0 .911 0c.003-.002.004-.006.008-.008.002-.002.005 0 .007-.002L99.55 81.812a.916.916 0 0 0 .45-.791V55.828a.92.92 0 0 0-.124-.459zM41.104 65.867V42.812l37.424-22.219v23.055L41.104 65.867zM59.815 8.391l17.83 10.588L59.82 29.561 40.185 41.219l-17.83-10.586 37.46-22.242zM39.264 42.812V65.866L21.472 55.305V32.248l17.792 10.564z';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { usePageBreadcrumbLevels } from '@nocobase/app-client';
|
|
2
2
|
import type { PageBreadcrumbLevel } from '@nocobase/app-client';
|
|
3
3
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
4
|
+
import { ArrowLeftIcon } from 'lucide-react';
|
|
4
5
|
import { Fragment, type ReactElement } from 'react';
|
|
5
6
|
import { Link } from 'react-router';
|
|
6
7
|
|
|
@@ -23,10 +24,13 @@ import { settingsHomeRoute } from '#lib/navigation-transform';
|
|
|
23
24
|
import { cn } from 'cn';
|
|
24
25
|
|
|
25
26
|
import {
|
|
27
|
+
useRouteTree,
|
|
28
|
+
useRouteAccess,
|
|
26
29
|
useRouteTrail,
|
|
27
30
|
useRouteSearches,
|
|
28
31
|
type RouteTrailEntry,
|
|
29
32
|
} from '../routing/route-context.js';
|
|
33
|
+
import { navigationDestination } from '../routing/navigation-destination.js';
|
|
30
34
|
import { routeKey } from '../routing/route-navigation.js';
|
|
31
35
|
|
|
32
36
|
export interface BreadcrumbsProps {
|
|
@@ -45,7 +49,7 @@ export interface BreadcrumbsProps {
|
|
|
45
49
|
* `usePageBreadcrumb` from `@nocobase/app-client`. Earlier levels link where a page sits behind them; the last is the
|
|
46
50
|
* current page.
|
|
47
51
|
*
|
|
48
|
-
* On a phone
|
|
52
|
+
* On a phone the current title and a link to the nearest authorized parent show; on a medium screen a trail of three or more levels keeps its
|
|
49
53
|
* first and last and folds the middle into a menu.
|
|
50
54
|
*/
|
|
51
55
|
export function Breadcrumbs({
|
|
@@ -53,10 +57,18 @@ export function Breadcrumbs({
|
|
|
53
57
|
denied,
|
|
54
58
|
}: BreadcrumbsProps = {}): ReactElement | null {
|
|
55
59
|
const trail = useRouteTrail();
|
|
60
|
+
const routes = useRouteTree();
|
|
61
|
+
const contextAccess = useRouteAccess();
|
|
62
|
+
const access = denied
|
|
63
|
+
? {
|
|
64
|
+
...contextAccess,
|
|
65
|
+
denied: new Set([...contextAccess.denied, ...denied]),
|
|
66
|
+
}
|
|
67
|
+
: contextAccess;
|
|
56
68
|
const searches = useRouteSearches();
|
|
57
69
|
const declared = usePageBreadcrumbLevels();
|
|
58
70
|
const { t } = useTranslation();
|
|
59
|
-
const
|
|
71
|
+
const candidates =
|
|
60
72
|
declared ??
|
|
61
73
|
trail.flatMap((entry): PageBreadcrumbLevel[] => {
|
|
62
74
|
const title = routeLevelTitle(entry);
|
|
@@ -93,6 +105,19 @@ export function Breadcrumbs({
|
|
|
93
105
|
];
|
|
94
106
|
});
|
|
95
107
|
|
|
108
|
+
const trailLevels = candidates.flatMap(
|
|
109
|
+
(level, index): PageBreadcrumbLevel[] => {
|
|
110
|
+
if (!level.to) return [level];
|
|
111
|
+
const to = navigationDestination(level.to, routes, access);
|
|
112
|
+
// Keep the current title during permission checks, but never expose an inaccessible ancestor's name.
|
|
113
|
+
return to
|
|
114
|
+
? [{ ...level, to }]
|
|
115
|
+
: index === candidates.length - 1
|
|
116
|
+
? [{ label: level.label }]
|
|
117
|
+
: [];
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
96
121
|
if (trailLevels.length === 0) return null;
|
|
97
122
|
// A level is told apart by its depth and label; two levels may share either alone.
|
|
98
123
|
const levels = trailLevels.map((level, depth) => ({
|
|
@@ -101,6 +126,7 @@ export function Breadcrumbs({
|
|
|
101
126
|
}));
|
|
102
127
|
const last = levels.length - 1;
|
|
103
128
|
const folded = levels.slice(1, last);
|
|
129
|
+
const parent = levels.slice(0, last).findLast((level) => level.to);
|
|
104
130
|
|
|
105
131
|
return (
|
|
106
132
|
<Breadcrumb
|
|
@@ -108,6 +134,21 @@ export function Breadcrumbs({
|
|
|
108
134
|
className={cn('min-w-0', className)}
|
|
109
135
|
>
|
|
110
136
|
<BreadcrumbList className='flex-nowrap'>
|
|
137
|
+
{parent ? (
|
|
138
|
+
<BreadcrumbItem className='shrink-0 md:hidden'>
|
|
139
|
+
<BreadcrumbLink
|
|
140
|
+
aria-label={t('navigation.backTo', {
|
|
141
|
+
label: parent.label,
|
|
142
|
+
defaultValue: `Back to ${parent.label}`,
|
|
143
|
+
})}
|
|
144
|
+
title={parent.label}
|
|
145
|
+
className='inline-flex size-9 items-center justify-center rounded-sm focus-visible:outline-2 focus-visible:outline-ring'
|
|
146
|
+
render={<Link to={parent.to!} replace />}
|
|
147
|
+
>
|
|
148
|
+
<ArrowLeftIcon aria-hidden='true' className='size-4' />
|
|
149
|
+
</BreadcrumbLink>
|
|
150
|
+
</BreadcrumbItem>
|
|
151
|
+
) : null}
|
|
111
152
|
{levels.map((level, index) => (
|
|
112
153
|
<Fragment key={level.key}>
|
|
113
154
|
{index === 1 && folded.length > 0 ? (
|
|
@@ -154,7 +195,10 @@ export function Breadcrumbs({
|
|
|
154
195
|
)}
|
|
155
196
|
>
|
|
156
197
|
{index === last ? (
|
|
157
|
-
<BreadcrumbPage
|
|
198
|
+
<BreadcrumbPage
|
|
199
|
+
title={level.label}
|
|
200
|
+
className='truncate font-medium'
|
|
201
|
+
>
|
|
158
202
|
{level.label}
|
|
159
203
|
</BreadcrumbPage>
|
|
160
204
|
) : level.to ? (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ListState } from '@nocobase/app-client';
|
|
2
2
|
import { ListFeedback, ListFeedbackActions } from '#components/list-feedback';
|
|
3
|
+
import { RefreshIndicator } from '#components/loading';
|
|
3
4
|
import {
|
|
4
5
|
type ColumnDef,
|
|
5
6
|
type PaginationState,
|
|
@@ -17,7 +18,14 @@ import {
|
|
|
17
18
|
getSortedRowModel,
|
|
18
19
|
useReactTable,
|
|
19
20
|
} from '@tanstack/react-table';
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
type ReactElement,
|
|
23
|
+
type ReactNode,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
} from 'react';
|
|
21
29
|
|
|
22
30
|
import { DataTablePagination } from './pagination.js';
|
|
23
31
|
import {
|
|
@@ -41,7 +49,18 @@ export interface DataTableProps<TData, TValue = unknown> {
|
|
|
41
49
|
readonly sortingState?: SortingState;
|
|
42
50
|
readonly onSortingChange?: OnChangeFn<SortingState>;
|
|
43
51
|
readonly showSelectedCount?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The current request is in flight. With rows of the current query and permission scope this is a background
|
|
54
|
+
* refresh: rows stay and a delayed refresh indicator appears. Without them the body shows the loading state.
|
|
55
|
+
* Explicit false pauses loading feedback for an unresolved, inactive query; omitting it uses listState.
|
|
56
|
+
*/
|
|
44
57
|
readonly loading?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The current query's stable identity, such as its request path. When it
|
|
60
|
+
* changes the loading and refresh feedback restart their display delay;
|
|
61
|
+
* the table, its rows' geometry and its controls stay mounted.
|
|
62
|
+
*/
|
|
63
|
+
readonly loadingIdentity?: unknown;
|
|
45
64
|
readonly listState?: ListState;
|
|
46
65
|
readonly onRetry?: () => void;
|
|
47
66
|
readonly onClearFilters?: () => void;
|
|
@@ -95,7 +114,8 @@ export function DataTable<TData, TValue = unknown>({
|
|
|
95
114
|
sortingState,
|
|
96
115
|
onSortingChange,
|
|
97
116
|
showSelectedCount = true,
|
|
98
|
-
loading
|
|
117
|
+
loading,
|
|
118
|
+
loadingIdentity,
|
|
99
119
|
listState,
|
|
100
120
|
onRetry,
|
|
101
121
|
onClearFilters,
|
|
@@ -176,6 +196,37 @@ export function DataTable<TData, TValue = unknown>({
|
|
|
176
196
|
rows.length > 0 && (state === 'ready' || state === 'refresh-failed');
|
|
177
197
|
const showFeedback = !showRows || state === 'refresh-failed';
|
|
178
198
|
const feedbackColumnCount = Math.max(1, table.getVisibleLeafColumns().length);
|
|
199
|
+
const showPagination =
|
|
200
|
+
pagination &&
|
|
201
|
+
(state === 'ready' || (state === 'refresh-failed' && rows.length > 0));
|
|
202
|
+
// A new query hides the previous rows at once; keep their body and footer
|
|
203
|
+
// geometry while it loads so the page below does not jump.
|
|
204
|
+
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
|
205
|
+
const footerRef = useRef<HTMLDivElement>(null);
|
|
206
|
+
const [reserved, setReserved] = useState<{
|
|
207
|
+
readonly body: number;
|
|
208
|
+
readonly footer: number;
|
|
209
|
+
}>();
|
|
210
|
+
if (!showRows && state !== 'loading' && reserved !== undefined)
|
|
211
|
+
setReserved(undefined);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const body = bodyRef.current;
|
|
214
|
+
if (!showRows || !body || typeof ResizeObserver === 'undefined') return;
|
|
215
|
+
const footer = footerRef.current;
|
|
216
|
+
const observer = new ResizeObserver(() => {
|
|
217
|
+
const next = {
|
|
218
|
+
body: body.offsetHeight,
|
|
219
|
+
footer: footer?.offsetHeight ?? 0,
|
|
220
|
+
};
|
|
221
|
+
setReserved((old) =>
|
|
222
|
+
old?.body === next.body && old.footer === next.footer ? old : next,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
observer.observe(body);
|
|
226
|
+
if (footer) observer.observe(footer);
|
|
227
|
+
return () => observer.disconnect();
|
|
228
|
+
}, [showRows, showPagination]);
|
|
229
|
+
const reserving = state === 'loading' && reserved !== undefined;
|
|
179
230
|
|
|
180
231
|
return (
|
|
181
232
|
<div className={cn('flex flex-col gap-4', className)}>
|
|
@@ -194,8 +245,14 @@ export function DataTable<TData, TValue = unknown>({
|
|
|
194
245
|
/>
|
|
195
246
|
</div>
|
|
196
247
|
) : null}
|
|
197
|
-
<div className='overflow-hidden rounded-lg border'>
|
|
198
|
-
|
|
248
|
+
<div className='relative overflow-hidden rounded-lg border'>
|
|
249
|
+
{/* A retry after a refresh failure is announced by its own toolbar button. */}
|
|
250
|
+
<RefreshIndicator
|
|
251
|
+
pending={Boolean(loading && showRows && state !== 'refresh-failed')}
|
|
252
|
+
identity={loadingIdentity}
|
|
253
|
+
/>
|
|
254
|
+
{/* Only retained rows are busy; a loading body owns its own busy region and status. */}
|
|
255
|
+
<Table aria-busy={loading && showRows}>
|
|
199
256
|
<TableHeader>
|
|
200
257
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
201
258
|
<TableRow key={headerGroup.id}>
|
|
@@ -224,15 +281,27 @@ export function DataTable<TData, TValue = unknown>({
|
|
|
224
281
|
</TableRow>
|
|
225
282
|
))}
|
|
226
283
|
</TableHeader>
|
|
227
|
-
<TableBody>
|
|
284
|
+
<TableBody ref={bodyRef}>
|
|
228
285
|
{showFeedback ? (
|
|
229
286
|
<TableRow>
|
|
230
|
-
<TableCell
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
287
|
+
<TableCell
|
|
288
|
+
colSpan={feedbackColumnCount}
|
|
289
|
+
className='p-0'
|
|
290
|
+
style={
|
|
291
|
+
reserving && reserved.body > 0
|
|
292
|
+
? { height: reserved.body }
|
|
293
|
+
: undefined
|
|
294
|
+
}
|
|
295
|
+
>
|
|
296
|
+
{/* Readiness may stay unresolved while the parent query is paused. */}
|
|
297
|
+
{state !== 'loading' || loading !== false ? (
|
|
298
|
+
<ListFeedback
|
|
299
|
+
state={state}
|
|
300
|
+
identity={loadingIdentity}
|
|
301
|
+
empty={emptyMessage}
|
|
302
|
+
emptyAction={emptyAction}
|
|
303
|
+
/>
|
|
304
|
+
) : null}
|
|
236
305
|
</TableCell>
|
|
237
306
|
</TableRow>
|
|
238
307
|
) : null}
|
|
@@ -258,14 +327,16 @@ export function DataTable<TData, TValue = unknown>({
|
|
|
258
327
|
</TableBody>
|
|
259
328
|
</Table>
|
|
260
329
|
</div>
|
|
261
|
-
{pagination &&
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
330
|
+
{showPagination || (pagination && reserving) ? (
|
|
331
|
+
<div ref={footerRef} data-slot='data-table-footer'>
|
|
332
|
+
<DataTablePagination
|
|
333
|
+
table={table}
|
|
334
|
+
pageSizeOptions={pageSizeOptions}
|
|
335
|
+
showSelectedCount={showSelectedCount}
|
|
336
|
+
disabled={loading || !showPagination}
|
|
337
|
+
countPending={!showPagination}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
269
340
|
) : null}
|
|
270
341
|
</div>
|
|
271
342
|
);
|
|
@@ -26,6 +26,8 @@ export interface DataTablePaginationProps<
|
|
|
26
26
|
/** Hide the "n of m selected" summary for tables without row selection. */
|
|
27
27
|
readonly showSelectedCount?: boolean;
|
|
28
28
|
readonly disabled?: boolean;
|
|
29
|
+
/** The current query has no total yet: keep the controls' place but show no previous count. */
|
|
30
|
+
readonly countPending?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
const DEFAULT_PAGE_SIZE_OPTIONS: readonly number[] = [10, 20, 30, 40, 50];
|
|
@@ -40,6 +42,7 @@ export function DataTablePagination<TData>({
|
|
|
40
42
|
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
|
|
41
43
|
showSelectedCount = true,
|
|
42
44
|
disabled = false,
|
|
45
|
+
countPending = false,
|
|
43
46
|
className,
|
|
44
47
|
...props
|
|
45
48
|
}: DataTablePaginationProps<TData>): ReactElement {
|
|
@@ -60,7 +63,13 @@ export function DataTablePagination<TData>({
|
|
|
60
63
|
)}
|
|
61
64
|
{...props}
|
|
62
65
|
>
|
|
63
|
-
<div
|
|
66
|
+
<div
|
|
67
|
+
className={cn(
|
|
68
|
+
'flex-1 text-sm text-muted-foreground',
|
|
69
|
+
countPending && 'invisible',
|
|
70
|
+
)}
|
|
71
|
+
aria-hidden={countPending || undefined}
|
|
72
|
+
>
|
|
64
73
|
{showSelectedCount
|
|
65
74
|
? t('dataTable.selectedCount', {
|
|
66
75
|
defaultValue: '{{selected}} of {{total}} row(s) selected.',
|
|
@@ -102,7 +111,13 @@ export function DataTablePagination<TData>({
|
|
|
102
111
|
</SelectContent>
|
|
103
112
|
</Select>
|
|
104
113
|
</div>
|
|
105
|
-
<div
|
|
114
|
+
<div
|
|
115
|
+
className={cn(
|
|
116
|
+
'shrink-0 text-sm font-medium whitespace-nowrap tabular-nums',
|
|
117
|
+
countPending && 'invisible',
|
|
118
|
+
)}
|
|
119
|
+
aria-hidden={countPending || undefined}
|
|
120
|
+
>
|
|
106
121
|
{t('dataTable.pageOf', {
|
|
107
122
|
defaultValue: 'Page {{page}} of {{pageCount}}',
|
|
108
123
|
page: pageIndex + 1,
|
|
@@ -3,7 +3,6 @@ import { useTranslation } from '@nocobase/i18n/client';
|
|
|
3
3
|
import {
|
|
4
4
|
CircleAlert,
|
|
5
5
|
Inbox,
|
|
6
|
-
LoaderCircle,
|
|
7
6
|
LockKeyhole,
|
|
8
7
|
LogIn,
|
|
9
8
|
RefreshCw,
|
|
@@ -14,6 +13,8 @@ import { Fragment, type ReactElement, type ReactNode } from 'react';
|
|
|
14
13
|
import { Link } from 'react-router';
|
|
15
14
|
import { Button } from '#components/ui/button';
|
|
16
15
|
import { Alert } from '#components/ui/alert';
|
|
16
|
+
import { Loading } from '#components/loading';
|
|
17
|
+
import { PendingButton } from '#components/pending-button';
|
|
17
18
|
import {
|
|
18
19
|
Empty,
|
|
19
20
|
EmptyContent,
|
|
@@ -23,7 +24,6 @@ import {
|
|
|
23
24
|
} from '#components/ui/empty';
|
|
24
25
|
|
|
25
26
|
const feedback = {
|
|
26
|
-
loading: ['listFeedback.loading', 'Loading', LoaderCircle],
|
|
27
27
|
empty: ['listFeedback.empty', 'No data yet', Inbox],
|
|
28
28
|
'no-results': ['listFeedback.noResults', 'No results', SearchX],
|
|
29
29
|
forbidden: ['listFeedback.forbidden', 'Access denied', LockKeyhole],
|
|
@@ -38,6 +38,11 @@ export interface ListFeedbackProps {
|
|
|
38
38
|
readonly empty?: ReactNode;
|
|
39
39
|
/** Optional business-specific action; the standard create action belongs in the page header. */
|
|
40
40
|
readonly emptyAction?: ReactNode;
|
|
41
|
+
/**
|
|
42
|
+
* The current query's stable identity, such as its request path. A new
|
|
43
|
+
* query restarts the display delay without remounting the list.
|
|
44
|
+
*/
|
|
45
|
+
readonly identity?: unknown;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export interface ListFeedbackActionsProps {
|
|
@@ -67,19 +72,17 @@ export function ListFeedbackActions({
|
|
|
67
72
|
|
|
68
73
|
if ((state === 'failed' || state === 'refresh-failed') && onRetry) {
|
|
69
74
|
return (
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{
|
|
80
|
-
|
|
81
|
-
: t('listFeedback.retry', { defaultValue: 'Retry' })}
|
|
82
|
-
</Button>
|
|
75
|
+
<PendingButton
|
|
76
|
+
variant='outline'
|
|
77
|
+
busy={busy}
|
|
78
|
+
pendingLabel={t('listFeedback.retrying', {
|
|
79
|
+
defaultValue: 'Retrying…',
|
|
80
|
+
})}
|
|
81
|
+
onClick={onRetry}
|
|
82
|
+
>
|
|
83
|
+
<RefreshCw aria-hidden='true' />
|
|
84
|
+
{t('listFeedback.retry', { defaultValue: 'Retry' })}
|
|
85
|
+
</PendingButton>
|
|
83
86
|
);
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -105,6 +108,7 @@ export function ListFeedback({
|
|
|
105
108
|
children,
|
|
106
109
|
empty,
|
|
107
110
|
emptyAction,
|
|
111
|
+
identity,
|
|
108
112
|
}: ListFeedbackProps): ReactElement {
|
|
109
113
|
const { t } = useTranslation();
|
|
110
114
|
|
|
@@ -134,6 +138,17 @@ export function ListFeedback({
|
|
|
134
138
|
);
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
if (state === 'loading') {
|
|
142
|
+
// The body keeps its footprint at once; the indicator and one status follow the shared display delay.
|
|
143
|
+
return (
|
|
144
|
+
<Loading
|
|
145
|
+
identity={identity}
|
|
146
|
+
label={t('listFeedback.loading', { defaultValue: 'Loading' })}
|
|
147
|
+
className='min-h-24 px-4 py-4'
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
137
152
|
const [key, fallback, Icon] = feedback[state];
|
|
138
153
|
const isError =
|
|
139
154
|
state === 'failed' || state === 'forbidden' || state === 'unauthenticated';
|
|
@@ -145,15 +160,9 @@ export function ListFeedback({
|
|
|
145
160
|
<Empty
|
|
146
161
|
role={isError ? 'alert' : 'status'}
|
|
147
162
|
className='min-h-24 flex-row flex-none gap-2 rounded-none border-0 px-4 py-6 text-pretty text-card-foreground'
|
|
148
|
-
aria-busy={state === 'loading'}
|
|
149
163
|
>
|
|
150
164
|
<EmptyMedia className='mb-0 size-4 text-muted-foreground'>
|
|
151
|
-
<Icon
|
|
152
|
-
aria-hidden='true'
|
|
153
|
-
className={
|
|
154
|
-
state === 'loading' ? 'size-4 motion-safe:animate-spin' : 'size-4'
|
|
155
|
-
}
|
|
156
|
-
/>
|
|
165
|
+
<Icon aria-hidden='true' className='size-4' />
|
|
157
166
|
</EmptyMedia>
|
|
158
167
|
<EmptyTitle className='text-sm font-medium text-muted-foreground'>
|
|
159
168
|
{t(key, { defaultValue: fallback })}
|