@nocobase/app-template-default 1.0.0-beta.58 → 1.0.0-beta.60
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.md +28 -25
- package/README.MD +7 -57
- package/client/agent-annotations-host.ts +8 -2
- package/client/app.ts +0 -2
- package/client/components/brand-loading-mark.tsx +46 -0
- package/client/components/brand-mark-geometry.ts +7 -0
- package/client/components/brand-spinner.tsx +22 -0
- package/client/components/brand-watermark.tsx +32 -0
- package/client/components/loading.tsx +4 -3
- package/client/components/route-overlay.tsx +15 -4
- package/client/components/status-badge.tsx +29 -0
- package/client/components/ui/alert-dialog.tsx +186 -0
- package/client/components/ui/card.tsx +102 -0
- package/client/components/ui/dialog.tsx +2 -2
- package/client/components/ui/radio-group.tsx +35 -0
- package/client/components/ui/spinner.tsx +2 -20
- package/client/components/ui/toast.tsx +3 -2
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +3 -3
- package/client/extensions/nocobase-inbox/inbox-item.tsx +4 -3
- package/client/layouts/app-layout.tsx +8 -28
- package/client/layouts/components/app-sidebar.tsx +4 -4
- package/client/layouts/components/header-actions.tsx +3 -36
- package/client/layouts/index.ts +0 -1
- package/client/lib/toaster.ts +1 -1
- package/client/locales/en-US.ts +8 -164
- package/client/locales/system/en-US.ts +344 -0
- package/client/locales/system/zh-CN.ts +320 -0
- package/client/locales/zh-CN.ts +3 -155
- package/client/pages/auth/login.tsx +1 -0
- package/client/pages/auth/shared.tsx +9 -9
- package/client/pages/home/capabilities.ts +52 -0
- package/client/pages/home/index.tsx +190 -0
- package/client/pages/home/prompt-dialog.tsx +122 -0
- package/client/pages/home/use-copy.ts +38 -0
- package/client/plugins.ts +2 -2
- package/client/routes.ts +5 -11
- package/client/routing/app-router.tsx +5 -55
- package/client/routing/route-navigation.ts +1 -1
- package/client/styles.css +244 -0
- package/client/theme/theme-settings.tsx +22 -20
- package/client/theme/themes/compact.css +34 -16
- package/client/theme/themes/default.css +34 -16
- package/eslint.config.js +0 -13
- package/index.html +48 -16
- package/package.json +23 -27
- package/tests/components/app-layout-routes.test.tsx +1 -1
- package/tests/components/home.test.tsx +174 -0
- package/tests/components/inbox.test.tsx +0 -19
- package/tests/components/layout-i18n.test.tsx +5 -92
- package/tests/components/primitive-labels.test.tsx +61 -1
- package/tests/fixtures/theme-tokens.html +1 -1
- package/tests/fixtures/theme-tokens.tsx +7 -0
- package/tests/logic/app-server.test.ts +0 -15
- package/tests/logic/client-plugin-registry.test.ts +0 -21
- package/tests/logic/client-routes.test.ts +2 -6
- package/tests/logic/client-shell.test.tsx +11 -107
- package/tests/logic/client-theme.test.tsx +17 -1
- package/tests/logic/config.test.ts +0 -1
- package/tests/logic/plugin-commands.test.ts +1 -1
- package/tsconfig.json +1 -4
- package/tsconfig.server.json +1 -6
- package/vitest.config.ts +1 -8
- package/client/components/inbox-badge.ts +0 -49
- package/client/components/inbox-button.tsx +0 -132
- package/client/components/inbox-header-button.tsx +0 -25
- package/client/layouts/components/surface-empty.tsx +0 -35
- package/client/layouts/settings-layout.tsx +0 -172
- package/client/pages/home.tsx +0 -16
- package/server/config/workflow.ts +0 -24
- package/tests/logic/client-settings.test.tsx +0 -841
- package/tests/logic/dev-routes-production-build.test.ts +0 -168
- package/tests/logic/plugin-page-authz.test.ts +0 -53
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from '@nocobase/app-plugin-authentication/client';
|
|
12
12
|
import {
|
|
13
13
|
AuthorizationClient,
|
|
14
|
-
type AuthorizationCheck,
|
|
15
14
|
authorizationClientToken,
|
|
16
15
|
} from '@nocobase/app-plugin-authorization/client';
|
|
17
16
|
import {
|
|
@@ -29,12 +28,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
29
28
|
import { AppRouter } from '../../client/routing/app-router.tsx';
|
|
30
29
|
import { AppThemeProvider } from '../../client/theme/index.ts';
|
|
31
30
|
|
|
32
|
-
// The header's inbox button reads the in-app notification plugin's API, which these tests do not serve; it is
|
|
33
|
-
// covered by tests/components/inbox.test.tsx.
|
|
34
|
-
vi.mock('../../client/components/inbox-header-button', () => ({
|
|
35
|
-
InboxHeaderButton: () => null,
|
|
36
|
-
}));
|
|
37
|
-
|
|
38
31
|
afterEach(() => vi.unstubAllGlobals());
|
|
39
32
|
|
|
40
33
|
/** The sidebar's entries; the header's breadcrumb names the current page as well. */
|
|
@@ -109,47 +102,16 @@ describe('application shell', () => {
|
|
|
109
102
|
).toBeVisible();
|
|
110
103
|
});
|
|
111
104
|
|
|
112
|
-
it
|
|
113
|
-
'
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
createRoute(
|
|
123
|
-
'preferences',
|
|
124
|
-
'/settings/preferences',
|
|
125
|
-
'required',
|
|
126
|
-
() => <h2>Preferences</h2>,
|
|
127
|
-
'plugin',
|
|
128
|
-
'Preferences',
|
|
129
|
-
true,
|
|
130
|
-
),
|
|
131
|
-
],
|
|
132
|
-
});
|
|
133
|
-
await screen.findByRole('heading', { name: 'App client is ready' });
|
|
134
|
-
await waitFor(() =>
|
|
135
|
-
expect(can).toHaveBeenCalledWith(
|
|
136
|
-
expect.objectContaining({
|
|
137
|
-
resource: { type: 'page', id: 'preferences' },
|
|
138
|
-
action: 'access',
|
|
139
|
-
}),
|
|
140
|
-
),
|
|
141
|
-
);
|
|
142
|
-
if (allowed) {
|
|
143
|
-
expect(
|
|
144
|
-
await screen.findByRole('link', { name: 'Settings' }),
|
|
145
|
-
).toBeVisible();
|
|
146
|
-
} else {
|
|
147
|
-
expect(
|
|
148
|
-
screen.queryByRole('link', { name: 'Settings' }),
|
|
149
|
-
).not.toBeInTheDocument();
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
);
|
|
105
|
+
it('renders no Settings or Inbox entry in the header', async () => {
|
|
106
|
+
renderApplication('/', true);
|
|
107
|
+
await screen.findByRole('heading', { name: 'App client is ready' });
|
|
108
|
+
expect(
|
|
109
|
+
screen.queryByRole('link', { name: 'Settings' }),
|
|
110
|
+
).not.toBeInTheDocument();
|
|
111
|
+
expect(
|
|
112
|
+
screen.queryByRole('link', { name: /inbox/i }),
|
|
113
|
+
).not.toBeInTheDocument();
|
|
114
|
+
});
|
|
153
115
|
|
|
154
116
|
it('renders nested pages through manual outlets and selects the nearest menu ancestor', async () => {
|
|
155
117
|
const child = createRoute('detail', '/orders/42', 'required', () => (
|
|
@@ -286,57 +248,6 @@ describe('application shell', () => {
|
|
|
286
248
|
);
|
|
287
249
|
});
|
|
288
250
|
|
|
289
|
-
it('renders dev pages inside the application shell, outside its navigation', async () => {
|
|
290
|
-
const playground = createRoute(
|
|
291
|
-
'playground',
|
|
292
|
-
'/dev/playground',
|
|
293
|
-
'required',
|
|
294
|
-
() => <h2>Playground page</h2>,
|
|
295
|
-
'plugin',
|
|
296
|
-
'Playground',
|
|
297
|
-
);
|
|
298
|
-
const demos: AppClientRegisteredRoute = {
|
|
299
|
-
...createRoute('demos', '/dev/demos', 'required', () => null),
|
|
300
|
-
componentLoader: undefined,
|
|
301
|
-
navigation: { title: 'Demos' },
|
|
302
|
-
children: [
|
|
303
|
-
createRoute('chat', '/dev/demos/chat', 'required', () => (
|
|
304
|
-
<h2>Chat demo page</h2>
|
|
305
|
-
)),
|
|
306
|
-
],
|
|
307
|
-
};
|
|
308
|
-
renderApplication('/dev/playground', true, [], {
|
|
309
|
-
devRouteTree: [playground, demos],
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
expect(await screen.findByText('Playground page')).toBeVisible();
|
|
313
|
-
expect(
|
|
314
|
-
screen.getByRole('navigation', { name: 'Application navigation' }),
|
|
315
|
-
).toBeVisible();
|
|
316
|
-
expect(
|
|
317
|
-
within(sidebar()).queryByRole('link', { name: 'Playground' }),
|
|
318
|
-
).not.toBeInTheDocument();
|
|
319
|
-
expect(screen.queryByText('Demos')).not.toBeInTheDocument();
|
|
320
|
-
expect(
|
|
321
|
-
screen.queryByRole('link', { name: 'Settings' }),
|
|
322
|
-
).not.toBeInTheDocument();
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it('renders a dev page nested under a dev group', async () => {
|
|
326
|
-
const demos: AppClientRegisteredRoute = {
|
|
327
|
-
...createRoute('demos', '/dev/demos', 'required', () => null),
|
|
328
|
-
componentLoader: undefined,
|
|
329
|
-
children: [
|
|
330
|
-
createRoute('chat', '/dev/demos/chat', 'required', () => (
|
|
331
|
-
<h2>Chat demo page</h2>
|
|
332
|
-
)),
|
|
333
|
-
],
|
|
334
|
-
};
|
|
335
|
-
renderApplication('/dev/demos/chat', true, [], { devRouteTree: [demos] });
|
|
336
|
-
|
|
337
|
-
expect(await screen.findByText('Chat demo page')).toBeVisible();
|
|
338
|
-
});
|
|
339
|
-
|
|
340
251
|
it('keeps guest pages outside the application shell', async () => {
|
|
341
252
|
renderApplication('/login', false, [
|
|
342
253
|
createRoute('login', '/login', 'guest', GuestPage),
|
|
@@ -356,8 +267,6 @@ function renderApplication(
|
|
|
356
267
|
routes: readonly AppClientRegisteredRoute[] = [],
|
|
357
268
|
options: {
|
|
358
269
|
readonly authorization?: Pick<AuthorizationClient, 'can'>;
|
|
359
|
-
readonly settingsRouteTree?: readonly AppClientRegisteredRoute[];
|
|
360
|
-
readonly devRouteTree?: readonly AppClientRegisteredRoute[];
|
|
361
270
|
} = {},
|
|
362
271
|
): void {
|
|
363
272
|
const clientRoutes = [
|
|
@@ -383,7 +292,6 @@ function renderApplication(
|
|
|
383
292
|
]);
|
|
384
293
|
const app = {
|
|
385
294
|
config: createAppClientConfig({ rawConfig: {} }),
|
|
386
|
-
runtime: { settingsRouteTree: options.settingsRouteTree ?? [] },
|
|
387
295
|
services: {
|
|
388
296
|
has: (token: unknown) => registered.has(token),
|
|
389
297
|
resolve: (token: unknown) => {
|
|
@@ -397,11 +305,7 @@ function renderApplication(
|
|
|
397
305
|
<AuthenticationProvider>
|
|
398
306
|
<MemoryRouter initialEntries={[initialEntry]}>
|
|
399
307
|
<AppThemeProvider>
|
|
400
|
-
<AppRouter
|
|
401
|
-
devRouteTree={options.devRouteTree ?? []}
|
|
402
|
-
clientRoutes={clientRoutes}
|
|
403
|
-
settingsRouteTree={options.settingsRouteTree ?? []}
|
|
404
|
-
/>
|
|
308
|
+
<AppRouter clientRoutes={clientRoutes} />
|
|
405
309
|
</AppThemeProvider>
|
|
406
310
|
</MemoryRouter>
|
|
407
311
|
</AuthenticationProvider>
|
|
@@ -70,12 +70,28 @@ describe('app client theme', () => {
|
|
|
70
70
|
);
|
|
71
71
|
await openAppearance(appearance);
|
|
72
72
|
const option = await screen.findByRole('radio', { name: label });
|
|
73
|
-
expect(option).toHaveAttribute('value', 'default');
|
|
74
73
|
await userEvent.click(option);
|
|
74
|
+
expect(option).toBeChecked();
|
|
75
75
|
expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
|
|
76
76
|
},
|
|
77
77
|
);
|
|
78
78
|
|
|
79
|
+
it('changes presets with arrow keys and returns focus on Escape', async () => {
|
|
80
|
+
render(
|
|
81
|
+
<AppThemeProvider>
|
|
82
|
+
<ThemeSettings />
|
|
83
|
+
</AppThemeProvider>,
|
|
84
|
+
);
|
|
85
|
+
await openAppearance();
|
|
86
|
+
const compact = await screen.findByRole('radio', { name: 'Compact' });
|
|
87
|
+
act(() => compact.focus());
|
|
88
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
89
|
+
expect(screen.getByRole('radio', { name: 'Default' })).toBeChecked();
|
|
90
|
+
expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
|
|
91
|
+
await userEvent.keyboard('{Escape}');
|
|
92
|
+
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveFocus();
|
|
93
|
+
});
|
|
94
|
+
|
|
79
95
|
it('falls back from the removed Ant Design preset without changing mode', async () => {
|
|
80
96
|
localStorage.setItem('nocobase:crm:theme:preset', 'ant-design');
|
|
81
97
|
localStorage.setItem('nocobase:crm:theme:color-scheme', 'light');
|
|
@@ -187,7 +187,6 @@ describe('application config', () => {
|
|
|
187
187
|
expect(runtime.config.get('session.stores.redis.host')).toBe('127.0.0.1');
|
|
188
188
|
expect(runtime.config.get('logging.console.pretty')).toBe(false);
|
|
189
189
|
expect(runtime.config.get('session.cookie.secure')).toBe(true);
|
|
190
|
-
expect(runtime.config.get('workflow')).toBeUndefined();
|
|
191
190
|
delete runtime.env.APP_SERVER_PORT;
|
|
192
191
|
delete runtime.env.APP_SERVER_START_LOG;
|
|
193
192
|
await runtime.config.reload();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment node
|
|
2
2
|
|
|
3
3
|
// Users develop, build and start their app through these scripts. Nothing at runtime depends on them, so dropping or
|
|
4
|
-
// renaming one — a bad merge resolution did exactly that once — breaks the documented
|
|
4
|
+
// renaming one — a bad merge resolution did exactly that once — breaks the documented development steps silently: the app still
|
|
5
5
|
// builds, starts, and passes every other test. These assertions are the alarm.
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
package/tsconfig.json
CHANGED
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
|
5
5
|
},
|
|
6
|
-
|
|
7
|
-
// Vite build resolves through the workflow plugin's virtual module, so it is
|
|
8
|
-
// typechecked here rather than by the server build.
|
|
9
|
-
"include": ["client", "workflows/*/client"],
|
|
6
|
+
"include": ["client"],
|
|
10
7
|
"exclude": ["registry"]
|
|
11
8
|
}
|
package/tsconfig.server.json
CHANGED
package/vitest.config.ts
CHANGED
|
@@ -24,14 +24,7 @@ export default createReactVitestConfig({
|
|
|
24
24
|
// those tests were never run at all.
|
|
25
25
|
include: ['tests/**/*.test.{ts,tsx}'],
|
|
26
26
|
// Playwright runs tests/playwright/ against a built application; its files are named *.test.ts too.
|
|
27
|
-
exclude: [
|
|
28
|
-
'tests/playwright/**',
|
|
29
|
-
// TODO: Re-enable Workflow examples and page tests with the capability tests.
|
|
30
|
-
'tests/components/workflow-waiting-task.test.tsx',
|
|
31
|
-
'tests/logic/workflow-examples.test.ts',
|
|
32
|
-
'tests/logic/quotation-review-routes.test.ts',
|
|
33
|
-
'tests/logic/analytics-report-date.test.ts',
|
|
34
|
-
],
|
|
27
|
+
exclude: ['tests/playwright/**'],
|
|
35
28
|
setupFiles: ['./tests/setup/client-config.ts'],
|
|
36
29
|
// Keep the command usable if an application intentionally removes all scaffold tests before adding its own.
|
|
37
30
|
passWithNoTests: true,
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* What the inbox button shows: the count of what waits on the viewer (`decisions`), or, with none waiting, the count
|
|
5
|
-
* of unread items; nothing otherwise.
|
|
6
|
-
*
|
|
7
|
-
* What waits wins outright rather than adding up with the unread items. The two overlap: a waiting decision is also an
|
|
8
|
-
* unread item until opened, so a sum would count it twice. And a sum would blur what the badge means: amber says
|
|
9
|
-
* "needs you", which an unread notification does not.
|
|
10
|
-
*/
|
|
11
|
-
export type InboxBadge = {
|
|
12
|
-
readonly kind: 'decisions' | 'unread';
|
|
13
|
-
readonly count: number;
|
|
14
|
-
readonly text: string;
|
|
15
|
-
} | null;
|
|
16
|
-
|
|
17
|
-
/** The badge's text for `count`: "99+" above 99 so the badge keeps its size, none at zero. */
|
|
18
|
-
export function inboxBadgeText(count: number): string | null {
|
|
19
|
-
if (!Number.isFinite(count) || count <= 0) return null;
|
|
20
|
-
return count > 99 ? '99+' : String(count);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function inboxBadge(decisions: number, unread: number): InboxBadge {
|
|
24
|
-
const pending = inboxBadgeText(decisions);
|
|
25
|
-
if (pending) return { kind: 'decisions', count: decisions, text: pending };
|
|
26
|
-
const text = inboxBadgeText(unread);
|
|
27
|
-
return text ? { kind: 'unread', count: unread, text } : null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const TITLE_COUNT = /^\(\d+\+?\) /;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The browser tab title with the badge text in front (`(3) Inbox`), or without it when `text` is null. Any earlier
|
|
34
|
-
* count prefix is replaced, so applying it again never stacks prefixes.
|
|
35
|
-
*/
|
|
36
|
-
export function inboxTitle(title: string, text: string | null): string {
|
|
37
|
-
const base = title.replace(TITLE_COUNT, '');
|
|
38
|
-
return text ? `(${text}) ${base}` : base;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Puts the badge text in front of the browser tab title while mounted, and takes it off again. */
|
|
42
|
-
export function useDocumentTitleBadge(text: string | null): void {
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
document.title = inboxTitle(document.title, text);
|
|
45
|
-
return () => {
|
|
46
|
-
document.title = inboxTitle(document.title, null);
|
|
47
|
-
};
|
|
48
|
-
}, [text]);
|
|
49
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
-
import { InboxIcon } from 'lucide-react';
|
|
3
|
-
import type { ReactElement } from 'react';
|
|
4
|
-
import { Link, useLocation } from 'react-router';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
Tooltip,
|
|
8
|
-
TooltipContent,
|
|
9
|
-
TooltipTrigger,
|
|
10
|
-
} from '#components/ui/tooltip';
|
|
11
|
-
import { cn } from 'cn';
|
|
12
|
-
|
|
13
|
-
import type { InboxBadge } from './inbox-badge.js';
|
|
14
|
-
|
|
15
|
-
/** Other words for the button, in place of its `inboxButton.*` translations. */
|
|
16
|
-
export interface InboxButtonLabels {
|
|
17
|
-
readonly title?: string;
|
|
18
|
-
readonly pending?: (count: number) => string;
|
|
19
|
-
readonly unread?: (count: number) => string;
|
|
20
|
-
readonly pendingHint?: (count: number) => string;
|
|
21
|
-
readonly unreadHint?: (count: number) => string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface InboxButtonProps {
|
|
25
|
-
/** Where the inbox is. */
|
|
26
|
-
readonly to?: string;
|
|
27
|
-
/** What the badge shows (`inboxBadge`); none when null. */
|
|
28
|
-
readonly badge: InboxBadge;
|
|
29
|
-
/** Marks the button current; on `to` and below it when left out. */
|
|
30
|
-
readonly active?: boolean;
|
|
31
|
-
readonly labels?: InboxButtonLabels;
|
|
32
|
-
/** The prefix of its test ids: `<prefix>-button` and `<prefix>-badge`. */
|
|
33
|
-
readonly idPrefix?: string;
|
|
34
|
-
readonly className?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* A header button linking to the inbox, with a badge: amber with the count of what waits on the viewer, or in the
|
|
39
|
-
* primary color with the unread count when nothing waits. The label and the tooltip say which count it is.
|
|
40
|
-
*/
|
|
41
|
-
export function InboxButton({
|
|
42
|
-
to = '/inbox',
|
|
43
|
-
badge,
|
|
44
|
-
active,
|
|
45
|
-
labels,
|
|
46
|
-
idPrefix = 'nocobase-inbox',
|
|
47
|
-
className,
|
|
48
|
-
}: InboxButtonProps): ReactElement {
|
|
49
|
-
const { t } = useTranslation();
|
|
50
|
-
const { pathname } = useLocation();
|
|
51
|
-
const current = active ?? (pathname === to || pathname.startsWith(`${to}/`));
|
|
52
|
-
const title =
|
|
53
|
-
labels?.title ?? t('inboxButton.title', { defaultValue: 'Inbox' });
|
|
54
|
-
const label = !badge
|
|
55
|
-
? title
|
|
56
|
-
: badge.kind === 'decisions'
|
|
57
|
-
? (labels?.pending?.(badge.count) ??
|
|
58
|
-
t('inboxButton.pending', {
|
|
59
|
-
count: badge.count,
|
|
60
|
-
defaultValue: 'Inbox, {{count}} waiting',
|
|
61
|
-
}))
|
|
62
|
-
: (labels?.unread?.(badge.count) ??
|
|
63
|
-
t('inboxButton.unread', {
|
|
64
|
-
count: badge.count,
|
|
65
|
-
defaultValue: 'Inbox, {{count}} unread',
|
|
66
|
-
}));
|
|
67
|
-
|
|
68
|
-
return (
|
|
69
|
-
<Tooltip>
|
|
70
|
-
<TooltipTrigger
|
|
71
|
-
render={
|
|
72
|
-
<Link
|
|
73
|
-
to={to}
|
|
74
|
-
aria-current={current ? 'page' : undefined}
|
|
75
|
-
className={cn(
|
|
76
|
-
'relative inline-flex size-9 shrink-0 items-center justify-center rounded-lg border transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
|
|
77
|
-
// On the inbox itself: filled and outlined in the text color, apart from the plain buttons beside it.
|
|
78
|
-
current
|
|
79
|
-
? 'border-foreground/40 bg-accent text-foreground hover:bg-accent'
|
|
80
|
-
: 'border-border/70 bg-background/60 text-foreground hover:bg-muted',
|
|
81
|
-
className,
|
|
82
|
-
)}
|
|
83
|
-
data-testid={`${idPrefix}-button`}
|
|
84
|
-
/>
|
|
85
|
-
}
|
|
86
|
-
aria-label={label}
|
|
87
|
-
>
|
|
88
|
-
<InboxIcon className='size-4.5' aria-hidden='true' />
|
|
89
|
-
{/* Amber means "needs you"; the primary color (the brand's, in a themed application) only that something is
|
|
90
|
-
unread. Ringed in the header's color so it stays apart from the border. */}
|
|
91
|
-
{badge ? (
|
|
92
|
-
<span
|
|
93
|
-
className={cn(
|
|
94
|
-
// Anchored to the corner and pushed a third of its size out, whatever its width.
|
|
95
|
-
'absolute top-0 right-0 flex h-4.5 min-w-4.5 translate-x-1/3 -translate-y-1/3 items-center justify-center rounded-full px-1 leading-none font-semibold tabular-nums ring-2 ring-background',
|
|
96
|
-
// "99+" in smaller type, so the badge stays a pill rather than covering the icon.
|
|
97
|
-
badge.text.length > 2 ? 'text-[9px]' : 'text-[11px]',
|
|
98
|
-
badge.kind === 'decisions'
|
|
99
|
-
? 'bg-amber-500 text-white'
|
|
100
|
-
: 'bg-primary text-primary-foreground',
|
|
101
|
-
)}
|
|
102
|
-
data-testid={`${idPrefix}-badge`}
|
|
103
|
-
data-kind={badge.kind}
|
|
104
|
-
aria-hidden='true'
|
|
105
|
-
>
|
|
106
|
-
{badge.text}
|
|
107
|
-
</span>
|
|
108
|
-
) : null}
|
|
109
|
-
</TooltipTrigger>
|
|
110
|
-
<TooltipContent side='bottom' className='max-w-64 flex-col items-start'>
|
|
111
|
-
<span>{title}</span>
|
|
112
|
-
{badge ? (
|
|
113
|
-
<span className='opacity-80'>
|
|
114
|
-
{badge.kind === 'decisions'
|
|
115
|
-
? (labels?.pendingHint?.(badge.count) ??
|
|
116
|
-
t('inboxButton.pendingHint', {
|
|
117
|
-
count: badge.count,
|
|
118
|
-
defaultValue:
|
|
119
|
-
'{{count}} waiting for you; it goes down once they are handled.',
|
|
120
|
-
}))
|
|
121
|
-
: (labels?.unreadHint?.(badge.count) ??
|
|
122
|
-
t('inboxButton.unreadHint', {
|
|
123
|
-
count: badge.count,
|
|
124
|
-
defaultValue:
|
|
125
|
-
'{{count}} unread; it goes down as you read them.',
|
|
126
|
-
}))}
|
|
127
|
-
</span>
|
|
128
|
-
) : null}
|
|
129
|
-
</TooltipContent>
|
|
130
|
-
</Tooltip>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
2
|
-
import {
|
|
3
|
-
useInboxRefresh,
|
|
4
|
-
useInboxUnreadCount,
|
|
5
|
-
} from '@nocobase/app-plugin-notification-in-app/client/inbox';
|
|
6
|
-
import type { ReactElement } from 'react';
|
|
7
|
-
|
|
8
|
-
import { inboxBadge, useDocumentTitleBadge } from '#components/inbox-badge';
|
|
9
|
-
import { InboxButton } from '#components/inbox-button';
|
|
10
|
-
|
|
11
|
-
/** The header's link to `/inbox` with the unread count, for a signed-in person only. */
|
|
12
|
-
export function InboxHeaderButton(): ReactElement | null {
|
|
13
|
-
const { session, isPending } = useAuthentication();
|
|
14
|
-
if (isPending || !session?.user) return null;
|
|
15
|
-
return <SignedInInboxButton key={session.user.id} />;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function SignedInInboxButton(): ReactElement {
|
|
19
|
-
useInboxRefresh();
|
|
20
|
-
const unread = useInboxUnreadCount();
|
|
21
|
-
// Nothing in this application asks the viewer to decide, so the badge counts what is unread.
|
|
22
|
-
const badge = inboxBadge(0, unread.data ?? 0);
|
|
23
|
-
useDocumentTitleBadge(badge?.text ?? null);
|
|
24
|
-
return <InboxButton badge={badge} />;
|
|
25
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
-
import { ArrowLeft } from 'lucide-react';
|
|
3
|
-
import { Link } from 'react-router';
|
|
4
|
-
import type { ReactElement } from 'react';
|
|
5
|
-
export interface SurfaceCopy {
|
|
6
|
-
/** Labels the navigation landmark and the loading state, such as `Settings` or `Dev tools`. */
|
|
7
|
-
readonly title: string;
|
|
8
|
-
/** The path this surface mounts at, used to strip the prefix from nested route paths. */
|
|
9
|
-
readonly pathPrefix: string;
|
|
10
|
-
readonly emptyTitle: string;
|
|
11
|
-
readonly emptyDescription: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function SurfaceEmpty({
|
|
15
|
-
copy,
|
|
16
|
-
}: {
|
|
17
|
-
readonly copy: SurfaceCopy;
|
|
18
|
-
}): ReactElement {
|
|
19
|
-
const { t } = useTranslation();
|
|
20
|
-
return (
|
|
21
|
-
<main className='grid min-h-svh place-items-center px-6'>
|
|
22
|
-
<section className='w-full max-w-lg space-y-3 text-center'>
|
|
23
|
-
<h1 className='text-xl font-semibold'>{copy.emptyTitle}</h1>
|
|
24
|
-
<p className='text-sm text-muted-foreground'>{copy.emptyDescription}</p>
|
|
25
|
-
<Link
|
|
26
|
-
className='inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline'
|
|
27
|
-
to='/'
|
|
28
|
-
>
|
|
29
|
-
<ArrowLeft className='size-4' />
|
|
30
|
-
{t('surface.backToApp', { defaultValue: 'Back to app' })}
|
|
31
|
-
</Link>
|
|
32
|
-
</section>
|
|
33
|
-
</main>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
-
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
3
|
-
import { ArrowLeft } from 'lucide-react';
|
|
4
|
-
import { useMemo, type ReactElement } from 'react';
|
|
5
|
-
import { Link, Navigate, Routes, useLocation, useNavigate } from 'react-router';
|
|
6
|
-
|
|
7
|
-
import { PageBreadcrumbProvider } from '@nocobase/app-client';
|
|
8
|
-
|
|
9
|
-
import { Breadcrumbs } from '#components/breadcrumbs';
|
|
10
|
-
import { Loading } from '#components/loading';
|
|
11
|
-
import { EMPTY_ARRAY } from '#lib/constants';
|
|
12
|
-
|
|
13
|
-
import { renderRouteTree } from '../routing/route-tree.js';
|
|
14
|
-
import { RouteTreeProvider } from '../routing/route-context.js';
|
|
15
|
-
import {
|
|
16
|
-
routeKey,
|
|
17
|
-
matchRouteTree,
|
|
18
|
-
navigationPages,
|
|
19
|
-
selectedNavigationId,
|
|
20
|
-
useRouteNavigation,
|
|
21
|
-
} from '../routing/route-navigation.js';
|
|
22
|
-
import { NavigationMenu } from './components/navigation-menu.js';
|
|
23
|
-
import { LayoutHeader } from './components/layout-header.js';
|
|
24
|
-
import {
|
|
25
|
-
AppSidebar,
|
|
26
|
-
AppSidebarProvider,
|
|
27
|
-
AppSidebarToggle,
|
|
28
|
-
} from './components/app-sidebar.js';
|
|
29
|
-
import { SurfaceEmpty, type SurfaceCopy } from './components/surface-empty.js';
|
|
30
|
-
import { HeaderActions } from './components/header-actions.js';
|
|
31
|
-
|
|
32
|
-
export interface SettingsLayoutProps {
|
|
33
|
-
readonly routeTree: readonly AppClientRegisteredRoute[];
|
|
34
|
-
readonly routes?: readonly AppClientRegisteredRoute[];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function SettingsLayout({
|
|
38
|
-
routeTree,
|
|
39
|
-
routes = EMPTY_ARRAY,
|
|
40
|
-
}: SettingsLayoutProps): ReactElement {
|
|
41
|
-
const location = useLocation();
|
|
42
|
-
const navigate = useNavigate();
|
|
43
|
-
const { t } = useTranslation();
|
|
44
|
-
const copy: SurfaceCopy = {
|
|
45
|
-
pathPrefix: '/settings',
|
|
46
|
-
title: t('settings.title', { defaultValue: 'Settings' }),
|
|
47
|
-
emptyTitle: t('settings.emptyTitle', {
|
|
48
|
-
defaultValue: 'No settings available',
|
|
49
|
-
}),
|
|
50
|
-
emptyDescription: t('settings.emptyDescription', {
|
|
51
|
-
defaultValue:
|
|
52
|
-
'No enabled plugin contributes a settings page you have access to.',
|
|
53
|
-
}),
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const { items: navEntries, loading, denied } = useRouteNavigation(routeTree);
|
|
57
|
-
const selectedKey = selectedNavigationId(
|
|
58
|
-
routeTree,
|
|
59
|
-
location.pathname,
|
|
60
|
-
denied,
|
|
61
|
-
);
|
|
62
|
-
const visible = navigationPages(navEntries);
|
|
63
|
-
// The match below and every consumer of the route tree key off this, so it keeps its identity.
|
|
64
|
-
const allRoutes = useMemo(
|
|
65
|
-
() => [...routeTree, ...routes],
|
|
66
|
-
[routeTree, routes],
|
|
67
|
-
);
|
|
68
|
-
const matches = matchRouteTree(allRoutes, location.pathname);
|
|
69
|
-
if (loading)
|
|
70
|
-
return (
|
|
71
|
-
<Loading
|
|
72
|
-
className='min-h-svh'
|
|
73
|
-
label={t('surface.loading', {
|
|
74
|
-
title: copy.title,
|
|
75
|
-
defaultValue: `Loading ${copy.title}`,
|
|
76
|
-
})}
|
|
77
|
-
/>
|
|
78
|
-
);
|
|
79
|
-
if (
|
|
80
|
-
matches
|
|
81
|
-
?.filter(({ route }) => route.componentLoader)
|
|
82
|
-
.slice(0, 1)
|
|
83
|
-
.some(({ route }) => denied.has(routeKey(route))) ||
|
|
84
|
-
!matches?.some(({ route }) => route.componentLoader)
|
|
85
|
-
) {
|
|
86
|
-
return visible[0] ? (
|
|
87
|
-
<Navigate to={visible[0].path} replace />
|
|
88
|
-
) : (
|
|
89
|
-
<SurfaceEmpty copy={copy} />
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return (
|
|
94
|
-
// The header's trail reads the same route tree as the pages, and the trail a page declares.
|
|
95
|
-
<RouteTreeProvider routes={allRoutes}>
|
|
96
|
-
<PageBreadcrumbProvider>
|
|
97
|
-
<AppSidebarProvider>
|
|
98
|
-
<AppSidebar
|
|
99
|
-
label={t('surface.navigation', {
|
|
100
|
-
title: copy.title,
|
|
101
|
-
defaultValue: `${copy.title} navigation`,
|
|
102
|
-
})}
|
|
103
|
-
>
|
|
104
|
-
<NavigationMenu
|
|
105
|
-
items={navEntries}
|
|
106
|
-
label={copy.title}
|
|
107
|
-
selectedKey={selectedKey}
|
|
108
|
-
/>
|
|
109
|
-
</AppSidebar>
|
|
110
|
-
<div className='flex min-w-0 flex-1 flex-col'>
|
|
111
|
-
<LayoutHeader className='sticky top-0 z-40 justify-between'>
|
|
112
|
-
<div className='flex min-w-0 items-center gap-3'>
|
|
113
|
-
<AppSidebarToggle />
|
|
114
|
-
<div className='hidden h-5 w-px bg-border md:block' />
|
|
115
|
-
<Link
|
|
116
|
-
className='inline-flex min-w-0 items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground'
|
|
117
|
-
to='/'
|
|
118
|
-
>
|
|
119
|
-
<ArrowLeft className='size-4 shrink-0' />
|
|
120
|
-
<span className='truncate'>
|
|
121
|
-
{t('surface.backToApp', { defaultValue: 'Back to app' })}
|
|
122
|
-
</span>
|
|
123
|
-
</Link>
|
|
124
|
-
<div className='hidden h-5 w-px shrink-0 bg-border md:block' />
|
|
125
|
-
<Breadcrumbs className='hidden md:block' denied={denied} />
|
|
126
|
-
</div>
|
|
127
|
-
<HeaderActions
|
|
128
|
-
showSettings={navigationPages(navEntries).length > 0}
|
|
129
|
-
/>
|
|
130
|
-
</LayoutHeader>
|
|
131
|
-
<main className='relative min-w-0 flex-1 overflow-hidden'>
|
|
132
|
-
{/* main only positions; the page scrolls in here, so a child page layer laid over main is neither
|
|
133
|
-
moved by the page's scrolling nor stretched by its height. */}
|
|
134
|
-
<div className='h-full overflow-y-auto'>
|
|
135
|
-
<label className='sr-only' htmlFor='surface-page'>
|
|
136
|
-
{t('surface.page', {
|
|
137
|
-
title: copy.title,
|
|
138
|
-
defaultValue: `${copy.title} page`,
|
|
139
|
-
})}
|
|
140
|
-
</label>
|
|
141
|
-
<select
|
|
142
|
-
id='surface-page'
|
|
143
|
-
className='m-3 h-9 w-[calc(100%-1.5rem)] min-w-0 rounded-xl border border-border/70 bg-background px-3 text-sm md:hidden'
|
|
144
|
-
value={
|
|
145
|
-
visible.find((route) => routeKey(route) === selectedKey)
|
|
146
|
-
?.path ?? ''
|
|
147
|
-
}
|
|
148
|
-
onChange={(event) => {
|
|
149
|
-
void navigate(event.target.value);
|
|
150
|
-
}}
|
|
151
|
-
>
|
|
152
|
-
{visible.map((route) => (
|
|
153
|
-
<option key={routeKey(route)} value={route.path}>
|
|
154
|
-
{t(route.navigation!.title, {
|
|
155
|
-
ns: route.packageName,
|
|
156
|
-
defaultValue: route.navigation!.title,
|
|
157
|
-
})}
|
|
158
|
-
</option>
|
|
159
|
-
))}
|
|
160
|
-
</select>
|
|
161
|
-
<Routes>
|
|
162
|
-
{renderRouteTree(routeTree, copy.pathPrefix)}
|
|
163
|
-
{renderRouteTree(routes, copy.pathPrefix)}
|
|
164
|
-
</Routes>
|
|
165
|
-
</div>
|
|
166
|
-
</main>
|
|
167
|
-
</div>
|
|
168
|
-
</AppSidebarProvider>
|
|
169
|
-
</PageBreadcrumbProvider>
|
|
170
|
-
</RouteTreeProvider>
|
|
171
|
-
);
|
|
172
|
-
}
|