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