cabloy 5.1.167 → 5.1.168

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 (51) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/package.json +1 -1
  4. package/repo-e2e/specs/cabloy-basic.spec.ts +75 -0
  5. package/vona/pnpm-lock.yaml +235 -48
  6. package/vona/src/suite/a-training/modules/training-record/src/entity/record.tsx +2 -0
  7. package/zova/packages-zova/zova/package.json +2 -2
  8. package/zova/pnpm-lock.yaml +113 -84
  9. package/zova/src/suite/cabloy-basic/modules/basic-app/src/.metadata/component/routedDialog.ts +2 -1
  10. package/zova/src/suite/cabloy-basic/modules/basic-app/src/component/routedDialog/controller.tsx +6 -2
  11. package/zova/src/suite/cabloy-basic/modules/basic-app/src/service/appModal.ts +60 -8
  12. package/zova/src/suite/cabloy-basic/modules/basic-app/src/types/appModal.ts +40 -8
  13. package/zova/src/suite/cabloy-basic/modules/basic-page/src/.metadata/index.ts +6 -6
  14. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockPage/controller.tsx +95 -10
  15. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockTable/controller.tsx +1 -0
  16. package/zova/src/suite/cabloy-basic/modules/basic-page/src/config/locale/en-us.ts +1 -0
  17. package/zova/src/suite/cabloy-basic/modules/basic-page/src/config/locale/zh-cn.ts +1 -0
  18. package/zova/src/suite/cabloy-basic/modules/basic-page/src/index.ts +1 -0
  19. package/zova/src/suite/cabloy-basic/modules/basic-page/src/lib/selection.ts +35 -0
  20. package/zova/src/suite/cabloy-basic/modules/basic-page/test/lib/selection.test.ts +24 -0
  21. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/.metadata/component/blockResourcePickerActions.ts +34 -0
  22. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/.metadata/index.ts +30 -8
  23. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/.metadata/locales.ts +7 -0
  24. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/component/blockResourcePickerActions/controller.tsx +92 -0
  25. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/component/formFieldResourcePicker/controller.tsx +219 -11
  26. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/config/locale/en-us.ts +8 -0
  27. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/config/locale/zh-cn.ts +7 -0
  28. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/index.ts +2 -0
  29. package/zova/src/suite/cabloy-basic/modules/basic-resource/src/lib/resourcePicker.ts +29 -0
  30. package/zova/src/suite/cabloy-basic/modules/basic-resource/test/lib/resourcePicker.test.ts +32 -0
  31. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +18 -12
  32. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  33. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/component/resourcePickerContext.ts +31 -0
  34. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/index.ts +48 -5
  35. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/page/resourcePicker.ts +16 -0
  36. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/component/resourcePickerContext/controller.tsx +26 -0
  37. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/index.ts +2 -0
  38. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/lib/resourcePicker.ts +1 -0
  39. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/resourcePicker/controller.tsx +144 -0
  40. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/routes.ts +9 -0
  41. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  42. package/zova/src/suite-vendor/a-zova/modules/a-bean/cli/controller/metadata/generateFileComponent.ts +40 -1
  43. package/zova/src/suite-vendor/a-zova/modules/a-bean/package.json +1 -1
  44. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  45. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routerViewBase.tsx +5 -0
  46. package/zova/src/suite-vendor/a-zova/modules/a-routerstack/package.json +1 -1
  47. package/zova/src/suite-vendor/a-zova/modules/a-routerstack/src/component/routerViewStack/controller.tsx +7 -1
  48. package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
  49. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/controller.tsx +2 -0
  50. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/render.tsx +13 -10
  51. package/zova/src/suite-vendor/a-zova/package.json +5 -5
@@ -6,7 +6,8 @@ import {
6
6
  routerViewLocationKey,
7
7
  viewDepthKey,
8
8
  } from '@cabloy/vue-router';
9
- import { computed, defineComponent, provide, shallowReactive } from 'vue';
9
+ import { computed, provide, shallowReactive } from 'vue';
10
+ import { defineComponent } from 'vue';
10
11
  import { prepareComponentOptions, useController } from 'zova';
11
12
 
12
13
  import type { ControllerRoutedDialogProps } from '../../component/routedDialog/controller.jsx';
@@ -5,7 +5,7 @@ import { BeanControllerBase, cast } from 'zova';
5
5
  import { Controller } from 'zova-module-a-bean';
6
6
  import { ZRouterViewStack } from 'zova-module-a-routerstack';
7
7
 
8
- import type { IModalRoutedDialogItem } from '../../types/appModal.js';
8
+ import { IModalRoutedDialogItem, routedDialogContextKey } from '../../types/appModal.js';
9
9
 
10
10
  export interface ControllerRoutedDialogProps {
11
11
  item: IModalRoutedDialogItem;
@@ -41,7 +41,11 @@ export class ControllerRoutedDialog extends BeanControllerBase {
41
41
  if (!this.$$modelStack) return <div class="loading loading-spinner"></div>;
42
42
  const router = item.state.router;
43
43
  if (!router) return <div class="text-error">Router unavailable</div>;
44
- const vnode = h(ZRouterViewStack);
44
+ const vnode = h(ZRouterViewStack, {
45
+ pageHostProviders: {
46
+ [routedDialogContextKey]: item.state.context,
47
+ },
48
+ });
45
49
  cast(vnode).zovaHostProviders = {
46
50
  'a-router.bean.router': router,
47
51
  '$$modelStack': this.$$modelStack,
@@ -14,7 +14,9 @@ import {
14
14
  IModalPromptOptions,
15
15
  IModalRoutedDialogOptions,
16
16
  IModalRoutedDialogPresentationOptions,
17
+ IModalRoutedDialogItem,
17
18
  IModalRoutedDialogState,
19
+ IRoutedDialogContext,
18
20
  IRoutedDialogHandle,
19
21
  } from '../types/appModal.js';
20
22
 
@@ -27,6 +29,11 @@ export class ServiceAppModal extends BeanBase {
27
29
  { resolve: () => void; reject: (error: unknown) => void }
28
30
  >();
29
31
 
32
+ private routedDialogResultSettlers = new Map<
33
+ number,
34
+ { resolve: (value: unknown | undefined) => void; reject: (error: unknown) => void }
35
+ >();
36
+
30
37
  private routedDialogHistories = new Map<number, RoutedDialogHistory>();
31
38
 
32
39
  protected async __init__() {}
@@ -101,10 +108,14 @@ export class ServiceAppModal extends BeanBase {
101
108
  return new AppModalItem(this, modalItem);
102
109
  }
103
110
 
104
- public routedDialog(
105
- options: IModalRoutedDialogOptions,
111
+ public routedDialog<
112
+ TResult = unknown,
113
+ TProps extends Record<string, unknown> = Record<string, unknown>,
114
+ TSession = unknown,
115
+ >(
116
+ options: IModalRoutedDialogOptions<TProps, TSession>,
106
117
  dialogOptions?: IModalRoutedDialogPresentationOptions,
107
- ): IRoutedDialogHandle {
118
+ ): IRoutedDialogHandle<TResult> {
108
119
  const id = this.newModalItemId();
109
120
  let resolveReady!: () => void;
110
121
  let rejectReady!: (error: unknown) => void;
@@ -112,25 +123,47 @@ export class ServiceAppModal extends BeanBase {
112
123
  resolveReady = resolve;
113
124
  rejectReady = reject;
114
125
  });
115
- // A close-before-ready rejection remains observable through the handle,
116
- // without becoming an unhandled rejection when callers do not await it.
126
+ let resolveResult!: (value: TResult | undefined) => void;
127
+ let rejectResult!: (error: unknown) => void;
128
+ const result = new Promise<TResult | undefined>((resolve, reject) => {
129
+ resolveResult = resolve;
130
+ rejectResult = reject;
131
+ });
132
+ // Both promises remain observable through the handle without becoming
133
+ // unhandled rejections when callers only need one lifecycle channel.
117
134
  void ready.catch(() => {});
118
- const modalItem: IModalItem = {
135
+ void result.catch(() => {});
136
+ let modalItem!: IModalRoutedDialogItem<TResult, TProps, TSession>;
137
+ const context: IRoutedDialogContext<TResult, TProps, TSession> = {
138
+ id,
139
+ props: options.props,
140
+ session: options.session as TSession,
141
+ resolve: value => this._resolveRoutedDialog(modalItem, value),
142
+ cancel: () => this.close(id),
143
+ };
144
+ modalItem = {
119
145
  id,
120
146
  type: 'routedDialog',
121
147
  options,
122
148
  dialogOptions,
123
- state: shallowReactive<IModalRoutedDialogState>({
149
+ state: shallowReactive<IModalRoutedDialogState<TResult, TProps, TSession>>({
124
150
  status: 'loading',
125
151
  canGoBack: false,
126
152
  ready,
153
+ result,
154
+ context,
127
155
  }),
128
156
  };
129
157
  this.modalItems.push(modalItem);
130
158
  this.routedDialogReadySettlers.set(id, { resolve: resolveReady, reject: rejectReady });
131
- const handle = new AppModalItem(this, modalItem) as IRoutedDialogHandle;
159
+ this.routedDialogResultSettlers.set(id, {
160
+ resolve: value => resolveResult(value as TResult | undefined),
161
+ reject: rejectResult,
162
+ });
163
+ const handle = new AppModalItem(this, modalItem) as IRoutedDialogHandle<TResult>;
132
164
  Object.defineProperties(handle, {
133
165
  ready: { enumerable: true, get: () => ready },
166
+ result: { enumerable: true, get: () => result },
134
167
  push: {
135
168
  enumerable: true,
136
169
  value: (to: any) => this._navigateRoutedDialog(modalItem, to, false),
@@ -197,6 +230,7 @@ export class ServiceAppModal extends BeanBase {
197
230
  modalItem.state.status = 'error';
198
231
  modalItem.state.error = error;
199
232
  this._settleRoutedDialogReady(modalItem.id, 'reject', error);
233
+ this._settleRoutedDialogResult(modalItem.id, 'reject', error);
200
234
  }
201
235
  }
202
236
 
@@ -233,6 +267,23 @@ export class ServiceAppModal extends BeanBase {
233
267
  else settler.reject(error);
234
268
  }
235
269
 
270
+ private _settleRoutedDialogResult(id: number, action: 'resolve' | 'reject', value?: unknown) {
271
+ const settler = this.routedDialogResultSettlers.get(id);
272
+ if (!settler) return;
273
+ this.routedDialogResultSettlers.delete(id);
274
+ if (action === 'resolve') settler.resolve(value);
275
+ else settler.reject(value);
276
+ }
277
+
278
+ private _resolveRoutedDialog(
279
+ modalItem: Extract<IModalItem, { type: 'routedDialog' }>,
280
+ value: unknown,
281
+ ) {
282
+ if (!this._isRoutedDialogOpen(modalItem)) return;
283
+ this._settleRoutedDialogResult(modalItem.id, 'resolve', value);
284
+ this.close(modalItem.id);
285
+ }
286
+
236
287
  public close(id: number, _reason: string = 'api') {
237
288
  const [index, modalItem] = this.findModalItem(id);
238
289
  if (index === -1 || !modalItem) return;
@@ -245,6 +296,7 @@ export class ServiceAppModal extends BeanBase {
245
296
  this.routedDialogHistories.delete(modalItem.id);
246
297
  modalItem.state.router = undefined;
247
298
  this._settleRoutedDialogReady(modalItem.id, 'reject', new Error('routedDialog is closed'));
299
+ this._settleRoutedDialogResult(modalItem.id, 'resolve');
248
300
  modalItem.options.onClose?.();
249
301
  return;
250
302
  }
@@ -71,15 +71,37 @@ export interface IModalDialogRenderOptions {
71
71
  onClose?: () => void;
72
72
  }
73
73
 
74
- export interface IModalRoutedDialogOptions extends IModalRoutedDialogPresentationOptions {
74
+ export const routedDialogContextKey = '$routedDialogContext';
75
+
76
+ export interface IRoutedDialogContext<
77
+ TResult = unknown,
78
+ TProps extends Record<string, unknown> = Record<string, unknown>,
79
+ TSession = unknown,
80
+ > {
81
+ readonly id: number;
82
+ readonly props?: TProps;
83
+ readonly session: TSession;
84
+ resolve(value: TResult): void;
85
+ cancel(): void;
86
+ }
87
+
88
+ export interface IModalRoutedDialogOptions<
89
+ TProps extends Record<string, unknown> = Record<string, unknown>,
90
+ TSession = unknown,
91
+ > extends IModalRoutedDialogPresentationOptions {
75
92
  route: RouteLocationRaw;
76
93
  icon?: keyof IIconRecord;
77
94
  title?: string;
78
- props?: Record<string, unknown>;
95
+ props?: TProps;
96
+ session?: TSession;
79
97
  onClose?: () => void;
80
98
  }
81
99
 
82
- export interface IModalRoutedDialogState {
100
+ export interface IModalRoutedDialogState<
101
+ TResult = unknown,
102
+ TProps extends Record<string, unknown> = Record<string, unknown>,
103
+ TSession = unknown,
104
+ > {
83
105
  status: 'loading' | 'ready' | 'error' | 'closed';
84
106
  closed?: boolean;
85
107
  error?: unknown;
@@ -87,24 +109,34 @@ export interface IModalRoutedDialogState {
87
109
  currentRoute?: RouteLocationNormalizedLoadedGeneric;
88
110
  canGoBack: boolean;
89
111
  ready: Promise<void>;
112
+ result: Promise<TResult | undefined>;
113
+ context: IRoutedDialogContext<TResult, TProps, TSession>;
90
114
  }
91
115
 
92
- export type IModalRoutedDialogInput = Omit<IModalRoutedDialogOptions, 'route'>;
116
+ export type IModalRoutedDialogInput<
117
+ TProps extends Record<string, unknown> = Record<string, unknown>,
118
+ TSession = unknown,
119
+ > = Omit<IModalRoutedDialogOptions<TProps, TSession>, 'route'>;
93
120
 
94
121
  export interface IModalRoutedDialogNavigate {
95
122
  navigate(to: RouteLocationRaw, replace?: boolean): Promise<NavigationFailure | void | undefined>;
96
123
  }
97
124
 
98
- export interface IModalRoutedDialogItem {
125
+ export interface IModalRoutedDialogItem<
126
+ TResult = unknown,
127
+ TProps extends Record<string, unknown> = Record<string, unknown>,
128
+ TSession = unknown,
129
+ > {
99
130
  id: number;
100
131
  type: 'routedDialog';
101
- options: IModalRoutedDialogOptions;
132
+ options: IModalRoutedDialogOptions<TProps, TSession>;
102
133
  dialogOptions?: IModalRoutedDialogPresentationOptions;
103
- state: IModalRoutedDialogState;
134
+ state: IModalRoutedDialogState<TResult, TProps, TSession>;
104
135
  }
105
136
 
106
- export interface IRoutedDialogHandle extends AppModalItem {
137
+ export interface IRoutedDialogHandle<TResult = unknown> extends AppModalItem {
107
138
  readonly ready: Promise<void>;
139
+ readonly result: Promise<TResult | undefined>;
108
140
  push(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>;
109
141
  replace(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>;
110
142
  }
@@ -46,12 +46,12 @@ declare module 'zova-module-basic-page' {
46
46
  }
47
47
  /** controller: end */
48
48
  /** controller: begin */
49
- import { ControllerBlockFilter } from '../component/blockFilter/controller.jsx';
50
- import { ControllerBlockFilterActions } from '../component/blockFilterActions/controller.jsx';
51
- import { ControllerBlockPage } from '../component/blockPage/controller.jsx';
52
- import { ControllerBlockPager } from '../component/blockPager/controller.jsx';
53
- import { ControllerBlockTable } from '../component/blockTable/controller.jsx';
54
- import { ControllerBlockToolbarBulk } from '../component/blockToolbarBulk/controller.jsx';
49
+ import type { ControllerBlockFilter } from '../component/blockFilter/controller.jsx';
50
+ import type { ControllerBlockFilterActions } from '../component/blockFilterActions/controller.jsx';
51
+ import type { ControllerBlockPage } from '../component/blockPage/controller.jsx';
52
+ import type { ControllerBlockPager } from '../component/blockPager/controller.jsx';
53
+ import type { ControllerBlockTable } from '../component/blockTable/controller.jsx';
54
+ import type { ControllerBlockToolbarBulk } from '../component/blockToolbarBulk/controller.jsx';
55
55
  import 'zova';
56
56
  declare module 'zova' {
57
57
  export interface IBeanRecordLocal {
@@ -6,6 +6,7 @@ import type {
6
6
  IPageScope,
7
7
  IResourceBlockOptionsBase,
8
8
  IResourceRenderBlockOptionsBlock,
9
+ IResourceTableSelectionPayload,
9
10
  ITablePaged,
10
11
  ITableQuery,
11
12
  ITableResPaged,
@@ -21,7 +22,14 @@ import { $QueriesEnsureLoaded } from 'zova-module-a-model';
21
22
  import { BeanControllerTableBase } from 'zova-module-a-table';
22
23
  import { ModelResource } from 'zova-module-rest-resource';
23
24
 
24
- import { reconcileSelection, selectionKey, selectionRowId } from '../../lib/selection.js';
25
+ import {
26
+ limitPickerRowSelection,
27
+ reconcileSelection,
28
+ resolvePickerSelectionMax,
29
+ selectionKey,
30
+ selectionRowId,
31
+ TypeResourcePickerSelectionMode,
32
+ } from '../../lib/selection.js';
25
33
 
26
34
  declare module 'zova-module-a-openapi' {
27
35
  export interface IResourceBlockRecord {
@@ -32,9 +40,15 @@ declare module 'zova-module-a-openapi' {
32
40
  export interface ControllerBlockPageProps extends IResourceBlockOptionsBase {
33
41
  blocks?: IResourceRenderBlockOptionsBlock[];
34
42
  resource?: string;
43
+ actionPath?: string;
35
44
  pageSize?: number;
36
45
  queryFixed?: ITableQuery;
37
46
  selectionPolicy?: 'always' | 'onDemand' | false;
47
+ selectionMode?: TypeResourcePickerSelectionMode;
48
+ selectionMax?: number;
49
+ selectedIds?: readonly TableIdentity[];
50
+ selectedRows?: readonly Record<string, unknown>[];
51
+ onSelectionChange?: (selection: IResourceTableSelectionPayload) => void;
38
52
  }
39
53
 
40
54
  @Controller()
@@ -65,6 +79,10 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
65
79
  selectionVisible = false;
66
80
  selectionRequiredCount = 0;
67
81
 
82
+ private _selectionChangeListeners = new Set<
83
+ (selection: IResourceTableSelectionPayload<TData>) => void
84
+ >();
85
+
68
86
  $$modelResource: ModelResource<TData>;
69
87
 
70
88
  protected async __init__() {
@@ -80,9 +98,9 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
80
98
  this.queryFilterData = {};
81
99
  this.querySortingData = {};
82
100
  this.sorting = [];
83
- this.rowSelection = {};
84
- this.selectedIds = [];
85
- this.selectedRows = new Map();
101
+ this.selectedIds = this._normalizeSelectedIds(this.$props.selectedIds ?? []);
102
+ this.rowSelection = Object.fromEntries(this.selectedIds.map(id => [selectionKey(id), true]));
103
+ this.selectedRows = this._normalizeSelectedRows(this.$props.selectedRows ?? []);
86
104
  this.$watch(
87
105
  () => this.$props.queryFixed,
88
106
  queryFixed => {
@@ -103,6 +121,8 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
103
121
  () => this.$$modelResource.apiSchemasSelect.sdk,
104
122
  () => this.queryData,
105
123
  );
124
+ this._refreshSelectedRows((this.data ?? []) as unknown as Record<string, unknown>[]);
125
+ this._notifySelectionChange();
106
126
  // watch
107
127
  this.$watch(
108
128
  () => this.permissions,
@@ -115,6 +135,7 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
115
135
  () => this.data,
116
136
  data => {
117
137
  this._refreshSelectedRows((data ?? []) as unknown as Record<string, unknown>[]);
138
+ this._notifySelectionChange();
118
139
  },
119
140
  );
120
141
  }
@@ -123,6 +144,32 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
123
144
  return this.$props.resource;
124
145
  }
125
146
 
147
+ private _normalizeSelectedIds(ids: readonly TableIdentity[]) {
148
+ const selectedIds: TableIdentity[] = [];
149
+ const keys = new Set<string>();
150
+ for (const id of ids) {
151
+ const key = selectionKey(id);
152
+ if (keys.has(key)) continue;
153
+ keys.add(key);
154
+ selectedIds.push(id);
155
+ }
156
+ return this.selectionMode === 'single'
157
+ ? selectedIds.slice(-1)
158
+ : selectedIds.slice(0, this.selectionMax);
159
+ }
160
+
161
+ private _normalizeSelectedRows(rows: readonly Record<string, unknown>[]) {
162
+ const selectedKeys = new Set(this.selectedIds.map(selectionKey));
163
+ const selectedRows = new Map<string, TData>();
164
+ for (const row of rows) {
165
+ const id = selectionRowId(row);
166
+ const key = selectionKey(id);
167
+ if (!selectedKeys.has(key) || selectedRows.has(key)) continue;
168
+ selectedRows.set(key, row as TData);
169
+ }
170
+ return selectedRows;
171
+ }
172
+
126
173
  get query() {
127
174
  const { where: whereFixed, ...queryFixed } = this.queryFixedData;
128
175
  const { where: whereFilter, ...queryFilter } = this.queryFilterData;
@@ -136,7 +183,7 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
136
183
  }
137
184
 
138
185
  get queryData() {
139
- return this.$$modelResource.select(this.query);
186
+ return this.$$modelResource.selectGeneral(this.$props.actionPath, this.query);
140
187
  }
141
188
 
142
189
  get data() {
@@ -174,6 +221,18 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
174
221
  };
175
222
  }
176
223
 
224
+ get selectionMode(): TypeResourcePickerSelectionMode {
225
+ return this.$props.selectionMode ?? 'multiple';
226
+ }
227
+
228
+ get selectionMax() {
229
+ return resolvePickerSelectionMax(this.$props.selectionMode, this.$props.selectionMax);
230
+ }
231
+
232
+ get selectionPersistent() {
233
+ return this.$props.selectionMode !== undefined || this.$props.selectionMax !== undefined;
234
+ }
235
+
177
236
  get selectionAvailable() {
178
237
  const policy = this.$props.selectionPolicy;
179
238
  return (
@@ -192,19 +251,45 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
192
251
  return policy === 'always' || (this.selectionVisible && policy !== false);
193
252
  }
194
253
 
254
+ onSelectionChange(listener: (selection: IResourceTableSelectionPayload<TData>) => void) {
255
+ this._selectionChangeListeners.add(listener);
256
+ return () => this._selectionChangeListeners.delete(listener);
257
+ }
258
+
195
259
  onRowSelectionChange(
196
260
  updater: RowSelectionState | ((old: RowSelectionState) => RowSelectionState),
197
261
  ) {
198
262
  const data = (this.data ?? []) as unknown as Record<string, unknown>[];
199
263
  const next = functionalUpdate(updater, this.rowSelection);
200
- this.rowSelection = reconcileSelection(this.rowSelection, data, next);
264
+ const reconciled = reconcileSelection(this.rowSelection, data, next);
265
+ this.rowSelection = this._limitRowSelection(reconciled, data);
201
266
  this._reconcileSelectedRows(data);
267
+ this._notifySelectionChange();
268
+ }
269
+
270
+ private _limitRowSelection(
271
+ rowSelection: RowSelectionState,
272
+ data: readonly Record<string, unknown>[],
273
+ ) {
274
+ if (this.$props.selectionMode === undefined && this.$props.selectionMax === undefined) {
275
+ return rowSelection;
276
+ }
277
+ return limitPickerRowSelection(rowSelection, data, this.selectionMode, this.selectionMax);
202
278
  }
203
279
 
204
280
  clearSelection() {
205
281
  this.rowSelection = {};
206
282
  this.selectedIds = [];
207
283
  this.selectedRows = new Map();
284
+ this._notifySelectionChange();
285
+ }
286
+
287
+ private _notifySelectionChange() {
288
+ const selection = this.selection;
289
+ this.$props.onSelectionChange?.(selection);
290
+ for (const listener of this._selectionChangeListeners) {
291
+ listener(selection);
292
+ }
208
293
  }
209
294
 
210
295
  private _refreshSelectedRows(data: readonly Record<string, unknown>[]) {
@@ -263,7 +348,7 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
263
348
 
264
349
  setPageSize(pageSize: number) {
265
350
  if (this.queryPaged.pageSize !== pageSize) {
266
- this.clearSelection();
351
+ if (!this.selectionPersistent) this.clearSelection();
267
352
  this.queryPaged.pageSize = pageSize;
268
353
  this.queryPaged.pageNo = 1;
269
354
  }
@@ -272,14 +357,14 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
272
357
  setQueryFixed(queryFixed?: ITableQuery) {
273
358
  const next = queryFixed ?? {};
274
359
  if (deepEqual(this.queryFixedData, next)) return;
275
- this.clearSelection();
360
+ if (!this.selectionPersistent) this.clearSelection();
276
361
  this.queryFixedData = next;
277
362
  this.queryPaged.pageNo = 1;
278
363
  }
279
364
 
280
365
  onFilter(data: ITableQuery) {
281
366
  if (deepEqual(this.queryFilterData, data)) return;
282
- this.clearSelection();
367
+ if (!this.selectionPersistent) this.clearSelection();
283
368
  this.queryFilterData = data;
284
369
  this.queryPaged.pageNo = 1;
285
370
  }
@@ -287,7 +372,7 @@ export class ControllerBlockPage<TData extends {} = {}> extends BeanControllerBa
287
372
  onSortingChange(updater: SortingState | ((old: SortingState) => SortingState)) {
288
373
  const sorting = functionalUpdate(updater, this.sorting).slice(0, 1);
289
374
  if (deepEqual(this.sorting, sorting)) return;
290
- this.clearSelection();
375
+ if (!this.selectionPersistent) this.clearSelection();
291
376
  this.sorting = sorting;
292
377
  const first = this.sorting[0];
293
378
  this.querySortingData = first ? { orders: [[first.id, first.desc ? 'desc' : 'asc']] } : {};
@@ -46,6 +46,7 @@ export class ControllerBlockTable<TData extends {} = {}> extends BeanControllerB
46
46
  sorting={$$page.sorting}
47
47
  onSortingChange={updater => $$page.onSortingChange(updater)}
48
48
  enableRowSelection={$$page.selectionEnabled}
49
+ selectionMode={$$page.selectionMode}
49
50
  rowSelection={$$page.rowSelection}
50
51
  onRowSelectionChange={updater => $$page.onRowSelectionChange(updater)}
51
52
  tableScope={$$page.jsxCelScope}
@@ -7,6 +7,7 @@ export default {
7
7
  Done: 'Done',
8
8
  SelectedItems: 'Selected %d items',
9
9
  SelectedItems_1: 'Selected one item',
10
+ SelectedItemsWithMax: 'Selected %d / %d items',
10
11
  BulkActions: 'Bulk actions',
11
12
  SelectionActionUnavailable: 'Select one or more permitted rows to enable this action',
12
13
  SelectionActionMaxExceeded: 'Select at most %d items to enable this action',
@@ -6,6 +6,7 @@ export default {
6
6
  Select: '选择',
7
7
  Done: '完成',
8
8
  SelectedItems: '已选择 %d 项',
9
+ SelectedItemsWithMax: '已选择 %d / %d 项',
9
10
  BulkActions: '批量操作',
10
11
  SelectionActionUnavailable: '请选择一个或多个有权限的行以启用此操作',
11
12
  SelectionActionMaxExceeded: '最多选择 %d 项以启用此操作',
@@ -1,3 +1,4 @@
1
1
  export * from './.metadata/index.js';
2
2
  export * from './.metadata/locales.js';
3
3
  export * from './types/index.js';
4
+ export * from './lib/selection.js';
@@ -8,11 +8,30 @@ import type {
8
8
  import { isNil } from '@cabloy/utils';
9
9
 
10
10
  export const ResourceSelectedMaxIds = 100;
11
+ export const ResourcePickerSelectionMax = 100;
12
+
13
+ export type TypeResourcePickerSelectionMode = 'single' | 'multiple';
11
14
 
12
15
  export function resolveSelectionMaxIds(selectedMaxIds: number | undefined): number {
13
16
  return selectedMaxIds ?? ResourceSelectedMaxIds;
14
17
  }
15
18
 
19
+ export function resolvePickerSelectionMax(
20
+ selectionMode: TypeResourcePickerSelectionMode | undefined,
21
+ selectionMax: number | undefined,
22
+ ): number {
23
+ if (selectionMode === 'single') return 1;
24
+ if (
25
+ selectionMax === undefined ||
26
+ !Number.isFinite(selectionMax) ||
27
+ !Number.isInteger(selectionMax) ||
28
+ selectionMax < 0
29
+ ) {
30
+ return ResourcePickerSelectionMax;
31
+ }
32
+ return selectionMax;
33
+ }
34
+
16
35
  export type TypeTableActionBulkDynamicDisabledReason = 'maxExceeded' | 'selectionUnavailable';
17
36
 
18
37
  export interface IResourceTableActionBulkDynamicProps {
@@ -82,3 +101,19 @@ export function reconcileSelection(
82
101
  }
83
102
  return result;
84
103
  }
104
+
105
+ export function limitPickerRowSelection(
106
+ selection: RowSelectionState,
107
+ rows: readonly Record<string, unknown>[],
108
+ selectionMode: TypeResourcePickerSelectionMode,
109
+ selectionMax: number,
110
+ ): RowSelectionState {
111
+ const selectedKeys = Object.keys(selection).filter(key => selection[key]);
112
+ if (selectedKeys.length <= selectionMax) return selection;
113
+ const currentPageKeys = selectionRowIds(rows);
114
+ const retainedKeys =
115
+ selectionMode === 'single'
116
+ ? selectedKeys.filter(key => currentPageKeys.has(key)).slice(-1)
117
+ : selectedKeys.slice(0, selectionMax);
118
+ return Object.fromEntries(retainedKeys.map(key => [key, true]));
119
+ }
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import {
5
+ limitPickerRowSelection,
5
6
  reconcileSelection,
7
+ resolvePickerSelectionMax,
6
8
  resolveSelectionMaxIds,
7
9
  resolveTableActionBulkDynamicProps,
8
10
  selectionKey,
@@ -16,6 +18,17 @@ test('selection maximum uses action metadata before the frontend fallback', () =
16
18
  assert.equal(resolveSelectionMaxIds(0), 0);
17
19
  });
18
20
 
21
+ test('picker selection maximum distinguishes single and multiple modes', () => {
22
+ assert.equal(resolvePickerSelectionMax(undefined, undefined), 100);
23
+ assert.equal(resolvePickerSelectionMax('multiple', undefined), 100);
24
+ assert.equal(resolvePickerSelectionMax('multiple', 3), 3);
25
+ assert.equal(resolvePickerSelectionMax('multiple', 0), 0);
26
+ assert.equal(resolvePickerSelectionMax('single', 99), 1);
27
+ assert.equal(resolvePickerSelectionMax('multiple', -1), 100);
28
+ assert.equal(resolvePickerSelectionMax('multiple', 1.5), 100);
29
+ assert.equal(resolvePickerSelectionMax('multiple', Number.NaN), 100);
30
+ });
31
+
19
32
  test('bulk action runtime props do not overwrite static action metadata', () => {
20
33
  const selection = { ids: [1], rows: [{ id: 1 }], count: 1 };
21
34
 
@@ -97,3 +110,14 @@ test('current-page reconciliation removes only deselected loaded identities', ()
97
110
 
98
111
  assert.deepEqual(selection, { previous: true });
99
112
  });
113
+
114
+ test('picker selection caps page-wide updates while retaining earlier identities', () => {
115
+ const rows = [{ id: '2' }, { id: '3' }, { id: '4' }];
116
+ assert.deepEqual(
117
+ limitPickerRowSelection({ 1: true, 2: true, 3: true, 4: true }, rows, 'multiple', 3),
118
+ { 1: true, 2: true, 3: true },
119
+ );
120
+ assert.deepEqual(limitPickerRowSelection({ previous: true, 2: true }, rows, 'single', 1), {
121
+ 2: true,
122
+ });
123
+ });
@@ -0,0 +1,34 @@
1
+ import type { TypeControllerInnerProps } from 'zova';
2
+
3
+ import { defineComponent } from 'vue';
4
+ import { prepareComponentOptions, useController } from 'zova';
5
+
6
+ import type { ControllerBlockResourcePickerActionsProps } from '../../component/blockResourcePickerActions/controller.jsx';
7
+
8
+ import { ControllerBlockResourcePickerActions } from '../../component/blockResourcePickerActions/controller.jsx';
9
+ export type ZBlockResourcePickerActionsProps = {
10
+ controllerRef?: (ref: ControllerBlockResourcePickerActions) => void;
11
+ } & ControllerBlockResourcePickerActionsProps;
12
+
13
+ type ControllerInnerProps = TypeControllerInnerProps<
14
+ ControllerBlockResourcePickerActionsProps,
15
+ keyof typeof ControllerBlockResourcePickerActions.$propsDefault
16
+ >;
17
+ declare module 'zova-module-basic-resource' {
18
+ export interface ControllerBlockResourcePickerActions {
19
+ $props: ControllerInnerProps;
20
+ }
21
+ }
22
+
23
+ export const ZBlockResourcePickerActions = defineComponent(
24
+ (_props: ZBlockResourcePickerActionsProps) => {
25
+ useController(ControllerBlockResourcePickerActions, undefined, undefined);
26
+ return () => {};
27
+ },
28
+ prepareComponentOptions(ControllerBlockResourcePickerActions.$componentOptions),
29
+ );
30
+ declare module 'zova-module-a-bean' {
31
+ export interface IVonaComponentRecord {
32
+ 'basic-resource:blockResourcePickerActions': ControllerBlockResourcePickerActionsProps;
33
+ }
34
+ }