@vidyano-labs/virtual-service 0.3.0 → 0.4.1

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 (4) hide show
  1. package/README.md +283 -210
  2. package/index.d.ts +376 -150
  3. package/index.js +1391 -1597
  4. package/package.json +2 -2
package/index.js CHANGED
@@ -36,395 +36,214 @@ function toServiceValue(value, type) {
36
36
  }
37
37
 
38
38
  /**
39
- * Registry for managing PersistentObject configurations and instances
39
+ * Business rule validator that supports built-in and custom rules
40
40
  */
41
- class VirtualPersistentObjectRegistry {
42
- #configs = new Map();
43
- #actionHandlers;
44
- #validator;
45
- #queryRegistry;
46
- #actionsRegistry;
47
- constructor(validator, actionHandlers, queryRegistry, actionsRegistry) {
48
- this.#validator = validator;
49
- this.#actionHandlers = actionHandlers;
50
- this.#queryRegistry = queryRegistry;
51
- this.#actionsRegistry = actionsRegistry;
52
- }
53
- /**
54
- * Registers a PersistentObject configuration
55
- */
56
- register(config) {
57
- this.#configs.set(config.type, config);
58
- }
41
+ class BusinessRuleValidator {
42
+ #builtInRules = new Map();
43
+ #customRules = new Map();
44
+ #service;
59
45
  /**
60
- * Gets a registered PersistentObject configuration
46
+ * Checks if a rules string contains NotEmpty or Required rule
47
+ * @param rules - The rules string (semicolon-separated)
48
+ * @returns true if rules contain NotEmpty or Required
61
49
  */
62
- getConfig(type) {
63
- return this.#configs.get(type);
50
+ static hasRequiredRule(rules) {
51
+ if (!rules)
52
+ return false;
53
+ const ruleNames = rules
54
+ .split(";")
55
+ .map(rule => rule.trim())
56
+ .map(rule => {
57
+ const match = rule.match(/^(\w+)/);
58
+ return match ? match[1] : "";
59
+ });
60
+ return ruleNames.includes("NotEmpty") || ruleNames.includes("Required");
64
61
  }
65
- /**
66
- * Gets or creates a PersistentObject DTO
67
- */
68
- async getPersistentObject(type, objectId, isNew, parent = null) {
69
- const config = this.#configs.get(type);
70
- if (!config)
71
- throw new Error(`PersistentObject type "${type}" is not registered`);
72
- // Create new instance
73
- let po = await buildPersistentObjectDto(config, this.#queryRegistry, objectId, isNew);
74
- // Auto-add actions based on overridden lifecycle methods
75
- if (this.#actionsRegistry.isMethodOverridden(config.type, "onSave")) {
76
- if (!po.actions.includes("Save"))
77
- po.actions.push("Save");
78
- }
79
- // Call lifecycle hooks
80
- const conversionContext = this.#createConversionContext();
81
- // Always call onConstruct
82
- this.#actionsRegistry.executeConstruct(po, conversionContext);
83
- // Call onLoad only for existing objects
84
- if (!isNew)
85
- po = await this.#actionsRegistry.executeLoad(po, parent, conversionContext);
86
- return po;
62
+ constructor(service) {
63
+ this.#service = service;
64
+ // Register all built-in rules
65
+ this.#builtInRules.set("IsBase64", this.#validateIsBase64.bind(this));
66
+ this.#builtInRules.set("IsEmail", this.#validateIsEmail.bind(this));
67
+ this.#builtInRules.set("IsRegex", this.#validateIsRegex.bind(this));
68
+ this.#builtInRules.set("IsUrl", this.#validateIsUrl.bind(this));
69
+ this.#builtInRules.set("IsWord", this.#validateIsWord.bind(this));
70
+ this.#builtInRules.set("MaxLength", this.#validateMaxLength.bind(this));
71
+ this.#builtInRules.set("MaxValue", this.#validateMaxValue.bind(this));
72
+ this.#builtInRules.set("MinLength", this.#validateMinLength.bind(this));
73
+ this.#builtInRules.set("MinValue", this.#validateMinValue.bind(this));
74
+ this.#builtInRules.set("NotEmpty", this.#validateNotEmpty.bind(this));
75
+ this.#builtInRules.set("Required", this.#validateRequired.bind(this));
87
76
  }
88
77
  /**
89
- * Creates a new PersistentObject with the New action lifecycle
90
- * @param type - The PersistentObject type
91
- * @param parent - The parent PersistentObject (or null)
92
- * @param query - The Query from which New was invoked (or null)
93
- * @param parameters - Additional parameters (or null)
94
- * @returns The new PersistentObject DTO
78
+ * Register a custom business rule
79
+ * @throws Error if attempting to override a built-in rule
95
80
  */
96
- async createNewPersistentObject(type, parent, query, parameters) {
97
- const config = this.#configs.get(type);
98
- if (!config)
99
- throw new Error(`PersistentObject type "${type}" is not registered`);
100
- // Create new instance with a generated ID
101
- const objectId = crypto.randomUUID();
102
- let po = await buildPersistentObjectDto(config, this.#queryRegistry, objectId, true);
103
- // Auto-add actions based on overridden lifecycle methods
104
- if (this.#actionsRegistry.isMethodOverridden(config.type, "onSave")) {
105
- if (!po.actions.includes("Save"))
106
- po.actions.push("Save");
107
- }
108
- // Call lifecycle hooks
109
- const conversionContext = this.#createConversionContext();
110
- // Always call onConstruct first
111
- this.#actionsRegistry.executeConstruct(po, conversionContext);
112
- // Then call onNew for new objects
113
- po = await this.#actionsRegistry.executeNew(po, parent, query, parameters, conversionContext);
114
- return po;
81
+ registerCustomRule(name, validator) {
82
+ if (this.#builtInRules.has(name))
83
+ throw new Error(`Cannot override built-in rule: ${name}`);
84
+ this.#customRules.set(name, validator);
115
85
  }
116
86
  /**
117
- * Executes an action on a PersistentObject
87
+ * Validate an attribute against its rules
88
+ * @param attr - The wrapped attribute to validate (has getValue/setValue methods and persistentObject reference)
89
+ * @param _po - Deprecated: The persistent object is now accessible via attr.persistentObject
90
+ * @returns Error message if validation fails, null if valid
118
91
  */
119
- async executeAction(request) {
120
- let parent = request.parent;
121
- if (!parent)
122
- throw new Error("ExecuteAction requires a parent PersistentObject");
123
- const type = parent.type;
124
- const actionName = request.action.split(".").pop();
125
- // Handle refresh action
126
- if (actionName === "Refresh")
127
- return this.#handleRefresh(request);
128
- // Validate before Save action
129
- if (actionName === "Save") {
130
- const config = this.#configs.get(type);
92
+ validateAttribute(attr, _po) {
93
+ // The attr already has rules from config (merged at entry point via #wrapPersistentObject)
94
+ // Get the converted value using the wrapped attribute's getValue()
95
+ const convertedValue = attr.getValue();
96
+ if (!attr.rules)
97
+ return null;
98
+ const rules = this.parseRules(attr.rules);
99
+ for (const rule of rules) {
100
+ const validator = this.#builtInRules.get(rule.name) || this.#customRules.get(rule.name);
101
+ if (!validator)
102
+ throw new Error(`Unknown business rule: ${rule.name}`);
131
103
  try {
132
- const validationFailed = this.#validateAttributes(parent, config);
133
- if (validationFailed) {
134
- return {
135
- result: parent
136
- };
137
- }
104
+ validator(convertedValue, attr, ...rule.params);
138
105
  }
139
106
  catch (error) {
140
- parent.notification = error instanceof Error ? error.message : String(error);
141
- parent.notificationType = "Error";
142
- return {
143
- result: parent
144
- };
107
+ return error instanceof Error ? error.message : String(error);
145
108
  }
146
- // Call onSave from actions registry
147
- const conversionContext = this.#createConversionContext();
148
- parent = await this.#actionsRegistry.executeSave(parent, conversionContext);
149
- return {
150
- result: parent
151
- };
152
- }
153
- // Get custom action handler
154
- const handler = this.#actionHandlers.get(actionName);
155
- if (!handler)
156
- throw new Error(`Action "${actionName}" is not registered`);
157
- // Create action context
158
- const context = this.#createActionContext(parent);
159
- // Build unified action args for PersistentObject actions
160
- const args = {
161
- parent: parent,
162
- query: undefined,
163
- selectedItems: undefined,
164
- parameters: request.parameters,
165
- context: context
166
- };
167
- // Execute handler and get result
168
- const result = await handler(args);
169
- // Handle both old and new API formats for backwards compatibility
170
- // Old API: handler returns { result: PersistentObjectDto }
171
- // New API: handler returns PersistentObjectDto | null
172
- let finalResult;
173
- if (result && typeof result === "object" && "result" in result) {
174
- // Old API format - extract the result property
175
- finalResult = result.result || parent;
176
- }
177
- else {
178
- // New API format - use directly
179
- finalResult = result || parent;
180
109
  }
181
- // Return response with result
182
- return {
183
- result: finalResult
184
- };
110
+ return null;
185
111
  }
186
112
  /**
187
- * Handles the PersistentObject.Refresh action
113
+ * Parse a rules string into individual rules with parameters
114
+ * Example: "NotEmpty; MaxLength(40); IsEmail" -> [
115
+ * { name: "NotEmpty", params: [] },
116
+ * { name: "MaxLength", params: [40] },
117
+ * { name: "IsEmail", params: [] }
118
+ * ]
188
119
  */
189
- async #handleRefresh(request) {
190
- let parent = request.parent;
191
- const type = parent.type;
192
- const config = this.#configs.get(type);
193
- if (!config)
194
- throw new Error(`PersistentObject type "${type}" is not registered`);
195
- // Get the attribute that triggered the refresh
196
- const refreshedAttributeId = request.parameters?.RefreshedPersistentObjectAttributeId;
197
- const triggeredAttribute = refreshedAttributeId
198
- ? parent.attributes?.find(a => a.id === refreshedAttributeId)
199
- : undefined;
200
- // Call onRefresh from actions registry
201
- const conversionContext = this.#createConversionContext();
202
- parent = await this.#actionsRegistry.executeRefresh(parent, triggeredAttribute, conversionContext);
203
- return {
204
- result: parent
205
- };
120
+ parseRules(rulesString) {
121
+ if (!rulesString?.trim())
122
+ return [];
123
+ return rulesString
124
+ .split(";")
125
+ .map(rule => rule.trim())
126
+ .filter(rule => rule.length > 0)
127
+ .map(rule => this.#parseRule(rule));
206
128
  }
207
- /**
208
- * Validates all attributes on a PersistentObject
209
- * @returns true if validation failed, false if all valid
210
- */
211
- #validateAttributes(po, config) {
212
- if (!po.attributes || !config)
213
- return false;
214
- let hasErrors = false;
215
- for (const attr of po.attributes) {
216
- // Clear previous validation errors
217
- attr.validationError = undefined;
218
- // Find the attribute config to get rules
219
- const attrConfig = config.attributes.find(a => a.name === attr.name);
220
- if (!attrConfig)
221
- continue;
222
- // Always use server config for rules and isRequired (never trust client values)
223
- const attrWithRules = {
224
- ...attr,
225
- rules: attrConfig.rules,
226
- isRequired: hasRequiredRule(attrConfig.rules)
227
- };
228
- // Validate the attribute
229
- const error = this.#validator.validateAttribute(attrWithRules, po);
230
- if (error) {
231
- attr.validationError = error;
232
- hasErrors = true;
233
- }
129
+ #parseRule(ruleString) {
130
+ const match = ruleString.match(/^(\w+)(?:\(([^)]*)\))?$/);
131
+ if (!match)
132
+ throw new Error(`Invalid rule format: ${ruleString}`);
133
+ const name = match[1];
134
+ const paramsString = match[2];
135
+ if (!paramsString) {
136
+ return { name, params: [] };
234
137
  }
235
- return hasErrors;
236
- }
237
- /**
238
- * Creates an action context for custom action handlers
239
- */
240
- #createActionContext(po) {
241
- return {
242
- getAttribute: (name) => {
243
- return po.attributes?.find(a => a.name === name);
244
- },
245
- getAttributeValue: (name) => {
246
- const attr = po.attributes?.find(a => a.name === name);
247
- if (!attr)
248
- return undefined;
249
- return fromServiceValue(attr.value, attr.type);
250
- },
251
- setAttributeValue: (name, value) => {
252
- const attr = po.attributes?.find(a => a.name === name);
253
- if (attr) {
254
- attr.value = toServiceValue(value, attr.type);
255
- attr.isValueChanged = true;
256
- }
257
- },
258
- getConvertedValue: (attr) => {
259
- return fromServiceValue(attr.value, attr.type);
260
- },
261
- setConvertedValue: (attr, value) => {
262
- attr.value = toServiceValue(value, attr.type);
263
- attr.isValueChanged = true;
264
- },
265
- setValidationError: (name, error) => {
266
- const attr = po.attributes?.find(a => a.name === name);
267
- if (attr)
268
- attr.validationError = error;
269
- },
270
- clearValidationError: (name) => {
271
- const attr = po.attributes?.find(a => a.name === name);
272
- if (attr)
273
- attr.validationError = undefined;
274
- },
275
- setNotification: (message, type, duration) => {
276
- po.notification = message;
277
- po.notificationType = type;
278
- po.notificationDuration = duration;
279
- }
280
- };
138
+ // Parse parameters (can be comma-separated)
139
+ const params = paramsString
140
+ .split(",")
141
+ .map(p => p.trim())
142
+ .map(p => {
143
+ // Try to parse as number
144
+ const num = Number(p);
145
+ if (p !== "" && !isNaN(num))
146
+ return num;
147
+ // Try to parse as boolean
148
+ if (p === "true")
149
+ return true;
150
+ if (p === "false")
151
+ return false;
152
+ // Otherwise return as string (remove quotes if present)
153
+ return p.replace(/^["']|["']$/g, "");
154
+ });
155
+ return { name, params };
281
156
  }
282
- /**
283
- * Creates a conversion context for type conversions
284
- */
285
- #createConversionContext() {
286
- return {
287
- getConvertedValue: (attr) => {
288
- return fromServiceValue(attr.value, attr.type);
289
- },
290
- setConvertedValue: (attr, value) => {
291
- attr.value = toServiceValue(value, attr.type);
292
- attr.isValueChanged = true;
293
- }
294
- };
157
+ // Built-in validators - throw errors instead of returning strings
158
+ #validateIsBase64(value, _attr) {
159
+ if (value == null || value === "")
160
+ return;
161
+ const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
162
+ if (!base64Regex.test(String(value)))
163
+ throw new Error(this.#service.getMessage("IsBase64"));
295
164
  }
296
- }
297
- /**
298
- * Builds a PersistentObjectDto from configuration
299
- */
300
- async function buildPersistentObjectDto(config, queryRegistry, objectId, isNew) {
301
- const id = crypto.randomUUID();
302
- const fullTypeName = `MockNamespace.${config.type}`;
303
- // Build attributes using existing DTO type
304
- const attributes = await Promise.all(config.attributes
305
- .map((attr, index) => buildAttributeDto(attr, index, queryRegistry)));
306
- // Build tabs using existing DTO type
307
- const tabs = {};
308
- if (config.tabs) {
309
- Object.entries(config.tabs).forEach(([key, tab]) => {
310
- tabs[key] = {
311
- name: tab.name || key,
312
- columnCount: tab.columnCount || 0,
313
- id: tab.id,
314
- layout: tab.layout
315
- };
316
- });
165
+ #validateIsEmail(value, _attr) {
166
+ if (value == null || value === "")
167
+ return;
168
+ // Only allow ASCII characters in email addresses
169
+ const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
170
+ if (!emailRegex.test(String(value)))
171
+ throw new Error(this.#service.getMessage("IsEmail"));
317
172
  }
318
- else {
319
- // Default tab
320
- tabs[""] = { name: "", columnCount: 0 };
321
- }
322
- // Build action names (reference by name)
323
- const actions = config.actions || [];
324
- // Build queries (detail queries)
325
- const queries = [];
326
- if (config.queries) {
327
- for (const queryName of config.queries) {
328
- const queryDto = await queryRegistry.getQuery(queryName);
329
- queries.push(queryDto);
173
+ #validateIsRegex(value, _attr) {
174
+ if (value == null || value === "")
175
+ return;
176
+ try {
177
+ new RegExp(String(value));
178
+ }
179
+ catch {
180
+ throw new Error(this.#service.getMessage("IsRegex"));
330
181
  }
331
182
  }
332
- // Return proper PersistentObjectDto
333
- return {
334
- id,
335
- objectId,
336
- type: config.type,
337
- fullTypeName,
338
- label: config.label || config.type,
339
- isNew,
340
- isReadOnly: false,
341
- stateBehavior: config.stateBehavior || "StayInEdit",
342
- attributes,
343
- actions,
344
- tabs,
345
- queries
346
- };
347
- }
348
- /**
349
- * Checks if rules string contains NotEmpty or Required
350
- */
351
- function hasRequiredRule(rules) {
352
- if (!rules)
353
- return false;
354
- // Parse the rules string (semicolon-separated)
355
- const ruleNames = rules
356
- .split(";")
357
- .map(rule => rule.trim())
358
- .map(rule => {
359
- // Extract just the rule name (before any parentheses)
360
- const match = rule.match(/^(\w+)/);
361
- return match ? match[1] : "";
362
- });
363
- return ruleNames.includes("NotEmpty") || ruleNames.includes("Required");
364
- }
365
- /**
366
- * Builds a PersistentObjectAttributeDto from configuration
367
- */
368
- async function buildAttributeDto(config, index, queryRegistry) {
369
- // Automatically set isRequired if rules contain NotEmpty or Required
370
- const isRequired = hasRequiredRule(config.rules);
371
- const baseDto = {
372
- id: config.id || crypto.randomUUID(),
373
- name: config.name,
374
- type: config.type || "String",
375
- label: config.label || config.name,
376
- value: config.value,
377
- isRequired,
378
- isReadOnly: config.isReadOnly || false,
379
- rules: config.rules,
380
- visibility: config.visibility || "Always",
381
- group: config.group || "",
382
- tab: config.tab || "",
383
- triggersRefresh: config.triggersRefresh || false,
384
- options: config.options,
385
- typeHints: config.typeHints,
386
- column: config.column,
387
- columnSpan: config.columnSpan || 4,
388
- offset: config.offset ?? index
389
- };
390
- // Handle Reference attributes with lookup
391
- if (config.lookup) {
392
- if (!queryRegistry)
393
- throw new Error(`Cannot build reference attribute "${config.name}" without queryRegistry`);
394
- const lookupQuery = await queryRegistry.getQuery(config.lookup);
395
- // Determine displayAttribute - use provided or first visible column from lookup query
396
- let displayAttribute = config.displayAttribute;
397
- if (!displayAttribute && lookupQuery.columns && lookupQuery.columns.length > 0) {
398
- // Find first non-Id visible column, or use first column
399
- const firstVisibleColumn = lookupQuery.columns.find(c => !c.isHidden && c.name !== "Id");
400
- displayAttribute = firstVisibleColumn?.name || lookupQuery.columns[0].name;
183
+ #validateIsUrl(value, _attr) {
184
+ if (value == null || value === "")
185
+ return;
186
+ try {
187
+ new URL(String(value));
401
188
  }
402
- if (!displayAttribute)
403
- displayAttribute = "Id";
404
- // Build reference attribute DTO
405
- const refDto = {
406
- ...baseDto,
407
- type: "Reference",
408
- lookup: lookupQuery,
409
- objectId: config.value || null,
410
- displayAttribute,
411
- canAddNewReference: config.canAddNewReference ?? false,
412
- selectInPlace: config.selectInPlace ?? false
413
- };
414
- return refDto;
189
+ catch {
190
+ throw new Error(this.#service.getMessage("IsUrl"));
191
+ }
192
+ }
193
+ #validateIsWord(value, _attr) {
194
+ if (value == null || value === "")
195
+ return;
196
+ const wordRegex = /^\w+$/;
197
+ if (!wordRegex.test(String(value)))
198
+ throw new Error(this.#service.getMessage("IsWord"));
199
+ }
200
+ #validateMaxLength(value, _attr, maxLength) {
201
+ if (value == null || value === "")
202
+ return;
203
+ const length = String(value).length;
204
+ if (length > maxLength)
205
+ throw new Error(this.#service.getMessage("MaxLength", maxLength));
206
+ }
207
+ #validateMaxValue(value, _attr, maximum) {
208
+ if (value == null || value === "")
209
+ return;
210
+ const num = Number(value);
211
+ if (isNaN(num))
212
+ throw new Error("Value must be a number");
213
+ if (num > maximum)
214
+ throw new Error(this.#service.getMessage("MaxValue", maximum));
215
+ }
216
+ #validateMinLength(value, _attr, minLength) {
217
+ if (value == null || value === "")
218
+ return;
219
+ const length = String(value).length;
220
+ if (length < minLength)
221
+ throw new Error(this.#service.getMessage("MinLength", minLength));
222
+ }
223
+ #validateMinValue(value, _attr, minimum) {
224
+ if (value == null || value === "")
225
+ return;
226
+ const num = Number(value);
227
+ if (isNaN(num))
228
+ throw new Error("Value must be a number");
229
+ if (num < minimum)
230
+ throw new Error(this.#service.getMessage("MinValue", minimum));
231
+ }
232
+ #validateRequired(value, _attr) {
233
+ if (value == null)
234
+ throw new Error(this.#service.getMessage("Required"));
235
+ }
236
+ #validateNotEmpty(value, _attr) {
237
+ if (value == null || value === "" || (typeof value === "string" && value.trim() === ""))
238
+ throw new Error(this.#service.getMessage("NotEmpty"));
415
239
  }
416
- return baseDto;
417
240
  }
418
241
 
419
242
  /**
420
- * Registry for managing Query configurations
243
+ * Registry for managing Query configurations (config + columns store)
421
244
  */
422
245
  class VirtualQueryRegistry {
423
246
  #configs = new Map();
424
- #actionsRegistry;
425
- constructor(actionsRegistry) {
426
- this.#actionsRegistry = actionsRegistry || null;
427
- }
428
247
  /**
429
248
  * Checks if a query is registered
430
249
  * @param name - The query name
@@ -432,6 +251,34 @@ class VirtualQueryRegistry {
432
251
  hasQuery(name) {
433
252
  return this.#configs.has(name);
434
253
  }
254
+ /**
255
+ * Gets a registered Query configuration
256
+ * @param name - The query name
257
+ */
258
+ getQueryConfig(name) {
259
+ return this.#configs.get(name)?.config;
260
+ }
261
+ /**
262
+ * Gets the PersistentObject configuration for a query
263
+ * @param name - The query name
264
+ */
265
+ getPersistentObjectConfig(name) {
266
+ return this.#configs.get(name)?.persistentObjectConfig;
267
+ }
268
+ /**
269
+ * Gets the columns for a query
270
+ * @param name - The query name
271
+ */
272
+ getColumns(name) {
273
+ return this.#configs.get(name)?.columns || [];
274
+ }
275
+ /**
276
+ * Gets the default data for a query
277
+ * @param name - The query name
278
+ */
279
+ getData(name) {
280
+ return this.#configs.get(name)?.data || [];
281
+ }
435
282
  /**
436
283
  * Registers a Query configuration
437
284
  */
@@ -472,7 +319,6 @@ class VirtualQueryRegistry {
472
319
  // Filter arrays (empty for mock)
473
320
  includes: [],
474
321
  excludes: []
475
- // Note: typeHints are not stored on columns, only on QueryResultItemValueDto
476
322
  };
477
323
  });
478
324
  }
@@ -506,405 +352,78 @@ class VirtualQueryRegistry {
506
352
  .trim();
507
353
  }
508
354
  /**
509
- * Gets a QueryDto by name
510
- * @param name - The query name
511
- * @param parent - The parent PersistentObject (for detail queries) or null
355
+ * Builds a QueryResultDto from execution result
356
+ * @param result - The query execution result with items and totalItems
357
+ * @param columns - The query columns
358
+ * @param pageSize - The page size
359
+ * @returns The QueryResultDto
512
360
  */
513
- async getQuery(name, parent) {
514
- const entry = this.#configs.get(name);
515
- if (!entry)
516
- throw new Error(`Query '${name}' is not registered`);
517
- const { config, columns, persistentObjectConfig } = entry;
518
- // Build default actions
519
- const actions = ["RefreshQuery"];
520
- // Auto-add New if onNew is overridden for the PersistentObject type
521
- if (this.#actionsRegistry?.isMethodOverridden(persistentObjectConfig.type, "onNew")) {
522
- if (!actions.includes("New"))
523
- actions.push("New");
524
- }
525
- // Auto-add Delete if onDelete is overridden for the PersistentObject type
526
- if (this.#actionsRegistry?.isMethodOverridden(persistentObjectConfig.type, "onDelete")) {
527
- if (!actions.includes("Delete"))
528
- actions.push("Delete");
529
- }
530
- // Add custom query-level actions
531
- if (config.actions) {
532
- for (const actionName of config.actions)
533
- if (!actions.includes(actionName))
534
- actions.push(actionName);
535
- }
536
- // Add custom item-level actions
537
- if (config.itemActions) {
538
- for (const actionName of config.itemActions)
539
- if (!actions.includes(actionName))
540
- actions.push(actionName);
541
- }
542
- // Build the query DTO first (needed for onConstructQuery and executeQuery)
543
- const queryDto = buildQueryDto(config, columns, actions, persistentObjectConfig);
544
- // Call onConstructQuery lifecycle hook
545
- if (this.#actionsRegistry)
546
- this.#actionsRegistry.executeConstructQuery(queryDto, parent || null);
547
- // Handle autoQuery flag - execute query to get initial results
548
- if (config.autoQuery !== false && this.#actionsRegistry) {
549
- const result = await this.executeQuery(queryDto, parent || null);
550
- queryDto.result = result;
551
- }
552
- return queryDto;
553
- }
554
- /**
555
- * Executes a query by delegating to the actions registry's onExecuteQuery
556
- * @param query - The query DTO with textSearch, sortOptions, skip, top set
557
- * @param parent - The parent PersistentObject (for detail queries) or null
558
- */
559
- async executeQuery(query, parent) {
560
- if (!this.#actionsRegistry)
561
- throw new Error("Actions registry is required for query execution");
562
- const entry = this.#configs.get(query.name);
563
- if (!entry)
564
- throw new Error(`Query '${query.name}' is not registered`);
565
- const { columns, data } = entry;
566
- // Use registered columns (with isHidden) instead of request columns
567
- const queryWithColumns = { ...query, columns };
568
- // Delegate to actions registry to get items, passing default data from config
569
- const result = await this.#actionsRegistry.executeQuery(queryWithColumns, parent, data);
570
- // Convert data rows to QueryResultItemDto
571
- const items = result.items.map(row => buildQueryResultItemDto(row, columns));
572
- // Build and return QueryResultDto
573
- return buildQueryResultDto(items, columns, result.totalItems, undefined, // No continuation - use skip/top instead
574
- query.pageSize, query.skip, query.sortOptions);
575
- }
576
- }
577
- /**
578
- * Builds a QueryDto from configuration
579
- */
580
- function buildQueryDto(config, columns, actions, persistentObjectConfig) {
581
- // Build minimal PersistentObjectDto for the query
582
- const persistentObject = {
583
- type: persistentObjectConfig.type,
584
- id: crypto.randomUUID(),
585
- objectId: null,
586
- label: persistentObjectConfig.type,
587
- fullTypeName: `Mock.${persistentObjectConfig.type}`,
588
- isNew: false,
589
- isReadOnly: true,
590
- attributes: [],
591
- actions: [],
592
- tabs: {},
593
- queries: []
594
- };
595
- return {
596
- id: crypto.randomUUID(),
597
- name: config.name,
598
- label: config.label || config.name,
599
- columns,
600
- actions,
601
- allowTextSearch: config.allowTextSearch ?? true,
602
- disableBulkEdit: config.disableBulkEdit ?? false,
603
- autoQuery: config.autoQuery ?? true,
604
- pageSize: config.pageSize || 20,
605
- persistentObject,
606
- sortOptions: "",
607
- textSearch: "",
608
- skip: 0,
609
- top: config.pageSize || 20,
610
- totalItems: -1,
611
- canRead: true,
612
- canReorder: false,
613
- enableSelectAll: true
614
- };
615
- }
616
- /**
617
- * Builds a QueryResultDto from result data
618
- */
619
- function buildQueryResultDto(items, columns, totalItems, continuation, pageSize, skip, sortOptions) {
620
- return {
621
- items,
622
- columns,
623
- totalItems,
624
- continuation,
625
- pageSize,
626
- skip,
627
- sortOptions: sortOptions || "",
628
- charts: []
629
- };
630
- }
631
- /**
632
- * Builds a QueryResultItemDto from a data row
633
- */
634
- function buildQueryResultItemDto(data, columns) {
635
- // Extract or generate ID (check both lowercase "id" and capitalized "Id")
636
- let id;
637
- if (data["id"] != null && (typeof data["id"] === "string" || typeof data["id"] === "number"))
638
- id = String(data["id"]);
639
- else if (data["Id"] != null && (typeof data["Id"] === "string" || typeof data["Id"] === "number"))
640
- id = String(data["Id"]);
641
- else
642
- id = crypto.randomUUID();
643
- // Build values array
644
- const values = columns.map(column => {
645
- return buildQueryResultItemValueDto(column.name, data[column.name], column.type, data[column.name + "Id"], data[column.name + "$typeHints"]);
646
- });
647
- // Extract typeHints and tag from row if present
648
- const typeHints = data.$typeHints;
649
- const tag = data.$tag;
650
- return {
651
- id,
652
- values,
653
- typeHints,
654
- tag
655
- };
656
- }
657
- /**
658
- * Builds a QueryResultItemValueDto from a single value
659
- * Converts JavaScript primitives to service string format
660
- */
661
- function buildQueryResultItemValueDto(key, rawValue, type, objectId, typeHints) {
662
- // Convert to service string format using DataType.toServiceString
663
- const value = rawValue == null
664
- ? null
665
- : DataType.toServiceString(rawValue, type);
666
- return {
667
- key,
668
- value,
669
- objectId,
670
- typeHints
671
- };
672
- }
673
-
674
- /**
675
- * Base class for PersistentObject lifecycle methods
676
- * Extend this class to override lifecycle hooks like onLoad, onSave, onRefresh, etc.
677
- * All methods have default implementations, so you only need to override what you need.
678
- */
679
- class VirtualPersistentObjectActions {
680
- /**
681
- * Called every time a PersistentObject DTO is created (both new and existing objects)
682
- * Use this to set metadata on attributes that can only be known at runtime
683
- * @param obj - The PersistentObject DTO being constructed
684
- */
685
- onConstruct(obj) {
686
- // Default implementation: do nothing
687
- }
688
- /**
689
- * Called when loading an existing PersistentObject by ID
690
- * Use this to load entity data and populate attribute values
691
- * @param obj - The constructed PersistentObject DTO (after onConstruct)
692
- * @param parent - The parent PersistentObject if loaded in a master-detail context, null otherwise
693
- * @returns The PersistentObject with loaded data
694
- */
695
- async onLoad(obj, parent) {
696
- // Default implementation: return obj as-is
697
- return obj;
698
- }
699
- /**
700
- * Called when creating a new PersistentObject via the "New" action
701
- * Use this to create a fresh entity instance with default values
702
- * @param obj - The constructed PersistentObject DTO (after onConstruct)
703
- * @param parent - The parent PersistentObject if creating from a detail query, null otherwise
704
- * @param query - The Query from which the New action was invoked, null if not from a query
705
- * @param parameters - Optional parameters including "MenuOption" if NewOptions exist
706
- * @returns The PersistentObject with default values set
707
- */
708
- async onNew(obj, parent, query, parameters) {
709
- // Default implementation: return obj as-is
710
- return obj;
711
- }
712
- /**
713
- * Called when an attribute with triggersRefresh: true is changed
714
- * Use this to update other attributes based on the changed attribute
715
- * @param obj - The PersistentObject DTO in edit mode
716
- * @param attribute - The attribute that triggered the refresh (the one with triggersRefresh=true that was changed), wrapped with getValue/setValue
717
- * @returns The PersistentObject with refreshed attributes
718
- */
719
- async onRefresh(obj, attribute) {
720
- // Default implementation: return obj as-is
721
- return obj;
722
- }
723
- /**
724
- * Called when the Save action is executed
725
- * Orchestrates the save process by calling saveNew or saveExisting based on obj.isNew
726
- * @param obj - The PersistentObject DTO to save
727
- * @returns The saved PersistentObject
728
- */
729
- async onSave(obj) {
730
- // Default implementation: delegate to saveNew or saveExisting based on isNew flag
731
- if (obj.isNew)
732
- return await this.saveNew(obj);
733
- else
734
- return await this.saveExisting(obj);
735
- }
736
- /**
737
- * Called by onSave when obj.isNew === true
738
- * Use this to save a new entity to the data store
739
- * @param obj - The new PersistentObject DTO to save
740
- * @returns The saved PersistentObject
741
- */
742
- async saveNew(obj) {
743
- // Default implementation: return obj as-is (no persistence in mock)
744
- return obj;
745
- }
746
- /**
747
- * Called by onSave when obj.isNew === false
748
- * Use this to update an existing entity in the data store
749
- * @param obj - The existing PersistentObject DTO to save
750
- * @returns The saved PersistentObject
751
- */
752
- async saveExisting(obj) {
753
- // Default implementation: return obj as-is (no persistence in mock)
754
- return obj;
755
- }
756
- /**
757
- * Called when a reference attribute is changed (via changeReference() on a PersistentObjectAttributeWithReference)
758
- * The base implementation sets objectId, value, and isValueChanged on the reference attribute.
759
- * Override this to add custom logic after the reference is set.
760
- * @param parent - The PersistentObject that contains the reference attribute
761
- * @param referenceAttribute - The reference attribute that was changed
762
- * @param query - The query that was used to select the reference
763
- * @param selectedItem - The QueryResultItem that was selected, or null if reference was cleared
764
- */
765
- async onSelectReference(_parent, referenceAttribute, _query, selectedItem) {
766
- const refAttr = referenceAttribute;
767
- if (selectedItem == null) {
768
- // Clear the reference
769
- refAttr.objectId = null;
770
- refAttr.value = null;
771
- }
772
- else {
773
- // Set the reference to the selected item
774
- refAttr.objectId = selectedItem.id;
775
- // Get the display value from the item using displayAttribute
776
- const displayAttribute = refAttr.displayAttribute;
777
- const displayValue = selectedItem.values?.find((v) => v.key === displayAttribute);
778
- refAttr.value = displayValue?.value || selectedItem.id;
779
- }
780
- refAttr.isValueChanged = true;
781
- }
782
- /**
783
- * Called when the Delete action is executed on selected items in a Query
784
- * Use this to handle deletion of entities
785
- * @param parent - The parent PersistentObject if deleting from a detail query, null for top-level queries
786
- * @param query - The query from which items are being deleted
787
- * @param selectedItems - The QueryResultItems that are selected for deletion
788
- */
789
- async onDelete(parent, query, selectedItems) {
790
- // Default implementation: do nothing (no persistence in mock)
791
- }
792
- /**
793
- * Called when a Query of this PersistentObject type is constructed
794
- * Use this to set metadata on query columns that can only be known at runtime
795
- * @param query - The Query DTO being constructed
796
- * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
797
- */
798
- onConstructQuery(query, parent) {
799
- // Default implementation: do nothing
800
- }
801
- /**
802
- * Called when a Query is executed.
803
- * The base implementation calls getEntities() and applies text search, sorting, and pagination.
804
- * @param query - The Query DTO with textSearch, sortOptions, skip, top properties set
805
- * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
806
- * @param data - Default data from the query config (used if getEntities is not overridden)
807
- * @returns The items matching the query criteria and the total count before pagination
808
- */
809
- async onExecuteQuery(query, parent, data) {
810
- // Get all items from subclass or use provided data
811
- let result = await this.getEntities(query, parent, data);
812
- // Apply text search if provided
813
- if (query.textSearch && query.textSearch.trim() !== "") {
814
- const searchLower = query.textSearch.toLowerCase();
815
- const stringColumns = query.columns?.filter(c => c.type === "String" && !c.isHidden) || [];
816
- result = result.filter(row => {
817
- for (const column of stringColumns) {
818
- const value = String(row[column.name] || "").toLowerCase();
819
- if (value.includes(searchLower))
820
- return true;
821
- }
822
- return false;
823
- });
824
- }
825
- // Apply sorting if provided
826
- if (query.sortOptions && query.sortOptions.trim() !== "") {
827
- const sortParts = query.sortOptions.split(";").map(s => s.trim()).filter(Boolean);
828
- const sortOptions = sortParts.map(part => {
829
- const [name, dir] = part.split(/\s+/);
830
- return { name, direction: dir?.toUpperCase() === "DESC" ? "DESC" : "ASC" };
831
- });
832
- result = [...result].sort((a, b) => {
833
- for (const { name, direction } of sortOptions) {
834
- const aVal = a[name];
835
- const bVal = b[name];
836
- // Null handling
837
- if (aVal == null && bVal == null)
838
- continue;
839
- if (aVal == null)
840
- return direction === "ASC" ? -1 : 1;
841
- if (bVal == null)
842
- return direction === "ASC" ? 1 : -1;
843
- // Type-aware comparison
844
- let cmp = 0;
845
- if (typeof aVal === "boolean")
846
- cmp = aVal === bVal ? 0 : (aVal ? 1 : -1);
847
- else if (aVal instanceof Date && bVal instanceof Date)
848
- cmp = aVal.getTime() - bVal.getTime();
849
- else if (typeof aVal === "number" && typeof bVal === "number")
850
- cmp = aVal - bVal;
851
- else
852
- cmp = String(aVal).toLowerCase().localeCompare(String(bVal).toLowerCase());
853
- if (cmp !== 0)
854
- return direction === "ASC" ? cmp : -cmp;
855
- }
856
- return 0;
857
- });
858
- }
859
- // Apply pagination
860
- const skip = query.skip || 0;
861
- const top = query.top || query.pageSize || 20;
862
- const items = result.slice(skip, skip + top);
361
+ static buildQueryResultDto(result, columns, pageSize) {
362
+ const items = result.items.map(row => VirtualQueryRegistry.buildQueryResultItemDto(row, columns));
863
363
  return {
864
364
  items,
865
- totalItems: result.length
365
+ columns,
366
+ totalItems: result.totalItems,
367
+ pageSize,
368
+ sortOptions: "",
369
+ charts: []
866
370
  };
867
371
  }
868
372
  /**
869
- * Override this to provide the full list of items for a query.
870
- * The base onExecuteQuery will handle text search, sorting, and pagination automatically.
871
- * @param query - The Query DTO with textSearch, sortOptions properties set
872
- * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
873
- * @param data - Default data from the query config
874
- * @returns All items matching the query criteria (before pagination)
373
+ * Builds a QueryResultItemDto from a data row
374
+ * @param data - The data row
375
+ * @param columns - The query columns
376
+ * @returns The QueryResultItemDto
875
377
  */
876
- async getEntities(_query, _parent, data) {
877
- // Default implementation: return provided data (from query config)
878
- return [...data];
378
+ static buildQueryResultItemDto(data, columns) {
379
+ // Extract or generate ID
380
+ let id;
381
+ if (data["id"] != null && (typeof data["id"] === "string" || typeof data["id"] === "number"))
382
+ id = String(data["id"]);
383
+ else if (data["Id"] != null && (typeof data["Id"] === "string" || typeof data["Id"] === "number"))
384
+ id = String(data["Id"]);
385
+ else
386
+ id = crypto.randomUUID();
387
+ // Build values array
388
+ const values = columns.map(column => {
389
+ const rawValue = data[column.name];
390
+ return {
391
+ key: column.name,
392
+ value: rawValue == null ? null : toServiceValue(rawValue, column.type),
393
+ objectId: data[column.name + "Id"],
394
+ typeHints: data[column.name + "$typeHints"]
395
+ };
396
+ });
397
+ return {
398
+ id,
399
+ values,
400
+ typeHints: data.$typeHints,
401
+ tag: data.$tag
402
+ };
879
403
  }
880
404
  }
881
405
 
882
406
  /**
883
- * Creates a VirtualPersistentObjectAttribute by wrapping an attribute DTO with a Proxy
884
- * The Proxy intercepts property access to provide getValue/setValue methods
885
- * @param attr - The PersistentObjectAttributeDto to wrap
886
- * @param conversionContext - The conversion context for type conversions
887
- * @returns A VirtualPersistentObjectAttribute that combines DTO properties with helper methods
407
+ * Creates a VirtualQueryColumn by wrapping a QueryColumnDto with a Proxy
408
+ * @param dto - The QueryColumnDto to wrap
409
+ * @param query - The parent VirtualQuery
410
+ * @returns A VirtualQueryColumn that combines DTO properties with helper methods
888
411
  */
889
- function createVirtualPersistentObjectAttribute(attr, conversionContext) {
412
+ function createVirtualQueryColumn(dto, query) {
890
413
  const helpers = {
891
- getValue() {
892
- return conversionContext.getConvertedValue(attr);
893
- },
894
- setValue(value) {
895
- conversionContext.setConvertedValue(attr, value);
414
+ get query() {
415
+ return query;
896
416
  },
897
- setValidationError(error) {
898
- attr.validationError = error;
899
- },
900
- clearValidationError() {
901
- attr.validationError = undefined;
417
+ get service() {
418
+ return query.service;
902
419
  }
903
420
  };
904
- return new Proxy(attr, {
421
+ return new Proxy(dto, {
905
422
  get(target, prop) {
906
- if (prop in helpers)
907
- return helpers[prop];
423
+ if (prop in helpers) {
424
+ const value = helpers[prop];
425
+ return typeof value === "function" ? value : value;
426
+ }
908
427
  return target[prop];
909
428
  },
910
429
  set(target, prop, value) {
@@ -914,587 +433,219 @@ function createVirtualPersistentObjectAttribute(attr, conversionContext) {
914
433
  });
915
434
  }
916
435
  /**
917
- * Creates a VirtualPersistentObject by wrapping a DTO with a Proxy
918
- * The Proxy intercepts property access to provide helper methods while keeping the underlying DTO unchanged
919
- * @param dto - The PersistentObjectDto to wrap
920
- * @param conversionContext - The conversion context for type conversions
921
- * @returns A VirtualPersistentObject that combines DTO properties with helper methods
436
+ * Creates a VirtualQuery by wrapping a QueryDto with a Proxy
437
+ * The Proxy intercepts property access to provide helper methods
438
+ * @param dto - The QueryDto to wrap
439
+ * @param config - Optional query config for metadata
440
+ * @param service - The VirtualService instance
441
+ * @returns A VirtualQuery that combines DTO properties with helper methods
922
442
  */
923
- function createVirtualPersistentObject(dto, conversionContext) {
924
- // Helper methods - logic is inlined here, only using conversionContext for type conversion
443
+ function createVirtualQuery(dto, config, service) {
444
+ // Create proxy first so we can reference it in helpers
445
+ let proxy;
925
446
  const helpers = {
926
- getAttribute(name) {
927
- const attr = dto.attributes?.find(a => a.name === name);
928
- if (!attr)
447
+ getColumn(name) {
448
+ const col = dto.columns?.find(c => c.name === name);
449
+ if (!col)
929
450
  return undefined;
930
- return createVirtualPersistentObjectAttribute(attr, conversionContext);
931
- },
932
- getAttributeValue(name) {
933
- const attr = dto.attributes?.find(a => a.name === name);
934
- if (!attr)
935
- return undefined;
936
- return conversionContext.getConvertedValue(attr);
937
- },
938
- setAttributeValue(name, value) {
939
- const attr = dto.attributes?.find(a => a.name === name);
940
- if (attr)
941
- conversionContext.setConvertedValue(attr, value);
942
- },
943
- setValidationError(name, error) {
944
- const attr = dto.attributes?.find(a => a.name === name);
945
- if (attr)
946
- attr.validationError = error;
947
- },
948
- clearValidationError(name) {
949
- const attr = dto.attributes?.find(a => a.name === name);
950
- if (attr)
951
- attr.validationError = undefined;
451
+ return createVirtualQueryColumn(col, proxy);
952
452
  },
953
453
  setNotification(message, type, duration) {
954
454
  dto.notification = message;
955
455
  dto.notificationType = type;
956
456
  dto.notificationDuration = duration;
457
+ },
458
+ get __config() {
459
+ return config;
460
+ },
461
+ get service() {
462
+ return service;
957
463
  }
958
464
  };
959
- // Create a Proxy that intercepts property access
960
- return new Proxy(dto, {
465
+ proxy = new Proxy(dto, {
961
466
  get(target, prop) {
962
- // If accessing a helper method, return it
963
- if (prop in helpers)
964
- return helpers[prop];
965
- // Otherwise, return the DTO property
467
+ if (prop in helpers) {
468
+ const value = helpers[prop];
469
+ return typeof value === "function" ? value : value;
470
+ }
966
471
  return target[prop];
967
472
  },
968
473
  set(target, prop, value) {
969
- // Allow setting DTO properties directly
970
474
  target[prop] = value;
971
475
  return true;
972
476
  }
973
477
  });
478
+ return proxy;
974
479
  }
975
480
  /**
976
- * Unwraps a VirtualPersistentObject to get the underlying DTO
977
- * Since the Proxy wraps the DTO, we can safely cast it back
978
- * @param wrapped - The VirtualPersistentObject to unwrap
979
- * @returns The underlying PersistentObjectDto
481
+ * Unwraps a VirtualQuery to get the underlying DTO
482
+ * @param wrapped - The VirtualQuery to unwrap
483
+ * @returns The underlying QueryDto
980
484
  */
981
- function unwrapVirtualPersistentObject(wrapped) {
982
- // The wrapped object is a Proxy around the DTO
983
- // We can return it as-is since the DTO is the target of the Proxy
485
+ function unwrapVirtualQuery(wrapped) {
984
486
  return wrapped;
985
487
  }
986
-
987
488
  /**
988
- * Registry for managing VirtualPersistentObjectActions classes
989
- * Provides methods to register actions classes and execute lifecycle hooks
489
+ * Creates a VirtualQueryResultItem by wrapping a QueryResultItemDto with a Proxy
490
+ * @param dto - The QueryResultItemDto to wrap
491
+ * @param query - The parent VirtualQuery
492
+ * @returns A VirtualQueryResultItem that combines DTO properties with helper methods
990
493
  */
991
- class VirtualPersistentObjectActionsRegistry {
992
- #actionsClasses = new Map();
993
- #overrideInfo = new Map();
994
- /**
995
- * Registers a VirtualPersistentObjectActions class for a specific type
996
- * @param type - The PersistentObject type name
997
- * @param ActionsClass - The VirtualPersistentObjectActions class constructor
998
- */
999
- register(type, ActionsClass) {
1000
- this.#actionsClasses.set(type, ActionsClass);
1001
- // Detect overridden methods
1002
- const overriddenMethods = this.#detectOverriddenMethods(ActionsClass);
1003
- this.#overrideInfo.set(type, { overriddenMethods });
1004
- }
1005
- /**
1006
- * Detects which lifecycle methods are overridden in the given class
1007
- * @param ActionsClass - The VirtualPersistentObjectActions class constructor
1008
- * @returns Set of overridden method names
1009
- */
1010
- #detectOverriddenMethods(ActionsClass) {
1011
- const overridden = new Set();
1012
- const baseProto = VirtualPersistentObjectActions.prototype;
1013
- const classProto = ActionsClass.prototype;
1014
- const methodsToCheck = [
1015
- "onSave", "onNew", "onDelete", "onRefresh",
1016
- "onLoad", "onConstruct", "onConstructQuery", "onSelectReference", "onExecuteQuery", "getEntities"
1017
- ];
1018
- for (const methodName of methodsToCheck) {
1019
- if (classProto[methodName] !== baseProto[methodName])
1020
- overridden.add(methodName);
1021
- }
1022
- return overridden;
1023
- }
1024
- /**
1025
- * Checks if a specific lifecycle method is overridden for a type
1026
- * @param type - The PersistentObject type name
1027
- * @param methodName - The lifecycle method name
1028
- * @returns true if the method is overridden, false otherwise
1029
- */
1030
- isMethodOverridden(type, methodName) {
1031
- const info = this.#overrideInfo.get(type);
1032
- if (!info)
1033
- return false;
1034
- return info.overriddenMethods.has(methodName);
1035
- }
1036
- /**
1037
- * Checks if a type has registered actions
1038
- * @param type - The PersistentObject type name
1039
- * @returns true if the type has registered actions, false otherwise
1040
- */
1041
- has(type) {
1042
- return this.#actionsClasses.has(type);
1043
- }
1044
- /**
1045
- * Creates a new instance of the actions class for a type
1046
- * Returns a default VirtualPersistentObjectActions if no custom actions are registered
1047
- * @param type - The PersistentObject type name
1048
- * @returns A new instance of the VirtualPersistentObjectActions class
1049
- */
1050
- createInstance(type) {
1051
- const ActionsClass = this.#actionsClasses.get(type);
1052
- if (!ActionsClass)
1053
- return new VirtualPersistentObjectActions();
1054
- return new ActionsClass();
1055
- }
1056
- /**
1057
- * Executes onConstruct lifecycle hook
1058
- * @param dto - The PersistentObject DTO
1059
- * @param conversionContext - The conversion context for type conversions
1060
- */
1061
- executeConstruct(dto, conversionContext) {
1062
- const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1063
- const instance = this.createInstance(dto.type);
1064
- instance.onConstruct(wrappedObj);
1065
- }
1066
- /**
1067
- * Executes onLoad lifecycle hook
1068
- * @param dto - The PersistentObject DTO
1069
- * @param parent - The parent DTO (or null)
1070
- * @param conversionContext - The conversion context for type conversions
1071
- * @returns The updated DTO
1072
- */
1073
- async executeLoad(dto, parent, conversionContext) {
1074
- const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1075
- // Wrap parent if provided
1076
- let wrappedParent = null;
1077
- if (parent) {
1078
- const parentContext = this.#createConversionContext();
1079
- wrappedParent = createVirtualPersistentObject(parent, parentContext);
1080
- }
1081
- const instance = this.createInstance(dto.type);
1082
- const result = await instance.onLoad(wrappedObj, wrappedParent);
1083
- return unwrapVirtualPersistentObject(result);
1084
- }
1085
- /**
1086
- * Executes onNew lifecycle hook
1087
- * @param dto - The PersistentObject DTO
1088
- * @param parent - The parent DTO (or null)
1089
- * @param query - The query DTO (or null)
1090
- * @param parameters - The parameters (or null)
1091
- * @param conversionContext - The conversion context for type conversions
1092
- * @returns The updated DTO
1093
- */
1094
- async executeNew(dto, parent, query, parameters, conversionContext) {
1095
- const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1096
- // Wrap parent if provided
1097
- let wrappedParent = null;
1098
- if (parent) {
1099
- const parentContext = this.#createConversionContext();
1100
- wrappedParent = createVirtualPersistentObject(parent, parentContext);
1101
- }
1102
- const instance = this.createInstance(dto.type);
1103
- const result = await instance.onNew(wrappedObj, wrappedParent, query, parameters);
1104
- return unwrapVirtualPersistentObject(result);
1105
- }
1106
- /**
1107
- * Executes onRefresh lifecycle hook
1108
- * @param dto - The PersistentObject DTO
1109
- * @param attribute - The attribute that triggered the refresh (or undefined)
1110
- * @param conversionContext - The conversion context for type conversions
1111
- * @returns The updated DTO
1112
- */
1113
- async executeRefresh(dto, attribute, conversionContext) {
1114
- const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1115
- // Wrap attribute if provided
1116
- let wrappedAttribute;
1117
- if (attribute)
1118
- wrappedAttribute = createVirtualPersistentObjectAttribute(attribute, conversionContext);
1119
- const instance = this.createInstance(dto.type);
1120
- const result = await instance.onRefresh(wrappedObj, wrappedAttribute);
1121
- return unwrapVirtualPersistentObject(result);
1122
- }
1123
- /**
1124
- * Executes onSave lifecycle hook
1125
- * @param dto - The PersistentObject DTO
1126
- * @param conversionContext - The conversion context for type conversions
1127
- * @returns The updated DTO
1128
- */
1129
- async executeSave(dto, conversionContext) {
1130
- const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
1131
- const instance = this.createInstance(dto.type);
1132
- const result = await instance.onSave(wrappedObj);
1133
- return unwrapVirtualPersistentObject(result);
1134
- }
1135
- /**
1136
- * Executes onSelectReference lifecycle hook
1137
- * @param parent - The parent DTO
1138
- * @param referenceAttribute - The reference attribute DTO
1139
- * @param query - The query DTO
1140
- * @param selectedItem - The selected item (or null)
1141
- * @param conversionContext - The conversion context for type conversions
1142
- */
1143
- async executeSelectReference(parent, referenceAttribute, query, selectedItem, conversionContext) {
1144
- const wrappedParent = createVirtualPersistentObject(parent, conversionContext);
1145
- const instance = this.createInstance(parent.type);
1146
- await instance.onSelectReference(wrappedParent, referenceAttribute, query, selectedItem);
1147
- }
1148
- /**
1149
- * Executes onDelete lifecycle hook
1150
- * @param parent - The parent DTO (or null)
1151
- * @param query - The query DTO
1152
- * @param selectedItems - The selected items
1153
- */
1154
- async executeDelete(parent, query, selectedItems) {
1155
- // Wrap parent if provided
1156
- let wrappedParent = null;
1157
- if (parent) {
1158
- const conversionContext = this.#createConversionContext();
1159
- wrappedParent = createVirtualPersistentObject(parent, conversionContext);
1160
- }
1161
- // Get type from query's persistentObject
1162
- const type = query.persistentObject?.type;
1163
- if (!type)
1164
- throw new Error("Query does not have a persistentObject type");
1165
- const instance = this.createInstance(type);
1166
- await instance.onDelete(wrappedParent, query, selectedItems);
1167
- }
1168
- /**
1169
- * Executes onConstructQuery lifecycle hook
1170
- * @param query - The query DTO
1171
- * @param parent - The parent DTO (or null)
1172
- */
1173
- executeConstructQuery(query, parent) {
1174
- // Wrap parent if provided
1175
- let wrappedParent = null;
1176
- if (parent) {
1177
- const conversionContext = this.#createConversionContext();
1178
- wrappedParent = createVirtualPersistentObject(parent, conversionContext);
1179
- }
1180
- // Get type from query's persistentObject
1181
- const type = query.persistentObject?.type;
1182
- if (!type)
1183
- throw new Error("Query does not have a persistentObject type");
1184
- const instance = this.createInstance(type);
1185
- instance.onConstructQuery(query, wrappedParent);
1186
- }
1187
- /**
1188
- * Executes onExecuteQuery lifecycle hook
1189
- * @param query - The query DTO with textSearch, sortOptions, skip, top set
1190
- * @param parent - The parent DTO (or null)
1191
- * @param data - Default data from the query config (used if getEntities is not overridden)
1192
- * @returns The query execution result with items and totalItems
1193
- */
1194
- async executeQuery(query, parent, data) {
1195
- // Wrap parent if provided
1196
- let wrappedParent = null;
1197
- if (parent) {
1198
- const conversionContext = this.#createConversionContext();
1199
- wrappedParent = createVirtualPersistentObject(parent, conversionContext);
494
+ function createVirtualQueryResultItem(dto, query) {
495
+ const helpers = {
496
+ getValue(columnName) {
497
+ const valueEntry = dto.values?.find((v) => v.key === columnName);
498
+ return valueEntry?.value;
499
+ },
500
+ get query() {
501
+ return query;
502
+ },
503
+ get service() {
504
+ return query.service;
1200
505
  }
1201
- // Get type from query's persistentObject
1202
- const type = query.persistentObject?.type;
1203
- if (!type)
1204
- throw new Error("Query does not have a persistentObject type");
1205
- const instance = this.createInstance(type);
1206
- return await instance.onExecuteQuery(query, wrappedParent, data);
1207
- }
1208
- /**
1209
- * Creates a conversion context for type conversions
1210
- * @returns A ConversionContext with type conversion methods
1211
- */
1212
- #createConversionContext() {
1213
- return {
1214
- getConvertedValue: (attr) => {
1215
- return fromServiceValue(attr.value, attr.type);
1216
- },
1217
- setConvertedValue: (attr, value) => {
1218
- attr.value = toServiceValue(value, attr.type);
1219
- attr.isValueChanged = true;
1220
- }
1221
- };
1222
- }
1223
- }
1224
-
1225
- /**
1226
- * Business rule validator that supports built-in and custom rules
1227
- */
1228
- class BusinessRuleValidator {
1229
- #builtInRules = new Map();
1230
- #customRules = new Map();
1231
- #translate;
1232
- constructor() {
1233
- this.#translate = this.#defaultTranslate;
1234
- // Register all built-in rules
1235
- this.#builtInRules.set("IsBase64", this.#validateIsBase64.bind(this));
1236
- this.#builtInRules.set("IsEmail", this.#validateIsEmail.bind(this));
1237
- this.#builtInRules.set("IsRegex", this.#validateIsRegex.bind(this));
1238
- this.#builtInRules.set("IsUrl", this.#validateIsUrl.bind(this));
1239
- this.#builtInRules.set("IsWord", this.#validateIsWord.bind(this));
1240
- this.#builtInRules.set("MaxLength", this.#validateMaxLength.bind(this));
1241
- this.#builtInRules.set("MaxValue", this.#validateMaxValue.bind(this));
1242
- this.#builtInRules.set("MinLength", this.#validateMinLength.bind(this));
1243
- this.#builtInRules.set("MinValue", this.#validateMinValue.bind(this));
1244
- this.#builtInRules.set("NotEmpty", this.#validateNotEmpty.bind(this));
1245
- this.#builtInRules.set("Required", this.#validateRequired.bind(this));
1246
- }
1247
- /**
1248
- * Sets the translation function
1249
- */
1250
- setTranslate(translate) {
1251
- this.#translate = translate;
1252
- }
1253
- /**
1254
- * Default English translations - used when no translate function provided
1255
- */
1256
- #defaultTranslate(key, ...params) {
1257
- const defaults = {
1258
- "Required": () => "This field is required",
1259
- "NotEmpty": () => "This field cannot be empty",
1260
- "IsEmail": () => "Email format is invalid",
1261
- "IsUrl": () => "Value must be a valid URL",
1262
- "MaxLength": (max) => `Maximum length is ${max} characters`,
1263
- "MinLength": (min) => `Minimum length is ${min} characters`,
1264
- "MaxValue": (max) => `Maximum value is ${max}`,
1265
- "MinValue": (min) => `Minimum value is ${min}`,
1266
- "IsBase64": () => "Value must be a valid base64 string",
1267
- "IsRegex": () => "Value must be a valid regular expression",
1268
- "IsWord": () => "Value must contain only word characters"
1269
- };
1270
- const translator = defaults[key];
1271
- return translator ? translator(...params) : key;
1272
- }
1273
- /**
1274
- * Register a custom business rule
1275
- * @throws Error if attempting to override a built-in rule
1276
- */
1277
- registerCustomRule(name, validator) {
1278
- if (this.#builtInRules.has(name))
1279
- throw new Error(`Cannot override built-in rule: ${name}`);
1280
- this.#customRules.set(name, validator);
1281
- }
1282
- /**
1283
- * Validate an attribute against its rules
1284
- * @param attr - The attribute to validate
1285
- * @param po - The persistent object containing the attribute
1286
- * @returns Error message if validation fails, null if valid
1287
- */
1288
- validateAttribute(attr, po) {
1289
- // Create conversion context for wrappers
1290
- const conversionContext = {
1291
- getConvertedValue: (attribute) => this.#getConvertedValue(attribute),
1292
- setConvertedValue: (attribute, value) => {
1293
- attribute.value = toServiceValue(value, attribute.type);
1294
- attribute.isValueChanged = true;
1295
- }
1296
- };
1297
- // Create wrapped objects for the validation context
1298
- const wrappedPo = createVirtualPersistentObject(po, conversionContext);
1299
- const wrappedAttr = createVirtualPersistentObjectAttribute(attr, conversionContext);
1300
- // Create validation context
1301
- const context = {
1302
- persistentObject: wrappedPo,
1303
- attribute: wrappedAttr,
1304
- translate: this.#translate
1305
- };
1306
- // Get the converted value (e.g., boolean from "True"/"False", number from string)
1307
- const convertedValue = this.#getConvertedValue(attr);
1308
- // Parse and validate rules string
1309
- // Note: isRequired is auto-set from rules for UI purposes only (e.g., showing asterisks)
1310
- // All validation logic is handled by the rules themselves
1311
- if (!attr.rules)
1312
- return null;
1313
- const rules = this.parseRules(attr.rules);
1314
- for (const rule of rules) {
1315
- const validator = this.#builtInRules.get(rule.name) || this.#customRules.get(rule.name);
1316
- if (!validator)
1317
- throw new Error(`Unknown business rule: ${rule.name}`);
1318
- try {
1319
- validator(convertedValue, context, ...rule.params);
1320
- }
1321
- catch (error) {
1322
- return error instanceof Error ? error.message : String(error);
506
+ };
507
+ return new Proxy(dto, {
508
+ get(target, prop) {
509
+ if (prop in helpers) {
510
+ const value = helpers[prop];
511
+ return typeof value === "function" ? value : value;
1323
512
  }
513
+ return target[prop];
514
+ },
515
+ set(target, prop, value) {
516
+ target[prop] = value;
517
+ return true;
1324
518
  }
1325
- return null;
1326
- }
1327
- /**
1328
- * Parse a rules string into individual rules with parameters
1329
- * Example: "NotEmpty; MaxLength(40); IsEmail" -> [
1330
- * { name: "NotEmpty", params: [] },
1331
- * { name: "MaxLength", params: [40] },
1332
- * { name: "IsEmail", params: [] }
1333
- * ]
1334
- */
1335
- parseRules(rulesString) {
1336
- if (!rulesString?.trim())
1337
- return [];
1338
- return rulesString
1339
- .split(";")
1340
- .map(rule => rule.trim())
1341
- .filter(rule => rule.length > 0)
1342
- .map(rule => this.#parseRule(rule));
1343
- }
1344
- #parseRule(ruleString) {
1345
- const match = ruleString.match(/^(\w+)(?:\(([^)]*)\))?$/);
1346
- if (!match)
1347
- throw new Error(`Invalid rule format: ${ruleString}`);
1348
- const name = match[1];
1349
- const paramsString = match[2];
1350
- if (!paramsString) {
1351
- return { name, params: [] };
1352
- }
1353
- // Parse parameters (can be comma-separated)
1354
- const params = paramsString
1355
- .split(",")
1356
- .map(p => p.trim())
1357
- .map(p => {
1358
- // Try to parse as number
1359
- const num = Number(p);
1360
- if (p !== "" && !isNaN(num))
1361
- return num;
1362
- // Try to parse as boolean
1363
- if (p === "true")
1364
- return true;
1365
- if (p === "false")
1366
- return false;
1367
- // Otherwise return as string (remove quotes if present)
1368
- return p.replace(/^["']|["']$/g, "");
1369
- });
1370
- return { name, params };
1371
- }
1372
- // Helper to convert attribute values based on type
1373
- #getConvertedValue(attr) {
1374
- return fromServiceValue(attr.value, attr.type);
1375
- }
1376
- // Built-in validators - throw errors instead of returning strings
1377
- #validateIsBase64(value, _context) {
1378
- if (value == null || value === "")
1379
- return;
1380
- const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
1381
- if (!base64Regex.test(String(value)))
1382
- throw new Error(this.#translate("IsBase64"));
1383
- }
1384
- #validateIsEmail(value, _context) {
1385
- if (value == null || value === "")
1386
- return;
1387
- // Only allow ASCII characters in email addresses
1388
- const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
1389
- if (!emailRegex.test(String(value)))
1390
- throw new Error(this.#translate("IsEmail"));
1391
- }
1392
- #validateIsRegex(value, _context) {
1393
- if (value == null || value === "")
1394
- return;
1395
- try {
1396
- new RegExp(String(value));
519
+ });
520
+ }
521
+
522
+ /**
523
+ * Creates a VirtualPersistentObjectAttribute by wrapping an attribute DTO with a Proxy
524
+ * The Proxy intercepts property access to provide getValue/setValue methods
525
+ * @param attr - The PersistentObjectAttributeDto to wrap
526
+ * @param persistentObject - The parent VirtualPersistentObject
527
+ * @param service - The VirtualService instance
528
+ * @returns A VirtualPersistentObjectAttribute that combines DTO properties with helper methods
529
+ */
530
+ function createVirtualPersistentObjectAttribute(attr, persistentObject, service) {
531
+ const helpers = {
532
+ getValue() {
533
+ return fromServiceValue(attr.value, attr.type);
534
+ },
535
+ setValue(value) {
536
+ attr.value = toServiceValue(value, attr.type);
537
+ attr.isValueChanged = true;
538
+ },
539
+ setValidationError(error) {
540
+ attr.validationError = error || undefined;
541
+ },
542
+ get persistentObject() {
543
+ return persistentObject;
544
+ },
545
+ get service() {
546
+ return service;
1397
547
  }
1398
- catch {
1399
- throw new Error(this.#translate("IsRegex"));
548
+ };
549
+ return new Proxy(attr, {
550
+ get(target, prop) {
551
+ if (prop in helpers) {
552
+ const value = helpers[prop];
553
+ return typeof value === "function" ? value : value;
554
+ }
555
+ return target[prop];
556
+ },
557
+ set(target, prop, value) {
558
+ target[prop] = value;
559
+ return true;
1400
560
  }
1401
- }
1402
- #validateIsUrl(value, _context) {
1403
- if (value == null || value === "")
1404
- return;
1405
- try {
1406
- new URL(String(value));
561
+ });
562
+ }
563
+ /**
564
+ * Creates a VirtualPersistentObject by wrapping a DTO with a Proxy
565
+ * The Proxy intercepts property access to provide helper methods while keeping the underlying DTO unchanged
566
+ * @param dto - The PersistentObjectDto to wrap
567
+ * @param service - The VirtualService instance
568
+ * @returns A VirtualPersistentObject that combines DTO properties with helper methods
569
+ */
570
+ function createVirtualPersistentObject(dto, service) {
571
+ // Create proxy first so we can reference it in helpers
572
+ let proxy;
573
+ // Cache wrapped queries to ensure same instance is returned
574
+ let wrappedQueries;
575
+ // Helper methods - delegate to attribute helpers where possible
576
+ const helpers = {
577
+ getAttribute(name) {
578
+ const attr = dto.attributes?.find(a => a.name === name);
579
+ if (!attr)
580
+ return undefined;
581
+ return createVirtualPersistentObjectAttribute(attr, proxy, service);
582
+ },
583
+ getAttributeValue(name) {
584
+ return proxy.getAttribute(name)?.getValue();
585
+ },
586
+ setAttributeValue(name, value) {
587
+ proxy.getAttribute(name)?.setValue(value);
588
+ },
589
+ setNotification(message, type, duration) {
590
+ dto.notification = message;
591
+ dto.notificationType = type;
592
+ dto.notificationDuration = duration;
593
+ },
594
+ get queries() {
595
+ if (!dto.queries)
596
+ return undefined;
597
+ // Wrap queries lazily and cache them
598
+ if (!wrappedQueries)
599
+ wrappedQueries = dto.queries.map(q => createVirtualQuery(q, undefined, service));
600
+ return wrappedQueries;
601
+ },
602
+ get service() {
603
+ return service;
1407
604
  }
1408
- catch {
1409
- throw new Error(this.#translate("IsUrl"));
605
+ };
606
+ // Create a Proxy that intercepts property access
607
+ proxy = new Proxy(dto, {
608
+ get(target, prop) {
609
+ // If accessing a helper method, return it
610
+ if (prop in helpers) {
611
+ const value = helpers[prop];
612
+ return typeof value === "function" ? value : value;
613
+ }
614
+ // Otherwise, return the DTO property
615
+ return target[prop];
616
+ },
617
+ set(target, prop, value) {
618
+ // Allow setting DTO properties directly
619
+ target[prop] = value;
620
+ return true;
1410
621
  }
1411
- }
1412
- #validateIsWord(value, _context) {
1413
- if (value == null || value === "")
1414
- return;
1415
- const wordRegex = /^\w+$/;
1416
- if (!wordRegex.test(String(value)))
1417
- throw new Error(this.#translate("IsWord"));
1418
- }
1419
- #validateMaxLength(value, _context, maxLength) {
1420
- if (value == null || value === "")
1421
- return;
1422
- const length = String(value).length;
1423
- if (length > maxLength)
1424
- throw new Error(this.#translate("MaxLength", maxLength));
1425
- }
1426
- #validateMaxValue(value, _context, maximum) {
1427
- if (value == null || value === "")
1428
- return;
1429
- const num = Number(value);
1430
- if (isNaN(num))
1431
- throw new Error("Value must be a number");
1432
- if (num > maximum)
1433
- throw new Error(this.#translate("MaxValue", maximum));
1434
- }
1435
- #validateMinLength(value, _context, minLength) {
1436
- if (value == null || value === "")
1437
- return;
1438
- const length = String(value).length;
1439
- if (length < minLength)
1440
- throw new Error(this.#translate("MinLength", minLength));
1441
- }
1442
- #validateMinValue(value, _context, minimum) {
1443
- if (value == null || value === "")
1444
- return;
1445
- const num = Number(value);
1446
- if (isNaN(num))
1447
- throw new Error("Value must be a number");
1448
- if (num < minimum)
1449
- throw new Error(this.#translate("MinValue", minimum));
1450
- }
1451
- #validateRequired(value, _context) {
1452
- if (value == null)
1453
- throw new Error(this.#translate("Required"));
1454
- }
1455
- #validateNotEmpty(value, _context) {
1456
- if (value == null || value === "" || (typeof value === "string" && value.trim() === ""))
1457
- throw new Error(this.#translate("NotEmpty"));
1458
- }
622
+ });
623
+ return proxy;
624
+ }
625
+ /**
626
+ * Unwraps a VirtualPersistentObject to get the underlying DTO
627
+ * Since the Proxy wraps the DTO, we can safely cast it back
628
+ * @param wrapped - The VirtualPersistentObject to unwrap
629
+ * @returns The underlying PersistentObjectDto
630
+ */
631
+ function unwrapVirtualPersistentObject(wrapped) {
632
+ // The wrapped object is a Proxy around the DTO
633
+ // We can return it as-is since the DTO is the target of the Proxy
634
+ return wrapped;
1459
635
  }
1460
636
 
637
+ var _a;
1461
638
  /**
1462
639
  * Virtual implementation of ServiceHooks for testing without a backend
1463
640
  */
1464
641
  class VirtualServiceHooks extends ServiceHooks {
1465
- #persistentObjectRegistry;
1466
- #queryRegistry;
1467
- #actionDefinitions = new Map();
1468
- #actionHandlers = new Map();
1469
- #validator;
1470
- #persistentObjectActionsRegistry;
1471
- #builtInActions = new Set(["New", "Delete", "SelectReference", "RefreshQuery", "Edit", "CancelEdit", "Save", "EndEdit"]);
642
+ #service;
1472
643
  constructor() {
1473
644
  super();
1474
- this.#validator = new BusinessRuleValidator();
1475
- this.#persistentObjectActionsRegistry = new VirtualPersistentObjectActionsRegistry();
1476
- this.#queryRegistry = new VirtualQueryRegistry(this.#persistentObjectActionsRegistry);
1477
- this.#persistentObjectRegistry = new VirtualPersistentObjectRegistry(this.#validator, this.#actionHandlers, this.#queryRegistry, this.#persistentObjectActionsRegistry);
1478
- // Register default action definitions (these are built-in actions without custom handlers)
1479
- this.#actionDefinitions.set("AddReference", { name: "AddReference", displayName: "Add", isPinned: false });
1480
- this.#actionDefinitions.set("BulkEdit", { name: "BulkEdit", displayName: "Edit", isPinned: false });
1481
- this.#actionDefinitions.set("CancelEdit", { name: "CancelEdit", displayName: "Cancel", isPinned: false });
1482
- this.#actionDefinitions.set("CancelSave", { name: "CancelSave", displayName: "Cancel", isPinned: false });
1483
- this.#actionDefinitions.set("Delete", { name: "Delete", displayName: "Delete", isPinned: false });
1484
- this.#actionDefinitions.set("Edit", { name: "Edit", displayName: "Edit", isPinned: false });
1485
- this.#actionDefinitions.set("EndEdit", { name: "EndEdit", displayName: "Save", isPinned: false });
1486
- this.#actionDefinitions.set("Filter", { name: "Filter", displayName: "", isPinned: false });
1487
- this.#actionDefinitions.set("New", { name: "New", displayName: "New", isPinned: false });
1488
- this.#actionDefinitions.set("RefreshQuery", { name: "RefreshQuery", displayName: "", isPinned: false });
1489
- this.#actionDefinitions.set("Remove", { name: "Remove", displayName: "Remove", isPinned: false });
1490
- this.#actionDefinitions.set("Save", { name: "Save", displayName: "Save", isPinned: false });
1491
- this.#actionDefinitions.set("SelectReference", { name: "SelectReference", displayName: "Select", isPinned: false });
1492
645
  }
1493
- /**
1494
- * Sets the translation function for system messages
1495
- */
1496
- setTranslate(translate) {
1497
- this.#validator.setTranslate(translate);
646
+ /** @internal */
647
+ initialize(service) {
648
+ this.#service = service;
1498
649
  }
1499
650
  /**
1500
651
  * Intercepts fetch requests and routes them to virtual handlers
@@ -1514,18 +665,112 @@ class VirtualServiceHooks extends ServiceHooks {
1514
665
  case "GetApplication":
1515
666
  result = this.#handleGetApplication();
1516
667
  break;
1517
- case "GetQuery":
1518
- result = await this.#handleGetQuery(body);
668
+ case "GetQuery": {
669
+ const queryName = body.id;
670
+ const poConfig = this.#service.queryRegistry.getPersistentObjectConfig(queryName);
671
+ const type = poConfig?.type;
672
+ if (!type)
673
+ throw new Error(`Query "${queryName}" is not registered or has no persistentObject type`);
674
+ const instance = this.#service.actionsRegistry.createInstance(type);
675
+ const query = await instance.onGetQuery(queryName, null);
676
+ result = { query: unwrapVirtualQuery(query) };
1519
677
  break;
1520
- case "ExecuteQuery":
1521
- result = await this.#handleExecuteQuery(body);
678
+ }
679
+ case "GetPersistentObject": {
680
+ const { persistentObjectTypeId: type, objectId, isNew } = body;
681
+ const wrappedParent = this.#wrapPersistentObject(body.parent);
682
+ const instance = this.#service.actionsRegistry.createInstance(type);
683
+ // Choose lifecycle based on isNew flag
684
+ const po = isNew
685
+ ? await instance.onNew(wrappedParent, null, null)
686
+ : await instance.onLoad(objectId || crypto.randomUUID(), wrappedParent);
687
+ // Execute detail queries AFTER onLoad/onNew completes (parent is fully set up)
688
+ await instance.executeIncludedQueries(po);
689
+ result = { result: unwrapVirtualPersistentObject(po) };
1522
690
  break;
1523
- case "GetPersistentObject":
1524
- result = await this.#handleGetPersistentObject(body);
691
+ }
692
+ case "ExecuteQuery": {
693
+ const wrappedQuery = this.#wrapQuery(body.query);
694
+ const wrappedParent = this.#wrapPersistentObject(body.parent);
695
+ const type = body.query?.persistentObject?.type;
696
+ if (!type)
697
+ throw new Error("Query does not have a persistentObject type");
698
+ const instance = this.#service.actionsRegistry.createInstance(type);
699
+ const data = this.#service.queryRegistry.getData(body.query.name);
700
+ const queryResult = await instance.onExecuteQuery(wrappedQuery, wrappedParent, data);
701
+ // Convert to QueryResultDto
702
+ const columns = wrappedQuery.columns || [];
703
+ const pageSize = body.query?.pageSize || 20;
704
+ result = {
705
+ result: VirtualQueryRegistry.buildQueryResultDto(queryResult, columns, pageSize)
706
+ };
1525
707
  break;
1526
- case "ExecuteAction":
1527
- result = await this.#handleExecuteAction(body);
708
+ }
709
+ case "ExecuteAction": {
710
+ const actionName = body.action.split(".").pop();
711
+ // WRAP IMMEDIATELY at entry point
712
+ const wrappedParent = this.#wrapPersistentObject(body.parent);
713
+ // Query actions (have body.query)
714
+ if (body.query) {
715
+ const wrappedQuery = this.#wrapQuery(body.query);
716
+ const type = body.query.persistentObject?.type;
717
+ if (!type)
718
+ throw new Error("Query does not have a persistentObject type");
719
+ const instance = this.#service.actionsRegistry.createInstance(type);
720
+ if (actionName === "New") {
721
+ const po = await instance.onNew(wrappedParent, wrappedQuery, body.parameters);
722
+ await instance.executeIncludedQueries(po);
723
+ result = { result: unwrapVirtualPersistentObject(po) };
724
+ }
725
+ else if (actionName === "Delete") {
726
+ const wrappedItems = body.selectedItems?.map((item) => createVirtualQueryResultItem(item, wrappedQuery)) || [];
727
+ await instance.onDelete(wrappedParent, wrappedQuery, wrappedItems);
728
+ result = { result: wrappedParent ? unwrapVirtualPersistentObject(wrappedParent) : null };
729
+ }
730
+ else if (actionName === "SelectReference") {
731
+ if (!wrappedParent)
732
+ throw new Error("SelectReference requires a parent PersistentObject");
733
+ const attributeId = body.parameters?.PersistentObjectAttributeId;
734
+ if (!attributeId)
735
+ throw new Error("SelectReference requires PersistentObjectAttributeId parameter");
736
+ // Use parent's type for SelectReference (onSelectReference is on parent's actions class)
737
+ const parentInstance = this.#service.actionsRegistry.createInstance(wrappedParent.type);
738
+ const refAttr = createVirtualPersistentObjectAttribute(wrappedParent.attributes.find(a => a.id === attributeId), wrappedParent, this.#service);
739
+ const selectedItem = body.selectedItems?.[0]
740
+ ? createVirtualQueryResultItem(body.selectedItems[0], wrappedQuery)
741
+ : null;
742
+ await parentInstance.onSelectReference(wrappedParent, refAttr, wrappedQuery, selectedItem);
743
+ result = { result: unwrapVirtualPersistentObject(wrappedParent) };
744
+ }
745
+ else {
746
+ // Custom query action - use action handler
747
+ result = await this.#executeCustomAction(actionName, wrappedParent, wrappedQuery, body);
748
+ }
749
+ }
750
+ // PersistentObject actions (no body.query)
751
+ else {
752
+ if (!wrappedParent)
753
+ throw new Error("ExecuteAction requires a parent PersistentObject");
754
+ const type = wrappedParent.type;
755
+ const instance = this.#service.actionsRegistry.createInstance(type);
756
+ if (actionName === "Save") {
757
+ const po = await instance.onSave(wrappedParent);
758
+ result = { result: unwrapVirtualPersistentObject(po) };
759
+ }
760
+ else if (actionName === "Refresh") {
761
+ const attr = body.parameters?.RefreshedPersistentObjectAttributeId
762
+ ? createVirtualPersistentObjectAttribute(wrappedParent.attributes.find(a => a.id === body.parameters.RefreshedPersistentObjectAttributeId), wrappedParent, this.#service)
763
+ : undefined;
764
+ const po = await instance.onRefresh(wrappedParent, attr);
765
+ result = { result: unwrapVirtualPersistentObject(po) };
766
+ }
767
+ else {
768
+ // Custom PO action - use action handler
769
+ result = await this.#executeCustomAction(actionName, wrappedParent, null, body);
770
+ }
771
+ }
1528
772
  break;
773
+ }
1529
774
  default:
1530
775
  throw new Error(`Virtual handler not implemented for method: ${method}`);
1531
776
  }
@@ -1545,103 +790,121 @@ class VirtualServiceHooks extends ServiceHooks {
1545
790
  }
1546
791
  }
1547
792
  /**
1548
- * Registers a PersistentObject configuration
793
+ * Executes a custom action handler
1549
794
  */
1550
- registerPersistentObject(config) {
1551
- // Validate configuration
1552
- if (!config.type)
1553
- throw new Error("VirtualPersistentObjectConfig.type is required");
1554
- if (!config.attributes || config.attributes.length === 0)
1555
- throw new Error("VirtualPersistentObjectConfig.attributes must have at least one attribute");
1556
- // Validate that referenced actions are registered (skip built-in actions)
1557
- if (config.actions) {
1558
- config.actions.forEach(actionName => {
1559
- if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1560
- throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
1561
- });
1562
- }
1563
- // Validate that referenced queries are registered
1564
- if (config.queries) {
1565
- config.queries.forEach(queryName => {
1566
- if (!this.#queryRegistry.hasQuery(queryName))
1567
- throw new Error(`Query "${queryName}" is not registered. Call registerQuery first.`);
1568
- });
1569
- }
1570
- // Validate that lookup queries for reference attributes are registered
1571
- if (config.attributes) {
1572
- config.attributes.forEach(attr => {
1573
- if (attr.lookup) {
1574
- if (!this.#queryRegistry.hasQuery(attr.lookup))
1575
- throw new Error(`Lookup query "${attr.lookup}" for attribute "${attr.name}" is not registered. Call registerQuery first.`);
1576
- }
1577
- });
1578
- }
1579
- // Register with registry
1580
- this.#persistentObjectRegistry.register(config);
795
+ async #executeCustomAction(actionName, parent, query, body) {
796
+ const handler = this.#service.actionHandlers.get(actionName);
797
+ if (!handler)
798
+ throw new Error(`Action "${actionName}" is not registered`);
799
+ // Build unified action args
800
+ const wrappedSelectedItems = body.selectedItems && query
801
+ ? body.selectedItems.map((item) => createVirtualQueryResultItem(item, query))
802
+ : undefined;
803
+ const args = {
804
+ parent,
805
+ query: query || undefined,
806
+ selectedItems: wrappedSelectedItems,
807
+ parameters: body.parameters
808
+ };
809
+ // Execute handler and get result
810
+ const handlerResult = await handler(args);
811
+ // Unwrap result
812
+ const contextPo = parent || (query ? query.persistentObject : null);
813
+ let finalResult;
814
+ if (handlerResult)
815
+ finalResult = unwrapVirtualPersistentObject(handlerResult);
816
+ else
817
+ finalResult = contextPo;
818
+ return { result: finalResult };
1581
819
  }
1582
820
  /**
1583
- * Registers a Query configuration
821
+ * Wraps an incoming PersistentObject DTO with config augmentation
822
+ * @param dto - The incoming PersistentObject DTO from client
823
+ * @returns The wrapped VirtualPersistentObject with config metadata applied
1584
824
  */
1585
- registerQuery(config) {
1586
- // Validate configuration
1587
- if (!config.name)
1588
- throw new Error("VirtualQueryConfig.name is required");
1589
- if (!config.persistentObject)
1590
- throw new Error("VirtualQueryConfig.persistentObject is required");
1591
- // Verify PersistentObject is already registered
1592
- const persistentObjectConfig = this.#persistentObjectRegistry.getConfig(config.persistentObject);
1593
- if (!persistentObjectConfig)
1594
- throw new Error(`PersistentObject type '${config.persistentObject}' must be registered before creating a query. Call registerPersistentObject first.`);
1595
- // Validate that referenced actions are registered (skip built-in actions)
1596
- if (config.actions) {
1597
- config.actions.forEach(actionName => {
1598
- if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1599
- throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
1600
- });
825
+ #wrapPersistentObject(dto) {
826
+ if (!dto)
827
+ return null;
828
+ const config = this.#service.persistentObjectRegistry.getConfig(dto.type);
829
+ if (!config)
830
+ throw new Error(`PersistentObject type "${dto.type}" is not registered`);
831
+ // Process attributes - iterate over CLIENT attributes only
832
+ if (dto.attributes) {
833
+ for (const clientAttr of dto.attributes) {
834
+ const configAttr = config.attributes.find(a => a.name === clientAttr.name);
835
+ if (!configAttr)
836
+ throw new Error(`Attribute "${clientAttr.name}" is not registered for PersistentObject type "${dto.type}"`);
837
+ this.#mergeAttributeWithConfig(clientAttr, configAttr);
838
+ }
1601
839
  }
1602
- if (config.itemActions) {
1603
- config.itemActions.forEach(actionName => {
1604
- if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1605
- throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
1606
- });
840
+ // Recursively wrap parent if present
841
+ if (dto.parent)
842
+ dto.parent = unwrapVirtualPersistentObject(this.#wrapPersistentObject(dto.parent));
843
+ return createVirtualPersistentObject(dto, this.#service);
844
+ }
845
+ /**
846
+ * Merges a client attribute with config metadata
847
+ */
848
+ #mergeAttributeWithConfig(clientAttr, configAttr) {
849
+ // SERVER PROVIDES (all metadata):
850
+ clientAttr.type = configAttr.type || "String";
851
+ clientAttr.rules = configAttr.rules;
852
+ clientAttr.typeHints = configAttr.typeHints;
853
+ clientAttr.isReadOnly = configAttr.isReadOnly || false;
854
+ clientAttr.triggersRefresh = configAttr.triggersRefresh || false;
855
+ clientAttr.isRequired = BusinessRuleValidator.hasRequiredRule(configAttr.rules);
856
+ clientAttr.label = configAttr.label || configAttr.name;
857
+ clientAttr.group = configAttr.group || "";
858
+ clientAttr.tab = configAttr.tab || "";
859
+ clientAttr.column = configAttr.column;
860
+ clientAttr.columnSpan = configAttr.columnSpan;
861
+ clientAttr.options = configAttr.options;
862
+ // Reference attribute properties - only set if explicitly configured
863
+ if (configAttr.lookup) {
864
+ const refAttr = clientAttr;
865
+ if (configAttr.displayAttribute !== undefined)
866
+ refAttr.displayAttribute = configAttr.displayAttribute;
1607
867
  }
1608
- // Register with query registry
1609
- this.#queryRegistry.register(config, persistentObjectConfig);
1610
- }
1611
- /**
1612
- * Registers a custom action that can be used on PersistentObjects and Queries
1613
- * @param config - The action configuration with handler
1614
- */
1615
- registerAction(config) {
1616
- // Validate configuration
1617
- if (!config.name)
1618
- throw new Error("ActionConfig.name is required");
1619
- if (!config.handler)
1620
- throw new Error("ActionConfig.handler is required");
1621
- // Register action definition
1622
- this.#actionDefinitions.set(config.name, {
1623
- name: config.name,
1624
- displayName: config.displayName || config.name,
1625
- isPinned: config.isPinned || false
1626
- });
1627
- // Register action handler
1628
- this.#actionHandlers.set(config.name, config.handler);
1629
868
  }
1630
869
  /**
1631
- * Registers a custom business rule for validation
1632
- * @param name - The rule name (cannot override built-in rules)
1633
- * @param validator - The validation function
870
+ * Maps attribute visibility to column isHidden property
1634
871
  */
1635
- registerBusinessRule(name, validator) {
1636
- this.#validator.registerCustomRule(name, validator);
872
+ static #mapVisibilityToIsHidden(visibility) {
873
+ if (!visibility || visibility === "Always" || visibility === "Read" || visibility === "Query")
874
+ return false;
875
+ if (visibility === "New" || visibility === "Never")
876
+ return true;
877
+ // Handle compound visibility values
878
+ if (visibility === "Read, Query" || visibility === "Query, New")
879
+ return false;
880
+ if (visibility === "Read, New")
881
+ return true;
882
+ return false;
1637
883
  }
1638
884
  /**
1639
- * Registers a VirtualPersistentObjectActions class for a specific type
1640
- * @param type - The PersistentObject type name
1641
- * @param ActionsClass - The VirtualPersistentObjectActions class constructor
885
+ * Wraps an incoming Query DTO with config augmentation
886
+ * @param dto - The incoming Query DTO from client
887
+ * @returns The wrapped VirtualQuery with config metadata applied
1642
888
  */
1643
- registerPersistentObjectActions(type, ActionsClass) {
1644
- this.#persistentObjectActionsRegistry.register(type, ActionsClass);
889
+ #wrapQuery(dto) {
890
+ if (!dto)
891
+ return null;
892
+ const queryConfig = this.#service.queryRegistry.getQueryConfig(dto.name);
893
+ if (!queryConfig)
894
+ throw new Error(`Query "${dto.name}" is not registered`);
895
+ const poConfig = this.#service.persistentObjectRegistry.getConfig(queryConfig.persistentObject);
896
+ // Process columns - iterate over CLIENT columns only
897
+ if (dto.columns && poConfig) {
898
+ for (const clientCol of dto.columns) {
899
+ const attrConfig = poConfig.attributes.find(a => a.name === clientCol.name);
900
+ if (attrConfig) {
901
+ clientCol.canSort = attrConfig.canSort ?? true;
902
+ // Ensure isHidden is set from visibility config
903
+ clientCol.isHidden = _a.#mapVisibilityToIsHidden(attrConfig.visibility);
904
+ }
905
+ }
906
+ }
907
+ return createVirtualQuery(dto, queryConfig, this.#service);
1645
908
  }
1646
909
  /**
1647
910
  * Handles GetClientData requests
@@ -1679,7 +942,7 @@ class VirtualServiceHooks extends ServiceHooks {
1679
942
  */
1680
943
  #handleGetApplication() {
1681
944
  // Build action definition items
1682
- const actionItems = Array.from(this.#actionDefinitions.values()).map(action => ({
945
+ const actionItems = Array.from(this.#service._actionDefinitions.values()).map(action => ({
1683
946
  id: crypto.randomUUID(),
1684
947
  values: [
1685
948
  { key: "Name", value: action.name },
@@ -1773,213 +1036,610 @@ class VirtualServiceHooks extends ServiceHooks {
1773
1036
  ]
1774
1037
  };
1775
1038
  return {
1776
- application: appPo,
1777
- userCultureInfo: "en-US",
1778
- userLanguage: "en",
1779
- userName: "VirtualUser",
1780
- hasSensitive: false
1039
+ application: appPo,
1040
+ userCultureInfo: "en-US",
1041
+ userLanguage: "en",
1042
+ userName: "VirtualUser",
1043
+ hasSensitive: false
1044
+ };
1045
+ }
1046
+ }
1047
+ _a = VirtualServiceHooks;
1048
+
1049
+ /**
1050
+ * @internal
1051
+ */
1052
+ const initializeActions = Symbol("initializeActions");
1053
+ /**
1054
+ * Base class for PersistentObject lifecycle methods
1055
+ * Extend this class to override lifecycle hooks like onLoad, onSave, onRefresh, etc.
1056
+ * All methods have default implementations, so you only need to override what you need.
1057
+ */
1058
+ class VirtualPersistentObjectActions {
1059
+ /**
1060
+ * Internal initialization method (called by registry during instance creation)
1061
+ * @internal
1062
+ */
1063
+ [initializeActions](validator, service, type) {
1064
+ this.validator = validator;
1065
+ this.service = service;
1066
+ this.type = type;
1067
+ }
1068
+ /**
1069
+ * Called every time a PersistentObject DTO is created (both new and existing objects)
1070
+ * Use this to set metadata on attributes that can only be known at runtime
1071
+ * @param obj - The PersistentObject DTO being constructed
1072
+ */
1073
+ onConstruct(obj) {
1074
+ // Default implementation: do nothing
1075
+ }
1076
+ /**
1077
+ * Called when loading an existing object.
1078
+ * @param objectId - The ID of the object to load
1079
+ * @param parent - The parent PersistentObject if loaded in a master-detail context, null otherwise
1080
+ * @returns The PersistentObject with loaded data
1081
+ */
1082
+ async onLoad(objectId, parent) {
1083
+ const obj = await this.buildPersistentObject(objectId, false);
1084
+ this.onConstruct(obj);
1085
+ return obj;
1086
+ }
1087
+ /**
1088
+ * Called when creating a new object.
1089
+ * @param parent - The parent PersistentObject if creating from a detail query, null otherwise
1090
+ * @param query - The Query from which the New action was invoked, null if not from a query
1091
+ * @param parameters - Optional parameters including "MenuOption" if NewOptions exist
1092
+ * @returns The PersistentObject with default values set
1093
+ */
1094
+ async onNew(parent, query, parameters) {
1095
+ const obj = await this.buildPersistentObject(crypto.randomUUID(), true);
1096
+ this.onConstruct(obj);
1097
+ return obj;
1098
+ }
1099
+ /**
1100
+ * Full query lifecycle - builds query, calls onConstructQuery, auto-executes if configured
1101
+ * Override this to customize how queries are retrieved
1102
+ * @param queryName - The name of the query to get
1103
+ * @param parent - The parent PersistentObject if this is a detail query, null otherwise
1104
+ * @returns The Query with results if autoQuery is enabled
1105
+ */
1106
+ async onGetQuery(queryName, parent) {
1107
+ const query = await this.buildQuery(queryName);
1108
+ this.onConstructQuery(query, parent);
1109
+ if (query.autoQuery) {
1110
+ const executeResult = await this.onExecuteQuery(query, parent, this.getQueryData(queryName));
1111
+ query.result = VirtualQueryRegistry.buildQueryResultDto(executeResult, query.columns || [], query.pageSize || 20);
1112
+ }
1113
+ return query;
1114
+ }
1115
+ /**
1116
+ * Called when an attribute with triggersRefresh: true is changed
1117
+ * Use this to update other attributes based on the changed attribute
1118
+ * @param obj - The PersistentObject DTO in edit mode
1119
+ * @param attribute - The attribute that triggered the refresh (the one with triggersRefresh=true that was changed), wrapped with getValue/setValue
1120
+ * @returns The PersistentObject with refreshed attributes
1121
+ */
1122
+ async onRefresh(obj, attribute) {
1123
+ // Default implementation: return obj as-is
1124
+ return obj;
1125
+ }
1126
+ /**
1127
+ * Called when the Save action is executed
1128
+ * Orchestrates the save process by calling saveNew or saveExisting based on obj.isNew
1129
+ * @param obj - The PersistentObject DTO to save
1130
+ * @returns The saved PersistentObject
1131
+ */
1132
+ async onSave(obj) {
1133
+ // Validate first - return with errors if invalid
1134
+ if (!this.checkRules(obj))
1135
+ return obj;
1136
+ // Delegate to saveNew or saveExisting based on isNew flag
1137
+ if (obj.isNew)
1138
+ return await this.saveNew(obj);
1139
+ else
1140
+ return await this.saveExisting(obj);
1141
+ }
1142
+ /**
1143
+ * Validates all attributes against their business rules
1144
+ * Override this to customize validation behavior
1145
+ * @param obj - The wrapped PersistentObject to validate (already has config metadata merged)
1146
+ * @returns true if all rules pass, false if any validation errors
1147
+ */
1148
+ checkRules(obj) {
1149
+ if (!this.validator || !obj.attributes)
1150
+ return true;
1151
+ let hasErrors = false;
1152
+ for (const attr of obj.attributes) {
1153
+ // Clear previous errors
1154
+ attr.validationError = undefined;
1155
+ // Get wrapped attribute (has getValue/setValue methods)
1156
+ const wrappedAttr = obj.getAttribute(attr.name);
1157
+ if (!wrappedAttr)
1158
+ continue;
1159
+ // Note: wrappedAttr.rules is already set from config via #wrapPersistentObject
1160
+ const error = this.validator.validateAttribute(wrappedAttr, obj);
1161
+ if (error) {
1162
+ attr.validationError = error;
1163
+ hasErrors = true;
1164
+ }
1165
+ }
1166
+ if (hasErrors)
1167
+ obj.setNotification(this.service.getMessage("ValidationRulesFailed"), "Error");
1168
+ return !hasErrors;
1169
+ }
1170
+ /**
1171
+ * Called by onSave when obj.isNew === true
1172
+ * Use this to save a new entity to the data store
1173
+ * @param obj - The new PersistentObject DTO to save
1174
+ * @returns The saved PersistentObject
1175
+ */
1176
+ async saveNew(obj) {
1177
+ // Default implementation: return obj as-is (no persistence in mock)
1178
+ return obj;
1179
+ }
1180
+ /**
1181
+ * Called by onSave when obj.isNew === false
1182
+ * Use this to update an existing entity in the data store
1183
+ * @param obj - The existing PersistentObject DTO to save
1184
+ * @returns The saved PersistentObject
1185
+ */
1186
+ async saveExisting(obj) {
1187
+ // Default implementation: return obj as-is (no persistence in mock)
1188
+ return obj;
1189
+ }
1190
+ /**
1191
+ * Called when a reference attribute is changed (via changeReference() on a PersistentObjectAttributeWithReference)
1192
+ * The base implementation sets objectId, value, and isValueChanged on the reference attribute.
1193
+ * Override this to add custom logic after the reference is set.
1194
+ * @param parent - The PersistentObject that contains the reference attribute
1195
+ * @param referenceAttribute - The reference attribute that was changed
1196
+ * @param query - The query that was used to select the reference
1197
+ * @param selectedItem - The QueryResultItem that was selected, or null if reference was cleared
1198
+ */
1199
+ async onSelectReference(_parent, referenceAttribute, _query, selectedItem) {
1200
+ // Access reference-specific properties via the underlying DTO
1201
+ const refAttr = referenceAttribute;
1202
+ if (selectedItem == null) {
1203
+ // Clear the reference
1204
+ refAttr.objectId = null;
1205
+ referenceAttribute.setValue(null);
1206
+ }
1207
+ else {
1208
+ // Set the reference to the selected item
1209
+ refAttr.objectId = selectedItem.id;
1210
+ // Get the display value from the item using displayAttribute
1211
+ const displayAttribute = refAttr.displayAttribute;
1212
+ referenceAttribute.setValue(selectedItem.getValue(displayAttribute) || selectedItem.id);
1213
+ }
1214
+ }
1215
+ /**
1216
+ * Called when the Delete action is executed on selected items in a Query
1217
+ * Use this to handle deletion of entities
1218
+ * @param parent - The parent PersistentObject if deleting from a detail query, null for top-level queries
1219
+ * @param query - The query from which items are being deleted
1220
+ * @param selectedItems - The QueryResultItems that are selected for deletion
1221
+ */
1222
+ async onDelete(parent, query, selectedItems) {
1223
+ // Default implementation: do nothing (no persistence in mock)
1224
+ }
1225
+ /**
1226
+ * Called when a Query of this PersistentObject type is constructed
1227
+ * Use this to set metadata on query columns that can only be known at runtime
1228
+ * @param query - The Query DTO being constructed
1229
+ * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
1230
+ */
1231
+ onConstructQuery(query, parent) {
1232
+ // Default implementation: do nothing
1233
+ }
1234
+ /**
1235
+ * Called when a Query is executed.
1236
+ * The base implementation calls getEntities() and applies text search, sorting, and pagination.
1237
+ * @param query - The Query DTO with textSearch, sortOptions, skip, top properties set
1238
+ * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
1239
+ * @param data - Default data from the query config (used if getEntities is not overridden)
1240
+ * @returns The items matching the query criteria and the total count before pagination
1241
+ */
1242
+ async onExecuteQuery(query, parent, data) {
1243
+ // Get all items from subclass or use provided data
1244
+ let result = await this.getEntities(query, parent, data);
1245
+ // Apply text search if provided
1246
+ if (query.textSearch && query.textSearch.trim() !== "") {
1247
+ const searchLower = query.textSearch.toLowerCase();
1248
+ const stringColumns = query.columns?.filter(c => c.type === "String" && !c.isHidden) || [];
1249
+ result = result.filter(row => {
1250
+ for (const column of stringColumns) {
1251
+ const value = String(row[column.name] || "").toLowerCase();
1252
+ if (value.includes(searchLower))
1253
+ return true;
1254
+ }
1255
+ return false;
1256
+ });
1257
+ }
1258
+ // Apply sorting if provided
1259
+ if (query.sortOptions && query.sortOptions.trim() !== "") {
1260
+ const sortParts = query.sortOptions.split(";").map(s => s.trim()).filter(Boolean);
1261
+ const sortOptions = sortParts.map(part => {
1262
+ const [name, dir] = part.split(/\s+/);
1263
+ return { name, direction: dir?.toUpperCase() === "DESC" ? "DESC" : "ASC" };
1264
+ });
1265
+ result = [...result].sort((a, b) => {
1266
+ for (const { name, direction } of sortOptions) {
1267
+ const aVal = a[name];
1268
+ const bVal = b[name];
1269
+ // Null handling
1270
+ if (aVal == null && bVal == null)
1271
+ continue;
1272
+ if (aVal == null)
1273
+ return direction === "ASC" ? -1 : 1;
1274
+ if (bVal == null)
1275
+ return direction === "ASC" ? 1 : -1;
1276
+ // Type-aware comparison
1277
+ let cmp = 0;
1278
+ if (typeof aVal === "boolean")
1279
+ cmp = aVal === bVal ? 0 : (aVal ? 1 : -1);
1280
+ else if (aVal instanceof Date && bVal instanceof Date)
1281
+ cmp = aVal.getTime() - bVal.getTime();
1282
+ else if (typeof aVal === "number" && typeof bVal === "number")
1283
+ cmp = aVal - bVal;
1284
+ else
1285
+ cmp = String(aVal).toLowerCase().localeCompare(String(bVal).toLowerCase());
1286
+ if (cmp !== 0)
1287
+ return direction === "ASC" ? cmp : -cmp;
1288
+ }
1289
+ return 0;
1290
+ });
1291
+ }
1292
+ // Apply pagination
1293
+ const skip = query.skip || 0;
1294
+ const top = query.top || query.pageSize || 20;
1295
+ const items = result.slice(skip, skip + top);
1296
+ return {
1297
+ items,
1298
+ totalItems: result.length
1299
+ };
1300
+ }
1301
+ /**
1302
+ * Override this to provide the full list of items for a query.
1303
+ * The base onExecuteQuery will handle text search, sorting, and pagination automatically.
1304
+ * @param query - The Query DTO with textSearch, sortOptions properties set
1305
+ * @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
1306
+ * @param data - Default data from the query config
1307
+ * @returns All items matching the query criteria (before pagination)
1308
+ */
1309
+ async getEntities(_query, _parent, data) {
1310
+ // Default implementation: return provided data (from query config)
1311
+ return [...data];
1312
+ }
1313
+ // === Internal helpers (moved from registries) ===
1314
+ /**
1315
+ * Builds a PersistentObject from configuration
1316
+ * @param objectId - The object ID
1317
+ * @param isNew - Whether this is a new object
1318
+ * @returns The wrapped VirtualPersistentObject
1319
+ */
1320
+ async buildPersistentObject(objectId, isNew) {
1321
+ if (!this.type)
1322
+ throw new Error("Type is not set on VirtualPersistentObjectActions instance");
1323
+ const config = this.service.persistentObjectRegistry.getConfig(this.type);
1324
+ if (!config)
1325
+ throw new Error(`PersistentObject type "${this.type}" is not registered`);
1326
+ const dto = await this.#buildPersistentObjectDto(config, objectId, isNew);
1327
+ // Auto-add Save action if onSave is overridden
1328
+ if (this.service.actionsRegistry.isMethodOverridden(this.type, "onSave")) {
1329
+ if (!dto.actions.includes("Save"))
1330
+ dto.actions.push("Save");
1331
+ }
1332
+ return createVirtualPersistentObject(dto, this.service);
1333
+ }
1334
+ /**
1335
+ * Builds a Query from configuration
1336
+ * @param name - The query name
1337
+ * @returns The wrapped VirtualQuery
1338
+ */
1339
+ async buildQuery(name) {
1340
+ const queryConfig = this.service.queryRegistry.getQueryConfig(name);
1341
+ if (!queryConfig)
1342
+ throw new Error(`Query '${name}' is not registered`);
1343
+ const poConfig = this.service.persistentObjectRegistry.getConfig(queryConfig.persistentObject);
1344
+ if (!poConfig)
1345
+ throw new Error(`PersistentObject type '${queryConfig.persistentObject}' is not registered`);
1346
+ const columns = this.service.queryRegistry.getColumns(name);
1347
+ const dto = this.#buildQueryDto(queryConfig, columns, poConfig);
1348
+ // Auto-add New/Delete actions if methods are overridden
1349
+ const type = poConfig.type;
1350
+ if (this.service.actionsRegistry.isMethodOverridden(type, "onNew")) {
1351
+ if (!dto.actions.includes("New"))
1352
+ dto.actions.push("New");
1353
+ }
1354
+ if (this.service.actionsRegistry.isMethodOverridden(type, "onDelete")) {
1355
+ if (!dto.actions.includes("Delete"))
1356
+ dto.actions.push("Delete");
1357
+ }
1358
+ return createVirtualQuery(dto, queryConfig, this.service);
1359
+ }
1360
+ /**
1361
+ * Executes queries that have isIncludedInParentObject set (defaults to true).
1362
+ * Called automatically after onLoad/onNew completes.
1363
+ * @param obj - The PersistentObject to execute queries for
1364
+ */
1365
+ async executeIncludedQueries(obj) {
1366
+ if (!obj.queries)
1367
+ return;
1368
+ for (const query of obj.queries) {
1369
+ // Default is true - only skip if explicitly set to false
1370
+ if (query.isIncludedInParentObject === false)
1371
+ continue;
1372
+ const data = this.getQueryData(query.name);
1373
+ const executeResult = await this.onExecuteQuery(query, obj, data);
1374
+ query.result = VirtualQueryRegistry.buildQueryResultDto(executeResult, query.columns || [], query.pageSize || 20);
1375
+ }
1376
+ }
1377
+ /**
1378
+ * Gets the default data for a query from configuration
1379
+ * @param name - The query name
1380
+ * @returns The default data array
1381
+ */
1382
+ getQueryData(name) {
1383
+ return this.service.queryRegistry.getData(name);
1384
+ }
1385
+ // === Private DTO building helpers ===
1386
+ async #buildPersistentObjectDto(config, objectId, isNew) {
1387
+ const id = crypto.randomUUID();
1388
+ const fullTypeName = `MockNamespace.${config.type}`;
1389
+ // Build attributes
1390
+ const attributes = await Promise.all(config.attributes.map((attr, index) => this.#buildAttributeDto(attr, index)));
1391
+ // Build tabs
1392
+ const tabs = {};
1393
+ if (config.tabs) {
1394
+ Object.entries(config.tabs).forEach(([key, tab]) => {
1395
+ tabs[key] = {
1396
+ name: tab.name || key,
1397
+ columnCount: tab.columnCount || 0,
1398
+ id: tab.id,
1399
+ layout: tab.layout
1400
+ };
1401
+ });
1402
+ }
1403
+ else {
1404
+ tabs[""] = { name: "", columnCount: 0 };
1405
+ }
1406
+ // Build action names
1407
+ const actions = config.actions ? [...config.actions] : [];
1408
+ // Build queries (detail queries)
1409
+ const queries = [];
1410
+ if (config.queries) {
1411
+ for (const queryName of config.queries) {
1412
+ const queryConfig = this.service.queryRegistry.getQueryConfig(queryName);
1413
+ if (!queryConfig)
1414
+ throw new Error(`Query '${queryName}' is not registered`);
1415
+ const poConfig = this.service.persistentObjectRegistry.getConfig(queryConfig.persistentObject);
1416
+ if (!poConfig)
1417
+ throw new Error(`PersistentObject type '${queryConfig.persistentObject}' is not registered`);
1418
+ const columns = this.service.queryRegistry.getColumns(queryName);
1419
+ const queryDto = this.#buildQueryDto(queryConfig, columns, poConfig);
1420
+ queries.push(queryDto);
1421
+ }
1422
+ }
1423
+ return {
1424
+ id,
1425
+ objectId,
1426
+ type: config.type,
1427
+ fullTypeName,
1428
+ label: config.label || config.type,
1429
+ isNew,
1430
+ isReadOnly: false,
1431
+ stateBehavior: config.stateBehavior || "StayInEdit",
1432
+ attributes,
1433
+ actions,
1434
+ tabs,
1435
+ queries
1436
+ };
1437
+ }
1438
+ #buildQueryDto(config, columns, poConfig) {
1439
+ // Build default actions
1440
+ const actions = ["RefreshQuery"];
1441
+ // Add custom query-level actions
1442
+ if (config.actions) {
1443
+ for (const actionName of config.actions)
1444
+ if (!actions.includes(actionName))
1445
+ actions.push(actionName);
1446
+ }
1447
+ // Add custom item-level actions
1448
+ if (config.itemActions) {
1449
+ for (const actionName of config.itemActions)
1450
+ if (!actions.includes(actionName))
1451
+ actions.push(actionName);
1452
+ }
1453
+ // Build minimal PersistentObjectDto for the query
1454
+ const persistentObject = {
1455
+ type: poConfig.type,
1456
+ id: crypto.randomUUID(),
1457
+ objectId: null,
1458
+ label: poConfig.type,
1459
+ fullTypeName: `Mock.${poConfig.type}`,
1460
+ isNew: false,
1461
+ isReadOnly: true,
1462
+ attributes: [],
1463
+ actions: [],
1464
+ tabs: {},
1465
+ queries: []
1466
+ };
1467
+ return {
1468
+ id: crypto.randomUUID(),
1469
+ name: config.name,
1470
+ label: config.label || config.name,
1471
+ columns,
1472
+ actions,
1473
+ allowTextSearch: config.allowTextSearch ?? true,
1474
+ disableBulkEdit: config.disableBulkEdit ?? false,
1475
+ autoQuery: config.autoQuery ?? true,
1476
+ pageSize: config.pageSize || 20,
1477
+ persistentObject,
1478
+ sortOptions: "",
1479
+ textSearch: "",
1480
+ skip: 0,
1481
+ top: config.pageSize || 20,
1482
+ totalItems: -1,
1483
+ canRead: true,
1484
+ canReorder: false,
1485
+ enableSelectAll: true
1486
+ };
1487
+ }
1488
+ async #buildAttributeDto(config, index) {
1489
+ const isRequired = BusinessRuleValidator.hasRequiredRule(config.rules);
1490
+ const baseDto = {
1491
+ id: config.id || crypto.randomUUID(),
1492
+ name: config.name,
1493
+ type: config.type || "String",
1494
+ label: config.label || config.name,
1495
+ value: config.value,
1496
+ isRequired,
1497
+ isReadOnly: config.isReadOnly || false,
1498
+ rules: config.rules,
1499
+ visibility: config.visibility || "Always",
1500
+ group: config.group || "",
1501
+ tab: config.tab || "",
1502
+ triggersRefresh: config.triggersRefresh || false,
1503
+ options: config.options,
1504
+ typeHints: config.typeHints,
1505
+ column: config.column,
1506
+ columnSpan: config.columnSpan || 4,
1507
+ offset: config.offset ?? index
1781
1508
  };
1509
+ // Handle Reference attributes with lookup
1510
+ if (config.lookup) {
1511
+ const lookupQueryConfig = this.service.queryRegistry.getQueryConfig(config.lookup);
1512
+ if (!lookupQueryConfig)
1513
+ throw new Error(`Lookup query "${config.lookup}" is not registered`);
1514
+ const lookupPoConfig = this.service.persistentObjectRegistry.getConfig(lookupQueryConfig.persistentObject);
1515
+ if (!lookupPoConfig)
1516
+ throw new Error(`PersistentObject type '${lookupQueryConfig.persistentObject}' is not registered`);
1517
+ const lookupColumns = this.service.queryRegistry.getColumns(config.lookup);
1518
+ const lookupQuery = this.#buildQueryDto(lookupQueryConfig, lookupColumns, lookupPoConfig);
1519
+ // Determine displayAttribute
1520
+ let displayAttribute = config.displayAttribute;
1521
+ if (!displayAttribute && lookupQuery.columns && lookupQuery.columns.length > 0) {
1522
+ const firstVisibleColumn = lookupQuery.columns.find(c => !c.isHidden && c.name !== "Id");
1523
+ displayAttribute = firstVisibleColumn?.name || lookupQuery.columns[0].name;
1524
+ }
1525
+ if (!displayAttribute)
1526
+ displayAttribute = "Id";
1527
+ const refDto = {
1528
+ ...baseDto,
1529
+ type: "Reference",
1530
+ lookup: lookupQuery,
1531
+ objectId: config.value || null,
1532
+ displayAttribute,
1533
+ canAddNewReference: config.canAddNewReference ?? false,
1534
+ selectInPlace: config.selectInPlace ?? false
1535
+ };
1536
+ return refDto;
1537
+ }
1538
+ return baseDto;
1539
+ }
1540
+ }
1541
+
1542
+ /**
1543
+ * Registry for managing VirtualPersistentObjectActions classes
1544
+ * Provides methods to register actions classes and create instances
1545
+ */
1546
+ class VirtualPersistentObjectActionsRegistry {
1547
+ #actionsClasses = new Map();
1548
+ #overrideInfo = new Map();
1549
+ #validator;
1550
+ #service;
1551
+ constructor(validator, service) {
1552
+ this.#validator = validator;
1553
+ this.#service = service;
1782
1554
  }
1783
1555
  /**
1784
- * Handles GetQuery requests
1556
+ * Registers a VirtualPersistentObjectActions class for a specific type
1557
+ * @param type - The PersistentObject type name
1558
+ * @param ActionsClass - The VirtualPersistentObjectActions class constructor
1785
1559
  */
1786
- async #handleGetQuery(request) {
1787
- const queryName = request.id;
1788
- const queryDto = await this.#queryRegistry.getQuery(queryName);
1789
- return {
1790
- query: queryDto
1791
- };
1560
+ register(type, ActionsClass) {
1561
+ this.#actionsClasses.set(type, ActionsClass);
1562
+ // Detect overridden methods
1563
+ const overriddenMethods = this.#detectOverriddenMethods(ActionsClass);
1564
+ this.#overrideInfo.set(type, { overriddenMethods });
1792
1565
  }
1793
1566
  /**
1794
- * Handles ExecuteQuery requests
1567
+ * Detects which lifecycle methods are overridden in the given class
1568
+ * @param ActionsClass - The VirtualPersistentObjectActions class constructor
1569
+ * @returns Set of overridden method names
1795
1570
  */
1796
- async #handleExecuteQuery(request) {
1797
- const query = request.query;
1798
- const result = await this.#queryRegistry.executeQuery(query, request.parent || null);
1799
- return {
1800
- result
1801
- };
1571
+ #detectOverriddenMethods(ActionsClass) {
1572
+ const overridden = new Set();
1573
+ const baseProto = VirtualPersistentObjectActions.prototype;
1574
+ const classProto = ActionsClass.prototype;
1575
+ const methodsToCheck = [
1576
+ "onSave", "onNew", "onDelete", "onRefresh",
1577
+ "onLoad", "onConstruct", "onConstructQuery", "onSelectReference", "onExecuteQuery", "getEntities"
1578
+ ];
1579
+ for (const methodName of methodsToCheck) {
1580
+ if (classProto[methodName] !== baseProto[methodName])
1581
+ overridden.add(methodName);
1582
+ }
1583
+ return overridden;
1802
1584
  }
1803
1585
  /**
1804
- * Handles GetPersistentObject requests
1586
+ * Checks if a specific lifecycle method is overridden for a type
1587
+ * @param type - The PersistentObject type name
1588
+ * @param methodName - The lifecycle method name
1589
+ * @returns true if the method is overridden, false otherwise
1805
1590
  */
1806
- async #handleGetPersistentObject(request) {
1807
- const type = request.persistentObjectTypeId;
1808
- const objectId = request.objectId || crypto.randomUUID();
1809
- const isNew = request.isNew || false;
1810
- const parent = request.parent || null;
1811
- const po = await this.#persistentObjectRegistry.getPersistentObject(type, objectId, isNew, parent);
1812
- return {
1813
- result: po
1814
- };
1591
+ isMethodOverridden(type, methodName) {
1592
+ const info = this.#overrideInfo.get(type);
1593
+ if (!info)
1594
+ return false;
1595
+ return info.overriddenMethods.has(methodName);
1815
1596
  }
1816
1597
  /**
1817
- * Handles ExecuteAction requests
1598
+ * Checks if a type has registered actions
1599
+ * @param type - The PersistentObject type name
1600
+ * @returns true if the type has registered actions, false otherwise
1818
1601
  */
1819
- async #handleExecuteAction(request) {
1820
- const actionName = request.action.split(".").pop();
1821
- // Check if this is a query action
1822
- const queryActionRequest = request;
1823
- if (queryActionRequest.query) {
1824
- // Query action - can have parent or not
1825
- const queryDto = await this.#queryRegistry.getQuery(queryActionRequest.query.name);
1826
- return await this.#executeQueryAction(request, queryDto, actionName);
1827
- }
1828
- // PersistentObject action - must have parent
1829
- return await this.#persistentObjectRegistry.executeAction(request);
1602
+ has(type) {
1603
+ return this.#actionsClasses.has(type);
1830
1604
  }
1831
1605
  /**
1832
- * Executes an action from a query context
1606
+ * Creates a new instance of the actions class for a type
1607
+ * Returns a default VirtualPersistentObjectActions if no custom actions are registered
1608
+ * Injects validator, service, and type into the instance
1609
+ * @param type - The PersistentObject type name
1610
+ * @returns A new instance of the VirtualPersistentObjectActions class
1833
1611
  */
1834
- async #executeQueryAction(request, query, actionName) {
1835
- // Note: query.persistentObject is the TEMPLATE/SCHEMA, not the parent!
1836
- const parent = request.parent;
1837
- // Handle built-in New action
1838
- if (actionName === "New") {
1839
- const type = query.persistentObject?.type;
1840
- if (!type)
1841
- throw new Error("Query does not have a persistentObject type");
1842
- // Create a new PersistentObject with the full lifecycle
1843
- const newPo = await this.#persistentObjectRegistry.createNewPersistentObject(type, parent, query, request.parameters || null);
1844
- return {
1845
- result: newPo
1846
- };
1847
- }
1848
- // Handle built-in SelectReference action
1849
- if (actionName === "SelectReference") {
1850
- if (!parent)
1851
- throw new Error("SelectReference requires a parent PersistentObject");
1852
- const attributeId = request.parameters?.PersistentObjectAttributeId;
1853
- if (!attributeId)
1854
- throw new Error("SelectReference requires PersistentObjectAttributeId parameter");
1855
- // Find the reference attribute
1856
- const refAttr = parent.attributes?.find(a => a.id === attributeId);
1857
- if (!refAttr)
1858
- throw new Error(`Attribute with id "${attributeId}" not found`);
1859
- // Get selected items from the request
1860
- const queryActionRequest = request;
1861
- const selectedItems = queryActionRequest.selectedItems || [];
1862
- const selectedItem = selectedItems.length > 0 ? selectedItems[0] : null;
1863
- // Call onSelectReference - base implementation sets objectId/value
1864
- const conversionContext = this.#createConversionContext();
1865
- await this.#persistentObjectActionsRegistry.executeSelectReference(parent, refAttr, query, selectedItem, conversionContext);
1866
- return {
1867
- result: parent
1868
- };
1869
- }
1870
- // Handle built-in Delete action
1871
- if (actionName === "Delete") {
1872
- const type = query.persistentObject?.type;
1873
- if (!type)
1874
- throw new Error("Query does not have a persistentObject type");
1875
- // Get selected items from the request
1876
- const queryActionRequest = request;
1877
- const selectedItems = queryActionRequest.selectedItems || [];
1878
- if (selectedItems.length === 0)
1879
- throw new Error("Delete requires at least one selected item");
1880
- // Call onDelete lifecycle hook
1881
- await this.#persistentObjectActionsRegistry.executeDelete(parent || null, query, selectedItems);
1882
- // Return parent or empty result
1883
- return {
1884
- result: parent || null
1885
- };
1886
- }
1887
- // Get action handler
1888
- const handler = this.#actionHandlers.get(actionName);
1889
- if (!handler)
1890
- throw new Error(`Action "${actionName}" is not registered`);
1891
- // Create action context - use parent if provided, otherwise fall back to query's template PO
1892
- const contextPo = parent || query.persistentObject;
1893
- const context = this.#createActionContext(contextPo);
1894
- // Build unified action args
1895
- const queryActionRequest = request;
1896
- const args = {
1897
- parent: parent ?? null,
1898
- query: query,
1899
- selectedItems: queryActionRequest.selectedItems,
1900
- parameters: request.parameters,
1901
- context: context
1902
- };
1903
- // Execute handler
1904
- const result = await handler(args);
1905
- // Handle result - use parent if available, otherwise use template
1906
- const finalResult = this.#normalizeActionResult(result, contextPo);
1907
- return {
1908
- result: finalResult
1909
- };
1612
+ createInstance(type) {
1613
+ const ActionsClass = this.#actionsClasses.get(type);
1614
+ const instance = ActionsClass ? new ActionsClass() : new VirtualPersistentObjectActions();
1615
+ // Inject validator, service, and type via internal symbol
1616
+ instance[initializeActions](this.#validator, this.#service, type);
1617
+ return instance;
1910
1618
  }
1619
+ }
1620
+
1621
+ /**
1622
+ * Registry for managing PersistentObject configurations (pure config store)
1623
+ */
1624
+ class VirtualPersistentObjectRegistry {
1625
+ #configs = new Map();
1911
1626
  /**
1912
- * Creates an action context for attribute manipulation
1627
+ * Registers a PersistentObject configuration
1913
1628
  */
1914
- #createActionContext(po) {
1915
- return {
1916
- getAttribute: (name) => {
1917
- return po.attributes?.find(a => a.name === name);
1918
- },
1919
- getAttributeValue: (name) => {
1920
- const attr = po.attributes?.find(a => a.name === name);
1921
- if (!attr)
1922
- return undefined;
1923
- return fromServiceValue(attr.value, attr.type);
1924
- },
1925
- setAttributeValue: (name, value) => {
1926
- const attr = po.attributes?.find(a => a.name === name);
1927
- if (attr) {
1928
- attr.value = toServiceValue(value, attr.type);
1929
- attr.isValueChanged = true;
1930
- }
1931
- },
1932
- getConvertedValue: (attr) => {
1933
- return fromServiceValue(attr.value, attr.type);
1934
- },
1935
- setConvertedValue: (attr, value) => {
1936
- attr.value = toServiceValue(value, attr.type);
1937
- attr.isValueChanged = true;
1938
- },
1939
- setValidationError: (name, error) => {
1940
- const attr = po.attributes?.find(a => a.name === name);
1941
- if (attr)
1942
- attr.validationError = error;
1943
- },
1944
- clearValidationError: (name) => {
1945
- const attr = po.attributes?.find(a => a.name === name);
1946
- if (attr)
1947
- attr.validationError = undefined;
1948
- },
1949
- setNotification: (message, type, duration) => {
1950
- po.notification = message;
1951
- po.notificationType = type;
1952
- po.notificationDuration = duration;
1953
- }
1954
- };
1629
+ register(config) {
1630
+ this.#configs.set(config.type, config);
1955
1631
  }
1956
1632
  /**
1957
- * Creates a conversion context for type conversions
1633
+ * Gets a registered PersistentObject configuration
1958
1634
  */
1959
- #createConversionContext() {
1960
- return {
1961
- getConvertedValue: (attr) => {
1962
- return fromServiceValue(attr.value, attr.type);
1963
- },
1964
- setConvertedValue: (attr, value) => {
1965
- attr.value = toServiceValue(value, attr.type);
1966
- attr.isValueChanged = true;
1967
- }
1968
- };
1635
+ getConfig(type) {
1636
+ return this.#configs.get(type);
1969
1637
  }
1970
1638
  /**
1971
- * Normalizes action result (handles both old and new API formats)
1639
+ * Checks if a type has a registered configuration
1972
1640
  */
1973
- #normalizeActionResult(result, defaultParent) {
1974
- // Handle both old and new API formats for backwards compatibility
1975
- // Old API: handler returns { result: PersistentObjectDto }
1976
- // New API: handler returns PersistentObjectDto | null
1977
- if (result && typeof result === "object" && "result" in result) {
1978
- // Old API format - extract the result property
1979
- return result.result || defaultParent;
1980
- }
1981
- // New API format - use directly
1982
- return result || defaultParent;
1641
+ hasConfig(type) {
1642
+ return this.#configs.has(type);
1983
1643
  }
1984
1644
  }
1985
1645
 
@@ -2002,32 +1662,167 @@ class VirtualServiceHooks extends ServiceHooks {
2002
1662
  */
2003
1663
  class VirtualService extends Service {
2004
1664
  #isInitialized = false;
1665
+ #businessRuleValidator;
1666
+ #actionsRegistry;
1667
+ #persistentObjectRegistry;
1668
+ #queryRegistry;
1669
+ #actionDefinitions = new Map();
1670
+ #actionHandlers = new Map();
1671
+ #builtInActions = new Set(["New", "Delete", "SelectReference", "RefreshQuery", "Edit", "CancelEdit", "Save", "EndEdit"]);
1672
+ // Global (static) messages
1673
+ static #messages = {
1674
+ "Required": "This field is required",
1675
+ "NotEmpty": "This field cannot be empty",
1676
+ "IsEmail": "Email format is invalid",
1677
+ "IsUrl": "Value must be a valid URL",
1678
+ "MaxLength": "Maximum length is {0} characters",
1679
+ "MinLength": "Minimum length is {0} characters",
1680
+ "MaxValue": "Maximum value is {0}",
1681
+ "MinValue": "Minimum value is {0}",
1682
+ "IsBase64": "Value must be a valid base64 string",
1683
+ "IsRegex": "Value must be a valid regular expression",
1684
+ "IsWord": "Value must contain only word characters",
1685
+ "ValidationRulesFailed": "Some required information is missing or incorrect."
1686
+ };
1687
+ /**
1688
+ * Gets a copy of the global messages dictionary.
1689
+ */
1690
+ static get messages() {
1691
+ return { ...VirtualService.#messages };
1692
+ }
1693
+ /**
1694
+ * Sets the global messages dictionary.
1695
+ * Use this to provide translations or override default messages.
1696
+ * @example
1697
+ * VirtualService.messages = {
1698
+ * "Required": "Dit veld is verplicht",
1699
+ * "MaxLength": "Maximale lengte is {0} tekens"
1700
+ * };
1701
+ */
1702
+ static set messages(value) {
1703
+ VirtualService.#messages = { ...value };
1704
+ }
1705
+ /**
1706
+ * Converts a service string value to a primitive JavaScript type.
1707
+ * Unlike DataType.fromServiceString, this returns number instead of BigNumber
1708
+ * for numeric types (Decimal, Double, Int64, etc.).
1709
+ */
1710
+ static fromServiceValue(value, type) {
1711
+ return fromServiceValue(value, type);
1712
+ }
1713
+ /**
1714
+ * Converts a primitive JavaScript value to a service string.
1715
+ */
1716
+ static toServiceValue(value, type) {
1717
+ return toServiceValue(value, type);
1718
+ }
1719
+ /**
1720
+ * Gets a message by key with optional parameters.
1721
+ * Resolution: static messages → return key unchanged
1722
+ * @param key - The message key (e.g., "Required", "MaxLength")
1723
+ * @param params - Positional parameters for {0}, {1} placeholders
1724
+ * @returns The formatted message, or the key if not found
1725
+ */
1726
+ getMessage(key, ...params) {
1727
+ const template = VirtualService.#messages[key];
1728
+ // Return key if not found
1729
+ if (!template)
1730
+ return key;
1731
+ // Replace {0}, {1}, etc. with params
1732
+ return template.replace(/\{(\d+)\}/g, (_, index) => {
1733
+ const paramIndex = parseInt(index, 10);
1734
+ return paramIndex < params.length ? String(params[paramIndex]) : `{${index}}`;
1735
+ });
1736
+ }
2005
1737
  /**
2006
1738
  * Creates a new VirtualService instance.
2007
1739
  * @param hooks - Optional custom hooks instance.
2008
1740
  */
2009
1741
  constructor(hooks) {
2010
1742
  super("http://virtual.local", hooks ?? new VirtualServiceHooks(), true);
1743
+ this.#businessRuleValidator = new BusinessRuleValidator(this);
1744
+ this.#actionsRegistry = new VirtualPersistentObjectActionsRegistry(this.#businessRuleValidator, this);
1745
+ this.#queryRegistry = new VirtualQueryRegistry();
1746
+ this.#persistentObjectRegistry = new VirtualPersistentObjectRegistry();
1747
+ this.#actionDefinitions.set("AddReference", { name: "AddReference", displayName: "Add", isPinned: false });
1748
+ this.#actionDefinitions.set("BulkEdit", { name: "BulkEdit", displayName: "Edit", isPinned: false });
1749
+ this.#actionDefinitions.set("CancelEdit", { name: "CancelEdit", displayName: "Cancel", isPinned: false });
1750
+ this.#actionDefinitions.set("CancelSave", { name: "CancelSave", displayName: "Cancel", isPinned: false });
1751
+ this.#actionDefinitions.set("Delete", { name: "Delete", displayName: "Delete", isPinned: false });
1752
+ this.#actionDefinitions.set("Edit", { name: "Edit", displayName: "Edit", isPinned: false });
1753
+ this.#actionDefinitions.set("EndEdit", { name: "EndEdit", displayName: "Save", isPinned: false });
1754
+ this.#actionDefinitions.set("Filter", { name: "Filter", displayName: "", isPinned: false });
1755
+ this.#actionDefinitions.set("New", { name: "New", displayName: "New", isPinned: false });
1756
+ this.#actionDefinitions.set("RefreshQuery", { name: "RefreshQuery", displayName: "", isPinned: false });
1757
+ this.#actionDefinitions.set("Remove", { name: "Remove", displayName: "Remove", isPinned: false });
1758
+ this.#actionDefinitions.set("Save", { name: "Save", displayName: "Save", isPinned: false });
1759
+ this.#actionDefinitions.set("SelectReference", { name: "SelectReference", displayName: "Select", isPinned: false });
1760
+ this.hooks.initialize(this);
1761
+ }
1762
+ /** @internal */
1763
+ get persistentObjectRegistry() {
1764
+ return this.#persistentObjectRegistry;
1765
+ }
1766
+ /** @internal */
1767
+ get queryRegistry() {
1768
+ return this.#queryRegistry;
1769
+ }
1770
+ /** @internal */
1771
+ get actionsRegistry() {
1772
+ return this.#actionsRegistry;
1773
+ }
1774
+ /** @internal */
1775
+ get _actionDefinitions() {
1776
+ return this.#actionDefinitions;
1777
+ }
1778
+ /** @internal */
1779
+ get actionHandlers() {
1780
+ return this.#actionHandlers;
2011
1781
  }
2012
1782
  /**
2013
- * Gets the VirtualServiceHooks instance.
1783
+ * Initializes the service and finalizes all registrations.
1784
+ * After this method is called, no more registrations are allowed.
2014
1785
  */
2015
- get virtualHooks() {
2016
- return this.hooks;
2017
- }
2018
- async initialize(arg) {
1786
+ async initialize() {
2019
1787
  this.#isInitialized = true;
2020
- return super.initialize(arg);
1788
+ return super.initialize(false);
2021
1789
  }
2022
1790
  /**
2023
1791
  * Registers a PersistentObject configuration.
2024
1792
  * Must be called before initialize().
2025
1793
  * @param config - The PersistentObject configuration.
1794
+ * @param lifecycle - Optional lifecycle class for hooks (onLoad, onSave, onNew, etc.).
2026
1795
  * @throws Error if called after initialize().
2027
1796
  */
2028
- registerPersistentObject(config) {
1797
+ registerPersistentObject(config, lifecycle) {
2029
1798
  this.#ensureNotInitialized();
2030
- this.virtualHooks.registerPersistentObject(config);
1799
+ if (!config.type)
1800
+ throw new Error("VirtualPersistentObjectConfig.type is required");
1801
+ if (!config.attributes || config.attributes.length === 0)
1802
+ throw new Error("VirtualPersistentObjectConfig.attributes must have at least one attribute");
1803
+ if (config.actions) {
1804
+ config.actions.forEach(actionName => {
1805
+ if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1806
+ throw new Error(`Action "${actionName}" is not registered. Call registerCustomAction first.`);
1807
+ });
1808
+ }
1809
+ if (config.queries) {
1810
+ config.queries.forEach(queryName => {
1811
+ if (!this.#queryRegistry.hasQuery(queryName))
1812
+ throw new Error(`Query "${queryName}" is not registered. Call registerQuery first.`);
1813
+ });
1814
+ }
1815
+ if (config.attributes) {
1816
+ config.attributes.forEach(attr => {
1817
+ if (attr.lookup) {
1818
+ if (!this.#queryRegistry.hasQuery(attr.lookup))
1819
+ throw new Error(`Lookup query "${attr.lookup}" for attribute "${attr.name}" is not registered. Call registerQuery first.`);
1820
+ }
1821
+ });
1822
+ }
1823
+ this.#persistentObjectRegistry.register(config);
1824
+ if (lifecycle)
1825
+ this.#actionsRegistry.register(config.type, lifecycle);
2031
1826
  }
2032
1827
  /**
2033
1828
  * Registers a Query configuration.
@@ -2037,17 +1832,40 @@ class VirtualService extends Service {
2037
1832
  */
2038
1833
  registerQuery(config) {
2039
1834
  this.#ensureNotInitialized();
2040
- this.virtualHooks.registerQuery(config);
1835
+ if (!config.name)
1836
+ throw new Error("VirtualQueryConfig.name is required");
1837
+ if (!config.persistentObject)
1838
+ throw new Error("VirtualQueryConfig.persistentObject is required");
1839
+ const persistentObjectConfig = this.#persistentObjectRegistry.getConfig(config.persistentObject);
1840
+ if (!persistentObjectConfig)
1841
+ throw new Error(`PersistentObject type '${config.persistentObject}' must be registered before creating a query. Call registerPersistentObject first.`);
1842
+ if (config.actions) {
1843
+ config.actions.forEach(actionName => {
1844
+ if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1845
+ throw new Error(`Action "${actionName}" is not registered. Call registerCustomAction first.`);
1846
+ });
1847
+ }
1848
+ if (config.itemActions) {
1849
+ config.itemActions.forEach(actionName => {
1850
+ if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
1851
+ throw new Error(`Action "${actionName}" is not registered. Call registerCustomAction first.`);
1852
+ });
1853
+ }
1854
+ this.#queryRegistry.register(config, persistentObjectConfig);
2041
1855
  }
2042
- /**
2043
- * Registers a custom action that can be used on PersistentObjects and Queries.
2044
- * Must be called before initialize().
2045
- * @param config - The action configuration with handler.
2046
- * @throws Error if called after initialize().
2047
- */
2048
- registerAction(config) {
1856
+ registerCustomAction(configOrName, handler) {
2049
1857
  this.#ensureNotInitialized();
2050
- this.virtualHooks.registerAction(config);
1858
+ const config = typeof configOrName === "string" ? { name: configOrName } : configOrName;
1859
+ if (!config.name)
1860
+ throw new Error("ActionConfig.name is required");
1861
+ if (!handler)
1862
+ throw new Error("ActionHandler is required");
1863
+ this.#actionDefinitions.set(config.name, {
1864
+ name: config.name,
1865
+ displayName: config.displayName || config.name,
1866
+ isPinned: config.isPinned || false
1867
+ });
1868
+ this.#actionHandlers.set(config.name, handler);
2051
1869
  }
2052
1870
  /**
2053
1871
  * Registers a custom business rule for validation.
@@ -2058,32 +1876,8 @@ class VirtualService extends Service {
2058
1876
  */
2059
1877
  registerBusinessRule(name, validator) {
2060
1878
  this.#ensureNotInitialized();
2061
- this.virtualHooks.registerBusinessRule(name, validator);
2062
- }
2063
- /**
2064
- * Registers a VirtualPersistentObjectActions class for a specific type.
2065
- * Must be called before initialize().
2066
- * @param type - The PersistentObject type name.
2067
- * @param ActionsClass - The VirtualPersistentObjectActions class constructor.
2068
- * @throws Error if called after initialize().
2069
- */
2070
- registerPersistentObjectActions(type, ActionsClass) {
2071
- this.#ensureNotInitialized();
2072
- this.virtualHooks.registerPersistentObjectActions(type, ActionsClass);
1879
+ this.#businessRuleValidator.registerCustomRule(name, validator);
2073
1880
  }
2074
- /**
2075
- * Registers a message translator for translating system messages.
2076
- * Must be called before initialize().
2077
- * @param translate - The translation function.
2078
- * @throws Error if called after initialize().
2079
- */
2080
- registerMessageTranslator(translate) {
2081
- this.#ensureNotInitialized();
2082
- this.virtualHooks.setTranslate(translate);
2083
- }
2084
- /**
2085
- * Throws an error if the service has already been initialized.
2086
- */
2087
1881
  #ensureNotInitialized() {
2088
1882
  if (this.#isInitialized)
2089
1883
  throw new Error("Cannot register after initialize() has been called");