@nocobase/app-template-default 1.0.0-beta.80 → 1.0.0-beta.82

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 (48) hide show
  1. package/.agents/rules/shell.md +2 -0
  2. package/.agents/rules/workspace.md +9 -0
  3. package/.prettierignore +3 -0
  4. package/AGENTS.md +2 -1
  5. package/README.MD +4 -0
  6. package/client/components/attachment-list.tsx +589 -0
  7. package/client/components/back-button.tsx +15 -4
  8. package/client/components/breadcrumbs.tsx +12 -1
  9. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +68 -71
  10. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +26 -12
  11. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +6 -6
  12. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +3 -3
  13. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +19 -19
  14. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +27 -19
  15. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +12 -8
  16. package/client/extensions/nocobase-file-component-ui/index.ts +6 -6
  17. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +14 -11
  18. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +49 -0
  19. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +47 -0
  20. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +48 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +49 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +46 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +51 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +49 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +47 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +43 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +43 -0
  28. package/client/extensions/nocobase-file-component-ui/types.ts +3 -1
  29. package/client/locales/en-US.ts +2 -0
  30. package/client/locales/system/settings-en-US.ts +3 -0
  31. package/client/locales/system/settings-zh-CN.ts +2 -0
  32. package/client/locales/zh-CN.ts +2 -0
  33. package/client/pages/settings/role-form.tsx +5 -1
  34. package/client/pages/settings/user-form.tsx +13 -1
  35. package/client/pages/settings/users.tsx +1 -1
  36. package/client/routing/route-context.tsx +33 -1
  37. package/eslint.config.js +2 -0
  38. package/package.json +23 -21
  39. package/scripts/check-packages.mjs +46 -0
  40. package/tailwind.config.mjs +3 -9
  41. package/tests/client/list-return-state.test.tsx +202 -0
  42. package/tests/client/role-return.test.tsx +104 -0
  43. package/tests/client/user-drawer.test.tsx +48 -0
  44. package/tests/project/internal-packages.test.ts +98 -0
  45. package/tests/project/tailwind-sources.test.ts +2 -2
  46. package/tests/server/authorization-assignment-safety.test.ts +10 -9
  47. package/tests/server/member-role.test.ts +299 -0
  48. package/client/extensions/nocobase-file-component-ui/README.md +0 -13
@@ -0,0 +1,202 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import {
3
+ TestI18nProvider,
4
+ createTestI18nRuntime,
5
+ } from '@nocobase/i18n/testing';
6
+ import { act, render, screen } from '@testing-library/react';
7
+ import userEvent from '@testing-library/user-event';
8
+ import {
9
+ createMemoryRouter,
10
+ Link,
11
+ Outlet,
12
+ RouterProvider,
13
+ useLocation,
14
+ useSearchParams,
15
+ } from 'react-router';
16
+ import { describe, expect, it } from 'vitest';
17
+ import { Breadcrumbs } from '../../client/components/breadcrumbs.js';
18
+ import { BackButton } from '../../client/components/back-button.js';
19
+ import { RouteTreeProvider } from '../../client/routing/route-context.js';
20
+ import enUS from '../../client/locales/en-US.js';
21
+
22
+ const runtime = await createTestI18nRuntime({
23
+ application: { namespace: '@nocobase/navigation-test', resources: enUS },
24
+ strict: false,
25
+ });
26
+ const route = (
27
+ path: string,
28
+ title: string,
29
+ children: AppClientRegisteredRoute[] = [],
30
+ ): AppClientRegisteredRoute => ({
31
+ id: path,
32
+ name: path,
33
+ path,
34
+ auth: 'required',
35
+ packageName: 'test',
36
+ source: 'application',
37
+ breadcrumb: { title },
38
+ componentLoader: async () => ({ default: () => null }),
39
+ children,
40
+ });
41
+ const routes = [
42
+ route('/orders', 'Orders', [
43
+ route('/orders/:id', 'Order', [route('/orders/:id/lines', 'Lines')]),
44
+ ]),
45
+ route('/other', 'Other', [route('/other/:id', 'Other detail')]),
46
+ ];
47
+ const initial = '/orders?q=Acme&status=open&page=2';
48
+ function List() {
49
+ const [query, setQuery] = useSearchParams();
50
+ const { pathname } = useLocation();
51
+ return (
52
+ <>
53
+ <input
54
+ aria-label='Search'
55
+ value={query.get('q') ?? ''}
56
+ onChange={(event) => {
57
+ const next = new URLSearchParams(query);
58
+ next.set('q', event.target.value);
59
+ next.delete('page');
60
+ setQuery(next);
61
+ }}
62
+ />
63
+ <output data-testid='page'>{query.get('page') ?? '1'}</output>
64
+ <output data-testid='record'>
65
+ {query.get('q') || 'All'}-{query.get('page') ?? '1'}
66
+ </output>
67
+ <Link to={`${pathname}/42?detailPage=7&q=child`}>Open detail</Link>
68
+ <button onClick={() => setQuery({})}>Clear</button>
69
+ </>
70
+ );
71
+ }
72
+ function Detail() {
73
+ return (
74
+ <>
75
+ <BackButton />
76
+ <Link to='lines?linePage=9'>Open lines</Link>
77
+ </>
78
+ );
79
+ }
80
+ function setup(url = initial) {
81
+ const router = createMemoryRouter(
82
+ [
83
+ {
84
+ element: (
85
+ <TestI18nProvider runtime={runtime}>
86
+ <RouteTreeProvider routes={routes}>
87
+ <Breadcrumbs />
88
+ <Outlet />
89
+ </RouteTreeProvider>
90
+ </TestI18nProvider>
91
+ ),
92
+ children: [
93
+ {
94
+ path: '/orders',
95
+ children: [
96
+ { index: true, element: <List /> },
97
+ {
98
+ path: ':id',
99
+ children: [
100
+ { index: true, element: <Detail /> },
101
+ { path: 'lines', element: <h1>Lines</h1> },
102
+ ],
103
+ },
104
+ ],
105
+ },
106
+ {
107
+ path: '/other',
108
+ children: [
109
+ { index: true, element: <List /> },
110
+ { path: ':id', element: <Detail /> },
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ { initialEntries: [url] },
117
+ );
118
+ render(<RouterProvider router={router} />);
119
+ return router;
120
+ }
121
+
122
+ describe('returning to a list', () => {
123
+ it('restores the actual list query through breadcrumbs across repeated visits', async () => {
124
+ const router = setup();
125
+ for (let i = 0; i < 3; i++) {
126
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
127
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
128
+ 'href',
129
+ initial,
130
+ );
131
+ await userEvent.click(screen.getByRole('link', { name: 'Orders' }));
132
+ expect(router.state.location.search).toBe('?q=Acme&status=open&page=2');
133
+ expect(screen.getByRole('textbox', { name: 'Search' })).toHaveValue(
134
+ 'Acme',
135
+ );
136
+ expect(screen.getByTestId('page')).toHaveTextContent('2');
137
+ expect(screen.getByTestId('record')).toHaveTextContent('Acme-2');
138
+ }
139
+ });
140
+ it('restores list state through browser back and the standard BackButton', async () => {
141
+ const router = setup();
142
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
143
+ await act(() => router.navigate(-1));
144
+ expect(screen.getByTestId('record')).toHaveTextContent('Acme-2');
145
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
146
+ await userEvent.click(screen.getByRole('link', { name: 'Back' }));
147
+ expect(router.state.location.pathname).toBe('/orders');
148
+ expect(screen.getByTestId('record')).toHaveTextContent('Acme-2');
149
+ });
150
+ it('keeps each visited ancestor separate from a nested list query', async () => {
151
+ setup();
152
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
153
+ await userEvent.click(screen.getByRole('link', { name: 'Open lines' }));
154
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
155
+ 'href',
156
+ initial,
157
+ );
158
+ // Middle levels appear in both the desktop trail and the collapsed menu.
159
+ expect(screen.getByRole('link', { name: 'Order' })).toHaveAttribute(
160
+ 'href',
161
+ '/orders/42?detailPage=7&q=child',
162
+ );
163
+ });
164
+ it('does not revive cleared filters or leak one list into another', async () => {
165
+ const router = setup();
166
+ await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
167
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
168
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
169
+ 'href',
170
+ '/orders',
171
+ );
172
+ await act(() => router.navigate('/other?q=Beta&page=3'));
173
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
174
+ expect(screen.getByRole('link', { name: 'Other' })).toHaveAttribute(
175
+ 'href',
176
+ '/other?q=Beta&page=3',
177
+ );
178
+ await act(() => router.navigate('/orders/42?detailPage=8'));
179
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
180
+ 'href',
181
+ '/orders',
182
+ );
183
+ });
184
+ it('does not apply a deep-linked detail query to an unvisited ancestor', () => {
185
+ setup('/orders/42?detailPage=7');
186
+ expect(screen.getByRole('link', { name: 'Orders' })).toHaveAttribute(
187
+ 'href',
188
+ '/orders',
189
+ );
190
+ });
191
+ it('uses the latest search after changing conditions and resetting the page', async () => {
192
+ setup();
193
+ await userEvent.clear(screen.getByRole('textbox', { name: 'Search' }));
194
+ await userEvent.type(
195
+ screen.getByRole('textbox', { name: 'Search' }),
196
+ 'Beta',
197
+ );
198
+ await userEvent.click(screen.getByRole('link', { name: 'Open detail' }));
199
+ await userEvent.click(screen.getByRole('link', { name: 'Orders' }));
200
+ expect(screen.getByTestId('record')).toHaveTextContent('Beta-1');
201
+ });
202
+ });
@@ -0,0 +1,104 @@
1
+ import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
2
+ import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
+ import {
4
+ AuthorizationClient,
5
+ authorizationClientToken,
6
+ } from '@nocobase/app-plugin-authorization/client';
7
+ import { screen, waitFor } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+ import { Outlet, Routes, Route, useLocation } from 'react-router';
10
+ import { expect, it, vi } from 'vitest';
11
+ import RoleForm from '../../client/pages/settings/role-form.js';
12
+ import enUS from '../../client/locales/en-US.js';
13
+ import zhCN from '../../client/locales/zh-CN.js';
14
+
15
+ const namespace = '@nocobase/role-return-test';
16
+ function Parent() {
17
+ return <Outlet context={{ reload: () => undefined }} />;
18
+ }
19
+ function Location() {
20
+ const { pathname, search } = useLocation();
21
+ return (
22
+ <output data-testid='location'>
23
+ {pathname}
24
+ {search}
25
+ </output>
26
+ );
27
+ }
28
+ it.each(
29
+ ['/roles', '/administration/roles'].flatMap((parent) =>
30
+ ['back', 'cancel', 'save'].map((action) => ({ parent, action })),
31
+ ),
32
+ )('returns to $parent on $action', async ({ parent, action }) => {
33
+ const calls = vi.fn(({ path }: { path: string }) => {
34
+ if (path === 'authorization/permissions')
35
+ return { data: { unrestricted: true, permissions: [] } };
36
+ if (path.endsWith('/options'))
37
+ return {
38
+ data: {
39
+ sections: [],
40
+ recordAccess: [],
41
+ subjectTypes: [],
42
+ collections: [],
43
+ },
44
+ };
45
+ return {
46
+ data: { key: 'sales', title: 'Sales', grants: [], revision: 'one' },
47
+ };
48
+ });
49
+ const result = await renderWithApp(
50
+ <>
51
+ <Routes>
52
+ <Route path={parent} element={<Parent />}>
53
+ <Route index element={<h1>Role list</h1>} />
54
+ <Route path=':key/edit' element={<RoleForm />} />
55
+ </Route>
56
+ </Routes>
57
+ <Location />
58
+ </>,
59
+ {
60
+ route: `${parent}/sales/edit?q=Sales&page=2`,
61
+ namespace,
62
+ namespaces: {
63
+ [namespace]: {
64
+ 'en-US': async () => ({ default: enUS }),
65
+ 'zh-CN': async () => ({ default: zhCN }),
66
+ },
67
+ },
68
+ services: (app: ClientApplication) =>
69
+ app.container.instance(
70
+ authorizationClientToken,
71
+ new AuthorizationClient(app.container.resolve(apiClientToken)),
72
+ ),
73
+ fetch: answerApi(calls),
74
+ },
75
+ );
76
+ expect(
77
+ await screen.findByRole('link', { name: enUS.settings.roles }),
78
+ ).toHaveAttribute('href', `${parent}?q=Sales&page=2`);
79
+ if (action === 'back') {
80
+ await userEvent.click(
81
+ screen.getByRole('link', { name: enUS.settings.roles }),
82
+ );
83
+ } else if (action === 'save') {
84
+ const name = await screen.findByRole('textbox', { name: /Role name/ });
85
+ await userEvent.type(name, ' updated');
86
+ await userEvent.click(
87
+ screen.getByRole('button', { name: 'Save', exact: true }),
88
+ );
89
+ await waitFor(() =>
90
+ expect(calls).toHaveBeenCalledWith(
91
+ expect.objectContaining({ method: 'PATCH' }),
92
+ ),
93
+ );
94
+ } else {
95
+ await userEvent.click(
96
+ await screen.findByRole('button', { name: 'Cancel' }),
97
+ );
98
+ }
99
+ await screen.findByRole('heading', { name: 'Role list' });
100
+ expect(screen.getByTestId('location')).toHaveTextContent(
101
+ `${parent}?q=Sales&page=2`,
102
+ );
103
+ result.unmount();
104
+ });
@@ -68,6 +68,7 @@ async function renderUsers(
68
68
  actions: string[] = [],
69
69
  initialRoles: Record<string, string[]> = {},
70
70
  requiredRole = false,
71
+ defaultRole = false,
71
72
  ) {
72
73
  let current = { ...original, roleScopes: initialRoles };
73
74
  let roleFailure = false;
@@ -105,7 +106,9 @@ async function renderUsers(
105
106
  label: 'Application',
106
107
  selection: 'multiple',
107
108
  requiredOnCreate: requiredRole,
109
+ ...(defaultRole ? { defaultValue: ['member'] } : {}),
108
110
  options: [
111
+ { value: 'member', label: 'Member' },
109
112
  { value: 'sales', label: 'Sales team' },
110
113
  { value: 'finance', label: 'Finance team' },
111
114
  {
@@ -535,3 +538,48 @@ it('focuses and describes the required role dropdown when creating without a rol
535
538
  view.calls.mock.calls.filter(([call]) => call.method === 'POST'),
536
539
  ).toHaveLength(0);
537
540
  });
541
+
542
+ it('uses Member only as a creation default and respects explicitly cleared selection', async () => {
543
+ const user = userEvent.setup();
544
+ const view = await renderUsers(
545
+ '/users',
546
+ '/create',
547
+ true,
548
+ true,
549
+ [],
550
+ {},
551
+ false,
552
+ true,
553
+ );
554
+ const roles = await screen.findByRole('combobox', {
555
+ name: 'Application',
556
+ exact: true,
557
+ });
558
+ expect(roles).toHaveTextContent('Member');
559
+ await user.click(roles);
560
+ await user.click(
561
+ await screen.findByRole('option', { name: 'Member', exact: true }),
562
+ );
563
+ await user.keyboard('{Escape}');
564
+ expect(roles).not.toHaveTextContent('Member');
565
+ expect(screen.getByText(enUS.settings.noRoleAccess)).toBeVisible();
566
+ await user.type(screen.getByRole('textbox', { name: 'Name *' }), 'Roleless');
567
+ await user.type(
568
+ screen.getByRole('textbox', { name: 'Email *' }),
569
+ 'roleless@example.test',
570
+ );
571
+ await user.type(
572
+ screen.getByLabelText(`${enUS.settings.password} *`),
573
+ 'Roleless-test-123!',
574
+ );
575
+ await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
576
+ await waitFor(() =>
577
+ expect(view.calls).toHaveBeenCalledWith(
578
+ expect.objectContaining({
579
+ method: 'POST',
580
+ path: 'users',
581
+ json: expect.objectContaining({ roleScopes: { app: [] } }),
582
+ }),
583
+ ),
584
+ );
585
+ });
@@ -0,0 +1,98 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { expect, it } from 'vitest';
14
+
15
+ import packageMetadata from '../../package.json' with { type: 'json' };
16
+
17
+ it('checks every internal package without selecting the root application and propagates a failed check', () => {
18
+ const root = mkdtempSync(path.join(os.tmpdir(), 'app-internal-checks-'));
19
+ try {
20
+ cpSync(
21
+ path.resolve(import.meta.dirname, '../../scripts'),
22
+ path.join(root, 'scripts'),
23
+ { recursive: true },
24
+ );
25
+ writeFileSync(
26
+ path.join(root, 'pnpm-workspace.yaml'),
27
+ "packages: ['packages/*']\nverifyDepsBeforeRun: false\n",
28
+ );
29
+ writeFileSync(
30
+ path.join(root, 'package.json'),
31
+ JSON.stringify({
32
+ name: 'fixture-app',
33
+ private: true,
34
+ scripts: {
35
+ 'check:packages': packageMetadata.scripts['check:packages'],
36
+ check: 'node -e "process.exit(99)"',
37
+ },
38
+ }),
39
+ );
40
+ for (const name of ['first', 'second']) {
41
+ const directory = path.join(root, 'packages', name);
42
+ mkdirSync(directory, { recursive: true });
43
+ writeFileSync(
44
+ path.join(directory, 'package.json'),
45
+ JSON.stringify({
46
+ name: `fixture-${name}`,
47
+ private: true,
48
+ scripts: { check: 'node check.cjs' },
49
+ }),
50
+ );
51
+ writeFileSync(
52
+ path.join(directory, 'check.cjs'),
53
+ "require('node:fs').writeFileSync('checked', 'yes');\n",
54
+ );
55
+ }
56
+ const run = () =>
57
+ spawnSync('pnpm', ['check:packages'], {
58
+ cwd: root,
59
+ encoding: 'utf8',
60
+ timeout: 30_000,
61
+ });
62
+ expect(run().status).toBe(0);
63
+ for (const name of ['first', 'second']) {
64
+ expect(
65
+ readFileSync(path.join(root, 'packages', name, 'checked'), 'utf8'),
66
+ ).toBe('yes');
67
+ }
68
+ writeFileSync(
69
+ path.join(root, 'packages/second/check.cjs'),
70
+ 'process.exit(7);\n',
71
+ );
72
+ expect(run().status).not.toBe(0);
73
+ rmSync(path.join(root, 'packages/first/checked'));
74
+ writeFileSync(
75
+ path.join(root, 'packages/second/package.json'),
76
+ JSON.stringify({ name: 'fixture-second', scripts: { check: ' ' } }),
77
+ );
78
+ const missing = run();
79
+ expect(missing.status).not.toBe(0);
80
+ expect(missing.stderr).toContain('packages/second');
81
+ expect(existsSync(path.join(root, 'packages/first/checked'))).toBe(false);
82
+ writeFileSync(
83
+ path.join(root, 'packages/second/package.json'),
84
+ JSON.stringify({ name: 'fixture-second' }),
85
+ );
86
+ expect(run().status).not.toBe(0);
87
+ // pnpm workspace exclusions apply to both the preflight and the script run.
88
+ writeFileSync(
89
+ path.join(root, 'pnpm-workspace.yaml'),
90
+ "packages: ['packages/*', '!packages/second']\nverifyDepsBeforeRun: false\n",
91
+ );
92
+ expect(run().status).toBe(0);
93
+ rmSync(path.join(root, 'packages'), { recursive: true });
94
+ expect(run().status).toBe(0);
95
+ } finally {
96
+ rmSync(root, { recursive: true, force: true });
97
+ }
98
+ }, 60_000);
@@ -101,7 +101,7 @@ describe('tailwind content sources', () => {
101
101
  }
102
102
  });
103
103
 
104
- it('scans canonical Registry source exposed by a plugin', () => {
104
+ it('ignores obsolete plugin-owned Registry recipes', () => {
105
105
  const store = mkdtempSync(path.join(tmpdir(), 'nb3-tailwind-registry-'));
106
106
  try {
107
107
  const registry = path.join(
@@ -124,7 +124,7 @@ describe('tailwind content sources', () => {
124
124
 
125
125
  const files = contentFilesIn(store);
126
126
 
127
- expect(files).toContain(path.join(realpathSync(registry), 'demo.tsx'));
127
+ expect(files).toEqual([]);
128
128
  } finally {
129
129
  rmSync(store, { recursive: true, force: true });
130
130
  }
@@ -11,14 +11,7 @@ type Assignment = {
11
11
  function unexpectedGlobalAssignments(
12
12
  assignments: readonly Assignment[],
13
13
  ): Assignment[] {
14
- return assignments.filter(
15
- (assignment) =>
16
- assignment.subjectId === '*' &&
17
- !(
18
- assignment.subjectType === 'authenticated' &&
19
- assignment.permissionSetKey === 'member'
20
- ),
21
- );
14
+ return assignments.filter((assignment) => assignment.subjectId === '*');
22
15
  }
23
16
 
24
17
  const test = createAppTest({
@@ -76,5 +69,13 @@ it('detects wildcard recipients regardless of the role name, while preserving ex
76
69
  global,
77
70
  { ...global, subjectType: 'everyone' },
78
71
  ]),
79
- ).toEqual([global, { ...global, subjectType: 'everyone' }]);
72
+ ).toEqual([
73
+ {
74
+ subjectType: 'authenticated',
75
+ subjectId: '*',
76
+ permissionSetKey: 'member',
77
+ },
78
+ global,
79
+ { ...global, subjectType: 'everyone' },
80
+ ]);
80
81
  });