@nocobase/app-template-default 1.0.0-beta.87 → 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/notification-templates.md +5 -0
- package/.agents/rules/shell.md +9 -3
- package/AGENTS.md +4 -2
- package/README.MD +11 -5
- package/client/components/back-button.tsx +37 -12
- package/client/components/body-portal.tsx +25 -0
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +60 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/flow-diagram.tsx +2 -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 +8 -8
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +26 -5
- package/client/locales/system/settings-en-US.ts +17 -0
- package/client/locales/system/settings-zh-CN.ts +15 -0
- package/client/locales/system/zh-CN.ts +26 -5
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/hooks.tsx +3 -68
- package/client/pages/settings/notification-routes.ts +109 -0
- package/client/pages/settings/notification-templates.tsx +26 -0
- package/client/pages/settings/permission-management-page.tsx +15 -90
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +107 -101
- package/client/pages/settings/settings-home.tsx +109 -0
- package/client/pages/settings/settings-module-page.tsx +158 -0
- package/client/pages/settings/shell-routes.ts +53 -0
- 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/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +199 -30
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +261 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/runtime.ts +2 -2
- package/client/styles.css +18 -1
- package/config.example.yml +1 -1
- package/package.json +13 -13
- package/server/config/auth.ts +1 -1
- package/server/providers/index.ts +2 -0
- package/server/providers/notification-management.ts +27 -0
- package/tests/client/account-permissions.test.tsx +1 -1
- 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-auth.test.tsx +10 -1
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +23 -8
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/layout-i18n.test.tsx +9 -4
- 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 +458 -17
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/registration-feedback.test.tsx +143 -0
- 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 +112 -0
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +230 -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
- package/tests/server/notification-templates.test.ts +144 -0
- package/tests/client/permission-management-routes.test.ts +0 -64
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { useNavigationGuard } from '@nocobase/app-client';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useState,
|
|
9
|
+
type PropsWithChildren,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { useLocation } from 'react-router';
|
|
13
|
+
import { ConfirmAction } from '../pages/settings/shared.js';
|
|
14
|
+
|
|
15
|
+
/* eslint-disable react-refresh/only-export-components -- the route boundary and its hook share one context */
|
|
16
|
+
|
|
17
|
+
type Decision = boolean | Promise<boolean>;
|
|
18
|
+
interface Prompt {
|
|
19
|
+
readonly owner: LeaveController;
|
|
20
|
+
readonly title: string;
|
|
21
|
+
readonly description: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class LeaveController {
|
|
25
|
+
dirty = false;
|
|
26
|
+
busy = false;
|
|
27
|
+
active = false;
|
|
28
|
+
private allowed = false;
|
|
29
|
+
private pending?: {
|
|
30
|
+
promise: Promise<boolean>;
|
|
31
|
+
resolve: (value: boolean) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
readonly origin: string,
|
|
36
|
+
private readonly show: (prompt: Prompt | undefined) => void,
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
title = '';
|
|
40
|
+
description = '';
|
|
41
|
+
onDiscard?: () => void;
|
|
42
|
+
|
|
43
|
+
update(
|
|
44
|
+
dirty: boolean,
|
|
45
|
+
busy: boolean,
|
|
46
|
+
onDiscard: (() => void) | undefined,
|
|
47
|
+
title: string,
|
|
48
|
+
description: string,
|
|
49
|
+
): void {
|
|
50
|
+
this.dirty = dirty;
|
|
51
|
+
this.busy = busy;
|
|
52
|
+
this.onDiscard = onDiscard;
|
|
53
|
+
this.title = title;
|
|
54
|
+
this.description = description;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
guard = (): Decision => {
|
|
58
|
+
if (this.allowed) return true;
|
|
59
|
+
if (this.busy) return false;
|
|
60
|
+
if (!this.dirty) return true;
|
|
61
|
+
if (!this.pending) {
|
|
62
|
+
let resolve!: (value: boolean) => void;
|
|
63
|
+
const promise = new Promise<boolean>((done) => {
|
|
64
|
+
resolve = done;
|
|
65
|
+
});
|
|
66
|
+
this.pending = { promise, resolve };
|
|
67
|
+
this.show({
|
|
68
|
+
owner: this,
|
|
69
|
+
title: this.title,
|
|
70
|
+
description: this.description,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return this.pending.promise;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
decide(value: boolean): void {
|
|
77
|
+
if (value) this.onDiscard?.();
|
|
78
|
+
this.allowed = value;
|
|
79
|
+
this.pending?.resolve(value);
|
|
80
|
+
this.pending = undefined;
|
|
81
|
+
this.show(undefined);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
allow = (): void => {
|
|
85
|
+
this.allowed = true;
|
|
86
|
+
};
|
|
87
|
+
reset(): void {
|
|
88
|
+
this.allowed = false;
|
|
89
|
+
}
|
|
90
|
+
cancel(): void {
|
|
91
|
+
if (this.pending) this.decide(false);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class LeaveScope {
|
|
96
|
+
private readonly entries = new Set<LeaveController>();
|
|
97
|
+
private hidden = false;
|
|
98
|
+
private generation = 0;
|
|
99
|
+
constructor(private readonly dirtyChanged: (dirty: boolean) => void) {}
|
|
100
|
+
|
|
101
|
+
changed = (): void => {
|
|
102
|
+
this.dirtyChanged([...this.entries].some((entry) => entry.dirty));
|
|
103
|
+
};
|
|
104
|
+
attach(entry: LeaveController): void {
|
|
105
|
+
entry.active = true;
|
|
106
|
+
this.entries.add(entry);
|
|
107
|
+
this.changed();
|
|
108
|
+
}
|
|
109
|
+
detach(entry: LeaveController): void {
|
|
110
|
+
entry.active = false;
|
|
111
|
+
// Activity disconnects Effects before the shell commits its new visibility.
|
|
112
|
+
queueMicrotask(() => {
|
|
113
|
+
if (!entry.active && !this.hidden) this.remove(entry);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
private remove(entry: LeaveController): void {
|
|
117
|
+
this.entries.delete(entry);
|
|
118
|
+
entry.cancel();
|
|
119
|
+
this.changed();
|
|
120
|
+
}
|
|
121
|
+
update(hidden: boolean, pathname: string): void {
|
|
122
|
+
if (this.hidden !== hidden) {
|
|
123
|
+
this.generation += 1;
|
|
124
|
+
this.hidden = hidden;
|
|
125
|
+
// A confirmation started before concealment must not discard or resume its old navigation on recovery.
|
|
126
|
+
if (hidden) for (const entry of this.entries) entry.cancel();
|
|
127
|
+
}
|
|
128
|
+
for (const entry of this.entries) {
|
|
129
|
+
if (pathname !== entry.origin && !pathname.startsWith(`${entry.origin}/`))
|
|
130
|
+
this.remove(entry);
|
|
131
|
+
else entry.reset();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
prune(): void {
|
|
135
|
+
if (!this.hidden)
|
|
136
|
+
for (const entry of this.entries) if (!entry.active) this.remove(entry);
|
|
137
|
+
}
|
|
138
|
+
guard = (): Decision => {
|
|
139
|
+
const generation = this.generation;
|
|
140
|
+
const remaining = [...this.entries];
|
|
141
|
+
const next = (): Decision => {
|
|
142
|
+
if (
|
|
143
|
+
generation !== this.generation ||
|
|
144
|
+
(this.hidden && this.entries.size > 0)
|
|
145
|
+
)
|
|
146
|
+
return false;
|
|
147
|
+
const entry = remaining.shift();
|
|
148
|
+
if (!entry) return true;
|
|
149
|
+
const value = entry.guard();
|
|
150
|
+
return typeof value === 'boolean'
|
|
151
|
+
? value && next()
|
|
152
|
+
: value.then((allowed) => allowed && next());
|
|
153
|
+
};
|
|
154
|
+
return next();
|
|
155
|
+
};
|
|
156
|
+
dispose(): void {
|
|
157
|
+
for (const entry of this.entries) entry.cancel();
|
|
158
|
+
this.entries.clear();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface BoundaryContext {
|
|
163
|
+
readonly scope: LeaveScope;
|
|
164
|
+
readonly show: (prompt: Prompt | undefined) => void;
|
|
165
|
+
}
|
|
166
|
+
const Context = createContext<BoundaryContext | undefined>(undefined);
|
|
167
|
+
|
|
168
|
+
function useBeforeUnload(dirty: boolean): void {
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!dirty) return;
|
|
171
|
+
const handler = (event: BeforeUnloadEvent) => {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
event.returnValue = '';
|
|
174
|
+
};
|
|
175
|
+
window.addEventListener('beforeunload', handler);
|
|
176
|
+
return () => window.removeEventListener('beforeunload', handler);
|
|
177
|
+
}, [dirty]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function promptDialog(prompt: Prompt | undefined): ReactElement | null {
|
|
181
|
+
return prompt ? (
|
|
182
|
+
<ConfirmAction
|
|
183
|
+
open
|
|
184
|
+
title={prompt.title}
|
|
185
|
+
description={prompt.description}
|
|
186
|
+
busy={false}
|
|
187
|
+
onCancel={() => prompt.owner.decide(false)}
|
|
188
|
+
onConfirm={() => prompt.owner.decide(true)}
|
|
189
|
+
/>
|
|
190
|
+
) : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The shell owns leave decisions and their UI, outside every page's Activity and its nested routes. */
|
|
194
|
+
export function RouteLeaveBoundary({
|
|
195
|
+
hidden,
|
|
196
|
+
children,
|
|
197
|
+
}: PropsWithChildren<{ readonly hidden: boolean }>): ReactElement {
|
|
198
|
+
const location = useLocation();
|
|
199
|
+
const [prompt, setPrompt] = useState<Prompt>();
|
|
200
|
+
const [dirty, setDirty] = useState(false);
|
|
201
|
+
const [context] = useState(() => ({
|
|
202
|
+
scope: new LeaveScope(setDirty),
|
|
203
|
+
show: setPrompt,
|
|
204
|
+
}));
|
|
205
|
+
useNavigationGuard(context.scope.guard);
|
|
206
|
+
useBeforeUnload(dirty);
|
|
207
|
+
useLayoutEffect(
|
|
208
|
+
() => context.scope.update(hidden, location.pathname),
|
|
209
|
+
[context, hidden, location.pathname, location.key],
|
|
210
|
+
);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
context.scope.prune();
|
|
213
|
+
}, [context, hidden]);
|
|
214
|
+
useEffect(() => () => context.scope.dispose(), [context]);
|
|
215
|
+
return (
|
|
216
|
+
<Context.Provider value={context}>
|
|
217
|
+
{children}
|
|
218
|
+
{promptDialog(prompt)}
|
|
219
|
+
</Context.Provider>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Compatible with standalone form renders; routed forms keep protection in the shell during permission rechecks. */
|
|
224
|
+
export function usePageLeave(
|
|
225
|
+
dirty: boolean,
|
|
226
|
+
busy: boolean,
|
|
227
|
+
onDiscard?: () => void,
|
|
228
|
+
) {
|
|
229
|
+
const boundary = useContext(Context);
|
|
230
|
+
const { t } = useTranslation();
|
|
231
|
+
const location = useLocation();
|
|
232
|
+
const [prompt, setPrompt] = useState<Prompt>();
|
|
233
|
+
const [controller] = useState(
|
|
234
|
+
() => new LeaveController(location.pathname, boundary?.show ?? setPrompt),
|
|
235
|
+
);
|
|
236
|
+
useNavigationGuard(boundary ? () => true : controller.guard);
|
|
237
|
+
useBeforeUnload(!boundary && dirty);
|
|
238
|
+
useLayoutEffect(() => {
|
|
239
|
+
controller.update(
|
|
240
|
+
dirty,
|
|
241
|
+
busy,
|
|
242
|
+
onDiscard,
|
|
243
|
+
t('settings.discardTitle'),
|
|
244
|
+
t('settings.discardHint'),
|
|
245
|
+
);
|
|
246
|
+
boundary?.scope.changed();
|
|
247
|
+
}, [controller, dirty, busy, onDiscard, boundary, t]);
|
|
248
|
+
useLayoutEffect(() => {
|
|
249
|
+
controller.reset();
|
|
250
|
+
}, [controller, location.key]);
|
|
251
|
+
useLayoutEffect(() => {
|
|
252
|
+
if (!boundary) return () => controller.cancel();
|
|
253
|
+
boundary.scope.attach(controller);
|
|
254
|
+
return () => boundary.scope.detach(controller);
|
|
255
|
+
}, [boundary, controller]);
|
|
256
|
+
return {
|
|
257
|
+
guard: controller.guard,
|
|
258
|
+
allow: controller.allow,
|
|
259
|
+
dialog: boundary ? null : promptDialog(prompt),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -8,7 +8,12 @@ import { useEffect, useMemo, useState } from 'react';
|
|
|
8
8
|
import { matchPath, matchRoutes, type RouteObject } from 'react-router';
|
|
9
9
|
|
|
10
10
|
import { EMPTY_ARRAY } from '#lib/constants';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
organizeNavigation,
|
|
13
|
+
settingsCatalog,
|
|
14
|
+
transformNavigation,
|
|
15
|
+
type SettingsCategory,
|
|
16
|
+
} from '#lib/navigation-transform';
|
|
12
17
|
|
|
13
18
|
export interface RouteNavigationItem {
|
|
14
19
|
/** Route keys represented by a single sidebar entry whose content uses tabs. */
|
|
@@ -22,7 +27,17 @@ export function buildRouteNavigation(
|
|
|
22
27
|
routes: readonly AppClientRegisteredRoute[],
|
|
23
28
|
denied: ReadonlySet<string>,
|
|
24
29
|
): RouteNavigationItem[] {
|
|
25
|
-
return transformNavigation(visibleRouteNavigation(routes, denied));
|
|
30
|
+
return transformNavigation(visibleRouteNavigation(routes, denied), routes);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The settings home's categories and modules the viewer may open (`settingsCatalog`). */
|
|
34
|
+
export function buildSettingsCatalog(
|
|
35
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
36
|
+
denied: ReadonlySet<string>,
|
|
37
|
+
): SettingsCategory[] {
|
|
38
|
+
return settingsCatalog(
|
|
39
|
+
organizeNavigation(visibleRouteNavigation(routes, denied), routes),
|
|
40
|
+
);
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
function visibleRouteNavigation(
|
|
@@ -96,6 +111,8 @@ function navigationPages(
|
|
|
96
111
|
]);
|
|
97
112
|
}
|
|
98
113
|
|
|
114
|
+
const NONE_DENIED: ReadonlySet<string> = new Set();
|
|
115
|
+
|
|
99
116
|
export function useRouteNavigation(
|
|
100
117
|
routes: readonly AppClientRegisteredRoute[],
|
|
101
118
|
) {
|
|
@@ -151,9 +168,8 @@ export function useRouteNavigation(
|
|
|
151
168
|
result.guards !== guards ||
|
|
152
169
|
result.revision !== revision),
|
|
153
170
|
);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
: new Set<string>();
|
|
171
|
+
// A stable empty set keeps consumers that memoize on `denied` from recomputing on every render.
|
|
172
|
+
const denied = (guards.length && result?.denied) || NONE_DENIED;
|
|
157
173
|
return {
|
|
158
174
|
loading,
|
|
159
175
|
items: loading ? [] : buildRouteNavigation(routes, denied),
|
|
@@ -165,11 +181,17 @@ export function matchRouteTree(
|
|
|
165
181
|
routes: readonly AppClientRegisteredRoute[],
|
|
166
182
|
pathname: string,
|
|
167
183
|
) {
|
|
168
|
-
const toMatch = (
|
|
184
|
+
const toMatch = (
|
|
185
|
+
nodes: readonly AppClientRegisteredRoute[],
|
|
186
|
+
parentPath = '',
|
|
187
|
+
): RouteObject[] =>
|
|
169
188
|
nodes.map((route) => ({
|
|
170
|
-
path: route
|
|
189
|
+
path: relativeRoutePath(route, parentPath),
|
|
171
190
|
handle: route,
|
|
172
|
-
children: toMatch(
|
|
191
|
+
children: toMatch(
|
|
192
|
+
route.children ?? EMPTY_ARRAY,
|
|
193
|
+
childRouteParentPath(route, parentPath),
|
|
194
|
+
),
|
|
173
195
|
}));
|
|
174
196
|
return matchRoutes(toMatch(routes), pathname)?.map((match) => ({
|
|
175
197
|
...match,
|
|
@@ -177,6 +199,48 @@ export function matchRouteTree(
|
|
|
177
199
|
}));
|
|
178
200
|
}
|
|
179
201
|
|
|
202
|
+
/**
|
|
203
|
+
* A registered route's path relative to its parent's, as the router nests it. Registered paths are absolute; a route at
|
|
204
|
+
* its parent's own path (a group's home page, a pathless group) nests with an empty path.
|
|
205
|
+
*/
|
|
206
|
+
export function relativeRoutePath(
|
|
207
|
+
route: AppClientRegisteredRoute,
|
|
208
|
+
parentPath: string,
|
|
209
|
+
): string {
|
|
210
|
+
if (!parentPath) return route.path;
|
|
211
|
+
if (
|
|
212
|
+
route.path === parentPath ||
|
|
213
|
+
parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
|
|
214
|
+
)
|
|
215
|
+
return '';
|
|
216
|
+
return route.path.slice(parentPath === '/' ? 1 : parentPath.length + 1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The path a route's children are relative to. */
|
|
220
|
+
export function childRouteParentPath(
|
|
221
|
+
route: AppClientRegisteredRoute,
|
|
222
|
+
parentPath: string,
|
|
223
|
+
): string {
|
|
224
|
+
return parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
|
|
225
|
+
? parentPath
|
|
226
|
+
: route.path;
|
|
227
|
+
}
|
|
228
|
+
|
|
180
229
|
export function routeKey(route: AppClientRegisteredRoute): string {
|
|
181
230
|
return `${route.packageName}:${route.path}:${route.id}`;
|
|
182
231
|
}
|
|
232
|
+
|
|
233
|
+
/** Pure groups can span shells; a page and its descendants always share their shell. */
|
|
234
|
+
export function filterRouteTree(
|
|
235
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
236
|
+
predicate: (route: AppClientRegisteredRoute) => boolean,
|
|
237
|
+
): AppClientRegisteredRoute[] {
|
|
238
|
+
return routes.flatMap((route) => {
|
|
239
|
+
if (route.componentLoader) return predicate(route) ? [route] : [];
|
|
240
|
+
const children = filterRouteTree(route.children ?? EMPTY_ARRAY, predicate);
|
|
241
|
+
// A group declared empty, such as a settings category waiting for modules, stays so its title is still known.
|
|
242
|
+
return children.length || !route.children?.length
|
|
243
|
+
? [{ ...route, children }]
|
|
244
|
+
: [];
|
|
245
|
+
});
|
|
246
|
+
}
|
|
@@ -4,7 +4,11 @@ import { Outlet, Route } from 'react-router';
|
|
|
4
4
|
|
|
5
5
|
import { EMPTY_ARRAY } from '#lib/constants';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
childRouteParentPath,
|
|
9
|
+
relativeRoutePath,
|
|
10
|
+
routeKey,
|
|
11
|
+
} from './route-navigation.js';
|
|
8
12
|
import { ClientRoute } from './client-route.js';
|
|
9
13
|
|
|
10
14
|
/** Groups pass children through; pages deliberately own their Outlet placement. */
|
|
@@ -15,14 +19,7 @@ export function renderRouteTree(
|
|
|
15
19
|
return routes.map((route) => (
|
|
16
20
|
<Route
|
|
17
21
|
key={routeKey(route)}
|
|
18
|
-
path={
|
|
19
|
-
parentPath
|
|
20
|
-
? route.path === parentPath ||
|
|
21
|
-
parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
|
|
22
|
-
? ''
|
|
23
|
-
: route.path.slice(parentPath === '/' ? 1 : parentPath.length + 1)
|
|
24
|
-
: route.path
|
|
25
|
-
}
|
|
22
|
+
path={relativeRoutePath(route, parentPath)}
|
|
26
23
|
element={
|
|
27
24
|
route.componentLoader ? (
|
|
28
25
|
<ClientRoute key={routeKey(route)} route={route} />
|
|
@@ -33,9 +30,7 @@ export function renderRouteTree(
|
|
|
33
30
|
>
|
|
34
31
|
{renderRouteTree(
|
|
35
32
|
route.children ?? EMPTY_ARRAY,
|
|
36
|
-
|
|
37
|
-
? parentPath
|
|
38
|
-
: route.path,
|
|
33
|
+
childRouteParentPath(route, parentPath),
|
|
39
34
|
)}
|
|
40
35
|
</Route>
|
|
41
36
|
));
|
package/client/runtime.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { defineAppRuntime } from '@nocobase/app-client/runtime';
|
|
|
5
5
|
import locales from './locales/index.js';
|
|
6
6
|
import plugins from './plugins.js';
|
|
7
7
|
import reactProviders from './react-providers.js';
|
|
8
|
-
import
|
|
8
|
+
import { createApplicationRoutes } from './routes.js';
|
|
9
9
|
import serviceProviders from './service-provider.js';
|
|
10
10
|
|
|
11
11
|
const appRuntime = defineAppRuntime({
|
|
@@ -15,7 +15,7 @@ const appRuntime = defineAppRuntime({
|
|
|
15
15
|
serviceProviders,
|
|
16
16
|
locales,
|
|
17
17
|
reactProviders,
|
|
18
|
-
routes,
|
|
18
|
+
routes: () => createApplicationRoutes(),
|
|
19
19
|
plugins,
|
|
20
20
|
});
|
|
21
21
|
|
package/client/styles.css
CHANGED
|
@@ -252,9 +252,26 @@ label:has(> .sr-only:focus-visible),
|
|
|
252
252
|
:is([data-slot='dialog-description'], [data-slot='alert-dialog-description']) {
|
|
253
253
|
line-height: 1.65;
|
|
254
254
|
}
|
|
255
|
-
|
|
255
|
+
/* Size semantic actions equally, including Button render={<Link />} and the
|
|
256
|
+
AlertDialog wrappers, which replace Button's slot. Footer sizing takes
|
|
257
|
+
precedence over primitive size utilities; long labels may grow and wrap. */
|
|
258
|
+
:is([data-slot='dialog-footer'], [data-slot='alert-dialog-footer'])
|
|
259
|
+
> :is(
|
|
260
|
+
[data-slot='button'],
|
|
261
|
+
[data-slot='dialog-close'],
|
|
262
|
+
[data-slot='alert-dialog-action'],
|
|
263
|
+
[data-slot='alert-dialog-cancel']
|
|
264
|
+
) {
|
|
265
|
+
height: auto;
|
|
256
266
|
min-height: calc(var(--spacing) * 10);
|
|
267
|
+
min-width: 0;
|
|
268
|
+
align-self: stretch;
|
|
269
|
+
flex-shrink: 1;
|
|
270
|
+
padding-block: calc(var(--spacing) * 2);
|
|
257
271
|
padding-inline: calc(var(--spacing) * 4);
|
|
272
|
+
white-space: normal;
|
|
273
|
+
overflow-wrap: anywhere;
|
|
274
|
+
text-align: center;
|
|
258
275
|
}
|
|
259
276
|
/* Route overlays compose the dialog primitives, so their close button sits where a dialog's does. */
|
|
260
277
|
:is([data-slot='dialog-content'], [data-slot='route-overlay-content'])
|
package/config.example.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/app-template-default",
|
|
3
3
|
"displayName": "Default Template",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.89",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"prettier": "@nocobase/dev-config/prettier",
|
|
7
7
|
"engines": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"nocobase": {
|
|
11
11
|
"templateKind": "app",
|
|
12
|
-
"defaultTemplateVersion": "1.0.0-beta.
|
|
12
|
+
"defaultTemplateVersion": "1.0.0-beta.89"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@ai-sdk/react": "4.0.131",
|
|
@@ -74,9 +74,9 @@
|
|
|
74
74
|
"elkjs": "^0.10.2",
|
|
75
75
|
"@types/jsdom": "^30.0.0",
|
|
76
76
|
"@noble/hashes": "2.3.0",
|
|
77
|
-
"@nocobase/app-client": "^4.0.0-beta.
|
|
77
|
+
"@nocobase/app-client": "^4.0.0-beta.9",
|
|
78
78
|
"@nocobase/app-plugin-template-print": "0.0.2-beta.2",
|
|
79
|
-
"@nocobase/app-skills": "0.1.0-beta.
|
|
79
|
+
"@nocobase/app-skills": "0.1.0-beta.60",
|
|
80
80
|
"@nocobase/app-testing": "0.1.0-beta.8",
|
|
81
81
|
"@nocobase/app-websocket": "^0.1.0-beta.1",
|
|
82
82
|
"@nocobase/dev-config": "0.1.0-beta.25",
|
|
@@ -144,22 +144,22 @@
|
|
|
144
144
|
"hono": "^4.13.2",
|
|
145
145
|
"@nocobase/concurrency": "^0.1.0-beta.1",
|
|
146
146
|
"@nocobase/app-plugin-concurrency": "^0.1.0-beta.2",
|
|
147
|
-
"@nocobase/app-plugin-agents": "^2.0.0-beta.
|
|
147
|
+
"@nocobase/app-plugin-agents": "^2.0.0-beta.6",
|
|
148
148
|
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.21",
|
|
149
|
-
"@nocobase/app-plugin-authentication": "^2.0.0-beta.
|
|
150
|
-
"@nocobase/app-plugin-authorization": "^1.0.0-beta.
|
|
149
|
+
"@nocobase/app-plugin-authentication": "^2.0.0-beta.19",
|
|
150
|
+
"@nocobase/app-plugin-authorization": "^1.0.0-beta.36",
|
|
151
151
|
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.19",
|
|
152
152
|
"@nocobase/app-plugin-file": "^1.0.0-beta.29",
|
|
153
|
-
"@nocobase/app-plugin-foundation": "^0.1.0-beta.
|
|
154
|
-
"@nocobase/app-plugin-i18n": "^1.0.0-beta.
|
|
153
|
+
"@nocobase/app-plugin-foundation": "^0.1.0-beta.16",
|
|
154
|
+
"@nocobase/app-plugin-i18n": "^1.0.0-beta.21",
|
|
155
155
|
"@nocobase/app-plugin-knowledge": "^0.1.0-beta.17",
|
|
156
156
|
"@nocobase/app-plugin-mail": "^1.0.0-beta.21",
|
|
157
|
-
"@nocobase/app-plugin-notification": "^2.0.0-beta.
|
|
157
|
+
"@nocobase/app-plugin-notification": "^2.0.0-beta.5",
|
|
158
158
|
"@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.2",
|
|
159
159
|
"@nocobase/app-plugin-notification-providers": "^1.0.0-beta.18",
|
|
160
160
|
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.23",
|
|
161
|
-
"@nocobase/app-plugin-users": "^2.0.0-beta.
|
|
162
|
-
"@nocobase/app-server": "^5.0.0-beta.
|
|
161
|
+
"@nocobase/app-plugin-users": "^2.0.0-beta.16",
|
|
162
|
+
"@nocobase/app-server": "^5.0.0-beta.1",
|
|
163
163
|
"@nocobase/config": "^0.1.0-beta.3",
|
|
164
164
|
"@nocobase/db": "^1.0.0-beta.25",
|
|
165
165
|
"@nocobase/db-sqlite": "^0.1.0-beta.6",
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
"@nocobase/caching": "^1.0.0-beta.4",
|
|
177
177
|
"@nocobase/repository-input": "^0.1.0-beta.3",
|
|
178
178
|
"@nocobase/app-cli": "^1.0.0-beta.32",
|
|
179
|
-
"@nocobase/realtime": "^1.0.0-beta.
|
|
179
|
+
"@nocobase/realtime": "^1.0.0-beta.1"
|
|
180
180
|
},
|
|
181
181
|
"description": "NocoBase 3 application template with the standard product capabilities.",
|
|
182
182
|
"imports": {
|
package/server/config/auth.ts
CHANGED
|
@@ -29,7 +29,7 @@ const auth: AppConfigFactory<AuthConfig> = defineAuthConfig({
|
|
|
29
29
|
}),
|
|
30
30
|
bearer(),
|
|
31
31
|
],
|
|
32
|
-
emailAndPassword: { enabled: true, autoSignIn:
|
|
32
|
+
emailAndPassword: { enabled: true, autoSignIn: true },
|
|
33
33
|
session: { storeSessionInDatabase: true },
|
|
34
34
|
account: { encryptOAuthTokens: true },
|
|
35
35
|
},
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import NotificationManagementProvider from './notification-management.js';
|
|
1
2
|
import SubmissionProvider from './submission.js';
|
|
2
3
|
import type { ApplicationServiceProviderConstructor } from '@nocobase/app-server/application';
|
|
3
4
|
|
|
4
5
|
const serviceProviders: readonly ApplicationServiceProviderConstructor[] = [
|
|
5
6
|
SubmissionProvider,
|
|
7
|
+
NotificationManagementProvider,
|
|
6
8
|
];
|
|
7
9
|
|
|
8
10
|
export default serviceProviders;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ServiceProvider } from '@nocobase/service-provider';
|
|
2
|
+
import type { AppPluginApplication } from '@nocobase/app-server/plugins';
|
|
3
|
+
import type { MailersConfig } from '@nocobase/app-server/mailers/config';
|
|
4
|
+
import {
|
|
5
|
+
describeInvitationEmailChannel,
|
|
6
|
+
type UsersConfig,
|
|
7
|
+
} from '@nocobase/app-plugin-users/server/config';
|
|
8
|
+
|
|
9
|
+
/** Only a derived boolean reaches the existing SPA bootstrap; all transport configuration stays private. */
|
|
10
|
+
export default class NotificationManagementProvider extends ServiceProvider<AppPluginApplication> {
|
|
11
|
+
readonly name = 'app.notification-management';
|
|
12
|
+
override boot(): Promise<void> {
|
|
13
|
+
const channel = describeInvitationEmailChannel({
|
|
14
|
+
invitations: this.app.config.get<UsersConfig>('users')?.invitations,
|
|
15
|
+
mailers: this.app.config.get<MailersConfig>('mailers'),
|
|
16
|
+
channels: this.app.config.get<{
|
|
17
|
+
channels?: Readonly<Record<string, { enabled?: boolean }>>;
|
|
18
|
+
}>('notification')?.channels,
|
|
19
|
+
});
|
|
20
|
+
this.app.config.mergeDefaults({
|
|
21
|
+
client: {
|
|
22
|
+
notificationTemplates: { invitationConfigured: channel.configured },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -115,7 +115,7 @@ describe('account permission changes without a browser reload', () => {
|
|
|
115
115
|
await expectMenu(true);
|
|
116
116
|
await changePermissions(view, false);
|
|
117
117
|
invalidate(view);
|
|
118
|
-
expect(screen.
|
|
118
|
+
expect(screen.getByText('users content')).not.toBeVisible();
|
|
119
119
|
await expectMenu(false);
|
|
120
120
|
await changePermissions(view, true);
|
|
121
121
|
invalidate(view);
|
|
@@ -149,6 +149,6 @@ describe('authentication translations', () => {
|
|
|
149
149
|
await act(() => value.changeLanguage('zh-CN'));
|
|
150
150
|
expect(screen.getByText('此密码重置链接无效或已过期。')).toBeVisible();
|
|
151
151
|
expect(screen.getByText('如果账户存在,重置链接已发送。')).toBeVisible();
|
|
152
|
-
expect(screen.
|
|
152
|
+
expect(await screen.findByRole('status', { name: '加载中' })).toBeVisible();
|
|
153
153
|
});
|
|
154
154
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, it } from 'vitest';
|
|
2
|
+
import { createAuthenticatedClientRenderer } from '../helpers/authenticated-client.js';
|
|
3
|
+
|
|
4
|
+
const render = createAuthenticatedClientRenderer();
|
|
5
|
+
const identities = new Set<string>();
|
|
6
|
+
const permissionSets = new Set<string>();
|
|
7
|
+
const resource = { type: 'settings', id: 'authorization.default-access' };
|
|
8
|
+
|
|
9
|
+
it.each([
|
|
10
|
+
{ name: 'administrator', member: false, granted: false },
|
|
11
|
+
{ name: 'permitted member', member: true, granted: true },
|
|
12
|
+
{ name: 'unpermitted member', member: true, granted: false },
|
|
13
|
+
])(
|
|
14
|
+
'isolates the $name session and grants in a reused application',
|
|
15
|
+
async ({ member, granted }) => {
|
|
16
|
+
const view = await render(<span>Isolated client</span>, {
|
|
17
|
+
member,
|
|
18
|
+
grants: granted ? [{ resource, actions: [{ action: 'read' }] }] : [],
|
|
19
|
+
});
|
|
20
|
+
expect(identities.has(view.session.user.id)).toBe(false);
|
|
21
|
+
expect(permissionSets.has(view.permissionSetKey)).toBe(false);
|
|
22
|
+
identities.add(view.session.user.id);
|
|
23
|
+
permissionSets.add(view.permissionSetKey);
|
|
24
|
+
expect(view.identity().session?.user.id).toBe(view.session.user.id);
|
|
25
|
+
|
|
26
|
+
const response = await view.session.fetch('/authorization/permissions');
|
|
27
|
+
expect(response.status).toBe(200);
|
|
28
|
+
const { data } = await response.json();
|
|
29
|
+
expect(data.unrestricted).toBe(!member);
|
|
30
|
+
const permission = expect.objectContaining({
|
|
31
|
+
resource,
|
|
32
|
+
actions: expect.arrayContaining(['read']),
|
|
33
|
+
});
|
|
34
|
+
if (granted) expect(data.permissions).toContainEqual(permission);
|
|
35
|
+
else expect(data.permissions).not.toContainEqual(permission);
|
|
36
|
+
},
|
|
37
|
+
);
|