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