@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.
- package/.agents/rules/build-and-deploy.md +4 -0
- package/.agents/rules/components.md +3 -1
- package/AGENTS.md +7 -1
- package/README.MD +3 -3
- package/cli/plugins.ts +2 -1
- package/client/components/data-table/data-table.tsx +2 -0
- package/client/components/data-table/pagination.tsx +6 -6
- package/client/components/flow-diagram.tsx +645 -83
- package/client/components/flow-layout.ts +117 -7
- package/client/components/lifecycle-diagram.tsx +175 -37
- package/client/components/record-link.tsx +20 -0
- package/client/components/required-role-setup.tsx +93 -0
- package/client/components/role-list.tsx +71 -31
- package/client/components/setup-readiness-state.ts +135 -0
- package/client/components/setup-readiness.tsx +91 -0
- package/client/components/ui/combobox.tsx +300 -0
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
- package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
- package/client/hooks/use-table-url-state.ts +93 -0
- package/client/lib/navigation-transform.ts +2 -1
- package/client/lib/required-role-setup.ts +98 -0
- package/client/lib/return-to.ts +19 -0
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +38 -1
- package/client/locales/system/settings-zh-CN.ts +34 -2
- package/client/locales/system/zh-CN.ts +10 -0
- package/client/pages/settings/default-access-form.tsx +4 -1
- package/client/pages/settings/default-access.tsx +40 -8
- package/client/pages/settings/role-form.tsx +1 -1
- package/client/pages/settings/roles.tsx +47 -6
- package/client/pages/settings/shared.tsx +15 -13
- package/client/pages/settings/user-detail.tsx +15 -259
- package/client/pages/settings/user-form.tsx +299 -147
- package/client/pages/settings/users.tsx +211 -123
- package/client/plugins.ts +2 -0
- package/config.example.yml +6 -5
- package/package.json +16 -14
- package/server/plugins.ts +2 -0
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/role-list.test.tsx +73 -8
- package/tests/components/roles.test.tsx +14 -7
- package/tests/components/settings-continuity.test.tsx +426 -0
- package/tests/components/settings-feedback.test.tsx +304 -0
- package/tests/components/settings.test.tsx +1 -1
- package/tests/components/setup-readiness.test.tsx +370 -0
- package/tests/components/table-url-state.test.tsx +95 -0
- package/tests/components/user-drawer.test.tsx +526 -0
- package/tests/logic/authorization-assignment-safety.test.ts +82 -0
- package/tests/logic/foundation-integration.test.ts +62 -0
- package/tests/logic/return-to.test.ts +19 -0
- 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 {
|
|
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('
|
|
22
|
-
it('renders
|
|
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={{
|
|
44
|
+
labels={{
|
|
45
|
+
name: '角色名称',
|
|
46
|
+
key: '标识',
|
|
47
|
+
type: '类型',
|
|
48
|
+
protected: '受保护',
|
|
49
|
+
custom: '自定义',
|
|
50
|
+
}}
|
|
29
51
|
/>
|
|
30
52
|
</MemoryRouter>,
|
|
31
53
|
);
|
|
32
|
-
expect(
|
|
33
|
-
|
|
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={{
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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(() =>
|
|
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',
|