@nocobase/app-template-default 1.0.0-beta.74 → 1.0.0-beta.76

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 (52) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/AGENTS.md +7 -1
  4. package/README.MD +3 -3
  5. package/cli/plugins.ts +2 -1
  6. package/client/components/data-table/data-table.tsx +2 -0
  7. package/client/components/data-table/pagination.tsx +6 -6
  8. package/client/components/flow-diagram.tsx +645 -83
  9. package/client/components/flow-layout.ts +117 -7
  10. package/client/components/lifecycle-diagram.tsx +175 -37
  11. package/client/components/record-link.tsx +20 -0
  12. package/client/components/required-role-setup.tsx +93 -0
  13. package/client/components/role-list.tsx +71 -31
  14. package/client/components/setup-readiness-state.ts +135 -0
  15. package/client/components/setup-readiness.tsx +91 -0
  16. package/client/components/ui/combobox.tsx +300 -0
  17. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  18. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  19. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  20. package/client/hooks/use-table-url-state.ts +93 -0
  21. package/client/lib/navigation-transform.ts +2 -1
  22. package/client/lib/required-role-setup.ts +98 -0
  23. package/client/lib/return-to.ts +19 -0
  24. package/client/locales/system/en-US.ts +10 -0
  25. package/client/locales/system/settings-en-US.ts +38 -1
  26. package/client/locales/system/settings-zh-CN.ts +34 -2
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +4 -1
  29. package/client/pages/settings/default-access.tsx +40 -8
  30. package/client/pages/settings/role-form.tsx +1 -1
  31. package/client/pages/settings/roles.tsx +47 -6
  32. package/client/pages/settings/shared.tsx +15 -13
  33. package/client/pages/settings/user-detail.tsx +15 -259
  34. package/client/pages/settings/user-form.tsx +299 -147
  35. package/client/pages/settings/users.tsx +211 -123
  36. package/client/plugins.ts +2 -0
  37. package/config.example.yml +6 -5
  38. package/package.json +16 -14
  39. package/server/plugins.ts +2 -0
  40. package/tests/components/default-access.test.tsx +141 -2
  41. package/tests/components/role-list.test.tsx +73 -8
  42. package/tests/components/roles.test.tsx +14 -7
  43. package/tests/components/settings-continuity.test.tsx +426 -0
  44. package/tests/components/settings-feedback.test.tsx +304 -0
  45. package/tests/components/settings.test.tsx +1 -1
  46. package/tests/components/setup-readiness.test.tsx +370 -0
  47. package/tests/components/table-url-state.test.tsx +95 -0
  48. package/tests/components/user-drawer.test.tsx +526 -0
  49. package/tests/logic/authorization-assignment-safety.test.ts +82 -0
  50. package/tests/logic/foundation-integration.test.ts +62 -0
  51. package/tests/logic/return-to.test.ts +19 -0
  52. package/tsconfig.json +1 -0
@@ -5,9 +5,9 @@ import {
5
5
  authorizationClientToken,
6
6
  type AuthorizationOptionsResponse,
7
7
  } from '@nocobase/app-plugin-authorization/client';
8
- import { screen, waitFor, within } from '@testing-library/react';
8
+ import { act, screen, waitFor, within } from '@testing-library/react';
9
9
  import userEvent from '@testing-library/user-event';
10
- import { Routes, Route } from 'react-router';
10
+ import { Routes, Route, useLocation, useNavigate } from 'react-router';
11
11
  import { expect, it, vi } from 'vitest';
12
12
  import Page from '../../client/pages/settings/default-access.js';
13
13
  import Form from '../../client/pages/settings/default-access-form.js';
@@ -552,3 +552,142 @@ it('maps a server scope validation error to the affected selector and focuses it
552
552
  expect(scope).toHaveTextContent('All records');
553
553
  expect(screen.queryByText('Private detail')).not.toBeInTheDocument();
554
554
  });
555
+
556
+ it('does not reset a title-filtered page while its metadata is still loading', async () => {
557
+ let resolveCatalog!: (value: { data: AuthorizationOptionsResponse }) => void;
558
+ const pending = new Promise<{ data: AuthorizationOptionsResponse }>(
559
+ (resolve) => {
560
+ resolveCatalog = resolve;
561
+ },
562
+ );
563
+ const resources = Array.from({ length: 25 }, (_, i) => ({
564
+ ...catalog.sections[0]!.subsections[0]!.resources[0]!,
565
+ id: `resource-${i}`,
566
+ title: `Projects ${i}`,
567
+ }));
568
+ const namedCatalog = {
569
+ ...catalog,
570
+ sections: [
571
+ {
572
+ ...catalog.sections[0]!,
573
+ subsections: [{ ...catalog.sections[0]!.subsections[0]!, resources }],
574
+ },
575
+ ],
576
+ };
577
+ const rules = resources.map((resource, i) => ({
578
+ ...existing,
579
+ key: `rule-${i}`,
580
+ resource: { type: 'composite', id: resource.id },
581
+ }));
582
+ function CurrentLocation() {
583
+ const location = useLocation();
584
+ return <output data-testid='metadata-location'>{location.search}</output>;
585
+ }
586
+ const calls = vi.fn(async ({ path }: { path: string }) =>
587
+ path === 'authorization/permissions'
588
+ ? { data: { unrestricted: true, permissions: [] } }
589
+ : path.endsWith('/options')
590
+ ? pending
591
+ : { data: rules },
592
+ );
593
+ await renderWithApp(
594
+ <>
595
+ <Page />
596
+ <CurrentLocation />
597
+ </>,
598
+ {
599
+ route: '/default-access?q=Projects&page=2&pageSize=20',
600
+ namespace,
601
+ namespaces,
602
+ services,
603
+ fetch: answerApi(calls),
604
+ },
605
+ );
606
+ await waitFor(() =>
607
+ expect(calls).toHaveBeenCalledWith(
608
+ expect.objectContaining({ path: 'authorization/defaultAccess' }),
609
+ ),
610
+ );
611
+ await act(async () => {
612
+ await new Promise((resolve) => setTimeout(resolve, 20));
613
+ });
614
+ expect(screen.getByTestId('metadata-location')).toHaveTextContent('page=2');
615
+ await act(async () => {
616
+ resolveCatalog({ data: namedCatalog });
617
+ });
618
+ expect(await screen.findByText('Page 2 of 2')).toBeVisible();
619
+ expect(screen.getByTestId('metadata-location')).toHaveTextContent('page=2');
620
+ });
621
+
622
+ it('replaces the saved editor history entry and keeps the list query when going back', async () => {
623
+ const query = '?q=Projects&pageSize=20&sort=resource&order=desc';
624
+ function History() {
625
+ const location = useLocation();
626
+ const navigate = useNavigate();
627
+ return (
628
+ <>
629
+ <output data-testid='history-location'>
630
+ {location.pathname}
631
+ {location.search}
632
+ </output>
633
+ <button onClick={() => void navigate(-1)}>Back in history</button>
634
+ </>
635
+ );
636
+ }
637
+ let patches = 0;
638
+ let rules = [existing];
639
+ await renderWithApp(
640
+ <>
641
+ {tree()}
642
+ <History />
643
+ </>,
644
+ {
645
+ route: `/default-access${query}`,
646
+ namespace,
647
+ namespaces,
648
+ services,
649
+ fetch: answerApi((request) => {
650
+ if (request.path === 'authorization/permissions')
651
+ return { data: { unrestricted: true, permissions: [] } };
652
+ if (request.path.endsWith('/options')) return { data: catalog };
653
+ if (request.method === 'PATCH') {
654
+ patches += 1;
655
+ expect(request.json).toEqual({ actions: [existing.actions[0]] });
656
+ rules = [{ ...existing, actions: [existing.actions[0]!] }];
657
+ return { data: rules[0] };
658
+ }
659
+ return { data: rules };
660
+ }),
661
+ },
662
+ );
663
+ await userEvent.click(await screen.findByRole('link', { name: 'Projects' }));
664
+ await userEvent.click(
665
+ await screen.findByRole('combobox', { name: 'Edit · Projects' }),
666
+ );
667
+ await userEvent.click(
668
+ await screen.findByRole('option', {
669
+ name: enUS.settings.defaultAccess.none,
670
+ }),
671
+ );
672
+ const save = screen.getByRole('button', { name: enUS.settings.save });
673
+ expect(save).toBeEnabled();
674
+ await userEvent.click(save);
675
+ await waitFor(() =>
676
+ expect(screen.getByTestId('history-location').textContent).toBe(
677
+ `/default-access${query}`,
678
+ ),
679
+ );
680
+ expect(patches).toBe(1);
681
+ await userEvent.click(
682
+ screen.getByRole('button', { name: 'Back in history' }),
683
+ );
684
+ await waitFor(() =>
685
+ expect(screen.getByTestId('history-location').textContent).toBe(
686
+ `/default-access${query}`,
687
+ ),
688
+ );
689
+ expect(
690
+ screen.queryByRole('button', { name: enUS.settings.save }),
691
+ ).not.toBeInTheDocument();
692
+ expect(patches).toBe(1);
693
+ });
@@ -1,9 +1,25 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import {
2
+ TestI18nProvider,
3
+ createTestI18nRuntime,
4
+ } from '@nocobase/i18n/testing';
5
+ import type { ReactElement } from 'react';
6
+ import { render as renderView, screen, within } from '@testing-library/react';
2
7
  import userEvent from '@testing-library/user-event';
3
8
  import { MemoryRouter, Route, Routes } from 'react-router';
4
9
  import { describe, expect, it } from 'vitest';
5
10
  import { RoleList } from '../../client/components/role-list.js';
6
11
 
12
+ import enUS from '../../client/locales/en-US.js';
13
+
14
+ const runtime = await createTestI18nRuntime({
15
+ application: { namespace: 'role-table-test', resources: enUS },
16
+ });
17
+ function render(view: ReactElement) {
18
+ return renderView(
19
+ <TestI18nProvider runtime={runtime}>{view}</TestI18nProvider>,
20
+ );
21
+ }
22
+
7
23
  const roles = [
8
24
  {
9
25
  key: 'member',
@@ -18,19 +34,30 @@ const roles = [
18
34
  protected: false,
19
35
  },
20
36
  ];
21
- describe('compact role list', () => {
22
- it('renders ordinary list content with full names, identifiers and translated state labels', () => {
37
+ describe('standard role table', () => {
38
+ it('renders standard table headers, full names, identifiers and translated state labels', () => {
23
39
  render(
24
40
  <MemoryRouter>
25
41
  <RoleList
26
42
  roles={roles}
27
43
  ariaLabel='角色与权限'
28
- labels={{ protected: '受保护', custom: '自定义' }}
44
+ labels={{
45
+ name: '角色名称',
46
+ key: '标识',
47
+ type: '类型',
48
+ protected: '受保护',
49
+ custom: '自定义',
50
+ }}
29
51
  />
30
52
  </MemoryRouter>,
31
53
  );
32
- expect(screen.getByRole('list', { name: '角色与权限' })).toBeVisible();
33
- expect(screen.getAllByRole('listitem')).toHaveLength(2);
54
+ expect(
55
+ within(screen.getByRole('region', { name: '角色与权限' })).getByRole(
56
+ 'table',
57
+ ),
58
+ ).toBeVisible();
59
+ expect(screen.getAllByRole('columnheader')).toHaveLength(3);
60
+ expect(screen.getAllByRole('row')).toHaveLength(3);
34
61
  expect(
35
62
  screen.queryByRole('heading', { name: 'Member' }),
36
63
  ).not.toBeInTheDocument();
@@ -51,7 +78,13 @@ describe('compact role list', () => {
51
78
  element={
52
79
  <RoleList
53
80
  roles={roles}
54
- labels={{ protected: 'Protected', custom: 'Custom' }}
81
+ labels={{
82
+ name: 'Role name',
83
+ key: 'Key',
84
+ type: 'Type',
85
+ protected: 'Protected',
86
+ custom: 'Custom',
87
+ }}
55
88
  />
56
89
  }
57
90
  />
@@ -63,9 +96,41 @@ describe('compact role list', () => {
63
96
  name: 'Member',
64
97
  });
65
98
  expect(link).toHaveAttribute('href', '/roles/member/edit?q=team');
66
- await user.tab();
99
+ for (let i = 0; i < 8 && document.activeElement !== link; i++)
100
+ await user.tab();
67
101
  expect(link).toHaveFocus();
68
102
  await user.keyboard('{Enter}');
69
103
  expect(screen.getByText('Edit member')).toBeVisible();
70
104
  });
105
+ it('uses standard pagination without losing edit links on later pages', async () => {
106
+ const user = userEvent.setup();
107
+ render(
108
+ <MemoryRouter>
109
+ <RoleList
110
+ roles={Array.from({ length: 12 }, (_, i) => ({
111
+ key: `role-${i}`,
112
+ title: `Role ${i}`,
113
+ to: `/roles/role-${i}/edit`,
114
+ protected: false,
115
+ }))}
116
+ labels={{
117
+ name: 'Role name',
118
+ key: 'Key',
119
+ type: 'Type',
120
+ protected: 'Protected',
121
+ custom: 'Custom',
122
+ }}
123
+ />
124
+ </MemoryRouter>,
125
+ );
126
+ expect(screen.getAllByRole('row')).toHaveLength(11);
127
+ expect(
128
+ screen.queryByRole('link', { name: 'Role 11', exact: true }),
129
+ ).not.toBeInTheDocument();
130
+ await user.click(screen.getByRole('button', { name: 'Go to next page' }));
131
+ expect(screen.getAllByRole('row')).toHaveLength(3);
132
+ expect(
133
+ screen.getByRole('link', { name: 'Role 11', exact: true }),
134
+ ).toHaveAttribute('href', '/roles/role-11/edit');
135
+ });
71
136
  });
@@ -4,7 +4,7 @@ import {
4
4
  AuthorizationClient,
5
5
  authorizationClientToken,
6
6
  } from '@nocobase/app-plugin-authorization/client';
7
- import { screen, waitFor } from '@testing-library/react';
7
+ import { screen, waitFor, within } from '@testing-library/react';
8
8
  import userEvent from '@testing-library/user-event';
9
9
  import { Routes, Route } from 'react-router';
10
10
  import { expect, it } from 'vitest';
@@ -24,7 +24,7 @@ const namespaces = {
24
24
  },
25
25
  };
26
26
 
27
- it('keeps the compact role list sorted, searchable and linked to the current query', async () => {
27
+ it('keeps the standard role table sorted, searchable and linked to the current query', async () => {
28
28
  const user = userEvent.setup();
29
29
  await renderWithApp(
30
30
  <Routes>
@@ -54,11 +54,15 @@ it('keeps the compact role list sorted, searchable and linked to the current que
54
54
  },
55
55
  );
56
56
  await screen.findByTitle('Customer');
57
- const list = screen.getByRole('list', { name: 'Roles and permissions' });
57
+ const region = screen.getByRole('region', {
58
+ name: 'Roles and permissions',
59
+ });
60
+ const table = within(region).getByRole('table');
61
+ expect(within(table).getAllByRole('columnheader')).toHaveLength(3);
58
62
  expect(
59
- Array.from(list.querySelectorAll('li')).map((item) =>
60
- item.querySelector('[title]')?.getAttribute('title'),
61
- ),
63
+ within(table)
64
+ .getAllByRole('link')
65
+ .map((link) => link.textContent),
62
66
  ).toEqual(['Customer', 'Member', 'NocoBase team']);
63
67
  expect(
64
68
  screen.queryByRole('heading', { name: 'Customer' }),
@@ -70,7 +74,10 @@ it('keeps the compact role list sorted, searchable and linked to the current que
70
74
  screen.getByRole('textbox', { name: 'Search role names' }),
71
75
  'Customer',
72
76
  );
73
- await waitFor(() => expect(list.querySelectorAll('li')).toHaveLength(1));
77
+ await waitFor(() => {
78
+ expect(within(table).getAllByRole('row')).toHaveLength(2);
79
+ expect(within(table).getAllByRole('link')).toHaveLength(1);
80
+ });
74
81
  expect(screen.getByTitle('Customer').closest('a')).toHaveAttribute(
75
82
  'href',
76
83
  '/settings/roles/cp-customer/edit?q=Customer',