@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.
Files changed (70) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/shell.md +8 -2
  3. package/AGENTS.md +1 -1
  4. package/client/components/back-button.tsx +37 -12
  5. package/client/components/brand-loading-mark.tsx +5 -2
  6. package/client/components/brand-mark-geometry.ts +3 -3
  7. package/client/components/breadcrumbs.tsx +47 -3
  8. package/client/components/data-table/data-table.tsx +90 -19
  9. package/client/components/data-table/pagination.tsx +17 -2
  10. package/client/components/list-feedback.tsx +31 -22
  11. package/client/components/loading.tsx +127 -16
  12. package/client/components/pending-button.tsx +111 -0
  13. package/client/components/role-list.tsx +7 -0
  14. package/client/components/route-child-page.tsx +72 -1
  15. package/client/components/route-overlay.tsx +149 -15
  16. package/client/components/task-progress.tsx +155 -0
  17. package/client/components/use-route-overlay.ts +8 -11
  18. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  19. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  20. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  29. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  30. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  31. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  32. package/client/layouts/app-layout.tsx +0 -4
  33. package/client/locales/system/en-US.ts +19 -0
  34. package/client/locales/system/zh-CN.ts +19 -0
  35. package/client/pages/settings/default-access.tsx +1 -1
  36. package/client/pages/settings/roles.tsx +27 -27
  37. package/client/pages/settings/routes.ts +15 -7
  38. package/client/pages/settings/user-detail.tsx +3 -17
  39. package/client/pages/settings/user-form.tsx +9 -15
  40. package/client/pages/settings/users.tsx +11 -27
  41. package/client/routing/client-route.tsx +199 -37
  42. package/client/routing/navigation-destination.ts +42 -0
  43. package/client/routing/route-leave-boundary.tsx +13 -1
  44. package/client/styles.css +18 -1
  45. package/package.json +8 -8
  46. package/tests/client/auth-i18n.test.tsx +1 -1
  47. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  48. package/tests/client/back-button.test.tsx +34 -0
  49. package/tests/client/client-route-recovery.test.tsx +93 -1
  50. package/tests/client/list-feedback.test.tsx +5 -5
  51. package/tests/client/list-loading.test.tsx +397 -0
  52. package/tests/client/loading.test.tsx +20 -11
  53. package/tests/client/manual-list-retry.test.tsx +2 -2
  54. package/tests/client/navigation-parent.test.tsx +172 -0
  55. package/tests/client/pending-button.test.tsx +106 -0
  56. package/tests/client/permission-management-tabs.test.tsx +48 -12
  57. package/tests/client/primitive-labels.test.tsx +2 -2
  58. package/tests/client/role-return.test.tsx +21 -3
  59. package/tests/client/roles.test.tsx +84 -3
  60. package/tests/client/route-leave-recheck.test.tsx +74 -0
  61. package/tests/client/route-presentation.test.tsx +557 -0
  62. package/tests/client/session-preparation.test.tsx +20 -17
  63. package/tests/client/settings-continuity.test.tsx +35 -11
  64. package/tests/client/settings-home.test.tsx +4 -2
  65. package/tests/client/settings-idle-list.test.tsx +124 -0
  66. package/tests/client/user-drawer.test.tsx +3 -4
  67. package/tests/fixtures/dialog-footer.html +12 -0
  68. package/tests/fixtures/dialog-footer.tsx +215 -0
  69. package/tests/helpers/authenticated-client.tsx +70 -22
  70. 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 page below another one — a covering child page, a record's own page — leaves by the header's breadcrumb when its route declares `breadcrumb` or the page declares its trail with `usePageBreadcrumb`, and otherwise by `BackButton` (`client/components/back-button.tsx`) above its heading; never both.
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
@@ -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 { useRouteSearches } from '../routing/route-context.js';
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 is kept unchanged.
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
- {children ?? t('navigation.back', { defaultValue: 'Back' })}
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 { BRAND_MARK_PATH, BRAND_MARK_TRANSLATE } from './brand-mark-geometry';
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='nocobase-loading-orbit'>
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 only the last level shows, beside the brand; on a medium screen a trail of three or more levels keeps its
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 trailLevels =
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 className='truncate font-medium'>
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 { type ReactElement, type ReactNode, useMemo, useState } from 'react';
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 = false,
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
- <Table aria-busy={loading}>
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 colSpan={feedbackColumnCount} className='p-0'>
231
- <ListFeedback
232
- state={state}
233
- empty={emptyMessage}
234
- emptyAction={emptyAction}
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
- (state === 'ready' || (state === 'refresh-failed' && rows.length > 0)) ? (
263
- <DataTablePagination
264
- table={table}
265
- pageSizeOptions={pageSizeOptions}
266
- showSelectedCount={showSelectedCount}
267
- disabled={loading}
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 className='flex-1 text-sm text-muted-foreground'>
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 className='shrink-0 text-sm font-medium whitespace-nowrap tabular-nums'>
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
- <Button variant='outline' disabled={busy} onClick={onRetry}>
71
- {busy ? (
72
- <LoaderCircle
73
- aria-hidden='true'
74
- className='motion-safe:animate-spin'
75
- />
76
- ) : (
77
- <RefreshCw aria-hidden='true' />
78
- )}
79
- {busy
80
- ? t('listFeedback.retrying', { defaultValue: 'Retrying…' })
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 })}