@vidyano-labs/virtual-service 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1116 -0
- package/index.d.ts +559 -0
- package/index.js +2008 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
# @vidyano-labs/virtual-service
|
|
2
|
+
|
|
3
|
+
A virtual service implementation for testing Vidyano applications without requiring a backend server. Perfect for unit tests, integration tests, and rapid prototyping.
|
|
4
|
+
|
|
5
|
+
> **Note:** The virtual service uses the exact same DTO types as the real backend, ensuring your tests accurately reflect production behavior.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @vidyano-labs/virtual-service
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Peer Dependencies
|
|
14
|
+
|
|
15
|
+
This package requires `@vidyano/core` as a peer dependency:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @vidyano/core
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { VirtualService } from "@vidyano-labs/virtual-service";
|
|
25
|
+
|
|
26
|
+
// Create virtual service
|
|
27
|
+
const service = new VirtualService();
|
|
28
|
+
|
|
29
|
+
// Register a mock persistent object type
|
|
30
|
+
service.registerPersistentObject({
|
|
31
|
+
type: "Person",
|
|
32
|
+
label: "Person",
|
|
33
|
+
attributes: [
|
|
34
|
+
{ name: "FirstName", type: "String", value: "John" },
|
|
35
|
+
{ name: "LastName", type: "String", value: "Smith" },
|
|
36
|
+
{ name: "Email", type: "String", rules: "IsEmail" }
|
|
37
|
+
]
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Initialize (must be called after all registrations)
|
|
41
|
+
await service.initialize();
|
|
42
|
+
|
|
43
|
+
// Load the mock object - works exactly like the real backend
|
|
44
|
+
const person = await service.getPersistentObject(null, "Person", "123");
|
|
45
|
+
console.log(person.getAttribute("FirstName").value); // "John"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Core Concepts
|
|
49
|
+
|
|
50
|
+
### VirtualService
|
|
51
|
+
|
|
52
|
+
The main class that provides mock backend functionality. It extends `Service` to plug into Vidyano's service layer seamlessly.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
const service = new VirtualService();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Key methods:**
|
|
59
|
+
- `registerPersistentObject(config)` - Register a mock persistent object type
|
|
60
|
+
- `registerQuery(config)` - Register a mock query
|
|
61
|
+
- `registerAction(config)` - Register a custom action handler
|
|
62
|
+
- `registerBusinessRule(name, validator)` - Add custom validation rules
|
|
63
|
+
- `registerPersistentObjectActions(type, ActionsClass)` - Register lifecycle handlers
|
|
64
|
+
- `initialize()` - Finalize registrations (must call before using service)
|
|
65
|
+
|
|
66
|
+
> **Important:** All registrations must happen BEFORE calling `initialize()`. Attempting to register after initialization throws an error.
|
|
67
|
+
|
|
68
|
+
### Registration Order
|
|
69
|
+
|
|
70
|
+
Dependencies must be registered before the things that reference them:
|
|
71
|
+
|
|
72
|
+
1. **Actions** first - Custom actions must be registered before PersistentObjects/Queries that reference them
|
|
73
|
+
2. **PersistentObjects** - Define the data schema
|
|
74
|
+
3. **Queries** - Must reference an already-registered PersistentObject type
|
|
75
|
+
4. **PersistentObjectActions** - Lifecycle handlers (can be registered anytime before initialize)
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// Correct order
|
|
79
|
+
service.registerPersistentObject({ type: "Person", attributes: [...] });
|
|
80
|
+
service.registerQuery({ name: "AllPeople", persistentObject: "Person", ... });
|
|
81
|
+
await service.initialize();
|
|
82
|
+
|
|
83
|
+
// Wrong - query references unknown type
|
|
84
|
+
service.registerQuery({ name: "AllPeople", persistentObject: "Person" }); // Error!
|
|
85
|
+
service.registerPersistentObject({ type: "Person", attributes: [...] });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> **Note:** If a PersistentObject has attributes with `lookup` queries or detail `queries`, those queries must be registered first.
|
|
89
|
+
|
|
90
|
+
## Defining Persistent Objects
|
|
91
|
+
|
|
92
|
+
Persistent objects are the core data structures in Vidyano. Each represents a type of business entity with attributes, tabs, validation rules, and behaviors.
|
|
93
|
+
|
|
94
|
+
### Basic Structure
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
service.registerPersistentObject({
|
|
98
|
+
type: "Contact", // Required: unique type identifier
|
|
99
|
+
label: "Contact", // Display name (defaults to type)
|
|
100
|
+
attributes: [...], // Fields that hold data
|
|
101
|
+
tabs: {...}, // Optional: organize attributes into tabs
|
|
102
|
+
queries: [...], // Optional: detail queries (master-detail)
|
|
103
|
+
stateBehavior: "None" // Optional: controls edit mode behavior
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Attributes
|
|
108
|
+
|
|
109
|
+
Attributes define the fields of a persistent object. Each attribute has a name, type, and optional configuration:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
service.registerPersistentObject({
|
|
113
|
+
type: "Contact",
|
|
114
|
+
attributes: [
|
|
115
|
+
// Minimal attribute - name only (type defaults to "String")
|
|
116
|
+
{ name: "FirstName" },
|
|
117
|
+
|
|
118
|
+
// With explicit type and initial value
|
|
119
|
+
{ name: "Age", type: "Int32", value: 25 },
|
|
120
|
+
|
|
121
|
+
// With custom label
|
|
122
|
+
{ name: "Email", type: "String", label: "Email Address" },
|
|
123
|
+
|
|
124
|
+
// Required field
|
|
125
|
+
{ name: "Company", type: "String", isRequired: true },
|
|
126
|
+
|
|
127
|
+
// Read-only field
|
|
128
|
+
{ name: "CreatedDate", type: "DateTime", isReadOnly: true }
|
|
129
|
+
]
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Attribute Types
|
|
134
|
+
|
|
135
|
+
Common attribute types supported by the virtual service:
|
|
136
|
+
|
|
137
|
+
| Type | Use For | Example Value |
|
|
138
|
+
|------|---------|---------------|
|
|
139
|
+
| `String` | Text, names, emails | `"John Doe"` |
|
|
140
|
+
| `Int32` | Whole numbers | `42` |
|
|
141
|
+
| `Int64` | Large integers | `9007199254740991` |
|
|
142
|
+
| `Decimal` | Decimal numbers | `19.99` |
|
|
143
|
+
| `Double` | Floating-point numbers | `3.14159` |
|
|
144
|
+
| `Boolean` | True/false values | `true` or `"True"` |
|
|
145
|
+
| `DateTime` | Dates and times | `"2026-01-23T10:00:00"` |
|
|
146
|
+
| `Date` | Dates only | `"2026-01-23"` |
|
|
147
|
+
| `Byte` | Small integers (0-255) | `128` |
|
|
148
|
+
|
|
149
|
+
> **Note:** Values are stored as strings in DTOs but converted to JavaScript types when accessed through `getValue()`. Boolean attributes accept both native booleans and string values `"True"`/`"False"`.
|
|
150
|
+
|
|
151
|
+
### Attribute Configuration
|
|
152
|
+
|
|
153
|
+
Full attribute configuration options:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
{
|
|
157
|
+
name: "Email", // Required: attribute name
|
|
158
|
+
type: "String", // Data type (default: "String")
|
|
159
|
+
label: "Email Address", // Display label (defaults to name)
|
|
160
|
+
value: "default@example.com", // Initial value
|
|
161
|
+
isRequired: true, // Makes attribute required
|
|
162
|
+
isReadOnly: false, // Makes attribute read-only
|
|
163
|
+
visibility: "Always", // When attribute is visible
|
|
164
|
+
triggersRefresh: false, // Triggers onRefresh when changed
|
|
165
|
+
rules: "NotEmpty; IsEmail", // Validation rules (semicolon-separated)
|
|
166
|
+
tab: "", // Tab key (empty string = default tab)
|
|
167
|
+
group: "Contact Info", // Group within tab
|
|
168
|
+
column: 0, // Column position
|
|
169
|
+
columnSpan: 4, // Column width (default: 4)
|
|
170
|
+
offset: 0, // Sort order offset
|
|
171
|
+
canSort: true, // Allow sorting in queries
|
|
172
|
+
options: ["Option1", "Option2"], // Predefined options for selection
|
|
173
|
+
typeHints: { "key": "value" }, // Type hints for the attribute
|
|
174
|
+
lookup: "AllContacts", // Query for reference lookups
|
|
175
|
+
displayAttribute: "Name", // Display attribute for references
|
|
176
|
+
canAddNewReference: false, // Allow adding new references
|
|
177
|
+
selectInPlace: false // Use fixed list for references
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Attribute Layout
|
|
182
|
+
|
|
183
|
+
Control how attributes are organized in tabs and groups:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
service.registerPersistentObject({
|
|
187
|
+
type: "Person",
|
|
188
|
+
tabs: {
|
|
189
|
+
"General": { name: "General", columnCount: 0 },
|
|
190
|
+
"Address": { name: "Address", columnCount: 0 }
|
|
191
|
+
},
|
|
192
|
+
attributes: [
|
|
193
|
+
// General tab, Contact group
|
|
194
|
+
{
|
|
195
|
+
name: "FirstName",
|
|
196
|
+
type: "String",
|
|
197
|
+
tab: "General", // Tab key
|
|
198
|
+
group: "Contact", // Group name
|
|
199
|
+
column: 0, // Column position
|
|
200
|
+
columnSpan: 2 // Width (default: 4)
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "Email",
|
|
204
|
+
type: "String",
|
|
205
|
+
tab: "General",
|
|
206
|
+
group: "Contact",
|
|
207
|
+
column: 2,
|
|
208
|
+
columnSpan: 2
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
// Address tab
|
|
212
|
+
{
|
|
213
|
+
name: "Street",
|
|
214
|
+
type: "String",
|
|
215
|
+
tab: "Address",
|
|
216
|
+
group: "Location"
|
|
217
|
+
}
|
|
218
|
+
]
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
> **Note:** Using an empty string `""` as the tab key creates a "default" tab. However, the tab's displayed label will be the PersistentObject's label (e.g., "Person"), not the tab's `name` property. Use explicit tab keys like `"General"` for predictable labeling.
|
|
223
|
+
|
|
224
|
+
## Attribute Visibility
|
|
225
|
+
|
|
226
|
+
Control when attributes appear based on the object's state:
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
service.registerPersistentObject({
|
|
230
|
+
type: "User",
|
|
231
|
+
attributes: [
|
|
232
|
+
// Always visible (default)
|
|
233
|
+
{ name: "Username", type: "String", visibility: "Always" },
|
|
234
|
+
|
|
235
|
+
// Only when creating new objects
|
|
236
|
+
{ name: "Password", type: "String", visibility: "New" },
|
|
237
|
+
|
|
238
|
+
// Only when viewing existing objects
|
|
239
|
+
{ name: "CreatedDate", type: "DateTime", visibility: "Read" },
|
|
240
|
+
|
|
241
|
+
// Only in query columns
|
|
242
|
+
{ name: "Status", type: "String", visibility: "Query" },
|
|
243
|
+
|
|
244
|
+
// Never visible (for computed/internal fields)
|
|
245
|
+
{ name: "InternalId", type: "String", visibility: "Never" }
|
|
246
|
+
]
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// New object - Password visible, CreatedDate hidden
|
|
250
|
+
const newUser = await service.getPersistentObject(null, "User", null, true);
|
|
251
|
+
|
|
252
|
+
// Existing object - Password hidden, CreatedDate visible
|
|
253
|
+
const existingUser = await service.getPersistentObject(null, "User", "123");
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Visibility options:**
|
|
257
|
+
- `Always` - Visible in all contexts (default)
|
|
258
|
+
- `New` - Only visible when creating new objects
|
|
259
|
+
- `Read` - Only visible when viewing existing objects
|
|
260
|
+
- `Query` - Only visible in query columns
|
|
261
|
+
- `Never` - Never visible
|
|
262
|
+
- Compound: `"Read, Query"` - Visible in multiple contexts
|
|
263
|
+
|
|
264
|
+
## Validation
|
|
265
|
+
|
|
266
|
+
### Built-in Business Rules
|
|
267
|
+
|
|
268
|
+
The virtual service includes built-in validation rules:
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
service.registerPersistentObject({
|
|
272
|
+
type: "Contact",
|
|
273
|
+
attributes: [
|
|
274
|
+
// Single rule
|
|
275
|
+
{ name: "Email", type: "String", rules: "IsEmail" },
|
|
276
|
+
|
|
277
|
+
// Multiple rules (semicolon-separated)
|
|
278
|
+
{
|
|
279
|
+
name: "Username",
|
|
280
|
+
type: "String",
|
|
281
|
+
rules: "NotEmpty; MinLength(3); MaxLength(20)"
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
// Rules with parameters
|
|
285
|
+
{ name: "Age", type: "Int32", rules: "MinValue(18); MaxValue(120)" },
|
|
286
|
+
|
|
287
|
+
// Required fields (same as NotEmpty rule)
|
|
288
|
+
{ name: "FirstName", type: "String", isRequired: true }
|
|
289
|
+
]
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Available rules:**
|
|
294
|
+
|
|
295
|
+
| Rule | Parameters | Description | Example |
|
|
296
|
+
|------|-----------|-------------|---------|
|
|
297
|
+
| `NotEmpty` | - | Value must not be empty/null | `"NotEmpty"` |
|
|
298
|
+
| `Required` | - | Alias for NotEmpty | `"Required"` |
|
|
299
|
+
| `IsEmail` | - | Valid email format | `"IsEmail"` |
|
|
300
|
+
| `IsUrl` | - | Valid URL format | `"IsUrl"` |
|
|
301
|
+
| `MinLength` | (length) | Minimum string length | `"MinLength(8)"` |
|
|
302
|
+
| `MaxLength` | (length) | Maximum string length | `"MaxLength(50)"` |
|
|
303
|
+
| `MinValue` | (number) | Minimum numeric value | `"MinValue(0)"` |
|
|
304
|
+
| `MaxValue` | (number) | Maximum numeric value | `"MaxValue(100)"` |
|
|
305
|
+
| `IsBase64` | - | Valid base64 string | `"IsBase64"` |
|
|
306
|
+
| `IsRegex` | - | Valid regex pattern | `"IsRegex"` |
|
|
307
|
+
| `IsWord` | - | Word characters only (\w+) | `"IsWord"` |
|
|
308
|
+
|
|
309
|
+
### Validation Flow
|
|
310
|
+
|
|
311
|
+
Validation runs automatically when the Save action executes:
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
const contact = await service.getPersistentObject(null, "Contact", null, true);
|
|
315
|
+
|
|
316
|
+
// Set invalid email
|
|
317
|
+
contact.getAttribute("Email").setValue("not-an-email");
|
|
318
|
+
|
|
319
|
+
// Try to save - validation fails
|
|
320
|
+
await contact.save();
|
|
321
|
+
|
|
322
|
+
// Check validation error
|
|
323
|
+
console.log(contact.getAttribute("Email").validationError);
|
|
324
|
+
// "Email format is invalid"
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**Validation behavior:**
|
|
328
|
+
- Runs before save handlers execute
|
|
329
|
+
- First failing rule stops validation for that attribute
|
|
330
|
+
- Validation errors set on the attribute's `validationError` property
|
|
331
|
+
- If any attribute fails, the save operation is aborted
|
|
332
|
+
- Null and undefined values skip validation (unless using `NotEmpty`/`Required`)
|
|
333
|
+
|
|
334
|
+
### Custom Business Rules
|
|
335
|
+
|
|
336
|
+
Register your own validation rules for domain-specific requirements:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
// Register a custom rule (before registerPersistentObject)
|
|
340
|
+
service.registerBusinessRule("IsPhoneNumber", (value: any) => {
|
|
341
|
+
if (value == null || value === "") return;
|
|
342
|
+
const phoneRegex = /^\+?[\d\s-()]+$/;
|
|
343
|
+
if (!phoneRegex.test(String(value)))
|
|
344
|
+
throw new Error("Invalid phone number format");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Use it in your configuration
|
|
348
|
+
service.registerPersistentObject({
|
|
349
|
+
type: "Contact",
|
|
350
|
+
attributes: [
|
|
351
|
+
{
|
|
352
|
+
name: "Phone",
|
|
353
|
+
type: "String",
|
|
354
|
+
rules: "NotEmpty; IsPhoneNumber"
|
|
355
|
+
}
|
|
356
|
+
]
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**Custom rule requirements:**
|
|
361
|
+
- Must be registered before `registerPersistentObject()`
|
|
362
|
+
- Throw an `Error` with a message if validation fails
|
|
363
|
+
- Return nothing (or undefined) if validation passes
|
|
364
|
+
- Cannot override built-in rules
|
|
365
|
+
|
|
366
|
+
## Queries
|
|
367
|
+
|
|
368
|
+
### Basic Query Registration
|
|
369
|
+
|
|
370
|
+
Queries allow browsing and searching collections of persistent objects:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
service.registerPersistentObject({
|
|
374
|
+
type: "Person",
|
|
375
|
+
attributes: [
|
|
376
|
+
{ name: "Name", type: "String" },
|
|
377
|
+
{ name: "Email", type: "String" },
|
|
378
|
+
{ name: "Age", type: "Int32" }
|
|
379
|
+
]
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
service.registerQuery({
|
|
383
|
+
name: "AllPeople",
|
|
384
|
+
persistentObject: "Person", // Required: links to PO type
|
|
385
|
+
label: "All People",
|
|
386
|
+
data: [
|
|
387
|
+
{ Name: "Alice", Email: "alice@example.com", Age: 30 },
|
|
388
|
+
{ Name: "Bob", Email: "bob@example.com", Age: 25 },
|
|
389
|
+
{ Name: "Charlie", Email: "charlie@example.com", Age: 35 }
|
|
390
|
+
]
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await service.initialize();
|
|
394
|
+
|
|
395
|
+
const query = await service.getQuery("AllPeople");
|
|
396
|
+
await query.search();
|
|
397
|
+
|
|
398
|
+
const items = await query.items.toArrayAsync();
|
|
399
|
+
console.log(items.length); // 3
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Query Configuration
|
|
403
|
+
|
|
404
|
+
Full query configuration options:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
{
|
|
408
|
+
name: "AllPeople", // Required: query name
|
|
409
|
+
persistentObject: "Person", // Required: linked PO type
|
|
410
|
+
label: "All People", // Display label
|
|
411
|
+
data: [...], // Static data for the query
|
|
412
|
+
autoQuery: true, // Execute automatically (default: true)
|
|
413
|
+
allowTextSearch: true, // Enable text search (default: true)
|
|
414
|
+
pageSize: 20, // Items per page (default: 20)
|
|
415
|
+
disableBulkEdit: false, // Disable bulk editing
|
|
416
|
+
actions: ["New", "Export"], // Query-level actions (by name)
|
|
417
|
+
itemActions: ["Edit", "Delete"] // Actions on selected items (by name)
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Query Columns
|
|
422
|
+
|
|
423
|
+
Columns are automatically derived from the PersistentObject's attributes:
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
service.registerPersistentObject({
|
|
427
|
+
type: "Person",
|
|
428
|
+
attributes: [
|
|
429
|
+
{ name: "Name", type: "String", canSort: true },
|
|
430
|
+
{ name: "Email", type: "String", canSort: true },
|
|
431
|
+
{ name: "InternalId", type: "String", visibility: "Never" } // Hidden column
|
|
432
|
+
]
|
|
433
|
+
});
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Column properties inherited from attributes:
|
|
437
|
+
- `canSort` - Whether column can be sorted (default: true)
|
|
438
|
+
- `isHidden` - Derived from visibility (Never, New = hidden)
|
|
439
|
+
- `offset` - Column display order
|
|
440
|
+
|
|
441
|
+
### Text Search
|
|
442
|
+
|
|
443
|
+
Queries support case-insensitive text search across visible string columns:
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
const query = await service.getQuery("AllPeople");
|
|
447
|
+
|
|
448
|
+
// Search for "alice"
|
|
449
|
+
query.textSearch = "alice";
|
|
450
|
+
await query.search();
|
|
451
|
+
|
|
452
|
+
const results = await query.items.toArrayAsync();
|
|
453
|
+
console.log(results.length); // 1
|
|
454
|
+
console.log(results[0].values.Name); // "Alice"
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Sorting
|
|
458
|
+
|
|
459
|
+
Sort queries by one or more columns:
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
const query = await service.getQuery("AllPeople");
|
|
463
|
+
|
|
464
|
+
// Sort by age ascending
|
|
465
|
+
query.sortOptions = [{ name: "Age", direction: "ASC" }];
|
|
466
|
+
await query.search();
|
|
467
|
+
|
|
468
|
+
// Sort by multiple columns
|
|
469
|
+
query.sortOptions = [
|
|
470
|
+
{ name: "Age", direction: "DESC" },
|
|
471
|
+
{ name: "Name", direction: "ASC" }
|
|
472
|
+
];
|
|
473
|
+
await query.search();
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Sorting is type-aware:
|
|
477
|
+
- Strings: Case-insensitive alphabetical
|
|
478
|
+
- Numbers: Numeric comparison
|
|
479
|
+
- Dates: Chronological order
|
|
480
|
+
- Null values sort first
|
|
481
|
+
|
|
482
|
+
### Pagination
|
|
483
|
+
|
|
484
|
+
Control page size and navigate results:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
service.registerQuery({
|
|
488
|
+
name: "AllPeople",
|
|
489
|
+
persistentObject: "Person",
|
|
490
|
+
pageSize: 10, // 10 items per page
|
|
491
|
+
data: [/* 100 items */]
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const query = await service.getQuery("AllPeople");
|
|
495
|
+
await query.search();
|
|
496
|
+
|
|
497
|
+
// Get first page
|
|
498
|
+
const page1 = await query.items.sliceAsync(0, 10);
|
|
499
|
+
|
|
500
|
+
// Get second page
|
|
501
|
+
const page2 = await query.items.sliceAsync(10, 20);
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## Actions
|
|
505
|
+
|
|
506
|
+
### Custom Actions
|
|
507
|
+
|
|
508
|
+
Register actions with custom handlers:
|
|
509
|
+
|
|
510
|
+
```typescript
|
|
511
|
+
service.registerAction({
|
|
512
|
+
name: "Approve",
|
|
513
|
+
displayName: "Approve Order",
|
|
514
|
+
isPinned: true,
|
|
515
|
+
handler: async (args: ActionArgs) => {
|
|
516
|
+
// Access context for reading/modifying the object
|
|
517
|
+
args.context.setAttributeValue("Status", "Approved");
|
|
518
|
+
args.context.setNotification("Order approved!", "OK", 3000);
|
|
519
|
+
|
|
520
|
+
// Return the updated object (or null for silent completion)
|
|
521
|
+
return args.parent;
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### ActionArgs
|
|
527
|
+
|
|
528
|
+
Action handlers receive `ActionArgs` with execution context:
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
import type { ActionArgs } from "@vidyano-labs/virtual-service";
|
|
532
|
+
|
|
533
|
+
interface ActionArgs {
|
|
534
|
+
parent: PersistentObjectDto | null; // The PO being acted on
|
|
535
|
+
query?: QueryDto; // The query (for query actions)
|
|
536
|
+
selectedItems?: QueryResultItemDto[]; // Selected items in query
|
|
537
|
+
parameters?: Record<string, any>; // Action parameters
|
|
538
|
+
context: ActionContext; // Helper methods
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### ActionContext
|
|
543
|
+
|
|
544
|
+
The context provides helper methods for modifying the persistent object:
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
handler: async (args: ActionArgs) => {
|
|
548
|
+
// Get an attribute
|
|
549
|
+
const emailAttr = args.context.getAttribute("Email");
|
|
550
|
+
|
|
551
|
+
// Read attribute values
|
|
552
|
+
const email = args.context.getAttributeValue("Email");
|
|
553
|
+
|
|
554
|
+
// Type-safe value access (pass the attribute DTO)
|
|
555
|
+
const age = args.context.getConvertedValue(args.context.getAttribute("Age")!);
|
|
556
|
+
|
|
557
|
+
// Modify attribute values
|
|
558
|
+
args.context.setAttributeValue("Status", "Active");
|
|
559
|
+
|
|
560
|
+
// Set with type conversion (pass the attribute DTO)
|
|
561
|
+
const countAttr = args.context.getAttribute("Count")!;
|
|
562
|
+
args.context.setConvertedValue(countAttr, 42);
|
|
563
|
+
|
|
564
|
+
// Set validation errors
|
|
565
|
+
if (!email?.includes("@"))
|
|
566
|
+
args.context.setValidationError("Email", "Invalid email format");
|
|
567
|
+
|
|
568
|
+
// Clear validation errors
|
|
569
|
+
args.context.clearValidationError("Email");
|
|
570
|
+
|
|
571
|
+
// Show notifications
|
|
572
|
+
args.context.setNotification("Saved successfully", "OK", 3000);
|
|
573
|
+
args.context.setNotification("Warning!", "Warning", 5000);
|
|
574
|
+
args.context.setNotification("Error occurred", "Error");
|
|
575
|
+
|
|
576
|
+
return args.parent;
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
**Context methods:**
|
|
581
|
+
|
|
582
|
+
| Method | Description |
|
|
583
|
+
|--------|-------------|
|
|
584
|
+
| `getAttribute(name)` | Get the full attribute DTO |
|
|
585
|
+
| `getAttributeValue(name)` | Get the raw attribute value |
|
|
586
|
+
| `getConvertedValue(attr)` | Get type-converted value from attribute DTO |
|
|
587
|
+
| `setAttributeValue(name, value)` | Update raw attribute value |
|
|
588
|
+
| `setConvertedValue(attr, value)` | Update attribute DTO with type conversion |
|
|
589
|
+
| `setValidationError(name, error)` | Set a validation error message |
|
|
590
|
+
| `clearValidationError(name)` | Clear validation error |
|
|
591
|
+
| `setNotification(msg, type, duration?)` | Show notification to user |
|
|
592
|
+
|
|
593
|
+
### Query Actions
|
|
594
|
+
|
|
595
|
+
Actions can operate on query results:
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
service.registerAction({
|
|
599
|
+
name: "BulkDelete",
|
|
600
|
+
handler: async (args: ActionArgs) => {
|
|
601
|
+
// Access selected items
|
|
602
|
+
for (const item of args.selectedItems || []) {
|
|
603
|
+
console.log(`Deleting item: ${item.id}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return null; // Silent completion
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
## Lifecycle Hooks
|
|
612
|
+
|
|
613
|
+
### VirtualPersistentObjectActions
|
|
614
|
+
|
|
615
|
+
For complex scenarios, create a class extending `VirtualPersistentObjectActions`:
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
import { VirtualPersistentObjectActions } from "@vidyano-labs/virtual-service";
|
|
619
|
+
import type { VirtualPersistentObject, VirtualPersistentObjectAttribute } from "@vidyano-labs/virtual-service";
|
|
620
|
+
import { Dto } from "@vidyano/core";
|
|
621
|
+
|
|
622
|
+
class PersonActions extends VirtualPersistentObjectActions {
|
|
623
|
+
// Called when any Person DTO is created
|
|
624
|
+
onConstruct(obj: VirtualPersistentObject): void {
|
|
625
|
+
// Set defaults - note: this is synchronous
|
|
626
|
+
obj.setAttributeValue("CreatedDate", new Date().toISOString());
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Called when loading an existing Person
|
|
630
|
+
async onLoad(
|
|
631
|
+
obj: VirtualPersistentObject,
|
|
632
|
+
parent: VirtualPersistentObject | null
|
|
633
|
+
): Promise<VirtualPersistentObject> {
|
|
634
|
+
// Load additional data based on ID
|
|
635
|
+
const id = obj.objectId;
|
|
636
|
+
console.log(`Loading person: ${id}`);
|
|
637
|
+
return obj;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Called when creating a new Person via "New" action
|
|
641
|
+
async onNew(
|
|
642
|
+
obj: VirtualPersistentObject,
|
|
643
|
+
parent: VirtualPersistentObject | null,
|
|
644
|
+
query: Dto.QueryDto | null,
|
|
645
|
+
parameters: Record<string, string> | null
|
|
646
|
+
): Promise<VirtualPersistentObject> {
|
|
647
|
+
// Initialize new object
|
|
648
|
+
obj.setAttributeValue("Status", "Draft");
|
|
649
|
+
return obj;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Called when saving
|
|
653
|
+
async onSave(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
|
|
654
|
+
// Calls saveNew or saveExisting based on obj.isNew
|
|
655
|
+
return await super.onSave(obj);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Called for new objects (protected)
|
|
659
|
+
protected async saveNew(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
|
|
660
|
+
obj.setAttributeValue("Id", crypto.randomUUID());
|
|
661
|
+
return obj;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Called for existing objects (protected)
|
|
665
|
+
protected async saveExisting(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
|
|
666
|
+
obj.setAttributeValue("ModifiedDate", new Date().toISOString());
|
|
667
|
+
return obj;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Register the actions class
|
|
672
|
+
service.registerPersistentObjectActions("Person", PersonActions);
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### Lifecycle Flow
|
|
676
|
+
|
|
677
|
+
```
|
|
678
|
+
PersistentObject Load: onConstruct → onLoad
|
|
679
|
+
PersistentObject New: onConstruct → onNew
|
|
680
|
+
PersistentObject Save: (validation) → onSave → (saveNew | saveExisting)
|
|
681
|
+
Query Construction: onConstructQuery
|
|
682
|
+
Query Execution: onExecuteQuery → (text search, sort, paginate)
|
|
683
|
+
Attribute Refresh: onRefresh
|
|
684
|
+
Reference Selection: onSelectReference
|
|
685
|
+
Deletion: onDelete
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### Refresh Handling
|
|
689
|
+
|
|
690
|
+
Handle attribute changes that trigger refresh:
|
|
691
|
+
|
|
692
|
+
```typescript
|
|
693
|
+
class OrderActions extends VirtualPersistentObjectActions {
|
|
694
|
+
async onRefresh(
|
|
695
|
+
obj: VirtualPersistentObject,
|
|
696
|
+
attribute: VirtualPersistentObjectAttribute | undefined
|
|
697
|
+
): Promise<VirtualPersistentObject> {
|
|
698
|
+
// Calculate total when quantity or price changes
|
|
699
|
+
const quantity = obj.getAttributeValue("Quantity") ?? 0;
|
|
700
|
+
const unitPrice = obj.getAttributeValue("UnitPrice") ?? 0;
|
|
701
|
+
obj.setAttributeValue("Total", quantity * unitPrice);
|
|
702
|
+
|
|
703
|
+
return obj;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
Mark attributes that should trigger refresh:
|
|
709
|
+
|
|
710
|
+
```typescript
|
|
711
|
+
service.registerPersistentObject({
|
|
712
|
+
type: "Order",
|
|
713
|
+
attributes: [
|
|
714
|
+
{ name: "Quantity", type: "Int32", triggersRefresh: true },
|
|
715
|
+
{ name: "UnitPrice", type: "Decimal", triggersRefresh: true },
|
|
716
|
+
{ name: "Total", type: "Decimal", isReadOnly: true }
|
|
717
|
+
]
|
|
718
|
+
});
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### Query Execution
|
|
722
|
+
|
|
723
|
+
Provide dynamic query data:
|
|
724
|
+
|
|
725
|
+
```typescript
|
|
726
|
+
class PersonActions extends VirtualPersistentObjectActions {
|
|
727
|
+
private database: PersonData[] = [];
|
|
728
|
+
|
|
729
|
+
// Provide data for query execution
|
|
730
|
+
// Framework handles text search, sort, and pagination automatically
|
|
731
|
+
async getEntities(
|
|
732
|
+
query: Dto.QueryDto,
|
|
733
|
+
parent: VirtualPersistentObject | null,
|
|
734
|
+
data: Record<string, any>[]
|
|
735
|
+
): Promise<Record<string, any>[]> {
|
|
736
|
+
// Return all entities - framework handles search/sort/pagination
|
|
737
|
+
return this.database;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Or fully control query execution
|
|
741
|
+
async onExecuteQuery(
|
|
742
|
+
query: Dto.QueryDto,
|
|
743
|
+
parent: VirtualPersistentObject | null,
|
|
744
|
+
data: Record<string, any>[]
|
|
745
|
+
): Promise<VirtualQueryExecuteResult> {
|
|
746
|
+
// Custom query logic - you handle everything
|
|
747
|
+
const results = this.database.filter(p => p.active);
|
|
748
|
+
return { items: results, totalItems: results.length };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
### Reference Attributes
|
|
754
|
+
|
|
755
|
+
Handle reference attribute selection:
|
|
756
|
+
|
|
757
|
+
```typescript
|
|
758
|
+
class OrderActions extends VirtualPersistentObjectActions {
|
|
759
|
+
async onSelectReference(
|
|
760
|
+
parent: VirtualPersistentObject,
|
|
761
|
+
referenceAttribute: Dto.PersistentObjectAttributeDto,
|
|
762
|
+
query: Dto.QueryDto,
|
|
763
|
+
selectedItem: Dto.QueryResultItemDto | null
|
|
764
|
+
): Promise<void> {
|
|
765
|
+
// Default: sets objectId and value from displayAttribute
|
|
766
|
+
await super.onSelectReference(parent, referenceAttribute, query, selectedItem);
|
|
767
|
+
|
|
768
|
+
// Custom: also copy related fields
|
|
769
|
+
if (selectedItem) {
|
|
770
|
+
const customerName = selectedItem.values?.find(v => v.key === "Name")?.value;
|
|
771
|
+
parent.setAttributeValue("CustomerName", customerName);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
Configure reference attributes:
|
|
778
|
+
|
|
779
|
+
```typescript
|
|
780
|
+
// Customer PO and lookup query must be registered first
|
|
781
|
+
service.registerPersistentObject({
|
|
782
|
+
type: "Customer",
|
|
783
|
+
attributes: [
|
|
784
|
+
{ name: "Name", type: "String" },
|
|
785
|
+
{ name: "Email", type: "String" }
|
|
786
|
+
]
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
service.registerQuery({
|
|
790
|
+
name: "AllCustomers",
|
|
791
|
+
persistentObject: "Customer",
|
|
792
|
+
data: [
|
|
793
|
+
{ Name: "Acme Corp", Email: "contact@acme.com" },
|
|
794
|
+
{ Name: "Globex", Email: "info@globex.com" }
|
|
795
|
+
]
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// Now Order can reference the lookup query
|
|
799
|
+
service.registerPersistentObject({
|
|
800
|
+
type: "Order",
|
|
801
|
+
attributes: [
|
|
802
|
+
{ name: "OrderNumber", type: "String" },
|
|
803
|
+
{
|
|
804
|
+
name: "Customer",
|
|
805
|
+
type: "Reference",
|
|
806
|
+
lookup: "AllCustomers", // References registered query
|
|
807
|
+
displayAttribute: "Name" // Column to display
|
|
808
|
+
}
|
|
809
|
+
]
|
|
810
|
+
});
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
## State Behavior
|
|
814
|
+
|
|
815
|
+
Control how persistent objects behave after actions:
|
|
816
|
+
|
|
817
|
+
```typescript
|
|
818
|
+
service.registerPersistentObject({
|
|
819
|
+
type: "Settings",
|
|
820
|
+
stateBehavior: "StayInEdit", // Keep form in edit mode after save
|
|
821
|
+
attributes: [
|
|
822
|
+
{ name: "Theme", type: "String" },
|
|
823
|
+
{ name: "Language", type: "String" }
|
|
824
|
+
]
|
|
825
|
+
});
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
**State behavior options:**
|
|
829
|
+
- `None` - Default behavior
|
|
830
|
+
- `OpenInEdit` - Open in edit mode by default
|
|
831
|
+
- `StayInEdit` - Stay in edit mode after save
|
|
832
|
+
- `AsDialog` - Open as a dialog
|
|
833
|
+
|
|
834
|
+
## Master-Detail Relationships
|
|
835
|
+
|
|
836
|
+
Configure detail queries for master-detail scenarios:
|
|
837
|
+
|
|
838
|
+
```typescript
|
|
839
|
+
// 1. First register the detail PersistentObject
|
|
840
|
+
service.registerPersistentObject({
|
|
841
|
+
type: "OrderLine",
|
|
842
|
+
attributes: [
|
|
843
|
+
{ name: "Product", type: "String" },
|
|
844
|
+
{ name: "Quantity", type: "Int32" },
|
|
845
|
+
{ name: "Price", type: "Decimal" }
|
|
846
|
+
]
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// 2. Then register the detail query
|
|
850
|
+
service.registerQuery({
|
|
851
|
+
name: "OrderLines",
|
|
852
|
+
persistentObject: "OrderLine"
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// 3. Finally register the master PersistentObject with the query reference
|
|
856
|
+
service.registerPersistentObject({
|
|
857
|
+
type: "Order",
|
|
858
|
+
attributes: [
|
|
859
|
+
{ name: "OrderNumber", type: "String" },
|
|
860
|
+
{ name: "Total", type: "Decimal" }
|
|
861
|
+
],
|
|
862
|
+
queries: ["OrderLines"] // Attach query as detail
|
|
863
|
+
});
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
Access detail queries:
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
const order = await service.getPersistentObject(null, "Order", "123");
|
|
870
|
+
const linesQuery = order.queries.find(q => q.name === "OrderLines");
|
|
871
|
+
await linesQuery.search();
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
## VirtualPersistentObject Helpers
|
|
875
|
+
|
|
876
|
+
The `VirtualPersistentObject` type provides convenient helper methods:
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
// In lifecycle hooks, objects are wrapped with helpers
|
|
880
|
+
async onSave(obj: VirtualPersistentObject): Promise<VirtualPersistentObject> {
|
|
881
|
+
// Get attribute by name (wrapped with helpers)
|
|
882
|
+
const attr = obj.getAttribute("Email");
|
|
883
|
+
|
|
884
|
+
// Get/set values
|
|
885
|
+
const email = obj.getAttributeValue("Email");
|
|
886
|
+
obj.setAttributeValue("Email", "new@example.com");
|
|
887
|
+
|
|
888
|
+
// Validation errors
|
|
889
|
+
obj.setValidationError("Email", "Invalid format");
|
|
890
|
+
obj.clearValidationError("Email");
|
|
891
|
+
|
|
892
|
+
// Notifications
|
|
893
|
+
obj.setNotification("Saved!", "OK", 3000);
|
|
894
|
+
|
|
895
|
+
return obj;
|
|
896
|
+
}
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
**VirtualPersistentObject methods:**
|
|
900
|
+
|
|
901
|
+
| Method | Description |
|
|
902
|
+
|--------|-------------|
|
|
903
|
+
| `getAttribute(name)` | Get attribute wrapped with helpers |
|
|
904
|
+
| `getAttributeValue(name)` | Get converted attribute value |
|
|
905
|
+
| `setAttributeValue(name, value)` | Set attribute value with conversion |
|
|
906
|
+
| `setValidationError(name, error)` | Set validation error |
|
|
907
|
+
| `clearValidationError(name)` | Clear validation error |
|
|
908
|
+
| `setNotification(msg, type, duration?)` | Set notification |
|
|
909
|
+
|
|
910
|
+
**VirtualPersistentObjectAttribute methods:**
|
|
911
|
+
|
|
912
|
+
| Method | Description |
|
|
913
|
+
|--------|-------------|
|
|
914
|
+
| `getValue()` | Get converted value |
|
|
915
|
+
| `setValue(value)` | Set value with conversion |
|
|
916
|
+
| `setValidationError(error)` | Set validation error |
|
|
917
|
+
| `clearValidationError()` | Clear validation error |
|
|
918
|
+
|
|
919
|
+
## Testing Examples
|
|
920
|
+
|
|
921
|
+
### Unit Test Example
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
import { test, expect } from "@playwright/test";
|
|
925
|
+
import { VirtualService } from "@vidyano-labs/virtual-service";
|
|
926
|
+
|
|
927
|
+
test("validates email format", async () => {
|
|
928
|
+
const service = new VirtualService();
|
|
929
|
+
|
|
930
|
+
service.registerPersistentObject({
|
|
931
|
+
type: "Contact",
|
|
932
|
+
attributes: [
|
|
933
|
+
{ name: "Email", type: "String", rules: "IsEmail" }
|
|
934
|
+
]
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
await service.initialize();
|
|
938
|
+
|
|
939
|
+
const contact = await service.getPersistentObject(null, "Contact", null, true);
|
|
940
|
+
contact.getAttribute("Email").setValue("not-an-email");
|
|
941
|
+
|
|
942
|
+
await contact.save();
|
|
943
|
+
|
|
944
|
+
expect(contact.getAttribute("Email").validationError).toBe("Email format is invalid");
|
|
945
|
+
});
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
### Integration Test Example
|
|
949
|
+
|
|
950
|
+
```typescript
|
|
951
|
+
import { test, expect } from "@playwright/test";
|
|
952
|
+
import { VirtualService } from "@vidyano-labs/virtual-service";
|
|
953
|
+
import type { ActionArgs } from "@vidyano-labs/virtual-service";
|
|
954
|
+
|
|
955
|
+
test("complete order workflow", async () => {
|
|
956
|
+
const service = new VirtualService();
|
|
957
|
+
|
|
958
|
+
service.registerAction({
|
|
959
|
+
name: "Submit",
|
|
960
|
+
handler: async (args: ActionArgs) => {
|
|
961
|
+
args.context.setAttributeValue("Status", "Submitted");
|
|
962
|
+
return args.parent;
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
service.registerAction({
|
|
967
|
+
name: "Approve",
|
|
968
|
+
handler: async (args: ActionArgs) => {
|
|
969
|
+
const status = args.context.getAttributeValue("Status");
|
|
970
|
+
if (status !== "Submitted") {
|
|
971
|
+
args.context.setNotification("Order must be submitted first", "Error");
|
|
972
|
+
return args.parent;
|
|
973
|
+
}
|
|
974
|
+
args.context.setAttributeValue("Status", "Approved");
|
|
975
|
+
return args.parent;
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
service.registerPersistentObject({
|
|
980
|
+
type: "Order",
|
|
981
|
+
attributes: [
|
|
982
|
+
{ name: "Status", type: "String", value: "Draft" },
|
|
983
|
+
{ name: "Total", type: "Decimal", value: "0" }
|
|
984
|
+
],
|
|
985
|
+
actions: ["Submit", "Approve"] // Actions must be listed here
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
await service.initialize();
|
|
989
|
+
|
|
990
|
+
const order = await service.getPersistentObject(null, "Order", "123");
|
|
991
|
+
expect(order.getAttribute("Status").value).toBe("Draft");
|
|
992
|
+
|
|
993
|
+
// Submit order
|
|
994
|
+
await order.getAction("Submit").execute();
|
|
995
|
+
expect(order.getAttribute("Status").value).toBe("Submitted");
|
|
996
|
+
|
|
997
|
+
// Approve order
|
|
998
|
+
await order.getAction("Approve").execute();
|
|
999
|
+
expect(order.getAttribute("Status").value).toBe("Approved");
|
|
1000
|
+
});
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
### Query Test Example
|
|
1004
|
+
|
|
1005
|
+
```typescript
|
|
1006
|
+
import { test, expect } from "@playwright/test";
|
|
1007
|
+
import { VirtualService } from "@vidyano-labs/virtual-service";
|
|
1008
|
+
|
|
1009
|
+
test("search and sort query results", async () => {
|
|
1010
|
+
const service = new VirtualService();
|
|
1011
|
+
|
|
1012
|
+
service.registerPersistentObject({
|
|
1013
|
+
type: "Person",
|
|
1014
|
+
attributes: [
|
|
1015
|
+
{ name: "Name", type: "String" },
|
|
1016
|
+
{ name: "Age", type: "Int32" }
|
|
1017
|
+
]
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
service.registerQuery({
|
|
1021
|
+
name: "AllPeople",
|
|
1022
|
+
persistentObject: "Person",
|
|
1023
|
+
data: [
|
|
1024
|
+
{ Name: "Alice", Age: 30 },
|
|
1025
|
+
{ Name: "Bob", Age: 25 },
|
|
1026
|
+
{ Name: "Charlie", Age: 35 }
|
|
1027
|
+
]
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
await service.initialize();
|
|
1031
|
+
|
|
1032
|
+
const query = await service.getQuery("AllPeople");
|
|
1033
|
+
|
|
1034
|
+
// Test search
|
|
1035
|
+
query.textSearch = "alice";
|
|
1036
|
+
await query.search();
|
|
1037
|
+
let items = await query.items.toArrayAsync();
|
|
1038
|
+
expect(items.length).toBe(1);
|
|
1039
|
+
|
|
1040
|
+
// Test sort
|
|
1041
|
+
query.textSearch = "";
|
|
1042
|
+
query.sortOptions = [{ name: "Age", direction: "ASC" }];
|
|
1043
|
+
await query.search();
|
|
1044
|
+
items = await query.items.toArrayAsync();
|
|
1045
|
+
expect(items[0].values.Name).toBe("Bob"); // Youngest first
|
|
1046
|
+
});
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
## API Reference
|
|
1050
|
+
|
|
1051
|
+
### VirtualService
|
|
1052
|
+
|
|
1053
|
+
| Method | Description |
|
|
1054
|
+
|--------|-------------|
|
|
1055
|
+
| `constructor(hooks?)` | Create service with optional custom hooks |
|
|
1056
|
+
| `registerPersistentObject(config)` | Register a PersistentObject type |
|
|
1057
|
+
| `registerQuery(config)` | Register a Query |
|
|
1058
|
+
| `registerAction(config)` | Register a custom action |
|
|
1059
|
+
| `registerBusinessRule(name, validator)` | Register a validation rule |
|
|
1060
|
+
| `registerPersistentObjectActions(type, Class)` | Register lifecycle handlers |
|
|
1061
|
+
| `initialize()` | Finalize registrations |
|
|
1062
|
+
|
|
1063
|
+
### VirtualPersistentObjectActions
|
|
1064
|
+
|
|
1065
|
+
| Method | Description |
|
|
1066
|
+
|--------|-------------|
|
|
1067
|
+
| `onConstruct(obj)` | Called when constructing the DTO |
|
|
1068
|
+
| `onLoad(obj, parent)` | Called when loading an existing object |
|
|
1069
|
+
| `onNew(obj, parent, query, params)` | Called when creating a new object |
|
|
1070
|
+
| `onSave(obj)` | Called when saving |
|
|
1071
|
+
| `saveNew(obj)` | Called for new objects (protected) |
|
|
1072
|
+
| `saveExisting(obj)` | Called for existing objects (protected) |
|
|
1073
|
+
| `onRefresh(obj, attribute)` | Called when refreshing |
|
|
1074
|
+
| `onDelete(parent, query, items)` | Called when deleting items |
|
|
1075
|
+
| `onConstructQuery(query, parent)` | Called when constructing a query |
|
|
1076
|
+
| `onExecuteQuery(query, parent, data)` | Called when executing a query |
|
|
1077
|
+
| `getEntities(query, parent, data)` | Provide query data |
|
|
1078
|
+
| `onSelectReference(parent, attr, query, item)` | Called when selecting a reference |
|
|
1079
|
+
|
|
1080
|
+
### Type Exports
|
|
1081
|
+
|
|
1082
|
+
```typescript
|
|
1083
|
+
import {
|
|
1084
|
+
VirtualService,
|
|
1085
|
+
VirtualServiceHooks,
|
|
1086
|
+
VirtualPersistentObjectActions
|
|
1087
|
+
} from "@vidyano-labs/virtual-service";
|
|
1088
|
+
|
|
1089
|
+
import type {
|
|
1090
|
+
VirtualPersistentObject,
|
|
1091
|
+
VirtualPersistentObjectAttribute,
|
|
1092
|
+
VirtualPersistentObjectConfig,
|
|
1093
|
+
VirtualPersistentObjectAttributeConfig,
|
|
1094
|
+
VirtualQueryConfig,
|
|
1095
|
+
VirtualQueryExecuteResult,
|
|
1096
|
+
ActionConfig,
|
|
1097
|
+
ActionHandler,
|
|
1098
|
+
ActionArgs,
|
|
1099
|
+
ActionContext,
|
|
1100
|
+
RuleValidatorFn
|
|
1101
|
+
} from "@vidyano-labs/virtual-service";
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
## Best Practices
|
|
1105
|
+
|
|
1106
|
+
- **Register in order** - PersistentObjects first, then Queries, then initialize
|
|
1107
|
+
- **Use lifecycle hooks** - Prefer `VirtualPersistentObjectActions` for complex logic over inline handlers
|
|
1108
|
+
- **Validate early** - Use built-in rules for common validations, custom rules for domain-specific
|
|
1109
|
+
- **Test comprehensively** - Cover validation, actions, queries, and edge cases
|
|
1110
|
+
- **Keep data realistic** - Use production-like test data to catch real issues
|
|
1111
|
+
- **Leverage type safety** - Use `getValue()` for type-safe value access
|
|
1112
|
+
- **Handle null values** - Check for null/undefined, especially in calculations
|
|
1113
|
+
|
|
1114
|
+
## License
|
|
1115
|
+
|
|
1116
|
+
MIT
|