cabloy 5.1.167 → 5.1.169

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 (53) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/package.json +1 -1
  4. package/repo-e2e/specs/cabloy-basic.spec.ts +75 -0
  5. package/vona/pnpm-lock.yaml +267 -53
  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 +8 -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 +43 -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 +224 -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/src/lib/resourcePickerPageHost.ts +64 -0
  31. package/zova/src/suite/cabloy-basic/modules/basic-resource/test/lib/resourcePicker.test.ts +32 -0
  32. package/zova/src/suite/cabloy-basic/modules/basic-resource/test/lib/resourcePickerPageHost.test.ts +94 -0
  33. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +18 -12
  34. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  35. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/component/resourcePickerContext.ts +31 -0
  36. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/index.ts +48 -5
  37. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/.metadata/page/resourcePicker.ts +16 -0
  38. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/component/resourcePickerContext/controller.tsx +26 -0
  39. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/index.ts +2 -0
  40. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/lib/resourcePicker.ts +43 -0
  41. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/resourcePicker/controller.tsx +85 -0
  42. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/routes.ts +9 -0
  43. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  44. package/zova/src/suite-vendor/a-zova/modules/a-bean/cli/controller/metadata/generateFileComponent.ts +40 -1
  45. package/zova/src/suite-vendor/a-zova/modules/a-bean/package.json +1 -1
  46. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  47. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routerViewBase.tsx +5 -0
  48. package/zova/src/suite-vendor/a-zova/modules/a-routerstack/package.json +1 -1
  49. package/zova/src/suite-vendor/a-zova/modules/a-routerstack/src/component/routerViewStack/controller.tsx +7 -1
  50. package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
  51. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/controller.tsx +2 -0
  52. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/render.tsx +13 -10
  53. package/zova/src/suite-vendor/a-zova/package.json +5 -5
@@ -1,5 +1,6 @@
1
1
  // eslint-disable
2
2
  /** controller: begin */
3
+ export * from '../component/blockResourcePickerActions/controller.jsx';
3
4
  export * from '../component/formFieldResourcePicker/controller.jsx';
4
5
 
5
6
  import 'zova';
@@ -9,6 +10,11 @@ declare module 'zova' {
9
10
  }
10
11
  declare module 'zova-module-basic-resource' {
11
12
 
13
+ export interface ControllerBlockResourcePickerActions {
14
+ /** @internal */
15
+ get scope(): ScopeModuleBasicResource;
16
+ }
17
+
12
18
  export interface ControllerFormFieldResourcePicker {
13
19
  /** @internal */
14
20
  get scope(): ScopeModuleBasicResource;
@@ -16,28 +22,35 @@ declare module 'zova-module-basic-resource' {
16
22
  }
17
23
  /** controller: end */
18
24
  /** controller: begin */
19
- import { ControllerFormFieldResourcePicker } from '../component/formFieldResourcePicker/controller.jsx';
25
+ import type { ControllerBlockResourcePickerActions } from '../component/blockResourcePickerActions/controller.jsx';
26
+ import type { ControllerFormFieldResourcePicker } from '../component/formFieldResourcePicker/controller.jsx';
20
27
  import 'zova';
21
28
  declare module 'zova' {
22
29
  export interface IBeanRecordLocal {
23
- 'basic-resource.controller.formFieldResourcePicker': ControllerFormFieldResourcePicker;
30
+ 'basic-resource.controller.blockResourcePickerActions': ControllerBlockResourcePickerActions;
31
+ 'basic-resource.controller.formFieldResourcePicker': ControllerFormFieldResourcePicker;
24
32
  }
25
33
  }
26
34
  /** controller: end */
27
35
 
28
36
  /** components: begin */
37
+ export * from './component/blockResourcePickerActions.js';
38
+ import { ZBlockResourcePickerActions } from './component/blockResourcePickerActions.js';
29
39
  export * from './component/formFieldResourcePicker.js';
30
40
  import { ZFormFieldResourcePicker } from './component/formFieldResourcePicker.js';
31
41
  export const components = {
32
- 'formFieldResourcePicker': ZFormFieldResourcePicker,
42
+ 'blockResourcePickerActions': ZBlockResourcePickerActions,
43
+ 'formFieldResourcePicker': ZFormFieldResourcePicker,
33
44
  };
34
45
  import 'zova';
35
46
  declare module 'zova' {
36
47
  export interface IComponentRecord {
37
- 'basic-resource:formFieldResourcePicker': ControllerFormFieldResourcePicker;
48
+ 'basic-resource:blockResourcePickerActions': ControllerBlockResourcePickerActions;
49
+ 'basic-resource:formFieldResourcePicker': ControllerFormFieldResourcePicker;
38
50
  }
39
51
  export interface IZovaComponentRecord {
40
- 'basic-resource:formFieldResourcePicker': typeof ZFormFieldResourcePicker;
52
+ 'basic-resource:blockResourcePickerActions': typeof ZBlockResourcePickerActions;
53
+ 'basic-resource:formFieldResourcePicker': typeof ZFormFieldResourcePicker;
41
54
  }
42
55
  }
43
56
  /** components: end */
@@ -68,7 +81,7 @@ declare module 'zova-module-basic-resource' {
68
81
  }
69
82
  /** tableCell: end */
70
83
  /** tableCell: begin */
71
- import { TableCellResourcePicker } from '../bean/tableCell.resourcePicker.jsx';
84
+ import type { TableCellResourcePicker } from '../bean/tableCell.resourcePicker.jsx';
72
85
  import 'zova';
73
86
  declare module 'zova' {
74
87
  export interface IBeanRecordGeneral {
@@ -76,8 +89,11 @@ declare module 'zova' {
76
89
  }
77
90
  }
78
91
  /** tableCell: end */
92
+ /** locale: begin */
93
+ import { locales } from './locales.js';
94
+ /** locale: end */
79
95
  /** scope: begin */
80
- import { BeanScopeBase, type BeanScopeUtil } from 'zova';
96
+ import { BeanScopeBase, type BeanScopeUtil, TypeModuleLocales, TypeLocaleBase } from 'zova';
81
97
  import { Scope } from 'zova-module-a-bean';
82
98
 
83
99
  @Scope()
@@ -85,6 +101,7 @@ export class ScopeModuleBasicResource extends BeanScopeBase {}
85
101
 
86
102
  export interface ScopeModuleBasicResource {
87
103
  util: BeanScopeUtil;
104
+ locale: TypeModuleLocales<(typeof locales)[TypeLocaleBase]>;
88
105
  }
89
106
 
90
107
  import 'zova';
@@ -95,9 +112,14 @@ declare module 'zova' {
95
112
 
96
113
 
97
114
 
98
-
115
+ export interface IBeanScopeLocale {
116
+ 'basic-resource': (typeof locales)[TypeLocaleBase];
117
+ }
99
118
 
100
119
 
101
120
  }
102
121
 
122
+ export function locale<K extends keyof (typeof locales)[TypeLocaleBase]>(key: K): `basic-resource::${K}` {
123
+ return `basic-resource::${key}`;
124
+ }
103
125
  /** scope: end */
@@ -0,0 +1,7 @@
1
+ import locale_en_us from '../config/locale/en-us.js';
2
+ import locale_zh_cn from '../config/locale/zh-cn.js';
3
+
4
+ export const locales = {
5
+ 'en-us': locale_en_us,
6
+ 'zh-cn': locale_zh_cn,
7
+ };
@@ -0,0 +1,92 @@
1
+ import type { IComponentOptions } from 'zova';
2
+ import type { IJsxRenderContextPage, IResourceBlockOptionsBase } from 'zova-module-a-openapi';
3
+ import type { IResourcePickerPageContext } from 'zova-module-rest-resource';
4
+
5
+ import { BeanControllerBase, Use } from 'zova';
6
+ import { Controller } from 'zova-module-a-bean';
7
+ import { resolvePickerSelectionMax } from 'zova-module-basic-page';
8
+ import { resourcePickerPageContextKey } from 'zova-module-rest-resource';
9
+
10
+ declare module 'zova-module-a-openapi' {
11
+ export interface IResourceBlockRecord {
12
+ 'basic-resource:blockResourcePickerActions'?: ControllerBlockResourcePickerActionsProps;
13
+ }
14
+ }
15
+
16
+ export interface ControllerBlockResourcePickerActionsProps extends IResourceBlockOptionsBase {}
17
+
18
+ @Controller()
19
+ export class ControllerBlockResourcePickerActions extends BeanControllerBase {
20
+ static $propsDefault = {
21
+ class: 'flex items-center justify-between gap-3',
22
+ };
23
+
24
+ static $componentOptions: IComponentOptions = { inheritAttrs: false, deepExtendDefault: true };
25
+
26
+ @Use({ injectionScope: 'host' })
27
+ $$renderContext: IJsxRenderContextPage;
28
+
29
+ @Use({ name: resourcePickerPageContextKey, injectionScope: 'host' })
30
+ $$pickerContext: IResourcePickerPageContext;
31
+
32
+ protected async __init__() {}
33
+
34
+ get pickerOptions() {
35
+ return this.$$pickerContext.options;
36
+ }
37
+
38
+ get selectionMax() {
39
+ return resolvePickerSelectionMax(
40
+ this.pickerOptions.selectionMode,
41
+ this.pickerOptions.selectionMax,
42
+ );
43
+ }
44
+
45
+ get selection() {
46
+ return this.$$renderContext.$$page.selection.ids;
47
+ }
48
+
49
+ get canConfirm() {
50
+ const count = this.selection.length;
51
+ return (
52
+ count > 0 &&
53
+ count <= this.selectionMax &&
54
+ (this.pickerOptions.selectionMode !== 'single' || count === 1)
55
+ );
56
+ }
57
+
58
+ public confirm() {
59
+ if (!this.canConfirm) return;
60
+ this.$$pickerContext.dialog.resolve(this.$$renderContext.$$page.selection);
61
+ }
62
+
63
+ public cancel() {
64
+ this.$$pickerContext.dialog.cancel();
65
+ }
66
+
67
+ protected render() {
68
+ const count = this.selection.length;
69
+ return (
70
+ <div class="flex items-center justify-between gap-3">
71
+ <span class="text-sm text-base-content/70" role="status" aria-live="polite">
72
+ {this.pickerOptions.selectionMode === 'multiple'
73
+ ? this.scope.locale.SelectedItemsWithMax(count, this.selectionMax)
74
+ : this.scope.locale.SelectedItems(count)}
75
+ </span>
76
+ <div class="join">
77
+ <button type="button" class="btn join-item" onClick={() => this.cancel()}>
78
+ {this.scope.locale.Cancel()}
79
+ </button>
80
+ <button
81
+ type="button"
82
+ class="btn btn-primary join-item"
83
+ disabled={!this.canConfirm}
84
+ onClick={() => this.confirm()}
85
+ >
86
+ {this.scope.locale.Select()}
87
+ </button>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
92
+ }
@@ -1,15 +1,36 @@
1
+ import type { TableIdentity } from 'table-identity';
1
2
  import type { IComponentOptions } from 'zova';
2
3
  import type { IFormFieldComponentOptions, IJsxRenderContextFormField } from 'zova-module-a-form';
3
- import type { IResourceFormFieldOptionsBase, ITableQuery } from 'zova-module-a-openapi';
4
+ import type {
5
+ IResourceFormFieldOptionsBase,
6
+ IResourceTableSelectionPayload,
7
+ ITableQuery,
8
+ } from 'zova-module-a-openapi';
9
+ import type {
10
+ IResourcePickerPageOptions,
11
+ IResourcePickerPageSession,
12
+ } from 'zova-module-rest-resource';
4
13
 
5
14
  import { classes } from 'typestyle';
6
15
  import { BeanControllerBase, Use } from 'zova';
7
16
  import { Controller } from 'zova-module-a-bean';
8
17
  import { ZFormField, ZFormFieldPreset } from 'zova-module-a-form';
9
18
  import { $QueryEnsureLoaded } from 'zova-module-a-model';
19
+ import { resolvePickerSelectionMax } from 'zova-module-basic-page';
10
20
  import { ZSelect, ZSelectProps } from 'zova-module-basic-select';
21
+ import { resourcePickerPageHostKey } from 'zova-module-rest-resource';
11
22
  import { ModelResource } from 'zova-module-rest-resource';
12
23
 
24
+ import type { TypeResourcePickerSelectionMode } from '../../lib/resourcePicker.js';
25
+
26
+ import {
27
+ normalizeResourcePickerIds,
28
+ resolveResourcePickerValue,
29
+ } from '../../lib/resourcePicker.js';
30
+ import { createResourcePickerPageHost } from '../../lib/resourcePickerPageHost.js';
31
+
32
+ export type TypeResourcePickerMode = 'select' | 'routedDialog';
33
+
13
34
  declare module 'zova-module-a-openapi' {
14
35
  export interface IResourceFormFieldRecord {
15
36
  'basic-resource:formFieldResourcePicker'?: IResourceFormFieldResourcePickerOptions;
@@ -22,6 +43,9 @@ export interface IResourceFormFieldResourcePickerOptions extends IResourceFormFi
22
43
  query?: ITableQuery;
23
44
  relationName?: string;
24
45
  selectOptions?: ZSelectProps;
46
+ pickerMode?: TypeResourcePickerMode;
47
+ selectionMode?: TypeResourcePickerSelectionMode;
48
+ selectionMax?: number;
25
49
  }
26
50
 
27
51
  export interface ControllerFormFieldResourcePickerProps extends IFormFieldComponentOptions {
@@ -37,21 +61,21 @@ export class ControllerFormFieldResourcePicker extends BeanControllerBase {
37
61
  static $componentOptions: IComponentOptions = { inheritAttrs: false, deepExtendDefault: true };
38
62
 
39
63
  $$modelResource: ModelResource;
64
+ private _openingDialog = false;
65
+ private _pendingLabelIds = new Set<string>();
66
+ private _labelHydrationAttemptedIds = new Set<string>();
40
67
 
41
68
  @Use({ injectionScope: 'host' })
42
69
  $$renderContext: IJsxRenderContextFormField;
43
70
 
44
71
  protected async __init__() {
45
72
  const { ctx } = this.$$renderContext;
46
- // readonly
47
- if (this.$props.readonly) return;
48
- // modelResource
73
+ if (this.$props.readonly || this.pickerMode === 'routedDialog') return;
49
74
  this.$$modelResource = await ctx.bean._getBeanSelector(
50
75
  'rest-resource.model.resource',
51
76
  true,
52
77
  this.resource,
53
78
  );
54
- // load data
55
79
  await $QueryEnsureLoaded(() => this.queryData);
56
80
  }
57
81
 
@@ -65,6 +89,14 @@ export class ControllerFormFieldResourcePicker extends BeanControllerBase {
65
89
  return this.$props.options;
66
90
  }
67
91
 
92
+ get pickerMode(): TypeResourcePickerMode {
93
+ return this.resourcePickerOptions?.pickerMode ?? 'select';
94
+ }
95
+
96
+ get selectionMode(): TypeResourcePickerSelectionMode {
97
+ return this.resourcePickerOptions?.selectionMode ?? 'single';
98
+ }
99
+
68
100
  get queryData() {
69
101
  return this.$$modelResource.selectGeneral(
70
102
  this.resourcePickerOptions?.actionPath,
@@ -86,6 +118,11 @@ export class ControllerFormFieldResourcePicker extends BeanControllerBase {
86
118
  ></ZFormFieldPreset>
87
119
  );
88
120
  }
121
+ if (this.pickerMode === 'routedDialog') return this._renderRoutedDialogField();
122
+ return this._renderSelectField();
123
+ }
124
+
125
+ private _renderSelectField() {
89
126
  return (
90
127
  <ZFormField
91
128
  {...this.$props}
@@ -131,14 +168,190 @@ export class ControllerFormFieldResourcePicker extends BeanControllerBase {
131
168
  );
132
169
  }
133
170
 
171
+ private _renderRoutedDialogField() {
172
+ return (
173
+ <ZFormField
174
+ {...this.$props}
175
+ slotDefault={({ propsBucket, props }, $$formField) => {
176
+ const className = !propsBucket.needHandleBorder
177
+ ? classes(
178
+ props.class,
179
+ 'grow w-full h-full min-h-0 border-0 rounded-none bg-transparent px-0 py-0 text-left outline-none',
180
+ )
181
+ : classes(props.class, 'input', !$$formField.field.state.meta.isValid && 'input-error');
182
+ return (
183
+ <button
184
+ {...props}
185
+ type="button"
186
+ class={className}
187
+ onBlur={() => $$formField.handleBlur()}
188
+ onClick={() => {
189
+ void this._openPicker(propsBucket.value, value => {
190
+ $$formField.setValue(value, propsBucket.disableNotifyChanged);
191
+ }).catch(error => {
192
+ this.$errorHandler(error, 'ControllerFormFieldResourcePicker.openPicker');
193
+ });
194
+ }}
195
+ >
196
+ {this._formatPickerValue(propsBucket.value) || this.scope.locale.PleaseSelect()}
197
+ </button>
198
+ );
199
+ }}
200
+ ></ZFormField>
201
+ );
202
+ }
203
+
204
+ private async _openPicker(value: unknown, setValue: (value: unknown) => void) {
205
+ if (this._openingDialog) return;
206
+ this._openingDialog = true;
207
+ try {
208
+ const selectedIds = normalizeResourcePickerIds(value);
209
+ const options: IResourcePickerPageOptions = {
210
+ resource: this.resource,
211
+ actionPath: this.resourcePickerOptions?.actionPath,
212
+ query: this.resourcePickerOptions?.query,
213
+ selectionMode: this.selectionMode,
214
+ selectionMax: this.resourcePickerOptions?.selectionMax,
215
+ selectedIds,
216
+ };
217
+ const session: IResourcePickerPageSession = {
218
+ selectedIds: [...selectedIds],
219
+ selectedRows: [],
220
+ };
221
+ const handle = this.$appModal.routedDialog<
222
+ IResourceTableSelectionPayload,
223
+ IResourcePickerPageOptions,
224
+ IResourcePickerPageSession
225
+ >({
226
+ route: {
227
+ name: 'rest-resource:resourcePicker',
228
+ params: { resource: this.resource },
229
+ },
230
+ props: options,
231
+ session,
232
+ createPageHostProviders: dialog => ({
233
+ [resourcePickerPageHostKey]: createResourcePickerPageHost(dialog),
234
+ }),
235
+ });
236
+ const result = await handle.result;
237
+ if (!result) return;
238
+ const nextValue = resolveResourcePickerValue(
239
+ result.ids,
240
+ this.selectionMode,
241
+ resolvePickerSelectionMax(this.selectionMode, this.resourcePickerOptions?.selectionMax),
242
+ );
243
+ this._syncRelationField(nextValue, result.rows);
244
+ setValue(nextValue);
245
+ } finally {
246
+ this._openingDialog = false;
247
+ }
248
+ }
249
+
250
+ private _formatPickerValue(value: unknown) {
251
+ const selectedIds = normalizeResourcePickerIds(value);
252
+ if (selectedIds.length === 0) return '';
253
+ const relationItems = this._getRelationItems();
254
+ const relationMap = new Map(
255
+ relationItems.map(item => [String(item.id as TableIdentity), item] as const),
256
+ );
257
+ const itemTitle = this._itemTitle;
258
+ const missingIds = selectedIds.filter(id => {
259
+ const item = relationMap.get(String(id));
260
+ return (
261
+ (item?.[itemTitle] === undefined || item[itemTitle] === null) &&
262
+ !this._labelHydrationAttemptedIds.has(String(id))
263
+ );
264
+ });
265
+ if (missingIds.length > 0) {
266
+ void this._hydrateMissingLabels(missingIds);
267
+ }
268
+ return selectedIds
269
+ .map(id => {
270
+ const title = relationMap.get(String(id))?.[itemTitle];
271
+ return title === undefined || title === null ? String(id) : String(title);
272
+ })
273
+ .join(', ');
274
+ }
275
+
134
276
  private _getValueByRelation() {
135
- const { $$form } = this.$$renderContext;
136
- let relationName = this.$props.options.relationName;
137
- if (!relationName) {
138
- relationName = this.$props.name!.substring(0, this.$props.name!.lastIndexOf('Id'));
277
+ const item = this._getRelationItems()[0];
278
+ return item?.[this._itemTitle];
279
+ }
280
+
281
+ private get _itemTitle() {
282
+ return String(this.resourcePickerOptions?.selectOptions?.itemTitle ?? 'name');
283
+ }
284
+
285
+ private get _relationName() {
286
+ const relationName = this.resourcePickerOptions?.relationName;
287
+ if (relationName) return relationName;
288
+ const fieldName = this.$props.name;
289
+ const index = fieldName?.lastIndexOf('Id') ?? -1;
290
+ return index > 0 ? fieldName!.substring(0, index) : undefined;
291
+ }
292
+
293
+ private _getRelationItems() {
294
+ const relationName = this._relationName;
295
+ if (!relationName) return [] as Record<string, unknown>[];
296
+ const value = this.$$renderContext.$$form.getFieldValue(relationName as never);
297
+ const items = Array.isArray(value) ? value : value ? [value] : [];
298
+ return items.filter(item => !!item && typeof item === 'object') as Record<string, unknown>[];
299
+ }
300
+
301
+ private _syncRelationField(value: unknown, rows: readonly Record<string, unknown>[]) {
302
+ const relationName = this._relationName;
303
+ if (!relationName) return;
304
+ const selectedIds = normalizeResourcePickerIds(value);
305
+ const itemTitle = this._itemTitle;
306
+ const rowMap = new Map(
307
+ this._getRelationItems().map(item => [String(item.id as TableIdentity), item] as const),
308
+ );
309
+ for (const row of rows) {
310
+ const id = row.id as TableIdentity;
311
+ if (id === undefined || id === null || id === '') continue;
312
+ const title = row[itemTitle];
313
+ if (title === undefined || title === null) continue;
314
+ rowMap.set(String(id), { id, [itemTitle]: title });
315
+ }
316
+ const relationItems = selectedIds.map(id => rowMap.get(String(id)) ?? { id });
317
+ const relationValue = this.selectionMode === 'multiple' ? relationItems : relationItems[0];
318
+ this.$$renderContext.$$form.setFieldValue(relationName as never, relationValue, true);
319
+ }
320
+
321
+ private async _hydrateMissingLabels(ids: readonly TableIdentity[]) {
322
+ const relationName = this._relationName;
323
+ if (!relationName) return;
324
+ const idsToLoad = ids.filter(id => {
325
+ const key = String(id);
326
+ if (this._pendingLabelIds.has(key) || this._labelHydrationAttemptedIds.has(key)) return false;
327
+ this._pendingLabelIds.add(key);
328
+ this._labelHydrationAttemptedIds.add(key);
329
+ return true;
330
+ });
331
+ if (idsToLoad.length === 0) return;
332
+ try {
333
+ const model = (await this.bean._getBeanSelector(
334
+ 'rest-resource.model.resource',
335
+ true,
336
+ this.resource,
337
+ )) as ModelResource;
338
+ const rows = await Promise.all(
339
+ idsToLoad.map(async id => {
340
+ const result = await model.view(id).refetch();
341
+ return result.data;
342
+ }),
343
+ );
344
+ const currentIds = normalizeResourcePickerIds(
345
+ this.$$renderContext.$$form.getFieldValue(this.$props.name as never),
346
+ );
347
+ this._syncRelationField(currentIds, rows.filter(Boolean) as Record<string, unknown>[]);
348
+ } catch (error) {
349
+ this.$errorHandler(error, 'ControllerFormFieldResourcePicker.hydrateMissingLabels');
350
+ } finally {
351
+ for (const id of idsToLoad) {
352
+ this._pendingLabelIds.delete(String(id));
353
+ }
139
354
  }
140
- const obj = $$form.getFieldValue(relationName);
141
- return obj?.[String(this.$props.options.selectOptions!.itemTitle)];
142
355
  }
143
356
 
144
357
  private _ensureEmptyItemFallback(
@@ -0,0 +1,8 @@
1
+ export default {
2
+ Cancel: 'Cancel',
3
+ PleaseSelect: 'Please select...',
4
+ Select: 'Select',
5
+ SelectedItems: 'Selected %d items',
6
+ SelectedItems_1: 'Selected one item',
7
+ SelectedItemsWithMax: 'Selected %d / %d items',
8
+ };
@@ -0,0 +1,7 @@
1
+ export default {
2
+ Cancel: '取消',
3
+ PleaseSelect: '请选择...',
4
+ Select: '选择',
5
+ SelectedItems: '已选择 %d 项',
6
+ SelectedItemsWithMax: '已选择 %d / %d 项',
7
+ };
@@ -1 +1,3 @@
1
+ export * from './.metadata/locales.js';
1
2
  export * from './.metadata/index.js';
3
+ export * from './lib/resourcePicker.js';
@@ -0,0 +1,29 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+
3
+ import { isNil } from '@cabloy/utils';
4
+
5
+ export type TypeResourcePickerSelectionMode = 'single' | 'multiple';
6
+
7
+ export function normalizeResourcePickerIds(value: unknown): TableIdentity[] {
8
+ const values = Array.isArray(value) ? value : isNil(value) || value === '' ? [] : [value];
9
+ const ids: TableIdentity[] = [];
10
+ const keys = new Set<string>();
11
+ for (const value of values) {
12
+ if (isNil(value) || value === '') continue;
13
+ const id = value as TableIdentity;
14
+ const key = String(id);
15
+ if (keys.has(key)) continue;
16
+ keys.add(key);
17
+ ids.push(id);
18
+ }
19
+ return ids;
20
+ }
21
+
22
+ export function resolveResourcePickerValue(
23
+ value: unknown,
24
+ selectionMode: TypeResourcePickerSelectionMode,
25
+ selectionMax: number,
26
+ ): TableIdentity | TableIdentity[] | undefined {
27
+ const selectedIds = normalizeResourcePickerIds(value).slice(0, selectionMax);
28
+ return selectionMode === 'single' ? selectedIds[0] : selectedIds;
29
+ }
@@ -0,0 +1,64 @@
1
+ import type {
2
+ IResourceBlockOptionsBase,
3
+ IResourceRenderBlockOptionsBlock,
4
+ IResourceTableSelectionPayload,
5
+ } from 'zova-module-a-openapi';
6
+ import type { IRoutedDialogContext } from 'zova-module-basic-app';
7
+ import type {
8
+ IResourcePickerPageContext,
9
+ IResourcePickerPageHost,
10
+ } from 'zova-module-rest-resource';
11
+
12
+ import { deepExtend } from 'zova';
13
+
14
+ export function createResourcePickerPageHost(
15
+ dialog: IRoutedDialogContext<
16
+ IResourceTableSelectionPayload,
17
+ IResourcePickerPageContext['options'],
18
+ IResourcePickerPageContext['session']
19
+ >,
20
+ ): IResourcePickerPageHost {
21
+ const options = dialog.props;
22
+ const session = dialog.session;
23
+ if (!options || !session) {
24
+ throw new Error('resource picker requires dialog options and session');
25
+ }
26
+ return {
27
+ options,
28
+ session,
29
+ resolve: selection => dialog.resolve(selection),
30
+ cancel: () => dialog.cancel(),
31
+ prepareBlocks: (blocks, pickerContext) => {
32
+ if (!blocks) return;
33
+ return blocks.map(block => {
34
+ if (block.render !== 'basic-page:blockPage') return block;
35
+ return {
36
+ ...block,
37
+ options: deepExtend({}, block.options, {
38
+ actionPath: pickerContext.options.actionPath,
39
+ queryFixed: pickerContext.options.query,
40
+ selectionPolicy: 'always',
41
+ selectionMode: pickerContext.options.selectionMode,
42
+ selectionMax: pickerContext.options.selectionMax,
43
+ selectedIds: pickerContext.session.selectedIds,
44
+ selectedRows: pickerContext.session.selectedRows,
45
+ onSelectionChange: (selection: IResourceTableSelectionPayload) => {
46
+ pickerContext.session.selectedIds = [...selection.ids];
47
+ pickerContext.session.selectedRows = selection.rows.map(row => ({ ...row }));
48
+ },
49
+ blocks: [
50
+ ...((
51
+ block.options as IResourceBlockOptionsBase & {
52
+ blocks?: IResourceRenderBlockOptionsBlock[];
53
+ }
54
+ ).blocks ?? []),
55
+ {
56
+ render: 'basic-resource:blockResourcePickerActions',
57
+ },
58
+ ],
59
+ }),
60
+ };
61
+ });
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ normalizeResourcePickerIds,
6
+ resolveResourcePickerValue,
7
+ } from '../../src/lib/resourcePicker.ts';
8
+
9
+ test('resource picker normalization preserves identity types and deduplicates equivalent identities', () => {
10
+ assert.deepEqual(normalizeResourcePickerIds(undefined), []);
11
+ assert.deepEqual(normalizeResourcePickerIds(''), []);
12
+ assert.deepEqual(normalizeResourcePickerIds(7), [7]);
13
+ assert.deepEqual(normalizeResourcePickerIds([7, '7', 8, undefined, '']), [7, 8]);
14
+ });
15
+
16
+ test('resource picker result resolves a scalar identity for single mode', () => {
17
+ assert.equal(resolveResourcePickerValue([7, 8], 'single', 1), 7);
18
+ assert.equal(resolveResourcePickerValue([], 'single', 1), undefined);
19
+ });
20
+
21
+ test('resource picker result enforces the configured multiple selection maximum', () => {
22
+ assert.deepEqual(resolveResourcePickerValue([1, 2, 3], 'multiple', 2), [1, 2]);
23
+ assert.deepEqual(resolveResourcePickerValue([1, 2], 'multiple', 0), []);
24
+ assert.deepEqual(
25
+ resolveResourcePickerValue(
26
+ Array.from({ length: 101 }, (_value, index) => index + 1),
27
+ 'multiple',
28
+ 100,
29
+ ),
30
+ Array.from({ length: 100 }, (_value, index) => index + 1),
31
+ );
32
+ });