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.
Files changed (23) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/cabloy-docs/.vitepress/config.mjs +1 -0
  4. package/cabloy-docs/frontend/form-layout-guide.md +48 -18
  5. package/cabloy-docs/frontend/table-resource-crud-cookbook.md +6 -3
  6. package/cabloy-docs/frontend/zova-form-source-reading-map.md +7 -5
  7. package/cabloy-docs/frontend/zova-form-under-the-hood.md +5 -2
  8. package/e2e/specs/cabloy-basic/basic.spec.ts +9 -0
  9. package/package.json +1 -1
  10. package/vona/env/.env +1 -1
  11. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +6 -1
  12. package/vona/src/suite/a-commerce/modules/commerce-trade/test/shipmentRefundRace.test.ts +377 -24
  13. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectResItem.tsx +4 -1
  14. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +10 -3
  15. package/zova/packages-zova/zova/package.json +2 -2
  16. package/zova/src/suite/cabloy-basic/modules/basic-form/src/component/blockFormLayout/controller.tsx +25 -4
  17. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  18. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +1 -0
  19. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.ts +21 -4
  20. package/zova/src/suite-vendor/a-zova/modules/a-form/src/types/formLayout.ts +13 -4
  21. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  22. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/formLayout.ts +14 -4
  23. package/zova/src/suite-vendor/a-zova/package.json +3 -3
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.119
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 | Field renderer selection, validation rules, submit policy |
17
- | `basic-form:blockFormLayout` | Resolving and rendering a structural `formLayout` tree in Cabloy Basic | Page-entry or filter actions |
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 and keeps filter actions as an explicit sibling block:
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
- /* structural nodes */
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
- A nonempty `blocks` list replaces `ZForm`'s automatic body and footer. Therefore a structured filter must explicitly include `basic-page:blockFilterActions`; it owns Search and Reset and keeps the filter's existing normalization and page-query handoff. See [Table + Resource CRUD Cookbook](/frontend/table-resource-crud-cookbook#use-blocks-for-a-structural-filter-layout) for the filter ownership model.
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
- └─ field
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 | Use it for |
107
- | --------- | ---------------------------------------------------------- | ------------------------ | ----------------------------------------- |
108
- | `field` | required `name`; optional `span` | none | Place one resolved schema field |
109
- | `section` | optional `id`, `title`, `description`, `layout`, `columns` | fields only | A Grid or wrapping flow field layout |
110
- | `group` | optional `id`, `title`, `description` | fields, groups, sections | A semantic, bordered fieldset-style group |
111
- | `tabs` | optional `id` | tabs only | One tab container |
112
- | `tab` | optional `id`; required `title` | fields, groups, sections | One tab panel |
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 section is a layout boundary. It uses the Grid strategy by default; set `layout: 'flow'` for compact, left-packed fields 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.
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 those compact wrappers left-packed and wraps them when necessary. `basic-page:blockFilterActions` remains required because the custom blocks replace automatic filter body/footer content.
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 and filter actions in `basic-page:blockFilterActions`.
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, compose the existing form layout block with the filter-specific action block:
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` only places schema fields. `basic-page:blockFilterActions` owns Search/Reset placement and invokes the filter command surface supplied through the form scope, so it preserves 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.
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. `vona/src/suite/a-training/modules/training-student/test/student.test.ts`
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 diagnostics
250
- - the Basic block controller renders sections, groups, tabs, and field spans while delegating widgets to `$$form.renderField(...)`
251
- - the Student test verifies emitted metadata nesting, columns, spans, and optional IDs; it is not a browser rendering test
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 tabs
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.119",
3
+ "version": "5.1.121",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
package/vona/env/.env CHANGED
@@ -38,7 +38,7 @@ LOGGER_ROTATE_MAXFILES = 7d
38
38
  TELEMETRY_ENABLED = false
39
39
 
40
40
  # metrics
41
- METRICS_ENABLED = true
41
+ METRICS_ENABLED = false
42
42
 
43
43
  # build
44
44
 
@@ -97,7 +97,11 @@ export type PaymentOutcomeFailureStage =
97
97
 
98
98
  type PaymentOutcomeStageCallback = (stage: PaymentOutcomeFailureStage) => void | Promise<void>;
99
99
 
100
- export type ShipmentFailureStage = 'afterShipmentInsert' | 'afterOrderState' | 'afterOrderAudit';
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<IFixture> {
35
- const fixture: IFixture = { customerName: `shipment-refund-${suffix}` };
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
- return fixture;
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
- Object.assign(fixture, await createPaidOrder(randomUUID().slice(0, 12)));
190
+ await createPaidOrder(fixture, randomUUID().slice(0, 12));
87
191
  });
88
- const requestRefund = app.bean.executor.mockCtx(async () => {
89
- await app.bean.passport.signinMock(fixture.customerName as any);
90
- try {
91
- return await app.scope('commerce-trade').service.order.requestRefund(fixture.orderId!, {
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
- } finally {
96
- await app.bean.passport.signout();
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 ship = app.bean.executor.mockCtx(async () => {
100
- await app.bean.passport.signinMock();
101
- try {
102
- return await app.scope('commerce-trade').service.order.ship(fixture.orderId!, {
103
- carrier: 'Cabloy Express',
104
- trackingNumber: 'CAB-RFD-1',
105
- });
106
- } finally {
107
- await app.bean.passport.signout();
108
- }
109
- });
110
- await Promise.allSettled([requestRefund, ship]);
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', 'basic-page:blockFilterActions'],
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
- formLayout?.children[0]?.children.map(item => item.name),
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(formLayout?.children[0]?.children[2]?.span, undefined);
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.141",
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.140"
49
+ "zova-suite-a-zova": "^5.1.141"
50
50
  },
51
51
  "devDependencies": {
52
52
  "clean-package": "^2.2.0",
@@ -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 _renderField(node: IResolvedFormLayoutField, sectionLayout?: 'grid' | 'flow') {
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}>{$$form.renderField(node.name)}</div>;
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._renderField(child, layout))}</div>
138
+ <div class={className}>{node.children.map(child => this._renderLeaf(child, layout))}</div>
118
139
  </section>
119
140
  );
120
141
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-form",
3
- "version": "5.1.45",
3
+ "version": "5.1.46",
4
4
  "gitHead": "09d901d17140a80ee0764211b441cda72fd94663",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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
- resolveField(item, tabPath, propertyNames, fieldNames, diagnostics, fieldTabPaths),
92
+ resolveLeaf(item, tabPath, propertyNames, fieldNames, diagnostics, fieldTabPaths),
88
93
  )
89
- .filter(Boolean) as IResolvedFormLayoutField[];
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
- IResolvedFormLayoutField | IResolvedFormLayoutGroup | IResolvedFormLayoutSection
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
- IResolvedFormLayoutField | IResolvedFormLayoutGroup | IResolvedFormLayoutSection
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<IResolvedFormLayoutField | IResolvedFormLayoutGroup | IResolvedFormLayoutSection>;
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: IResolvedFormLayoutField[];
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<IResolvedFormLayoutField | IResolvedFormLayoutGroup | IResolvedFormLayoutSection>;
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
- | IResolvedFormLayoutField
64
+ | IResolvedFormLayoutLeaf
56
65
  | IResolvedFormLayoutGroup
57
66
  | IResolvedFormLayoutSection
58
67
  | IResolvedFormLayoutTabs;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-openapi",
3
- "version": "5.1.46",
3
+ "version": "5.1.47",
4
4
  "gitHead": "09d901d17140a80ee0764211b441cda72fd94663",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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<IFormLayoutField | IFormLayoutGroup | IFormLayoutSection>;
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: IFormLayoutField[];
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<IFormLayoutField | IFormLayoutGroup | IFormLayoutSection>;
59
+ children: Array<IFormLayoutLeaf | IFormLayoutGroup | IFormLayoutSection>;
50
60
  }
51
61
 
52
62
  export type IFormLayoutNode =
53
- | IFormLayoutField
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.140",
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.45",
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.46",
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",