@nocobase/app-template-default 1.0.0-beta.69 → 1.0.0-beta.71

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.
@@ -0,0 +1,95 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+ import { PageContainer } from '#components/page-container';
3
+ import { PageHeader } from '#components/page-header';
4
+ import { Tabs, TabsList, TabsTrigger } from '#components/ui/tabs';
5
+ import { Card, CardContent, CardHeader, CardTitle } from '#components/ui/card';
6
+
7
+ export type WorkflowTab = 'definitions' | 'executions';
8
+
9
+ /** The caller owns routes, tab URLs and permissions; details replace the list chrome. */
10
+ export interface WorkflowManagementLayoutProps {
11
+ title: string;
12
+ description?: ReactNode;
13
+ labels: Record<WorkflowTab, string>;
14
+ tab: WorkflowTab;
15
+ onTabChange: (tab: WorkflowTab) => void;
16
+ detail?: boolean;
17
+ children: ReactNode;
18
+ }
19
+
20
+ export function WorkflowManagementLayout({
21
+ title,
22
+ description,
23
+ labels,
24
+ tab,
25
+ onTabChange,
26
+ detail = false,
27
+ children,
28
+ }: WorkflowManagementLayoutProps): ReactElement {
29
+ return (
30
+ <PageContainer>
31
+ {!detail && (
32
+ <>
33
+ <PageHeader title={title} description={description} />
34
+ <Tabs
35
+ value={tab}
36
+ onValueChange={(value) => onTabChange(value as WorkflowTab)}
37
+ >
38
+ <TabsList variant='line'>
39
+ <TabsTrigger value='definitions'>
40
+ {labels.definitions}
41
+ </TabsTrigger>
42
+ <TabsTrigger value='executions'>{labels.executions}</TabsTrigger>
43
+ </TabsList>
44
+ </Tabs>
45
+ </>
46
+ )}
47
+ {children}
48
+ </PageContainer>
49
+ );
50
+ }
51
+
52
+ export interface WorkflowDefinitionDetailProps {
53
+ diagram: ReactNode;
54
+ parameters?: ReactNode;
55
+ diagramTitle: string;
56
+ parametersTitle: string;
57
+ }
58
+
59
+ export function WorkflowDefinitionDetail({
60
+ diagram,
61
+ parameters,
62
+ diagramTitle,
63
+ parametersTitle,
64
+ }: WorkflowDefinitionDetailProps): ReactElement {
65
+ return (
66
+ <div className='flex flex-col gap-4'>
67
+ <div
68
+ className={
69
+ parameters == null
70
+ ? 'min-w-0'
71
+ : 'grid min-w-0 grid-cols-1 items-start gap-6 lg:grid-cols-2'
72
+ }
73
+ >
74
+ <Card className='min-w-0' role='region' aria-label={diagramTitle}>
75
+ <CardHeader>
76
+ <CardTitle>
77
+ <h2>{diagramTitle}</h2>
78
+ </CardTitle>
79
+ </CardHeader>
80
+ <CardContent className='flex flex-1 flex-col'>{diagram}</CardContent>
81
+ </Card>
82
+ {parameters != null && (
83
+ <Card className='min-w-0' role='region' aria-label={parametersTitle}>
84
+ <CardHeader>
85
+ <CardTitle>
86
+ <h2>{parametersTitle}</h2>
87
+ </CardTitle>
88
+ </CardHeader>
89
+ <CardContent>{parameters}</CardContent>
90
+ </Card>
91
+ )}
92
+ </div>
93
+ </div>
94
+ );
95
+ }
@@ -8,6 +8,28 @@ 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
+ flowDiagram: {
12
+ configure: 'Configure',
13
+ overview: 'Diagram overview',
14
+ actualSize: 'Actual size',
15
+ states: 'States',
16
+ transitions: 'Transitions',
17
+ configurable: 'Parameters',
18
+ current: 'Current',
19
+ visited: 'Visited',
20
+ initial: 'Initial',
21
+ final: 'Final',
22
+ state: 'State',
23
+ zoomOut: 'Zoom out',
24
+ zoomIn: 'Zoom in',
25
+ fit: 'Fit to view',
26
+
27
+ empty: 'No states to display.',
28
+ loading: 'Laying out diagram…',
29
+ failed: 'Unable to lay out this diagram.',
30
+ nodeHelp: 'Press Enter or Space to select a state.',
31
+ edgeHelp: 'Press Enter or Space to select a transition.',
32
+ },
11
33
  settings,
12
34
  'auth.preparingSession': 'Preparing your session…',
13
35
  'auth.connectionFailed': 'Unable to connect',
@@ -1,4 +1,5 @@
1
1
  const settings = {
2
+ unnamedPage: 'Unnamed page ({{path}})',
2
3
  revokeRoles: 'Revoke roles',
3
4
  keyConflict: 'This role key is already in use.',
4
5
  userPermissions: 'User management',
@@ -96,6 +97,7 @@ const settings = {
96
97
  selectAllPermissions: 'All',
97
98
  selectVisiblePermissions: 'Select visible permissions in {{name}}',
98
99
  permissionSummary: '{{enabled}} of {{total}} enabled',
100
+ preservedRecordScope: 'Keep existing configuration',
99
101
  noRecordScope: 'No records (not authorized)',
100
102
  permissionFilter: {
101
103
  limit: 'Up to 20 conditions',
@@ -1,4 +1,5 @@
1
1
  const settings = {
2
+ unnamedPage: '未命名页面({{path}})',
2
3
  revokeRoles: '撤销角色',
3
4
  keyConflict: '此角色标识已被使用。',
4
5
  userPermissions: '用户管理',
@@ -87,18 +88,19 @@ const settings = {
87
88
  total_other: '共 {{count}} 位用户',
88
89
  access: '访问',
89
90
  permissionSearch: '搜索分类、分组或权限',
90
- enabledOnly: '仅看已开启',
91
+ enabledOnly: '仅显示已授权',
91
92
  noPermissions: '暂无可配置权限',
92
93
  permissionCategories: '权限分类',
93
94
  selectAllPermissions: '全选',
94
95
  selectVisiblePermissions: '选择“{{name}}”中显示的权限',
95
- permissionSummary: '已开启 {{enabled}} / {{total}}',
96
- noRecordScope: '无记录(未授权)',
96
+ permissionSummary: '已授权 {{enabled}} / {{total}}',
97
+ noRecordScope: '不允许访问数据',
98
+ preservedRecordScope: '保留现有配置',
97
99
  permissionFilter: {
98
100
  limit: '最多支持 20 个条件',
99
- configure: '筛选条件',
100
- title: '配置记录筛选',
101
- description: '应用条件后还需保存角色,权限才会生效。',
101
+ configure: '设置条件',
102
+ title: '设置数据范围',
103
+ description: '仅允许访问符合以下条件的数据,保存角色后生效。',
102
104
  all: '满足全部条件',
103
105
  any: '满足任一条件',
104
106
  field: '字段',
@@ -5,6 +5,28 @@ 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
+ flowDiagram: {
9
+ configure: '配置',
10
+ overview: '流程缩略导航',
11
+ actualSize: '实际大小',
12
+ states: '状态',
13
+ transitions: '转移',
14
+ configurable: '可配置参数',
15
+ current: '当前',
16
+ visited: '已经过',
17
+ initial: '初始状态',
18
+ final: '终止状态',
19
+ state: '状态',
20
+ zoomOut: '缩小',
21
+ zoomIn: '放大',
22
+ fit: '适应画布',
23
+
24
+ empty: '暂无可展示的状态。',
25
+ loading: '正在布局流程图…',
26
+ failed: '无法布局此流程图。',
27
+ nodeHelp: '按 Enter 或空格键选择状态。',
28
+ edgeHelp: '按 Enter 或空格键选择转移。',
29
+ },
8
30
  settings,
9
31
  'auth.preparingSession': '正在准备登录会话…',
10
32
  'auth.connectionFailed': '连接失败',
@@ -0,0 +1,7 @@
1
+ import type { PagePermissionTitles } from './page-permissions.js';
2
+
3
+ /** Permission labels for application pages without navigation/breadcrumb titles (including overlays).
4
+ * Keys are existing route names; values are application translation keys.
5
+ * Do not add navigation/breadcrumbs or change authorization ids just to name a permission.
6
+ */
7
+ export const pagePermissionTitles: PagePermissionTitles = {};
@@ -0,0 +1,51 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import type { LocalizedText } from '@nocobase/app-plugin-authorization/client';
3
+ import type { CatalogResource } from './permissions-model.js';
4
+
5
+ /** Application route names are stable within their owner; labels never change stored grants. */
6
+ export type PagePermissionTitles = Readonly<Record<string, string>>;
7
+
8
+ export function pagePermissionTitle(
9
+ route: AppClientRegisteredRoute,
10
+ titles: PagePermissionTitles,
11
+ ): LocalizedText | undefined {
12
+ const key =
13
+ (route.source === 'application' && Object.hasOwn(titles, route.name)
14
+ ? titles[route.name]
15
+ : undefined) ??
16
+ route.navigation?.title ??
17
+ route.breadcrumb?.title;
18
+ return key?.trim() ? { key, ns: route.packageName } : undefined;
19
+ }
20
+
21
+ /** Preserve every existing resource id/action; never use a technical route id as its label. */
22
+ export function pagePermissionResources(
23
+ routes: readonly AppClientRegisteredRoute[],
24
+ titles: PagePermissionTitles,
25
+ unnamed: (path: string) => string,
26
+ ): CatalogResource[] {
27
+ const resources = new Map<string, CatalogResource>();
28
+ const visit = (items: readonly AppClientRegisteredRoute[]): void => {
29
+ for (const route of items) {
30
+ if (
31
+ route.componentLoader &&
32
+ typeof route.authz === 'object' &&
33
+ route.authz.resource.type === 'page'
34
+ ) {
35
+ const id = route.authz.resource.id;
36
+ // Resolved child routes may inherit the parent's authorization resource.
37
+ // They share one grant, so showing independent checkboxes is misleading.
38
+ if (!resources.has(id))
39
+ resources.set(id, {
40
+ type: 'page',
41
+ id,
42
+ title: pagePermissionTitle(route, titles) ?? unnamed(route.path),
43
+ actions: [{ name: 'access', title: { key: 'settings.access' } }],
44
+ });
45
+ }
46
+ visit(route.children ?? []);
47
+ }
48
+ };
49
+ visit(routes);
50
+ return [...resources.values()];
51
+ }
@@ -17,6 +17,15 @@ export function setPermission(
17
17
  action: string,
18
18
  enabled: boolean,
19
19
  ): readonly PermissionGrant[] {
20
+ const existing =
21
+ grants
22
+ .find(
23
+ (grant) =>
24
+ grant.resource.type === resource.type &&
25
+ grant.resource.id === resource.id,
26
+ )
27
+ ?.actions.some((entry) => entry.action === action) ?? false;
28
+ if (existing === enabled) return grants;
20
29
  const next = grants.map((grant) => ({
21
30
  ...grant,
22
31
  actions: [...grant.actions],
@@ -87,40 +96,55 @@ export function permissionSections(
87
96
  return {
88
97
  ...row,
89
98
  scopes: granted
90
- ? (resource.dataScopes?.[action.name] ?? []).map((scope) => ({
91
- id: JSON.stringify([
92
- resource.type,
93
- resource.id,
94
- action.name,
95
- scope.key,
96
- ]),
97
- label: text(
98
- typeof scope.title === 'string' &&
99
- scope.title === scope.collection
100
- ? { key: 'settings.recordScope' }
101
- : scope.title,
102
- ),
103
- levels: [
104
- {
105
- value: '',
106
- label: text({ key: 'settings.noRecordScope' }),
107
- },
108
- ...scope.recordAccess.map((key) => ({
109
- value: key,
110
- label: text(
111
- catalog.recordAccess.find(
112
- (entry) => entry.key === key,
113
- )?.title ?? key,
114
- ),
115
- })),
116
- ],
117
- level: scopeSelectionKey(
99
+ ? (resource.dataScopes?.[action.name] ?? []).map((scope) => {
100
+ const value = scopeSelectionKey(
118
101
  granted.policy?.scopes &&
119
102
  typeof granted.policy.scopes === 'object'
120
103
  ? Reflect.get(granted.policy.scopes, scope.key)
121
104
  : undefined,
122
- ),
123
- }))
105
+ );
106
+ const preserved =
107
+ value !== '' && !scope.recordAccess.includes(value);
108
+ return {
109
+ id: JSON.stringify([
110
+ resource.type,
111
+ resource.id,
112
+ action.name,
113
+ scope.key,
114
+ ]),
115
+ label: text(
116
+ typeof scope.title === 'string' &&
117
+ scope.title === scope.collection
118
+ ? { key: 'settings.recordScope' }
119
+ : scope.title,
120
+ ),
121
+ levels: [
122
+ ...(preserved
123
+ ? [
124
+ {
125
+ value,
126
+ label: text({
127
+ key: 'settings.preservedRecordScope',
128
+ }),
129
+ },
130
+ ]
131
+ : []),
132
+ {
133
+ value: '',
134
+ label: text({ key: 'settings.noRecordScope' }),
135
+ },
136
+ ...scope.recordAccess.map((key) => ({
137
+ value: key,
138
+ label: text(
139
+ catalog.recordAccess.find(
140
+ (entry) => entry.key === key,
141
+ )?.title ?? key,
142
+ ),
143
+ })),
144
+ ],
145
+ level: value,
146
+ };
147
+ })
124
148
  : [],
125
149
  };
126
150
  }),
@@ -136,6 +160,11 @@ export function setScope(
136
160
  value: unknown,
137
161
  ): readonly PermissionGrant[] {
138
162
  const [type, id, action, scope] = JSON.parse(rowId) as string[];
163
+ if (
164
+ typeof value === 'string' &&
165
+ scopeSelectionKey(getScopeSelection(grants, rowId)) === value
166
+ )
167
+ return grants;
139
168
  return grants.map((grant) =>
140
169
  grant.resource.type !== type || grant.resource.id !== id
141
170
  ? grant
@@ -216,7 +245,7 @@ function scopeSelectionKey(value: unknown): string {
216
245
  typeof Reflect.get(value, 'key') === 'string'
217
246
  )
218
247
  return String(Reflect.get(value, 'key'));
219
- return '';
248
+ return value == null || value === '' ? '' : '__preserved_scope__';
220
249
  }
221
250
 
222
251
  /** Legacy bare custom-filter keys require configuration before saving. */
@@ -16,7 +16,8 @@ import {
16
16
  type PermissionGrant,
17
17
  type PermissionSet,
18
18
  } from '@nocobase/app-plugin-authorization/client';
19
- import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
19
+ import { pagePermissionResources } from './page-permissions.js';
20
+ import { pagePermissionTitles } from './page-permission-titles.js';
20
21
  import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
21
22
  import {
22
23
  useLocation,
@@ -40,7 +41,6 @@ import {
40
41
  unconfiguredFilter,
41
42
  setPermissions,
42
43
  setScope,
43
- type CatalogResource,
44
44
  } from './permissions-model.js';
45
45
 
46
46
  function RoleEditor(): ReactElement {
@@ -100,32 +100,11 @@ function RoleEditor(): ReactElement {
100
100
  const catalog = useMemo(() => {
101
101
  if (!options.data) return undefined;
102
102
  const source = options.data.data;
103
- const pages: CatalogResource[] = [];
104
- const collect = (routes: readonly AppClientRegisteredRoute[]) => {
105
- for (const route of routes) {
106
- const pageTitle = route.navigation?.title ?? route.breadcrumb?.title;
107
- if (
108
- route.componentLoader &&
109
- typeof route.authz === 'object' &&
110
- route.authz.resource.type === 'page'
111
- )
112
- pages.push({
113
- type: 'page',
114
- id: route.authz.resource.id,
115
- title: pageTitle
116
- ? { key: pageTitle, ns: route.packageName }
117
- : route.id,
118
- actions: [
119
- {
120
- name: 'access',
121
- title: { key: 'settings.access' },
122
- },
123
- ],
124
- });
125
- collect(route.children ?? []);
126
- }
127
- };
128
- collect(app.runtime.routes);
103
+ const pages = pagePermissionResources(
104
+ app.runtime.routes,
105
+ pagePermissionTitles,
106
+ (path) => t('settings.unnamedPage', { path }),
107
+ );
129
108
  return {
130
109
  ...source,
131
110
  sections: [
@@ -180,7 +159,7 @@ function RoleEditor(): ReactElement {
180
159
  })),
181
160
  ],
182
161
  };
183
- }, [options.data, app]);
162
+ }, [options.data, app, t]);
184
163
  const allowed = creating
185
164
  ? canCreate
186
165
  : canUpdate &&
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nocobase/app-template-default",
3
3
  "displayName": "Default Template",
4
- "version": "1.0.0-beta.69",
4
+ "version": "1.0.0-beta.71",
5
5
  "type": "module",
6
6
  "prettier": "@nocobase/dev-config/prettier",
7
7
  "engines": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "nocobase": {
11
11
  "templateKind": "app",
12
- "defaultTemplateVersion": "1.0.0-beta.69"
12
+ "defaultTemplateVersion": "1.0.0-beta.71"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@ai-sdk/react": "2.0.0",
@@ -75,13 +75,15 @@
75
75
  "vite": "^6.3.5",
76
76
  "vitest": "^4.1.10",
77
77
  "tar": "^7.5.22",
78
+ "@xyflow/react": "^12.11.5",
79
+ "elkjs": "^0.10.2",
78
80
  "@nocobase/app-client": "^3.0.0-beta.2",
79
81
  "@nocobase/app-plugin-template-print": "0.0.2-beta.2",
80
- "@nocobase/app-skills": "0.1.0-beta.37",
82
+ "@nocobase/app-skills": "0.1.0-beta.39",
81
83
  "@nocobase/app-testing": "0.1.0-beta.4",
82
84
  "@nocobase/app-websocket": "^0.0.2-beta.0",
83
- "@nocobase/drive": "^0.1.0-beta.6",
84
85
  "@nocobase/dev-config": "0.1.0-beta.23",
86
+ "@nocobase/drive": "^0.1.0-beta.7",
85
87
  "@nocobase/logging": "^0.1.0-beta.7",
86
88
  "@nocobase/session": "^0.1.0-beta.3"
87
89
  },
@@ -146,19 +148,19 @@
146
148
  "@nocobase/app-plugin-authentication": "^2.0.0-beta.10",
147
149
  "@nocobase/app-plugin-authorization": "^1.0.0-beta.28",
148
150
  "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.15",
151
+ "@nocobase/app-plugin-file": "^1.0.0-beta.24",
149
152
  "@nocobase/app-plugin-i18n": "^1.0.0-beta.16",
150
153
  "@nocobase/app-plugin-mail": "^1.0.0-beta.14",
151
- "@nocobase/app-plugin-file": "^1.0.0-beta.24",
152
154
  "@nocobase/app-plugin-notification": "^1.0.0-beta.27",
153
155
  "@nocobase/app-plugin-notification-in-app": "^1.0.0-beta.25",
154
156
  "@nocobase/app-plugin-notification-providers": "^0.2.0-beta.12",
155
157
  "@nocobase/app-plugin-scheduler": "^1.0.0-beta.17",
156
158
  "@nocobase/app-plugin-users": "^2.0.0-beta.5",
159
+ "@nocobase/app-server": "^2.0.0-beta.9",
157
160
  "@nocobase/config": "^0.1.0-beta.3",
158
- "@nocobase/app-server": "^2.0.0-beta.8",
159
161
  "@nocobase/db": "^1.0.0-beta.21",
160
- "@nocobase/service-provider": "^0.0.2-beta.2",
161
162
  "@nocobase/db-sqlite": "^0.1.0-beta.6",
163
+ "@nocobase/service-provider": "^0.0.2-beta.2",
162
164
  "@nocobase/snowflake": "^1.0.0-beta.4",
163
165
  "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.13",
164
166
  "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.13",
@@ -168,9 +170,9 @@
168
170
  "@nocobase/jobs": "^0.1.0-beta.3",
169
171
  "@nocobase/lifecycle": "^0.1.0-beta.2",
170
172
  "@nocobase/i18n": "^1.0.0-beta.7",
171
- "@nocobase/repository-input": "^0.1.0-beta.3",
172
173
  "@nocobase/caching": "^0.1.0-beta.3",
173
- "@nocobase/app-cli": "^1.0.0-beta.23"
174
+ "@nocobase/repository-input": "^0.1.0-beta.3",
175
+ "@nocobase/app-cli": "^1.0.0-beta.24"
174
176
  },
175
177
  "description": "NocoBase 3 application template with the standard product capabilities.",
176
178
  "imports": {
@@ -475,3 +475,65 @@ it('aligns semantic actions across resources without dropping special or filtere
475
475
  view.container.querySelectorAll('[data-slot=permission-row]'),
476
476
  ).toHaveLength(1);
477
477
  });
478
+
479
+ it('keeps scope captions consistent in a category with multiple data ranges, including when filtered', async () => {
480
+ const level = [{ value: 'all', label: 'All records' }];
481
+ render(
482
+ <PermissionEditor
483
+ sections={[
484
+ {
485
+ id: 'business',
486
+ title: 'Business',
487
+ groups: [
488
+ {
489
+ id: 'quotes',
490
+ title: 'Quotes',
491
+ rows: [
492
+ {
493
+ id: 'view',
494
+ label: 'View quotes',
495
+ enabled: true,
496
+ scopes: [
497
+ {
498
+ id: 'view-records',
499
+ label: 'Visible quotes',
500
+ level: 'all',
501
+ levels: level,
502
+ },
503
+ ],
504
+ },
505
+ {
506
+ id: 'submit',
507
+ label: 'Submit quote',
508
+ enabled: true,
509
+ scopes: [
510
+ {
511
+ id: 'submit-projects',
512
+ label: 'Linked projects',
513
+ level: 'all',
514
+ levels: level,
515
+ },
516
+ {
517
+ id: 'submit-quotes',
518
+ label: 'Allowed quotes',
519
+ level: 'all',
520
+ levels: level,
521
+ },
522
+ ],
523
+ },
524
+ ],
525
+ },
526
+ ],
527
+ },
528
+ ]}
529
+ onEnabledChange={vi.fn()}
530
+ onLevelChange={vi.fn()}
531
+ />,
532
+ );
533
+ expect(screen.getByText('Visible quotes')).toBeVisible();
534
+ expect(screen.getByText('Linked projects')).toBeVisible();
535
+ expect(screen.getByText('Allowed quotes')).toBeVisible();
536
+ await userEvent.type(screen.getByRole('textbox'), 'View quotes');
537
+ expect(screen.queryByText('Linked projects')).not.toBeInTheDocument();
538
+ expect(screen.getByText('Visible quotes')).toBeVisible();
539
+ });