@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85
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 +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +8 -2
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/data-table/data-table.tsx +86 -32
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/list-feedback.tsx +174 -0
- package/client/components/permission-editor.tsx +419 -226
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
- package/client/hooks/use-list-data.ts +76 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +24 -1
- package/client/locales/system/settings-en-US.ts +8 -10
- package/client/locales/system/settings-zh-CN.ts +8 -8
- package/client/locales/system/zh-CN.ts +22 -1
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +100 -93
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -59
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +69 -95
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +105 -91
- package/client/routing/session-preparation.tsx +10 -3
- package/client/runtime.ts +0 -4
- package/config.example.yml +24 -0
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +24 -21
- package/server/app.ts +0 -2
- package/server/config/auth.ts +2 -0
- package/server/config/concurrency.ts +3 -0
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +1 -13
- package/server/config/notification.ts +1 -1
- package/server/config/queue.ts +1 -7
- package/server/plugins.ts +2 -0
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/data-table.test.tsx +9 -4
- package/tests/client/default-access.test.tsx +99 -5
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-feedback.test.tsx +301 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/manual-list-retry.test.tsx +190 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/permission-workspace.test.ts +13 -0
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +85 -5
- package/tests/client/settings-feedback.test.tsx +32 -15
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/permission-alignment.html +16 -0
- package/tests/fixtures/permission-alignment.tsx +124 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/playwright/permission-alignment.test.ts +109 -0
- package/tests/server/auth-email-config.test.ts +79 -0
- package/tests/server/concurrency.test.ts +61 -0
- package/tests/server/config.test.ts +34 -14
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
- package/client/route-overrides.ts +0 -9
- package/client/source-extensions.ts +0 -19
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ApiClientError, useApiClient } from '@nocobase/app-client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
/** A successful result belongs to one query and refresh generation only. */
|
|
5
|
+
export function useListData<T>(
|
|
6
|
+
path: string,
|
|
7
|
+
options: boolean | { keepPreviousData?: boolean; active?: boolean } = false,
|
|
8
|
+
) {
|
|
9
|
+
const { keepPreviousData = false, active = true } =
|
|
10
|
+
typeof options === 'boolean' ? { keepPreviousData: options } : options;
|
|
11
|
+
const api = useApiClient();
|
|
12
|
+
const [revision, setRevision] = useState(0);
|
|
13
|
+
const key = useMemo(
|
|
14
|
+
() => ({ path, revision, active }),
|
|
15
|
+
[path, revision, active],
|
|
16
|
+
);
|
|
17
|
+
const generationRef = useRef(0);
|
|
18
|
+
const controllerRef = useRef<AbortController | undefined>(undefined);
|
|
19
|
+
const [result, setResult] = useState<{
|
|
20
|
+
path: string;
|
|
21
|
+
key?: typeof key;
|
|
22
|
+
data?: T;
|
|
23
|
+
error?: unknown;
|
|
24
|
+
}>({ path });
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!active) return;
|
|
27
|
+
const request = new AbortController();
|
|
28
|
+
controllerRef.current = request;
|
|
29
|
+
const ticket = generationRef.current;
|
|
30
|
+
void api.request<T>({ path, signal: request.signal }).then(
|
|
31
|
+
(data) => {
|
|
32
|
+
if (!request.signal.aborted && ticket === generationRef.current)
|
|
33
|
+
setResult({ path, key, data });
|
|
34
|
+
},
|
|
35
|
+
(error: unknown) => {
|
|
36
|
+
if (request.signal.aborted || ticket !== generationRef.current) return;
|
|
37
|
+
setResult((old) => ({
|
|
38
|
+
...(old.path === path &&
|
|
39
|
+
!(
|
|
40
|
+
error instanceof ApiClientError &&
|
|
41
|
+
[401, 403, 404].includes(error.status)
|
|
42
|
+
)
|
|
43
|
+
? old
|
|
44
|
+
: {}),
|
|
45
|
+
path,
|
|
46
|
+
key,
|
|
47
|
+
error,
|
|
48
|
+
}));
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
return () => request.abort();
|
|
52
|
+
}, [api, path, key, active]);
|
|
53
|
+
const invalidate = useCallback(() => {
|
|
54
|
+
// Invalidate synchronously, before React commits the next request effect.
|
|
55
|
+
generationRef.current += 1;
|
|
56
|
+
controllerRef.current?.abort();
|
|
57
|
+
}, []);
|
|
58
|
+
return {
|
|
59
|
+
path: result.path,
|
|
60
|
+
data: result.path === path || keepPreviousData ? result.data : undefined,
|
|
61
|
+
loading: !active || result.path !== path || result.key !== key,
|
|
62
|
+
error: result.path === path ? result.error : undefined,
|
|
63
|
+
revision,
|
|
64
|
+
reload: useCallback(() => {
|
|
65
|
+
invalidate();
|
|
66
|
+
setRevision((value) => value + 1);
|
|
67
|
+
}, [invalidate]),
|
|
68
|
+
replaceData: useCallback(
|
|
69
|
+
(data: T) => {
|
|
70
|
+
invalidate();
|
|
71
|
+
setResult({ path, key, data });
|
|
72
|
+
},
|
|
73
|
+
[path, key, invalidate],
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useLocation,
|
|
3
|
+
useOutlet,
|
|
4
|
+
useResolvedPath,
|
|
5
|
+
useSearchParams,
|
|
6
|
+
} from 'react-router';
|
|
7
|
+
import { useRouteSearches } from '../routing/route-context.js';
|
|
8
|
+
|
|
9
|
+
/** Covered lists keep their own query; returning activates a fresh read. */
|
|
10
|
+
export function useListLocation() {
|
|
11
|
+
const location = useLocation();
|
|
12
|
+
const listPath = useResolvedPath('.').pathname;
|
|
13
|
+
const child = useOutlet();
|
|
14
|
+
const searches = useRouteSearches();
|
|
15
|
+
const [current, setParams] = useSearchParams();
|
|
16
|
+
const active =
|
|
17
|
+
(listPath === '/' && child === null) ||
|
|
18
|
+
location.pathname.replace(/\/$/u, '') === listPath.replace(/\/$/u, '');
|
|
19
|
+
const params = active
|
|
20
|
+
? current
|
|
21
|
+
: new URLSearchParams(searches.get(listPath) ?? '');
|
|
22
|
+
return { params, setParams, active };
|
|
23
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useLocation, useResolvedPath } from 'react-router';
|
|
2
|
+
import { useRouteSearches } from '../routing/route-context.js';
|
|
3
|
+
|
|
4
|
+
export interface ListTab {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
/** Tabs sharing a dataset may retain its filters, sorting and pagination. */
|
|
8
|
+
readonly dataset: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Tabs are routes; only a list's own parameters are discarded on a dataset change. */
|
|
12
|
+
export function useListTabs({
|
|
13
|
+
tabs,
|
|
14
|
+
parameterKeys,
|
|
15
|
+
pending = false,
|
|
16
|
+
}: {
|
|
17
|
+
tabs: readonly ListTab[];
|
|
18
|
+
parameterKeys: readonly string[];
|
|
19
|
+
pending?: boolean;
|
|
20
|
+
}) {
|
|
21
|
+
const location = useLocation();
|
|
22
|
+
const parent = useResolvedPath('.').pathname.replace(/\/+$/u, '');
|
|
23
|
+
const searches = useRouteSearches();
|
|
24
|
+
const segment = location.pathname.slice(parent.length + 1).split('/')[0];
|
|
25
|
+
const active = tabs.find((tab) => tab.path === segment);
|
|
26
|
+
const destination = (tab: ListTab) => {
|
|
27
|
+
const params = new URLSearchParams(location.search);
|
|
28
|
+
if (!active || tab.dataset !== active.dataset) {
|
|
29
|
+
for (const key of parameterKeys) params.delete(key);
|
|
30
|
+
}
|
|
31
|
+
return { pathname: `${parent}/${tab.path}`, search: params.toString() };
|
|
32
|
+
};
|
|
33
|
+
const activePath = active ? `${parent}/${active.path}` : undefined;
|
|
34
|
+
return {
|
|
35
|
+
active,
|
|
36
|
+
source: activePath
|
|
37
|
+
? { pathname: activePath, search: searches.get(activePath) ?? '' }
|
|
38
|
+
: undefined,
|
|
39
|
+
destination,
|
|
40
|
+
fallback: !pending && !active && tabs[0] ? destination(tabs[0]) : undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -1,50 +1,128 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import { useLocation, useSearchParams } from 'react-router';
|
|
3
4
|
import type {
|
|
4
5
|
OnChangeFn,
|
|
5
6
|
PaginationState,
|
|
6
7
|
SortingState,
|
|
7
8
|
} from '@tanstack/react-table';
|
|
8
9
|
|
|
10
|
+
/** A bounded recovery failed because concurrent writes kept moving the final page. */
|
|
11
|
+
export class ListRecoveryError extends Error {}
|
|
12
|
+
|
|
9
13
|
const sizes = [10, 20, 50, 100] as const;
|
|
10
14
|
|
|
15
|
+
interface TableUrlOptions {
|
|
16
|
+
defaultPageSize?: number;
|
|
17
|
+
pageSizeOptions?: readonly number[];
|
|
18
|
+
sortColumns: readonly string[];
|
|
19
|
+
defaultSorting?: SortingState;
|
|
20
|
+
/** Match the existing endpoint contract; do not translate URLs on upgrade. */
|
|
21
|
+
sortFormat?: 'sort-order' | 'orderBy';
|
|
22
|
+
rowCount?: number;
|
|
23
|
+
active?: boolean;
|
|
24
|
+
revision?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One parser for both the visible controls and the request. */
|
|
28
|
+
export function readTableUrlState(
|
|
29
|
+
params: URLSearchParams,
|
|
30
|
+
{
|
|
31
|
+
defaultPageSize = 10,
|
|
32
|
+
pageSizeOptions = sizes,
|
|
33
|
+
sortColumns,
|
|
34
|
+
defaultSorting = [],
|
|
35
|
+
sortFormat = 'sort-order',
|
|
36
|
+
}: TableUrlOptions,
|
|
37
|
+
) {
|
|
38
|
+
const page = Number(params.get('page'));
|
|
39
|
+
const size = Number(params.get('pageSize'));
|
|
40
|
+
const fallback = defaultSorting.filter((sort) =>
|
|
41
|
+
sortColumns.includes(sort.id),
|
|
42
|
+
);
|
|
43
|
+
const fields =
|
|
44
|
+
sortFormat === 'orderBy'
|
|
45
|
+
? (params.get('orderBy') ?? '').split(',').map((part) => {
|
|
46
|
+
const [id, direction = 'asc', ...rest] = part.trim().split(/\s+/u);
|
|
47
|
+
return { id, direction: rest.length ? '' : direction };
|
|
48
|
+
})
|
|
49
|
+
: [
|
|
50
|
+
{
|
|
51
|
+
id: params.get('sort') ?? sortColumns[0],
|
|
52
|
+
direction: params.get('order'),
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
const valid = fields.every(
|
|
56
|
+
({ id, direction }) =>
|
|
57
|
+
id &&
|
|
58
|
+
sortColumns.includes(id) &&
|
|
59
|
+
(direction === 'asc' || direction === 'desc'),
|
|
60
|
+
);
|
|
61
|
+
const sortingState: SortingState = valid
|
|
62
|
+
? fields
|
|
63
|
+
.filter(
|
|
64
|
+
(field, index) =>
|
|
65
|
+
fields.findIndex((other) => other.id === field.id) === index,
|
|
66
|
+
)
|
|
67
|
+
.map(({ id, direction }) => ({ id, desc: direction === 'desc' }))
|
|
68
|
+
: fallback;
|
|
69
|
+
return {
|
|
70
|
+
paginationState: {
|
|
71
|
+
pageIndex: Number.isSafeInteger(page) && page > 0 ? page - 1 : 0,
|
|
72
|
+
pageSize: pageSizeOptions.includes(size) ? size : defaultPageSize,
|
|
73
|
+
},
|
|
74
|
+
sortingState,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
11
78
|
/** Keep list position shareable across overlays, refresh and browser history. */
|
|
12
79
|
export function useTableUrlState({
|
|
13
80
|
defaultPageSize = 10,
|
|
14
81
|
pageSizeOptions = sizes,
|
|
15
82
|
sortColumns,
|
|
16
83
|
rowCount,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}) {
|
|
84
|
+
active = true,
|
|
85
|
+
revision = 0,
|
|
86
|
+
defaultSorting = [],
|
|
87
|
+
sortFormat = 'sort-order',
|
|
88
|
+
}: TableUrlOptions) {
|
|
23
89
|
const [params, setParams] = useSearchParams();
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
90
|
+
const { paginationState, sortingState } = readTableUrlState(params, {
|
|
91
|
+
defaultPageSize,
|
|
92
|
+
pageSizeOptions,
|
|
93
|
+
sortColumns,
|
|
94
|
+
defaultSorting,
|
|
95
|
+
sortFormat,
|
|
96
|
+
});
|
|
97
|
+
const { pageIndex, pageSize } = paginationState;
|
|
98
|
+
const { t } = useTranslation();
|
|
99
|
+
const { pathname } = useLocation();
|
|
100
|
+
const contextParams = new URLSearchParams(params);
|
|
101
|
+
contextParams.delete('page');
|
|
102
|
+
const context = JSON.stringify([
|
|
103
|
+
pathname,
|
|
104
|
+
contextParams.toString(),
|
|
105
|
+
revision,
|
|
106
|
+
]);
|
|
107
|
+
const correctionsRef = useRef({ context, attempts: 0, expectedPage: -1 });
|
|
108
|
+
const [blocked, setBlocked] = useState<string>();
|
|
40
109
|
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
41
110
|
const next =
|
|
42
111
|
typeof updater === 'function' ? updater(paginationState) : updater;
|
|
43
112
|
setParams(
|
|
44
113
|
(old) => {
|
|
45
114
|
const result = new URLSearchParams(old);
|
|
46
|
-
|
|
47
|
-
|
|
115
|
+
const size = pageSizeOptions.includes(next.pageSize)
|
|
116
|
+
? next.pageSize
|
|
117
|
+
: defaultPageSize;
|
|
118
|
+
const index =
|
|
119
|
+
size !== pageSize ||
|
|
120
|
+
!Number.isSafeInteger(next.pageIndex) ||
|
|
121
|
+
next.pageIndex < 0
|
|
122
|
+
? 0
|
|
123
|
+
: next.pageIndex;
|
|
124
|
+
result.set('page', String(index + 1));
|
|
125
|
+
result.set('pageSize', String(size));
|
|
48
126
|
return result;
|
|
49
127
|
},
|
|
50
128
|
{ replace: false },
|
|
@@ -57,7 +135,17 @@ export function useTableUrlState({
|
|
|
57
135
|
(old) => {
|
|
58
136
|
const result = new URLSearchParams(old);
|
|
59
137
|
result.delete('page');
|
|
60
|
-
if (
|
|
138
|
+
if (sortFormat === 'orderBy') {
|
|
139
|
+
const valid = next.filter((sort) => sortColumns.includes(sort.id));
|
|
140
|
+
if (valid.length)
|
|
141
|
+
result.set(
|
|
142
|
+
'orderBy',
|
|
143
|
+
valid
|
|
144
|
+
.map((sort) => `${sort.id}${sort.desc ? ' desc' : ''}`)
|
|
145
|
+
.join(','),
|
|
146
|
+
);
|
|
147
|
+
else result.delete('orderBy');
|
|
148
|
+
} else if (next[0] && sortColumns.includes(next[0].id)) {
|
|
61
149
|
result.set('sort', next[0].id);
|
|
62
150
|
result.set('order', next[0].desc ? 'desc' : 'asc');
|
|
63
151
|
} else {
|
|
@@ -71,9 +159,30 @@ export function useTableUrlState({
|
|
|
71
159
|
};
|
|
72
160
|
// Only pass a count for the current, successfully loaded query.
|
|
73
161
|
useEffect(() => {
|
|
74
|
-
if (
|
|
162
|
+
if (
|
|
163
|
+
!active ||
|
|
164
|
+
rowCount === undefined ||
|
|
165
|
+
!Number.isSafeInteger(rowCount) ||
|
|
166
|
+
rowCount < 0
|
|
167
|
+
)
|
|
168
|
+
return;
|
|
169
|
+
if (
|
|
170
|
+
correctionsRef.current.context !== context ||
|
|
171
|
+
(correctionsRef.current.expectedPage !== -1 &&
|
|
172
|
+
correctionsRef.current.expectedPage !== pageIndex)
|
|
173
|
+
)
|
|
174
|
+
correctionsRef.current = { context, attempts: 0, expectedPage: -1 };
|
|
75
175
|
const lastPage = Math.max(0, Math.ceil(rowCount / pageSize) - 1);
|
|
76
|
-
if (pageIndex <= lastPage)
|
|
176
|
+
if (pageIndex <= lastPage) {
|
|
177
|
+
correctionsRef.current.attempts = 0;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (correctionsRef.current.attempts >= 2) {
|
|
181
|
+
setBlocked(`${context}:${pageIndex}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
correctionsRef.current.attempts += 1;
|
|
185
|
+
correctionsRef.current.expectedPage = lastPage;
|
|
77
186
|
setParams(
|
|
78
187
|
(old) => {
|
|
79
188
|
const result = new URLSearchParams(old);
|
|
@@ -82,8 +191,12 @@ export function useTableUrlState({
|
|
|
82
191
|
},
|
|
83
192
|
{ replace: true },
|
|
84
193
|
);
|
|
85
|
-
}, [rowCount, pageSize, pageIndex, setParams]);
|
|
194
|
+
}, [rowCount, pageSize, pageIndex, setParams, active, context]);
|
|
86
195
|
return {
|
|
196
|
+
error:
|
|
197
|
+
blocked === `${context}:${pageIndex}`
|
|
198
|
+
? new ListRecoveryError(t('settings.listChanged'))
|
|
199
|
+
: undefined,
|
|
87
200
|
paginationState,
|
|
88
201
|
onPaginationChange,
|
|
89
202
|
sortingState,
|
|
@@ -8,6 +8,19 @@ import inboxEnUS from '#extensions/nocobase-inbox/locales/en-US';
|
|
|
8
8
|
* upgrade replaces this file, so put the application's own copy in `../en-US.ts` rather than here.
|
|
9
9
|
*/
|
|
10
10
|
const systemEnUS = {
|
|
11
|
+
listFeedback: {
|
|
12
|
+
loading: 'Loading',
|
|
13
|
+
empty: 'No data yet',
|
|
14
|
+
noResults: 'No results',
|
|
15
|
+
forbidden: 'Access denied',
|
|
16
|
+
unauthenticated: 'Session expired',
|
|
17
|
+
failed: 'Unable to load list',
|
|
18
|
+
refreshFailed: 'Refresh failed; showing previous data',
|
|
19
|
+
retry: 'Retry',
|
|
20
|
+
retrying: 'Retrying…',
|
|
21
|
+
clear: 'Clear filters',
|
|
22
|
+
signIn: 'Sign in',
|
|
23
|
+
},
|
|
11
24
|
flowDiagram: {
|
|
12
25
|
controls: 'Diagram controls',
|
|
13
26
|
fitView: 'Fit to view',
|
|
@@ -54,6 +67,16 @@ const systemEnUS = {
|
|
|
54
67
|
...deviceApprovalEnUS,
|
|
55
68
|
// The UI Library block of the `/inbox` page; the keys below may reword it.
|
|
56
69
|
...inboxEnUS,
|
|
70
|
+
// Shared form wording overrides the defaults included by composed blocks.
|
|
71
|
+
formFeedback: {
|
|
72
|
+
field: 'This value was rejected. Check it and try again.',
|
|
73
|
+
invalid: 'Check the highlighted fields and try again.',
|
|
74
|
+
failed: 'Unable to save. Your input has been kept. Try again.',
|
|
75
|
+
network:
|
|
76
|
+
'Unable to connect. Check your connection and try again. Your input has been kept.',
|
|
77
|
+
denied:
|
|
78
|
+
'You cannot submit this form with your current access. Your input has been kept.',
|
|
79
|
+
},
|
|
57
80
|
// The UI Library component of the header's inbox button.
|
|
58
81
|
'inboxButton.title': 'Inbox',
|
|
59
82
|
'inboxButton.pending': 'Inbox, {{count}} waiting',
|
|
@@ -389,7 +412,7 @@ const systemEnUS = {
|
|
|
389
412
|
back: 'Back',
|
|
390
413
|
},
|
|
391
414
|
dataTable: {
|
|
392
|
-
noResults: 'No results
|
|
415
|
+
noResults: 'No results',
|
|
393
416
|
sortAscending: 'Asc',
|
|
394
417
|
sortDescending: 'Desc',
|
|
395
418
|
hideColumn: 'Hide',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const settings = {
|
|
2
|
+
listChanged: 'The list has changed. Please retry.',
|
|
2
3
|
setup: {
|
|
3
4
|
title: 'Feature prerequisites',
|
|
4
5
|
checking: 'Checking current configuration…',
|
|
@@ -49,10 +50,6 @@ const settings = {
|
|
|
49
50
|
records: 'Selected records ({{count}})',
|
|
50
51
|
retained: 'Retain existing configuration',
|
|
51
52
|
search: 'Search business objects or keys',
|
|
52
|
-
empty: 'No default data scopes',
|
|
53
|
-
emptyHint:
|
|
54
|
-
'Create a rule to configure baseline access for a business object.',
|
|
55
|
-
readOnlyEmpty: 'Ask an administrator to configure baseline data access.',
|
|
56
53
|
noResources:
|
|
57
54
|
'Every available business object already has a default rule, or no configurable objects are registered.',
|
|
58
55
|
unsupported:
|
|
@@ -85,8 +82,6 @@ const settings = {
|
|
|
85
82
|
'Manage user information, account security and role assignments. Review each permission separately.',
|
|
86
83
|
businessOperationsHint:
|
|
87
84
|
'Choose the operations this role can perform and the data each operation can use.',
|
|
88
|
-
pageAccessHint:
|
|
89
|
-
'Choose the business pages this role can open. All system settings, including user management and business configuration, are controlled under Configuration permissions. Data and business actions require separate permissions.',
|
|
90
85
|
roleAssignment: 'Role assignment',
|
|
91
86
|
accountSecurity: 'Account security',
|
|
92
87
|
userInformation: 'User information',
|
|
@@ -144,10 +139,6 @@ const settings = {
|
|
|
144
139
|
noResults: 'No matching results',
|
|
145
140
|
clearHint: 'Try a different search or clear the filters.',
|
|
146
141
|
noUsers: 'No users yet',
|
|
147
|
-
usersReadOnlyEmpty:
|
|
148
|
-
'Contact an administrator to add users or grant the permissions needed to create users and assign roles.',
|
|
149
|
-
rolesReadOnlyEmpty:
|
|
150
|
-
'Contact an administrator to create a role or grant you permission to create roles.',
|
|
151
142
|
noRoleAccess:
|
|
152
143
|
'This user can still sign in and use account basics. No business access is granted by roles.',
|
|
153
144
|
noAssignedRoles: 'No assigned roles',
|
|
@@ -207,6 +198,13 @@ const settings = {
|
|
|
207
198
|
configurationPermissionsHint:
|
|
208
199
|
'Control access and actions for system settings, user management and business configuration. Only actual application features are shown.',
|
|
209
200
|
permissionSearch: 'Search categories, groups or permissions',
|
|
201
|
+
permissionAll: 'All',
|
|
202
|
+
permissionGranted: 'Granted',
|
|
203
|
+
permissionUngranted: 'Ungranted',
|
|
204
|
+
permissionStatus: 'Authorization status',
|
|
205
|
+
permissionClearSearch: 'Clear search',
|
|
206
|
+
permissionCollapseAll: 'Collapse all',
|
|
207
|
+
permissionExpandAll: 'Expand all',
|
|
210
208
|
enabledOnly: 'Enabled only',
|
|
211
209
|
noPermissions: 'No permissions to configure',
|
|
212
210
|
permissionEmptyHint:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const settings = {
|
|
2
|
+
listChanged: '列表已变化,请重试。',
|
|
2
3
|
setup: {
|
|
3
4
|
title: '功能前置配置',
|
|
4
5
|
checking: '正在检查当前配置…',
|
|
@@ -43,9 +44,6 @@ const settings = {
|
|
|
43
44
|
records: '指定记录({{count}} 条)',
|
|
44
45
|
retained: '保留现有配置',
|
|
45
46
|
search: '搜索业务对象或标识',
|
|
46
|
-
empty: '暂无默认数据范围',
|
|
47
|
-
emptyHint: '新建规则,为业务对象配置基础数据访问范围。',
|
|
48
|
-
readOnlyEmpty: '请联系管理员配置基础数据访问范围。',
|
|
49
47
|
noResources: '可用业务对象均已配置默认规则,或当前没有可配置的业务对象。',
|
|
50
48
|
unsupported:
|
|
51
49
|
'此规则包含当前插件目录中不存在的配置,已保留原值并以只读方式展示。',
|
|
@@ -74,8 +72,6 @@ const settings = {
|
|
|
74
72
|
'管理用户资料、账号安全和角色分配,请逐项确认需要授予的权限。',
|
|
75
73
|
businessOperationsHint:
|
|
76
74
|
'设置此角色可以执行的业务操作,以及每项操作可使用的数据范围。',
|
|
77
|
-
pageAccessHint:
|
|
78
|
-
'设置此角色可访问的业务页面。系统设置(包括用户管理、业务配置项)统一在“配置项权限”中控制。页面内的数据和业务操作仍需单独授权。',
|
|
79
75
|
roleAssignment: '角色分配',
|
|
80
76
|
accountSecurity: '账号安全',
|
|
81
77
|
userInformation: '用户资料',
|
|
@@ -133,9 +129,6 @@ const settings = {
|
|
|
133
129
|
noResults: '没有匹配的结果',
|
|
134
130
|
clearHint: '请更换搜索条件或清除筛选。',
|
|
135
131
|
noUsers: '暂无用户',
|
|
136
|
-
usersReadOnlyEmpty:
|
|
137
|
-
'请联系管理员添加用户,或授予创建用户和分配角色所需的权限。',
|
|
138
|
-
rolesReadOnlyEmpty: '请联系管理员创建角色,或授予你创建角色的权限。',
|
|
139
132
|
noRoleAccess: '此用户仍可登录并使用账号基础功能,角色未授予业务访问。',
|
|
140
133
|
noAssignedRoles: '未分配角色',
|
|
141
134
|
noRoles: '暂无角色',
|
|
@@ -190,6 +183,13 @@ const settings = {
|
|
|
190
183
|
configurationPermissionsHint:
|
|
191
184
|
'统一设置系统设置、用户管理和业务配置项的访问与操作权限,仅显示应用实际开放的功能。',
|
|
192
185
|
permissionSearch: '搜索分类、分组或权限',
|
|
186
|
+
permissionAll: '全部',
|
|
187
|
+
permissionGranted: '已授权',
|
|
188
|
+
permissionUngranted: '未授权',
|
|
189
|
+
permissionStatus: '授权状态',
|
|
190
|
+
permissionClearSearch: '清空搜索',
|
|
191
|
+
permissionCollapseAll: '全部收起',
|
|
192
|
+
permissionExpandAll: '全部展开',
|
|
193
193
|
enabledOnly: '仅显示已授权',
|
|
194
194
|
noPermissions: '暂无可配置权限',
|
|
195
195
|
permissionEmptyHint: '请联系应用开发者,开放此角色需要配置的权限。',
|
|
@@ -5,6 +5,19 @@ import inboxZhCN from '#extensions/nocobase-inbox/locales/zh-CN';
|
|
|
5
5
|
|
|
6
6
|
/** The Chinese copy of what the template ships; see `./en-US.ts`. */
|
|
7
7
|
const systemZhCN: SystemResource = {
|
|
8
|
+
listFeedback: {
|
|
9
|
+
loading: '加载中',
|
|
10
|
+
empty: '暂无数据',
|
|
11
|
+
noResults: '无结果',
|
|
12
|
+
forbidden: '无权访问',
|
|
13
|
+
unauthenticated: '会话已过期',
|
|
14
|
+
failed: '列表加载失败',
|
|
15
|
+
refreshFailed: '刷新失败,仍显示旧数据',
|
|
16
|
+
retry: '重试',
|
|
17
|
+
retrying: '正在重试…',
|
|
18
|
+
clear: '清空筛选',
|
|
19
|
+
signIn: '登录',
|
|
20
|
+
},
|
|
8
21
|
flowDiagram: {
|
|
9
22
|
controls: '流程图控制',
|
|
10
23
|
fitView: '适应画布',
|
|
@@ -50,6 +63,14 @@ const systemZhCN: SystemResource = {
|
|
|
50
63
|
...deviceApprovalZhCN,
|
|
51
64
|
// The UI Library block of the `/inbox` page; the keys below may reword it.
|
|
52
65
|
...inboxZhCN,
|
|
66
|
+
// Shared form wording overrides the defaults included by composed blocks.
|
|
67
|
+
formFeedback: {
|
|
68
|
+
field: '此字段的值未通过校验,请检查并修改后重试。',
|
|
69
|
+
invalid: '请检查标记的字段后重试。',
|
|
70
|
+
failed: '保存失败,已保留您的输入,请重试。',
|
|
71
|
+
network: '无法连接,请检查网络后重试。已保留您的输入。',
|
|
72
|
+
denied: '当前权限无法提交此表单。已保留您的输入。',
|
|
73
|
+
},
|
|
53
74
|
// The UI Library component of the header's inbox button.
|
|
54
75
|
'inboxButton.title': '收件箱',
|
|
55
76
|
'inboxButton.pending': '收件箱,{{count}} 项待处理',
|
|
@@ -366,7 +387,7 @@ const systemZhCN: SystemResource = {
|
|
|
366
387
|
back: '返回',
|
|
367
388
|
},
|
|
368
389
|
dataTable: {
|
|
369
|
-
noResults: '
|
|
390
|
+
noResults: '无结果',
|
|
370
391
|
sortAscending: '升序',
|
|
371
392
|
sortDescending: '降序',
|
|
372
393
|
hideColumn: '隐藏',
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { FeedbackForm } from '#components/feedback-form';
|
|
2
|
+
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
3
|
+
import { useSubmission } from '@nocobase/app-client';
|
|
1
4
|
import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
|
|
2
5
|
import {
|
|
3
6
|
useCan,
|
|
@@ -60,7 +63,12 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
60
63
|
action: creating ? 'create' : 'update',
|
|
61
64
|
}).can;
|
|
62
65
|
const [draft, setDraft] = useState<DefaultAccessRule>();
|
|
63
|
-
const
|
|
66
|
+
const { session } = useAuthentication();
|
|
67
|
+
const submission = useSubmission({
|
|
68
|
+
scope: `${session?.user.id}:settings:default-access:${key}`,
|
|
69
|
+
});
|
|
70
|
+
const [requestBusy, setRequestBusy] = useState(false);
|
|
71
|
+
const busy = requestBusy || submission.submitting;
|
|
64
72
|
const [error, setError] = useState<unknown>();
|
|
65
73
|
const [message, setMessage] = useState<string>();
|
|
66
74
|
const [submitted, setSubmitted] = useState(false);
|
|
@@ -245,28 +253,36 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
245
253
|
);
|
|
246
254
|
return;
|
|
247
255
|
}
|
|
248
|
-
|
|
256
|
+
setRequestBusy(true);
|
|
249
257
|
try {
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
258
|
+
const saved = await submission.run(
|
|
259
|
+
{ creating, key, value },
|
|
260
|
+
async (idempotencyKey, _signal, _snapshot, recovering) => {
|
|
261
|
+
if (!creating && !recovering) {
|
|
262
|
+
const latest = await api.request<{
|
|
263
|
+
data: readonly DefaultAccessRule[];
|
|
264
|
+
}>({ path: 'authorization/defaultAccess' });
|
|
265
|
+
if (
|
|
266
|
+
JSON.stringify(latest.data.find((rule) => rule.key === key)) !==
|
|
267
|
+
JSON.stringify(initial)
|
|
268
|
+
) {
|
|
269
|
+
setMessage(t('settings.defaultAccess.changed'));
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return api.request<{ data: DefaultAccessRule }>({
|
|
274
|
+
path: creating
|
|
275
|
+
? 'authorization/defaultAccess'
|
|
276
|
+
: `authorization/defaultAccess/${encodeURIComponent(key ?? '')}`,
|
|
277
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
278
|
+
method: creating ? 'POST' : 'PATCH',
|
|
279
|
+
json: creating
|
|
280
|
+
? { ...value, key: value.key.trim() }
|
|
281
|
+
: { actions: value.actions },
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
if (!saved) return;
|
|
270
286
|
toaster.show({
|
|
271
287
|
type: 'success',
|
|
272
288
|
title: t('settings.defaultAccess.saved', { name: value.key }),
|
|
@@ -331,7 +347,7 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
331
347
|
setMessage(t('settings.defaultAccess.invalid'));
|
|
332
348
|
else setError(next);
|
|
333
349
|
} finally {
|
|
334
|
-
|
|
350
|
+
setRequestBusy(false);
|
|
335
351
|
}
|
|
336
352
|
};
|
|
337
353
|
return (
|
|
@@ -360,7 +376,10 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
360
376
|
{!creating && !initial ? (
|
|
361
377
|
<p role='status'>{t('settings.missing')}</p>
|
|
362
378
|
) : (
|
|
363
|
-
<
|
|
379
|
+
<FeedbackForm
|
|
380
|
+
submitting={busy}
|
|
381
|
+
error={error}
|
|
382
|
+
errorContent={<SettingsError error={error} inline />}
|
|
364
383
|
ref={formRef}
|
|
365
384
|
noValidate
|
|
366
385
|
className='flex flex-col gap-6'
|
|
@@ -369,7 +388,6 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
369
388
|
void save();
|
|
370
389
|
}}
|
|
371
390
|
>
|
|
372
|
-
<SettingsError error={error} />
|
|
373
391
|
{message && (
|
|
374
392
|
<Alert variant='destructive'>
|
|
375
393
|
<AlertDescription>
|
|
@@ -487,7 +505,7 @@ function DefaultAccessFormBody(): ReactElement {
|
|
|
487
505
|
</Button>
|
|
488
506
|
)}
|
|
489
507
|
</div>
|
|
490
|
-
</
|
|
508
|
+
</FeedbackForm>
|
|
491
509
|
)}
|
|
492
510
|
{!creating && !initial && (
|
|
493
511
|
<Button variant='outline' onClick={close}>
|