cabloy 5.1.119 → 5.1.121
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/.cabloy-version +1 -1
- package/CHANGELOG.md +17 -0
- package/cabloy-docs/.vitepress/config.mjs +1 -0
- package/cabloy-docs/frontend/form-layout-guide.md +48 -18
- package/cabloy-docs/frontend/table-resource-crud-cookbook.md +6 -3
- package/cabloy-docs/frontend/zova-form-source-reading-map.md +7 -5
- package/cabloy-docs/frontend/zova-form-under-the-hood.md +5 -2
- package/e2e/specs/cabloy-basic/basic.spec.ts +9 -0
- package/package.json +1 -1
- package/vona/env/.env +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +6 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/shipmentRefundRace.test.ts +377 -24
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectResItem.tsx +4 -1
- package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +10 -3
- package/zova/packages-zova/zova/package.json +2 -2
- package/zova/src/suite/cabloy-basic/modules/basic-form/src/component/blockFormLayout/controller.tsx +25 -4
- package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +1 -0
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.ts +21 -4
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/types/formLayout.ts +13 -4
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/formLayout.ts +14 -4
- package/zova/src/suite-vendor/a-zova/package.json +3 -3
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.121
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.1.121
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Support embedded layout blocks in forms.
|
|
8
|
+
|
|
9
|
+
### Improvements
|
|
10
|
+
|
|
11
|
+
- Update the student selection result item component.
|
|
12
|
+
- Add commerce test coverage for shipment refund race conditions.
|
|
13
|
+
|
|
14
|
+
## 5.1.120
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
- Update functionality.
|
|
19
|
+
|
|
3
20
|
## 5.1.119
|
|
4
21
|
|
|
5
22
|
### Improvements
|
|
@@ -243,6 +243,7 @@ export default defineConfig({
|
|
|
243
243
|
{ text: 'Error Guide', link: '/backend/error-guide' },
|
|
244
244
|
{ text: 'Event Guide', link: '/backend/event-guide' },
|
|
245
245
|
{ text: 'Logger Guide', link: '/backend/logger-guide' },
|
|
246
|
+
{ text: 'Metrics Guide', link: '/backend/metrics-guide' },
|
|
246
247
|
{ text: 'Telemetry Guide', link: '/backend/telemetry-guide' },
|
|
247
248
|
{ text: 'Upload Guide', link: '/backend/upload-guide' },
|
|
248
249
|
{ text: 'Image Guide', link: '/backend/image-guide' },
|
|
@@ -13,8 +13,8 @@ Several APIs contain the word “layout,” but they own different concerns:
|
|
|
13
13
|
|
|
14
14
|
| Surface | Owns | Does not own |
|
|
15
15
|
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
|
16
|
-
| `formLayout` | Field placement, Grid/flow sections, groups, responsive spans, and tabs
|
|
17
|
-
| `basic-form:blockFormLayout` | Resolving and rendering a structural `formLayout` tree in Cabloy Basic
|
|
16
|
+
| `formLayout` | Field and renderable-block placement, Grid/flow sections, groups, responsive spans, and tabs | Field renderer selection, validation rules, submit policy |
|
|
17
|
+
| `basic-form:blockFormLayout` | Resolving and rendering a structural `formLayout` tree in Cabloy Basic, including embedded renderable blocks | Page-entry or filter action semantics |
|
|
18
18
|
| `layout`, `formFieldLayout`, `FormFieldLayout` | One field's label and wrapper presentation: inline/block mode, icons, borders, header/footer, class, and style | Sections, Grid/flow placement, groups, or tabs |
|
|
19
19
|
|
|
20
20
|
For example, `formFieldLayout: { inline: false }` makes each field use a block-style wrapper. It does not create a grid. Pair it with `basic-form:blockFormLayout` when the fields also need structural placement.
|
|
@@ -63,7 +63,7 @@ Form Layout changes neither readonly behavior nor actions. Create, update, and v
|
|
|
63
63
|
|
|
64
64
|
### Filter form composition
|
|
65
65
|
|
|
66
|
-
A filter uses the filter block as its host
|
|
66
|
+
A filter uses the filter block as its host. Place Search/Reset inside the structural layout when they should participate in the same Grid or flow as filter fields:
|
|
67
67
|
|
|
68
68
|
```tsx
|
|
69
69
|
ZovaRender.block('basic-page:blockFilter', {
|
|
@@ -72,16 +72,36 @@ ZovaRender.block('basic-page:blockFilter', {
|
|
|
72
72
|
ZovaRender.block('basic-form:blockFormLayout', {
|
|
73
73
|
formLayout: {
|
|
74
74
|
children: [
|
|
75
|
-
|
|
75
|
+
{
|
|
76
|
+
type: 'section',
|
|
77
|
+
layout: 'flow',
|
|
78
|
+
children: [
|
|
79
|
+
/* filter fields */
|
|
80
|
+
{
|
|
81
|
+
type: 'block',
|
|
82
|
+
block: ZovaRender.block('basic-page:blockFilterActions'),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
76
86
|
],
|
|
77
87
|
},
|
|
78
88
|
}),
|
|
79
|
-
ZovaRender.block('basic-page:blockFilterActions'),
|
|
80
89
|
],
|
|
81
90
|
});
|
|
82
91
|
```
|
|
83
92
|
|
|
84
|
-
|
|
93
|
+
The renderable block node controls only structural placement. `basic-page:blockFilterActions` still owns Search/Reset and obtains the filter command surface from the inherited form scope, preserving normalization and page-query handoff. A nonempty `blocks` list replaces `ZForm`'s automatic body and footer.
|
|
94
|
+
|
|
95
|
+
The legacy sibling composition remains supported when actions do not need to share a structural section:
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
blocks: [
|
|
99
|
+
ZovaRender.block('basic-form:blockFormLayout', { formLayout }),
|
|
100
|
+
ZovaRender.block('basic-page:blockFilterActions'),
|
|
101
|
+
];
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Use either the embedded layout block or the legacy sibling action block, never both; otherwise Search and Reset are rendered twice. See [Table + Resource CRUD Cookbook](/frontend/table-resource-crud-cookbook#use-blocks-for-a-structural-filter-layout) for the filter ownership model.
|
|
85
105
|
|
|
86
106
|
## Layout node grammar
|
|
87
107
|
|
|
@@ -90,28 +110,35 @@ A nonempty `blocks` list replaces `ZForm`'s automatic body and footer. Therefore
|
|
|
90
110
|
```text
|
|
91
111
|
formLayout
|
|
92
112
|
├─ field
|
|
113
|
+
├─ block
|
|
93
114
|
├─ section
|
|
94
|
-
│
|
|
115
|
+
│ ├─ field
|
|
116
|
+
│ └─ block
|
|
95
117
|
├─ group
|
|
96
118
|
│ ├─ field
|
|
119
|
+
│ ├─ block
|
|
97
120
|
│ ├─ group
|
|
98
121
|
│ └─ section
|
|
99
122
|
└─ tabs
|
|
100
123
|
└─ tab
|
|
101
124
|
├─ field
|
|
125
|
+
├─ block
|
|
102
126
|
├─ group
|
|
103
127
|
└─ section
|
|
104
128
|
```
|
|
105
129
|
|
|
106
|
-
| Node | Key properties | Allowed children
|
|
107
|
-
| --------- | ---------------------------------------------------------- |
|
|
108
|
-
| `field` | required `name`; optional `span` | none
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
130
|
+
| Node | Key properties | Allowed children | Use it for |
|
|
131
|
+
| --------- | ---------------------------------------------------------- | -------------------------------- | ------------------------------------------- |
|
|
132
|
+
| `field` | required `name`; optional `span` | none | Place one resolved schema field |
|
|
133
|
+
| `block` | required `block`; optional `span` | none | Place an existing renderable resource block |
|
|
134
|
+
| `section` | optional `id`, `title`, `description`, `layout`, `columns` | fields and blocks | A Grid or wrapping flow layout |
|
|
135
|
+
| `group` | optional `id`, `title`, `description` | fields, blocks, groups, sections | A semantic, bordered fieldset-style group |
|
|
136
|
+
| `tabs` | optional `id` | tabs only | One tab container |
|
|
137
|
+
| `tab` | optional `id`; required `title` | fields, blocks, groups, sections | One tab panel |
|
|
138
|
+
|
|
139
|
+
A section is a layout boundary. It uses the Grid strategy by default; set `layout: 'flow'` for compact, left-packed fields and blocks that wrap at their intrinsic widths. Use a group when the fields need a semantic or visual boundary, and place a section inside that group when it also needs Grid columns or flow placement. There is no separate `row` node: Grid and flow placement create rows automatically.
|
|
113
140
|
|
|
114
|
-
A
|
|
141
|
+
A `block` node is not a schema field and does not add a request, response, validation, or query value. It wraps an existing `ZovaRender.block(...)` descriptor and renders it with the current form JSX/CEL context. This lets a filter action block participate in a flow section without transferring filter-action behavior into Form Layout.
|
|
115
142
|
|
|
116
143
|
Nested tabs are not part of the current contract. Likewise, a section cannot contain a group or another section.
|
|
117
144
|
|
|
@@ -286,24 +313,27 @@ ZovaRender.block('basic-page:blockFilter', {
|
|
|
286
313
|
{ type: 'field', name: 'name' },
|
|
287
314
|
{ type: 'field', name: 'level' },
|
|
288
315
|
{ type: 'field', name: 'createdAt' },
|
|
316
|
+
{
|
|
317
|
+
type: 'block',
|
|
318
|
+
block: ZovaRender.block('basic-page:blockFilterActions'),
|
|
319
|
+
},
|
|
289
320
|
],
|
|
290
321
|
},
|
|
291
322
|
],
|
|
292
323
|
},
|
|
293
324
|
}),
|
|
294
|
-
ZovaRender.block('basic-page:blockFilterActions'),
|
|
295
325
|
],
|
|
296
326
|
});
|
|
297
327
|
```
|
|
298
328
|
|
|
299
|
-
Here `formFieldLayout.inline: true` controls how each field wrapper is presented. The flow section keeps
|
|
329
|
+
Here `formFieldLayout.inline: true` controls how each field wrapper is presented. The flow section keeps fields and the action block left-packed and wraps them together when necessary. `basic-page:blockFilterActions` remains required because the custom blocks replace automatic filter body/footer content, but it is now placed through the structural layout rather than as a sibling block.
|
|
300
330
|
|
|
301
331
|
## Authoring checklist
|
|
302
332
|
|
|
303
333
|
1. Start with DTO or resource metadata; do not hand-patch generated `.zova-rest` artifacts.
|
|
304
334
|
2. Use `formLayout` when the requirement is field placement, Grid or flow structure, groups, or tabs.
|
|
305
335
|
3. Use `layout`, `formFieldLayout`, `options`, or provider behaviors when the requirement is one field's wrapper or renderer.
|
|
306
|
-
4. Keep entry actions in page-entry toolbar blocks
|
|
336
|
+
4. Keep entry actions in page-entry toolbar blocks. Keep filter action semantics in `basic-page:blockFilterActions`; place that block inside Form Layout only when actions must share structural Grid or flow placement with fields.
|
|
307
337
|
5. Review field names against the scene-specific schema. Unlisted visible fields are appended; unknown and duplicate declarations are silently pruned from the rendered plan.
|
|
308
338
|
6. Use the smallest layout that communicates the form structure; reserve tabs for genuinely separate field groups.
|
|
309
339
|
|
|
@@ -180,7 +180,7 @@ A practical rule is:
|
|
|
180
180
|
|
|
181
181
|
### Use blocks for a structural filter layout
|
|
182
182
|
|
|
183
|
-
A bare `basic-page:blockFilter` keeps the default schema field rendering and adds Search/Reset controls automatically. For a structured filter
|
|
183
|
+
A bare `basic-page:blockFilter` keeps the default schema field rendering and adds Search/Reset controls automatically. For a structured filter whose actions should share the flow with filter fields, embed the existing action block in Form Layout:
|
|
184
184
|
|
|
185
185
|
```tsx
|
|
186
186
|
ZovaRender.block('basic-page:blockFilter', {
|
|
@@ -196,17 +196,20 @@ ZovaRender.block('basic-page:blockFilter', {
|
|
|
196
196
|
{ type: 'field', name: 'name' },
|
|
197
197
|
{ type: 'field', name: 'level' },
|
|
198
198
|
{ type: 'field', name: 'createdAt' },
|
|
199
|
+
{
|
|
200
|
+
type: 'block',
|
|
201
|
+
block: ZovaRender.block('basic-page:blockFilterActions'),
|
|
202
|
+
},
|
|
199
203
|
],
|
|
200
204
|
},
|
|
201
205
|
],
|
|
202
206
|
},
|
|
203
207
|
}),
|
|
204
|
-
ZovaRender.block('basic-page:blockFilterActions'),
|
|
205
208
|
],
|
|
206
209
|
});
|
|
207
210
|
```
|
|
208
211
|
|
|
209
|
-
`basic-form:blockFormLayout`
|
|
212
|
+
`basic-form:blockFormLayout` owns structural placement only. `basic-page:blockFilterActions` still owns Search/Reset behavior and invokes the filter command surface supplied through the inherited form scope, preserving filter normalization and page-query behavior. A nonempty `blocks` list replaces the automatic body and footer; include the action block explicitly to make the filter operable. The older sibling action-block composition remains supported, but do not combine it with an embedded action block.
|
|
210
213
|
|
|
211
214
|
`ZForm.inline` is no longer a form API. Use `formFieldLayout.inline` for field-level compact layout, or use blocks plus `basic-form:blockFormLayout` for structural layout. The flow section above keeps compact filters left-packed and wrapping; use the default Grid section when fields need responsive columns and spans. Read [Form Layout Guide](/frontend/form-layout-guide) for the full node grammar, section layout rules, resolver behavior, tabs, and entry-form composition.
|
|
212
215
|
|
|
@@ -223,7 +223,7 @@ Use this path when you are asking questions like:
|
|
|
223
223
|
Use this path when you are asking questions like:
|
|
224
224
|
|
|
225
225
|
- where does `formLayout` come from in a resource DTO?
|
|
226
|
-
- how are fields, sections, groups, and tabs normalized before rendering?
|
|
226
|
+
- how are fields, embedded blocks, sections, groups, and tabs normalized before rendering?
|
|
227
227
|
- why are omitted visible fields appended or duplicate fields removed?
|
|
228
228
|
- where does Cabloy Basic render responsive grids and tab error badges?
|
|
229
229
|
|
|
@@ -240,15 +240,17 @@ Use this path when you are asking questions like:
|
|
|
240
240
|
3. `zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/formLayout.ts`
|
|
241
241
|
4. `zova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.ts`
|
|
242
242
|
5. `zova/src/suite/cabloy-basic/modules/basic-form/src/component/blockFormLayout/controller.tsx`
|
|
243
|
-
6. `
|
|
243
|
+
6. `zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockFilterActions/controller.tsx`
|
|
244
|
+
7. `vona/src/suite/a-training/modules/training-student/test/student.test.ts`
|
|
244
245
|
|
|
245
246
|
### What each file clarifies
|
|
246
247
|
|
|
247
248
|
- the Student DTOs show the entry and filter block composition that supplies layout metadata
|
|
248
249
|
- the OpenAPI type contract defines the legal node grammar and responsive values
|
|
249
|
-
- the resolver reconciles metadata with visible schema fields, generated IDs, and
|
|
250
|
-
- the Basic block controller renders sections, groups, tabs,
|
|
251
|
-
-
|
|
250
|
+
- the resolver reconciles field metadata with visible schema fields, generated IDs, diagnostics, and preserved embedded blocks
|
|
251
|
+
- the Basic block controller renders sections, groups, tabs, field spans, and embedded blocks while delegating fields to `$$form.renderField(...)`
|
|
252
|
+
- `blockFilterActions` shows how a block rendered inside Form Layout reuses the inherited form CEL scope to invoke `$$filter`
|
|
253
|
+
- the Student test verifies emitted metadata nesting, columns, spans, embedded action blocks, and optional IDs; it is not a browser rendering test
|
|
252
254
|
|
|
253
255
|
## 8. Resource-driven CRUD page integration
|
|
254
256
|
|
|
@@ -414,11 +414,14 @@ That means automatic schema-driven rendering is not happening magically in the w
|
|
|
414
414
|
|
|
415
415
|
When `ZForm` receives a nonempty block list, the render bean delegates body rendering to those blocks instead of iterating schema fields directly. For Cabloy Basic structural forms, `basic-form:blockFormLayout` resolves `formLayout` against the form's current schema properties and calls `$$form.renderField(...)` for each surviving layout field.
|
|
416
416
|
|
|
417
|
+
Form Layout also supports a leaf `block` node. It wraps an existing resource block descriptor and the Basic renderer invokes it with the inherited `IJsxRenderContextForm`, including the same JSX runtime and CEL scope. The node has no schema property or field value; for example, a filter can place `basic-page:blockFilterActions` inside a flow section while that action block continues to read `$$filter` from the filter-owned form scope.
|
|
418
|
+
|
|
417
419
|
This keeps ownership separate:
|
|
418
420
|
|
|
419
421
|
- the form controller owns schema properties, field state, validation, and field rendering
|
|
420
|
-
- the shared form-layout resolver normalizes field placement metadata
|
|
421
|
-
- the Basic layout block renders sections, groups, grids, and
|
|
422
|
+
- the shared form-layout resolver normalizes field placement metadata while preserving non-field block nodes
|
|
423
|
+
- the Basic layout block renders sections, groups, grids, tabs, and embedded blocks
|
|
424
|
+
- embedded blocks own their behavior; Form Layout owns only their placement
|
|
422
425
|
- field-layout behaviors still own each field wrapper and its visible validation message
|
|
423
426
|
|
|
424
427
|
Read [Form Layout Guide](/frontend/form-layout-guide) for the DTO authoring grammar, resolver behavior, and Basic-specific responsive/tab behavior.
|
|
@@ -209,6 +209,8 @@ test(
|
|
|
209
209
|
await expect(name).toBeVisible();
|
|
210
210
|
await expect(level).toBeVisible();
|
|
211
211
|
await expect(dates).toHaveCount(2);
|
|
212
|
+
await expect(search).toHaveCount(1);
|
|
213
|
+
await expect(reset).toHaveCount(1);
|
|
212
214
|
await expect(search).toBeVisible();
|
|
213
215
|
await expect(reset).toBeVisible();
|
|
214
216
|
|
|
@@ -223,6 +225,13 @@ test(
|
|
|
223
225
|
for (const geometry of wideGeometry) {
|
|
224
226
|
expect(geometry.field.right).toBeLessThanOrEqual(geometry.container.right + 1);
|
|
225
227
|
}
|
|
228
|
+
const actionFlowMatchesFieldFlow = await search.evaluate(element => {
|
|
229
|
+
const actionFlow = element.parentElement?.parentElement?.parentElement;
|
|
230
|
+
const fieldFlow = element.closest('section')?.querySelector('label')
|
|
231
|
+
?.parentElement?.parentElement;
|
|
232
|
+
return actionFlow === fieldFlow;
|
|
233
|
+
});
|
|
234
|
+
expect(actionFlowMatchesFieldFlow).toBeTruthy();
|
|
226
235
|
|
|
227
236
|
await page.setViewportSize({ width: 700, height: 900 });
|
|
228
237
|
const drawer = page.locator('.drawer').first();
|
package/package.json
CHANGED
package/vona/env/.env
CHANGED
|
@@ -97,7 +97,11 @@ export type PaymentOutcomeFailureStage =
|
|
|
97
97
|
|
|
98
98
|
type PaymentOutcomeStageCallback = (stage: PaymentOutcomeFailureStage) => void | Promise<void>;
|
|
99
99
|
|
|
100
|
-
export type ShipmentFailureStage =
|
|
100
|
+
export type ShipmentFailureStage =
|
|
101
|
+
| 'beforeOrderLock'
|
|
102
|
+
| 'afterShipmentInsert'
|
|
103
|
+
| 'afterOrderState'
|
|
104
|
+
| 'afterOrderAudit';
|
|
101
105
|
|
|
102
106
|
type ShipmentStageCallback = (stage: ShipmentFailureStage) => void | Promise<void>;
|
|
103
107
|
|
|
@@ -678,6 +682,7 @@ export class ServiceOrder extends BeanBase {
|
|
|
678
682
|
if (!carrier) this.app.throw(400, 'shipment carrier is required');
|
|
679
683
|
if (!trackingNumber) this.app.throw(400, 'shipment trackingNumber is required');
|
|
680
684
|
|
|
685
|
+
await onStage?.('beforeOrderLock');
|
|
681
686
|
const order = await this.scope.model.order.getByIdForUpdate(orderId);
|
|
682
687
|
if (!order) this.app.throw(404, 'order not found');
|
|
683
688
|
const existingShipment = await this.scope.model.shipment.get({ orderId: order.id });
|
|
@@ -31,8 +31,8 @@ async function cleanup(fixture: IFixture) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
async function createPaidOrder(suffix: string): Promise<
|
|
35
|
-
|
|
34
|
+
async function createPaidOrder(fixture: IFixture, suffix: string): Promise<void> {
|
|
35
|
+
fixture.customerName = `shipment-refund-${suffix}`;
|
|
36
36
|
const customer = await app.bean.user.register({ name: fixture.customerName }, true);
|
|
37
37
|
fixture.userId = customer.id as number;
|
|
38
38
|
await app.bean.passport.signinMock(fixture.customerName as any);
|
|
@@ -61,7 +61,111 @@ async function createPaidOrder(suffix: string): Promise<IFixture> {
|
|
|
61
61
|
} finally {
|
|
62
62
|
await app.bean.passport.signout();
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runInMockCtx<TResult>(
|
|
67
|
+
customerName: string | undefined,
|
|
68
|
+
operation: () => Promise<TResult>,
|
|
69
|
+
): Promise<TResult> {
|
|
70
|
+
return await app.bean.executor.mockCtx(async () => {
|
|
71
|
+
if (customerName === undefined) {
|
|
72
|
+
await app.bean.passport.signinMock();
|
|
73
|
+
} else {
|
|
74
|
+
await app.bean.passport.signinMock(customerName as any);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return await operation();
|
|
78
|
+
} finally {
|
|
79
|
+
await app.bean.passport.signout();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function withTimeout<TResult>(promise: Promise<TResult>, message: string): Promise<TResult> {
|
|
85
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
86
|
+
try {
|
|
87
|
+
return await Promise.race([
|
|
88
|
+
promise,
|
|
89
|
+
new Promise<never>((_, reject) => {
|
|
90
|
+
timeout = setTimeout(() => reject(new Error(message)), 5_000);
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
} finally {
|
|
94
|
+
if (timeout) clearTimeout(timeout);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function waitForHolderStage(
|
|
99
|
+
entered: Promise<void>,
|
|
100
|
+
holder: Promise<unknown>,
|
|
101
|
+
label: string,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
return await withTimeout(
|
|
104
|
+
Promise.race([
|
|
105
|
+
entered,
|
|
106
|
+
holder.then(
|
|
107
|
+
() =>
|
|
108
|
+
Promise.reject(new Error(`${label} completed before reaching its lock-holding stage`)),
|
|
109
|
+
error => Promise.reject(error),
|
|
110
|
+
),
|
|
111
|
+
]),
|
|
112
|
+
`${label} did not reach its lock-holding stage`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function waitForPostgresWaiter(waiterPid: number, holderPid: number): Promise<void> {
|
|
117
|
+
const deadline = Date.now() + 5_000;
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
const result = await app.bean.database.current.connection.raw(
|
|
120
|
+
`
|
|
121
|
+
select wait_event_type
|
|
122
|
+
from pg_stat_activity
|
|
123
|
+
where pid = ?
|
|
124
|
+
and ? = any(pg_blocking_pids(pid))
|
|
125
|
+
`,
|
|
126
|
+
[waiterPid, holderPid],
|
|
127
|
+
);
|
|
128
|
+
if (result.rows[0]) {
|
|
129
|
+
assert.equal(result.rows[0].wait_event_type, 'Lock');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
133
|
+
}
|
|
134
|
+
assert.fail(`PostgreSQL backend ${waiterPid} was not blocked by backend ${holderPid}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function assertPersistedRefundOutcome(
|
|
138
|
+
orderId: number,
|
|
139
|
+
expected: {
|
|
140
|
+
orderState: string;
|
|
141
|
+
refundState: string;
|
|
142
|
+
refundAttemptState: string;
|
|
143
|
+
orderAuditOperations: readonly string[];
|
|
144
|
+
refundAuditStates: readonly string[];
|
|
145
|
+
},
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const trade = app.scope('commerce-trade');
|
|
148
|
+
const payment = app.scope('commerce-payment');
|
|
149
|
+
const order = await trade.model.order.getById(orderId);
|
|
150
|
+
const shipments = await trade.model.shipment.select({ where: { orderId } });
|
|
151
|
+
const requests = await payment.model.refundRequest.select({ where: { orderId } });
|
|
152
|
+
const attempts = await payment.model.refundAttempt.select({
|
|
153
|
+
where: { refundRequestId: requests[0]?.id },
|
|
154
|
+
});
|
|
155
|
+
const orderAudits = await trade.model.orderAudit.select({ where: { orderId } });
|
|
156
|
+
const refundAudits = await payment.model.refundAudit.select({ where: { orderId } });
|
|
157
|
+
|
|
158
|
+
assert.equal(order?.state, expected.orderState);
|
|
159
|
+
assert.equal(shipments.length, 0);
|
|
160
|
+
assert.equal(requests.length, 1);
|
|
161
|
+
assert.equal(requests[0]?.state, expected.refundState);
|
|
162
|
+
assert.equal(attempts.length, 1);
|
|
163
|
+
assert.equal(attempts[0]?.state, expected.refundAttemptState);
|
|
164
|
+
for (const operation of expected.orderAuditOperations) {
|
|
165
|
+
assert.equal(orderAudits.filter(item => item.operation === operation).length, 1);
|
|
166
|
+
}
|
|
167
|
+
assert.equal(orderAudits.filter(item => item.operation === 'shipped').length, 0);
|
|
168
|
+
assert.deepEqual(refundAudits.map(item => item.toRefundState).sort(), expected.refundAuditStates);
|
|
65
169
|
}
|
|
66
170
|
|
|
67
171
|
describe('shipmentRefundRace.test.ts', { concurrency: false, sequential: true }, () => {
|
|
@@ -83,31 +187,65 @@ describe('shipmentRefundRace.test.ts', { concurrency: false, sequential: true },
|
|
|
83
187
|
const fixture: IFixture = {};
|
|
84
188
|
try {
|
|
85
189
|
await app.bean.executor.mockCtx(async () => {
|
|
86
|
-
|
|
190
|
+
await createPaidOrder(fixture, randomUUID().slice(0, 12));
|
|
87
191
|
});
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
192
|
+
const requestEntered = Promise.withResolvers<void>();
|
|
193
|
+
const releaseRequest = Promise.withResolvers<void>();
|
|
194
|
+
let requestPid: number | undefined;
|
|
195
|
+
const requestRefund = runInMockCtx(fixture.customerName, async () => {
|
|
196
|
+
return await app.scope('commerce-trade').service.order.requestRefundForTest(
|
|
197
|
+
fixture.orderId!,
|
|
198
|
+
{
|
|
92
199
|
reason: 'race request',
|
|
93
200
|
idempotencyKey: 'shipment-refund-race-request-1',
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
201
|
+
},
|
|
202
|
+
async stage => {
|
|
203
|
+
if (stage !== 'afterRefundRequestOrderState') return;
|
|
204
|
+
const result = await app.bean.database.current.connection.raw(
|
|
205
|
+
'select pg_backend_pid() as pid',
|
|
206
|
+
);
|
|
207
|
+
requestPid = result.rows[0].pid;
|
|
208
|
+
requestEntered.resolve();
|
|
209
|
+
await releaseRequest.promise;
|
|
210
|
+
},
|
|
211
|
+
);
|
|
98
212
|
});
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await app.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
213
|
+
const shipmentReady = Promise.withResolvers<void>();
|
|
214
|
+
const releaseShipment = Promise.withResolvers<void>();
|
|
215
|
+
let shipmentPid: number | undefined;
|
|
216
|
+
let ship: Promise<unknown> | undefined;
|
|
217
|
+
try {
|
|
218
|
+
await waitForHolderStage(requestEntered.promise, requestRefund, 'refund request');
|
|
219
|
+
assert.notEqual(requestPid, undefined);
|
|
220
|
+
ship = runInMockCtx(undefined, async () => {
|
|
221
|
+
return await app.scope('commerce-trade').service.order.shipForTest(
|
|
222
|
+
fixture.orderId!,
|
|
223
|
+
{
|
|
224
|
+
carrier: 'Cabloy Express',
|
|
225
|
+
trackingNumber: 'CAB-RFD-1',
|
|
226
|
+
},
|
|
227
|
+
async stage => {
|
|
228
|
+
if (stage !== 'beforeOrderLock') return;
|
|
229
|
+
const result = await app.bean.database.current.connection.raw(
|
|
230
|
+
'select pg_backend_pid() as pid',
|
|
231
|
+
);
|
|
232
|
+
shipmentPid = result.rows[0].pid;
|
|
233
|
+
shipmentReady.resolve();
|
|
234
|
+
await releaseShipment.promise;
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
await waitForHolderStage(shipmentReady.promise, ship, 'shipment');
|
|
239
|
+
assert.notEqual(shipmentPid, undefined);
|
|
240
|
+
releaseShipment.resolve();
|
|
241
|
+
await app.bean.executor.mockCtx(async () => {
|
|
242
|
+
await waitForPostgresWaiter(shipmentPid!, requestPid!);
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
releaseShipment.resolve();
|
|
246
|
+
releaseRequest.resolve();
|
|
247
|
+
await Promise.allSettled([requestRefund, ship].filter(Boolean));
|
|
248
|
+
}
|
|
111
249
|
await app.bean.executor.mockCtx(async () => {
|
|
112
250
|
const trade = app.scope('commerce-trade');
|
|
113
251
|
const payment = app.scope('commerce-payment');
|
|
@@ -140,4 +278,219 @@ describe('shipmentRefundRace.test.ts', { concurrency: false, sequential: true },
|
|
|
140
278
|
});
|
|
141
279
|
}
|
|
142
280
|
});
|
|
281
|
+
|
|
282
|
+
it('rejects shipment when refund approval contends for the locked refund-requested order', async t => {
|
|
283
|
+
if (process.env.DATABASE_DEFAULT_CLIENT !== 'pg') {
|
|
284
|
+
t.skip('requires PostgreSQL row-lock contention');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const fixture: IFixture = {};
|
|
288
|
+
try {
|
|
289
|
+
await app.bean.executor.mockCtx(async () => {
|
|
290
|
+
await createPaidOrder(fixture, randomUUID().slice(0, 12));
|
|
291
|
+
});
|
|
292
|
+
await runInMockCtx(fixture.customerName, async () => {
|
|
293
|
+
await app.scope('commerce-trade').service.order.requestRefund(fixture.orderId!, {
|
|
294
|
+
reason: 'race approval request',
|
|
295
|
+
idempotencyKey: 'shipment-refund-race-approval-request-1',
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
const entered = Promise.withResolvers<void>();
|
|
299
|
+
const release = Promise.withResolvers<void>();
|
|
300
|
+
let holderPid: number | undefined;
|
|
301
|
+
const approveRefund = runInMockCtx(undefined, async () => {
|
|
302
|
+
return await app.scope('commerce-trade').service.order.reviewRefundForTest(
|
|
303
|
+
fixture.orderId!,
|
|
304
|
+
{
|
|
305
|
+
reason: 'race approval',
|
|
306
|
+
idempotencyKey: 'shipment-refund-race-approval-1',
|
|
307
|
+
},
|
|
308
|
+
'approved',
|
|
309
|
+
async stage => {
|
|
310
|
+
if (stage !== 'afterRefundReviewOrderState') return;
|
|
311
|
+
const result = await app.bean.database.current.connection.raw(
|
|
312
|
+
'select pg_backend_pid() as pid',
|
|
313
|
+
);
|
|
314
|
+
holderPid = result.rows[0].pid;
|
|
315
|
+
entered.resolve();
|
|
316
|
+
await release.promise;
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
const shipmentReady = Promise.withResolvers<void>();
|
|
321
|
+
const releaseShipment = Promise.withResolvers<void>();
|
|
322
|
+
let shipmentPid: number | undefined;
|
|
323
|
+
let ship: Promise<unknown> | undefined;
|
|
324
|
+
let results: PromiseSettledResult<unknown>[] | undefined;
|
|
325
|
+
try {
|
|
326
|
+
await waitForHolderStage(entered.promise, approveRefund, 'refund approval');
|
|
327
|
+
assert.notEqual(holderPid, undefined);
|
|
328
|
+
ship = runInMockCtx(undefined, async () => {
|
|
329
|
+
return await app.scope('commerce-trade').service.order.shipForTest(
|
|
330
|
+
fixture.orderId!,
|
|
331
|
+
{
|
|
332
|
+
carrier: 'Cabloy Express',
|
|
333
|
+
trackingNumber: 'CAB-RFD-APPROVAL-1',
|
|
334
|
+
},
|
|
335
|
+
async stage => {
|
|
336
|
+
if (stage !== 'beforeOrderLock') return;
|
|
337
|
+
const result = await app.bean.database.current.connection.raw(
|
|
338
|
+
'select pg_backend_pid() as pid',
|
|
339
|
+
);
|
|
340
|
+
shipmentPid = result.rows[0].pid;
|
|
341
|
+
shipmentReady.resolve();
|
|
342
|
+
await releaseShipment.promise;
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
await waitForHolderStage(shipmentReady.promise, ship, 'shipment');
|
|
347
|
+
assert.notEqual(shipmentPid, undefined);
|
|
348
|
+
releaseShipment.resolve();
|
|
349
|
+
await app.bean.executor.mockCtx(async () => {
|
|
350
|
+
await waitForPostgresWaiter(shipmentPid!, holderPid!);
|
|
351
|
+
});
|
|
352
|
+
} finally {
|
|
353
|
+
releaseShipment.resolve();
|
|
354
|
+
release.resolve();
|
|
355
|
+
results = await Promise.allSettled([approveRefund, ship].filter(Boolean));
|
|
356
|
+
}
|
|
357
|
+
const [approvalResult, shipmentResult] = results!;
|
|
358
|
+
const message = JSON.stringify(results);
|
|
359
|
+
assert.equal(approvalResult.status, 'fulfilled', message);
|
|
360
|
+
assert.equal(shipmentResult.status, 'rejected', message);
|
|
361
|
+
if (approvalResult.status !== 'fulfilled' || shipmentResult.status !== 'rejected') return;
|
|
362
|
+
assert.deepEqual(
|
|
363
|
+
[
|
|
364
|
+
approvalResult.value.orderState,
|
|
365
|
+
approvalResult.value.refundState,
|
|
366
|
+
approvalResult.value.refundAttemptState,
|
|
367
|
+
],
|
|
368
|
+
['refund_approved', 'approved', 'created'],
|
|
369
|
+
);
|
|
370
|
+
assert.equal(shipmentResult.reason?.code, 409);
|
|
371
|
+
await app.bean.executor.mockCtx(async () => {
|
|
372
|
+
await assertPersistedRefundOutcome(fixture.orderId!, {
|
|
373
|
+
orderState: 'refund_approved',
|
|
374
|
+
refundState: 'approved',
|
|
375
|
+
refundAttemptState: 'created',
|
|
376
|
+
orderAuditOperations: ['refund_requested', 'refund_approved'],
|
|
377
|
+
refundAuditStates: ['approved', 'requested'],
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
} finally {
|
|
381
|
+
await app.bean.executor.mockCtx(async () => {
|
|
382
|
+
await cleanup(fixture);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('rejects shipment when successful refund execution contends for the locked approved order', async t => {
|
|
388
|
+
if (process.env.DATABASE_DEFAULT_CLIENT !== 'pg') {
|
|
389
|
+
t.skip('requires PostgreSQL row-lock contention');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const fixture: IFixture = {};
|
|
393
|
+
try {
|
|
394
|
+
await app.bean.executor.mockCtx(async () => {
|
|
395
|
+
await createPaidOrder(fixture, randomUUID().slice(0, 12));
|
|
396
|
+
});
|
|
397
|
+
await runInMockCtx(fixture.customerName, async () => {
|
|
398
|
+
await app.scope('commerce-trade').service.order.requestRefund(fixture.orderId!, {
|
|
399
|
+
reason: 'race execution request',
|
|
400
|
+
idempotencyKey: 'shipment-refund-race-execution-request-1',
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
await runInMockCtx(undefined, async () => {
|
|
404
|
+
await app.scope('commerce-trade').service.order.approveRefund(fixture.orderId!, {
|
|
405
|
+
reason: 'race execution approval',
|
|
406
|
+
idempotencyKey: 'shipment-refund-race-execution-approval-1',
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
const entered = Promise.withResolvers<void>();
|
|
410
|
+
const release = Promise.withResolvers<void>();
|
|
411
|
+
let holderPid: number | undefined;
|
|
412
|
+
const executeRefund = runInMockCtx(undefined, async () => {
|
|
413
|
+
return await app.scope('commerce-trade').service.order.applyRefundOutcomeForTest(
|
|
414
|
+
fixture.orderId!,
|
|
415
|
+
{
|
|
416
|
+
outcome: 'succeeded',
|
|
417
|
+
idempotencyKey: 'shipment-refund-race-execution-outcome-1',
|
|
418
|
+
},
|
|
419
|
+
async stage => {
|
|
420
|
+
if (stage !== 'afterRefundOutcomeOrderState') return;
|
|
421
|
+
const result = await app.bean.database.current.connection.raw(
|
|
422
|
+
'select pg_backend_pid() as pid',
|
|
423
|
+
);
|
|
424
|
+
holderPid = result.rows[0].pid;
|
|
425
|
+
entered.resolve();
|
|
426
|
+
await release.promise;
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
const shipmentReady = Promise.withResolvers<void>();
|
|
431
|
+
const releaseShipment = Promise.withResolvers<void>();
|
|
432
|
+
let shipmentPid: number | undefined;
|
|
433
|
+
let ship: Promise<unknown> | undefined;
|
|
434
|
+
let results: PromiseSettledResult<unknown>[] | undefined;
|
|
435
|
+
try {
|
|
436
|
+
await waitForHolderStage(entered.promise, executeRefund, 'refund execution');
|
|
437
|
+
assert.notEqual(holderPid, undefined);
|
|
438
|
+
ship = runInMockCtx(undefined, async () => {
|
|
439
|
+
return await app.scope('commerce-trade').service.order.shipForTest(
|
|
440
|
+
fixture.orderId!,
|
|
441
|
+
{
|
|
442
|
+
carrier: 'Cabloy Express',
|
|
443
|
+
trackingNumber: 'CAB-RFD-EXECUTION-1',
|
|
444
|
+
},
|
|
445
|
+
async stage => {
|
|
446
|
+
if (stage !== 'beforeOrderLock') return;
|
|
447
|
+
const result = await app.bean.database.current.connection.raw(
|
|
448
|
+
'select pg_backend_pid() as pid',
|
|
449
|
+
);
|
|
450
|
+
shipmentPid = result.rows[0].pid;
|
|
451
|
+
shipmentReady.resolve();
|
|
452
|
+
await releaseShipment.promise;
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
await waitForHolderStage(shipmentReady.promise, ship, 'shipment');
|
|
457
|
+
assert.notEqual(shipmentPid, undefined);
|
|
458
|
+
releaseShipment.resolve();
|
|
459
|
+
await app.bean.executor.mockCtx(async () => {
|
|
460
|
+
await waitForPostgresWaiter(shipmentPid!, holderPid!);
|
|
461
|
+
});
|
|
462
|
+
} finally {
|
|
463
|
+
releaseShipment.resolve();
|
|
464
|
+
release.resolve();
|
|
465
|
+
results = await Promise.allSettled([executeRefund, ship].filter(Boolean));
|
|
466
|
+
}
|
|
467
|
+
const [refundResult, shipmentResult] = results!;
|
|
468
|
+
const message = JSON.stringify(results);
|
|
469
|
+
assert.equal(refundResult.status, 'fulfilled', message);
|
|
470
|
+
assert.equal(shipmentResult.status, 'rejected', message);
|
|
471
|
+
if (refundResult.status !== 'fulfilled' || shipmentResult.status !== 'rejected') return;
|
|
472
|
+
assert.deepEqual(
|
|
473
|
+
[
|
|
474
|
+
refundResult.value.orderState,
|
|
475
|
+
refundResult.value.refundState,
|
|
476
|
+
refundResult.value.refundAttemptState,
|
|
477
|
+
],
|
|
478
|
+
['refunded', 'refunded', 'succeeded'],
|
|
479
|
+
);
|
|
480
|
+
assert.equal(shipmentResult.reason?.code, 409);
|
|
481
|
+
await app.bean.executor.mockCtx(async () => {
|
|
482
|
+
await assertPersistedRefundOutcome(fixture.orderId!, {
|
|
483
|
+
orderState: 'refunded',
|
|
484
|
+
refundState: 'refunded',
|
|
485
|
+
refundAttemptState: 'succeeded',
|
|
486
|
+
orderAuditOperations: ['refund_requested', 'refund_approved', 'refunded'],
|
|
487
|
+
refundAuditStates: ['approved', 'refunded', 'requested'],
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
} finally {
|
|
491
|
+
await app.bean.executor.mockCtx(async () => {
|
|
492
|
+
await cleanup(fixture);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
});
|
|
143
496
|
});
|
|
@@ -28,12 +28,15 @@ export interface IDtoOptionsStudentSelectResItem extends IDecoratorDtoOptions {}
|
|
|
28
28
|
{ type: 'field', name: 'name' },
|
|
29
29
|
{ type: 'field', name: 'level' },
|
|
30
30
|
{ type: 'field', name: 'createdAt' },
|
|
31
|
+
{
|
|
32
|
+
type: 'block',
|
|
33
|
+
block: ZovaRender.block('basic-page:blockFilterActions'),
|
|
34
|
+
} as any,
|
|
31
35
|
],
|
|
32
36
|
},
|
|
33
37
|
],
|
|
34
38
|
},
|
|
35
39
|
}),
|
|
36
|
-
ZovaRender.block('basic-page:blockFilterActions'),
|
|
37
40
|
],
|
|
38
41
|
}),
|
|
39
42
|
ZovaRender.block('basic-page:blockToolbarBulk', {
|
|
@@ -57,16 +57,23 @@ describe('student.test.ts', () => {
|
|
|
57
57
|
assert.equal(filterBlock?.options?.formFieldLayout?.inline, true);
|
|
58
58
|
assert.deepEqual(
|
|
59
59
|
filterBlock?.options?.blocks?.map(item => item.render),
|
|
60
|
-
['basic-form:blockFormLayout'
|
|
60
|
+
['basic-form:blockFormLayout'],
|
|
61
61
|
);
|
|
62
62
|
const formLayout = filterBlock?.options?.blocks?.[0]?.options?.formLayout;
|
|
63
|
+
const filterLayoutChildren = formLayout?.children[0]?.children;
|
|
63
64
|
assert.equal(formLayout?.children[0]?.layout, 'flow');
|
|
64
65
|
assert.equal(formLayout?.children[0]?.columns, undefined);
|
|
66
|
+
assert.equal(filterLayoutChildren?.length, 4);
|
|
65
67
|
assert.deepEqual(
|
|
66
|
-
|
|
68
|
+
filterLayoutChildren?.map(item => item.type),
|
|
69
|
+
['field', 'field', 'field', 'block'],
|
|
70
|
+
);
|
|
71
|
+
assert.deepEqual(
|
|
72
|
+
filterLayoutChildren?.slice(0, 3).map(item => item.name),
|
|
67
73
|
['name', 'level', 'createdAt'],
|
|
68
74
|
);
|
|
69
|
-
assert.equal(
|
|
75
|
+
assert.equal(filterLayoutChildren?.[2]?.span, undefined);
|
|
76
|
+
assert.equal(filterLayoutChildren?.[3]?.block?.render, 'basic-page:blockFilterActions');
|
|
70
77
|
});
|
|
71
78
|
});
|
|
72
79
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zova",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.142",
|
|
4
4
|
"gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
|
|
5
5
|
"description": "A vue3 framework with ioc",
|
|
6
6
|
"keywords": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"zova-core": "^5.1.79",
|
|
49
|
-
"zova-suite-a-zova": "^5.1.
|
|
49
|
+
"zova-suite-a-zova": "^5.1.141"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"clean-package": "^2.2.0",
|
package/zova/src/suite/cabloy-basic/modules/basic-form/src/component/blockFormLayout/controller.tsx
CHANGED
|
@@ -2,7 +2,9 @@ import type { IComponentOptions } from 'zova';
|
|
|
2
2
|
import type {
|
|
3
3
|
IJsxRenderContextForm,
|
|
4
4
|
IResolvedFormLayout,
|
|
5
|
+
IResolvedFormLayoutBlock,
|
|
5
6
|
IResolvedFormLayoutField,
|
|
7
|
+
IResolvedFormLayoutLeaf,
|
|
6
8
|
IResolvedFormLayoutGroup,
|
|
7
9
|
IResolvedFormLayoutNode,
|
|
8
10
|
IResolvedFormLayoutSection,
|
|
@@ -78,6 +80,8 @@ export class ControllerBlockFormLayout extends BeanControllerBase {
|
|
|
78
80
|
switch (node.type) {
|
|
79
81
|
case 'field':
|
|
80
82
|
return this._renderField(node);
|
|
83
|
+
case 'block':
|
|
84
|
+
return this._renderBlock(node);
|
|
81
85
|
case 'group':
|
|
82
86
|
return this._renderGroup(node);
|
|
83
87
|
case 'section':
|
|
@@ -87,11 +91,28 @@ export class ControllerBlockFormLayout extends BeanControllerBase {
|
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
|
|
90
|
-
private
|
|
91
|
-
const { $$form } = this.$$renderContext;
|
|
94
|
+
private _renderLeaf(node: IResolvedFormLayoutLeaf, sectionLayout?: 'grid' | 'flow') {
|
|
92
95
|
const className =
|
|
93
96
|
sectionLayout === 'flow' ? 'min-w-0 max-w-full' : this._gridClasses('col-span', node.span);
|
|
94
|
-
return <div class={className}>{
|
|
97
|
+
return <div class={className}>{this._renderLeafContent(node)}</div>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private _renderLeafContent(node: IResolvedFormLayoutLeaf) {
|
|
101
|
+
if (node.type === 'field') return this.$$renderContext.$$form.renderField(node.name);
|
|
102
|
+
return this._renderBlockContent(node);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private _renderField(node: IResolvedFormLayoutField) {
|
|
106
|
+
return this._renderLeaf(node);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private _renderBlock(node: IResolvedFormLayoutBlock) {
|
|
110
|
+
return this._renderLeaf(node);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private _renderBlockContent(node: IResolvedFormLayoutBlock) {
|
|
114
|
+
const { $celScope, $jsx } = this.$$renderContext;
|
|
115
|
+
return $jsx.render(node.block.render!, node.block.options, $celScope, this.$$renderContext);
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
private _renderGroup(node: IResolvedFormLayoutGroup) {
|
|
@@ -114,7 +135,7 @@ export class ControllerBlockFormLayout extends BeanControllerBase {
|
|
|
114
135
|
<section>
|
|
115
136
|
{!!node.title && <h3 class="mb-1 text-lg font-semibold">{node.title}</h3>}
|
|
116
137
|
{!!node.description && <p class="mb-4 text-sm text-base-content/70">{node.description}</p>}
|
|
117
|
-
<div class={className}>{node.children.map(child => this.
|
|
138
|
+
<div class={className}>{node.children.map(child => this._renderLeaf(child, layout))}</div>
|
|
118
139
|
</section>
|
|
119
140
|
);
|
|
120
141
|
}
|
|
@@ -411,6 +411,7 @@ export class ControllerForm<
|
|
|
411
411
|
if (node.type === 'field') {
|
|
412
412
|
return this.formState.fieldMeta[node.name]?.errors?.length ? 1 : 0;
|
|
413
413
|
}
|
|
414
|
+
if (node.type === 'block') return 0;
|
|
414
415
|
return node.children.reduce((count, child) => {
|
|
415
416
|
return count + this._getFormLayoutErrorFieldCount(child);
|
|
416
417
|
}, 0);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
IFormLayout,
|
|
3
3
|
IFormLayoutField,
|
|
4
|
+
IFormLayoutLeaf,
|
|
4
5
|
IFormLayoutNode,
|
|
5
6
|
IFormLayoutTab,
|
|
6
7
|
IFormLayoutTabs,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
IFormLayoutDiagnostic,
|
|
12
13
|
IResolvedFormLayout,
|
|
13
14
|
IResolvedFormLayoutField,
|
|
15
|
+
IResolvedFormLayoutLeaf,
|
|
14
16
|
IResolvedFormLayoutGroup,
|
|
15
17
|
IResolvedFormLayoutNode,
|
|
16
18
|
IResolvedFormLayoutSection,
|
|
@@ -68,6 +70,9 @@ function resolveNode(
|
|
|
68
70
|
if (node.type === 'field') {
|
|
69
71
|
return resolveField(node, tabPath, propertyNames, fieldNames, diagnostics, fieldTabPaths);
|
|
70
72
|
}
|
|
73
|
+
if (node.type === 'block') {
|
|
74
|
+
return node;
|
|
75
|
+
}
|
|
71
76
|
if (node.type === 'tabs') {
|
|
72
77
|
return resolveTabs(
|
|
73
78
|
node,
|
|
@@ -84,9 +89,9 @@ function resolveNode(
|
|
|
84
89
|
if (node.type === 'section') {
|
|
85
90
|
const children = node.children
|
|
86
91
|
.map(item =>
|
|
87
|
-
|
|
92
|
+
resolveLeaf(item, tabPath, propertyNames, fieldNames, diagnostics, fieldTabPaths),
|
|
88
93
|
)
|
|
89
|
-
.filter(Boolean) as
|
|
94
|
+
.filter(Boolean) as IResolvedFormLayoutLeaf[];
|
|
90
95
|
return children.length ? { ...node, id, children } : undefined;
|
|
91
96
|
}
|
|
92
97
|
const children = node.children
|
|
@@ -103,11 +108,23 @@ function resolveNode(
|
|
|
103
108
|
),
|
|
104
109
|
)
|
|
105
110
|
.filter(Boolean) as Array<
|
|
106
|
-
|
|
111
|
+
IResolvedFormLayoutLeaf | IResolvedFormLayoutGroup | IResolvedFormLayoutSection
|
|
107
112
|
>;
|
|
108
113
|
return children.length ? { ...node, id, children } : undefined;
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
function resolveLeaf(
|
|
117
|
+
node: IFormLayoutLeaf,
|
|
118
|
+
tabPath: IResolvedFormLayout['fieldTabPaths'][string],
|
|
119
|
+
propertyNames: Set<string>,
|
|
120
|
+
fieldNames: Set<string>,
|
|
121
|
+
diagnostics: IFormLayoutDiagnostic[],
|
|
122
|
+
fieldTabPaths: IResolvedFormLayout['fieldTabPaths'],
|
|
123
|
+
): IResolvedFormLayoutLeaf | undefined {
|
|
124
|
+
if (node.type === 'block') return node;
|
|
125
|
+
return resolveField(node, tabPath, propertyNames, fieldNames, diagnostics, fieldTabPaths);
|
|
126
|
+
}
|
|
127
|
+
|
|
111
128
|
function resolveTabs(
|
|
112
129
|
node: IFormLayoutTabs,
|
|
113
130
|
indexPath: number[],
|
|
@@ -163,7 +180,7 @@ function resolveTab(
|
|
|
163
180
|
),
|
|
164
181
|
)
|
|
165
182
|
.filter(Boolean) as Array<
|
|
166
|
-
|
|
183
|
+
IResolvedFormLayoutLeaf | IResolvedFormLayoutGroup | IResolvedFormLayoutSection
|
|
167
184
|
>;
|
|
168
185
|
return children.length ? { ...node, id, children } : undefined;
|
|
169
186
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
IFormLayoutResponsiveColumns,
|
|
3
|
+
IResourceRenderBlockOptionsBlock,
|
|
3
4
|
TypeFormLayoutSectionLayout,
|
|
4
5
|
} from 'zova-module-a-openapi';
|
|
5
6
|
|
|
@@ -15,12 +16,20 @@ export interface IResolvedFormLayoutField {
|
|
|
15
16
|
span?: IFormLayoutResponsiveColumns;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
export interface IResolvedFormLayoutBlock {
|
|
20
|
+
type: 'block';
|
|
21
|
+
block: IResourceRenderBlockOptionsBlock;
|
|
22
|
+
span?: IFormLayoutResponsiveColumns;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type IResolvedFormLayoutLeaf = IResolvedFormLayoutField | IResolvedFormLayoutBlock;
|
|
26
|
+
|
|
18
27
|
export interface IResolvedFormLayoutGroup {
|
|
19
28
|
type: 'group';
|
|
20
29
|
id: string;
|
|
21
30
|
title?: string;
|
|
22
31
|
description?: string;
|
|
23
|
-
children: Array<
|
|
32
|
+
children: Array<IResolvedFormLayoutLeaf | IResolvedFormLayoutGroup | IResolvedFormLayoutSection>;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export interface IResolvedFormLayoutSection {
|
|
@@ -30,7 +39,7 @@ export interface IResolvedFormLayoutSection {
|
|
|
30
39
|
description?: string;
|
|
31
40
|
layout?: TypeFormLayoutSectionLayout;
|
|
32
41
|
columns?: IFormLayoutResponsiveColumns;
|
|
33
|
-
children:
|
|
42
|
+
children: IResolvedFormLayoutLeaf[];
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export interface IResolvedFormLayoutTabs {
|
|
@@ -43,7 +52,7 @@ export interface IResolvedFormLayoutTab {
|
|
|
43
52
|
type: 'tab';
|
|
44
53
|
id: string;
|
|
45
54
|
title: string;
|
|
46
|
-
children: Array<
|
|
55
|
+
children: Array<IResolvedFormLayoutLeaf | IResolvedFormLayoutGroup | IResolvedFormLayoutSection>;
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
export interface IResolvedFormLayoutTabRef {
|
|
@@ -52,7 +61,7 @@ export interface IResolvedFormLayoutTabRef {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export type IResolvedFormLayoutNode =
|
|
55
|
-
|
|
|
64
|
+
| IResolvedFormLayoutLeaf
|
|
56
65
|
| IResolvedFormLayoutGroup
|
|
57
66
|
| IResolvedFormLayoutSection
|
|
58
67
|
| IResolvedFormLayoutTabs;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { IResourceRenderBlockOptionsBlock } from './block.js';
|
|
2
|
+
|
|
1
3
|
export interface IFormLayout {
|
|
2
4
|
children: IFormLayoutNode[];
|
|
3
5
|
}
|
|
@@ -18,12 +20,20 @@ export interface IFormLayoutField {
|
|
|
18
20
|
span?: IFormLayoutResponsiveColumns;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
export interface IFormLayoutBlock {
|
|
24
|
+
type: 'block';
|
|
25
|
+
block: IResourceRenderBlockOptionsBlock;
|
|
26
|
+
span?: IFormLayoutResponsiveColumns;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type IFormLayoutLeaf = IFormLayoutField | IFormLayoutBlock;
|
|
30
|
+
|
|
21
31
|
export interface IFormLayoutGroup {
|
|
22
32
|
type: 'group';
|
|
23
33
|
id?: string;
|
|
24
34
|
title?: string;
|
|
25
35
|
description?: string;
|
|
26
|
-
children: Array<
|
|
36
|
+
children: Array<IFormLayoutLeaf | IFormLayoutGroup | IFormLayoutSection>;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
export interface IFormLayoutSection {
|
|
@@ -33,7 +43,7 @@ export interface IFormLayoutSection {
|
|
|
33
43
|
description?: string;
|
|
34
44
|
layout?: TypeFormLayoutSectionLayout;
|
|
35
45
|
columns?: IFormLayoutResponsiveColumns;
|
|
36
|
-
children:
|
|
46
|
+
children: IFormLayoutLeaf[];
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
export interface IFormLayoutTabs {
|
|
@@ -46,11 +56,11 @@ export interface IFormLayoutTab {
|
|
|
46
56
|
type: 'tab';
|
|
47
57
|
id?: string;
|
|
48
58
|
title: string;
|
|
49
|
-
children: Array<
|
|
59
|
+
children: Array<IFormLayoutLeaf | IFormLayoutGroup | IFormLayoutSection>;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
export type IFormLayoutNode =
|
|
53
|
-
|
|
|
63
|
+
| IFormLayoutLeaf
|
|
54
64
|
| IFormLayoutGroup
|
|
55
65
|
| IFormLayoutSection
|
|
56
66
|
| IFormLayoutTabs;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zova-suite-a-zova",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.141",
|
|
4
4
|
"gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
|
|
5
5
|
"description": "zova",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
"zova-module-a-boundary": "^5.1.21",
|
|
17
17
|
"zova-module-a-command": "^5.1.33",
|
|
18
18
|
"zova-module-a-fetch": "^5.1.24",
|
|
19
|
-
"zova-module-a-form": "^5.1.
|
|
19
|
+
"zova-module-a-form": "^5.1.46",
|
|
20
20
|
"zova-module-a-icon": "^5.1.26",
|
|
21
21
|
"zova-module-a-interceptor": "^5.1.29",
|
|
22
22
|
"zova-module-a-logger": "^5.1.26",
|
|
23
23
|
"zova-module-a-meta": "^5.1.21",
|
|
24
24
|
"zova-module-a-model": "^5.1.33",
|
|
25
|
-
"zova-module-a-openapi": "^5.1.
|
|
25
|
+
"zova-module-a-openapi": "^5.1.47",
|
|
26
26
|
"zova-module-a-router": "^5.1.31",
|
|
27
27
|
"zova-module-a-routerstack": "^5.1.26",
|
|
28
28
|
"zova-module-a-routertabs": "^5.1.33",
|