cabloy 5.1.120 → 5.1.122

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 (37) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/cabloy-docs/frontend/form-layout-guide.md +51 -20
  4. package/cabloy-docs/frontend/table-resource-crud-cookbook.md +31 -4
  5. package/cabloy-docs/frontend/zova-form-source-reading-map.md +7 -5
  6. package/cabloy-docs/frontend/zova-form-under-the-hood.md +5 -2
  7. package/cabloy-docs/fullstack/backend-metadata-to-frontend-table-actions.md +2 -2
  8. package/e2e/specs/cabloy-basic/basic.spec.ts +9 -0
  9. package/package.json +1 -1
  10. package/vona/packages-cli/cli/package.json +1 -1
  11. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/dto/{{resourceName}}SelectResItem.tsx_ +27 -2
  12. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/dto/{{resourceName}}SelectResItem.tsx_ +27 -2
  13. package/vona/packages-cli/cli-set-api/package.json +1 -1
  14. package/vona/pnpm-lock.yaml +39 -39
  15. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/categorySelectResItem.tsx +23 -1
  16. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productSelectResItem.tsx +23 -1
  17. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/skuSelectResItem.tsx +23 -1
  18. package/vona/src/suite/a-commerce/modules/commerce-member/src/dto/addressSelectResItem.tsx +24 -1
  19. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectResItem.tsx +23 -1
  20. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderSelectResItem.tsx +23 -1
  21. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/stockAuditSelectResItem.tsx +23 -1
  22. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/stockBalanceSelectResItem.tsx +23 -1
  23. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +6 -1
  24. package/vona/src/suite/a-commerce/modules/commerce-trade/test/shipmentRefundRace.test.ts +377 -24
  25. package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +24 -1
  26. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectResItem.tsx +4 -1
  27. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +10 -3
  28. package/zova/packages-zova/zova/package.json +2 -2
  29. package/zova/pnpm-lock.yaml +3 -3
  30. package/zova/src/suite/cabloy-basic/modules/basic-form/src/component/blockFormLayout/controller.tsx +25 -4
  31. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  32. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +1 -0
  33. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.ts +21 -4
  34. package/zova/src/suite-vendor/a-zova/modules/a-form/src/types/formLayout.ts +13 -4
  35. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  36. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/formLayout.ts +14 -4
  37. package/zova/src/suite-vendor/a-zova/package.json +3 -3
@@ -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
  });
@@ -16,7 +16,30 @@ export interface IDtoOptionsRecordSelectResItem extends IDecoratorDtoOptions {}
16
16
  blocks: [
17
17
  ZovaRender.block('basic-page:blockPage', {
18
18
  blocks: [
19
- ZovaRender.block('basic-page:blockFilter'),
19
+ ZovaRender.block('basic-page:blockFilter', {
20
+ formFieldLayout: { inline: true },
21
+ blocks: [
22
+ ZovaRender.block('basic-form:blockFormLayout', {
23
+ formLayout: {
24
+ children: [
25
+ {
26
+ type: 'section',
27
+ layout: 'flow',
28
+ children: [
29
+ { type: 'field', name: 'name' },
30
+ { type: 'field', name: 'studentId' },
31
+ { type: 'field', name: 'createdAt' },
32
+ {
33
+ type: 'block',
34
+ block: ZovaRender.block('basic-page:blockFilterActions'),
35
+ },
36
+ ],
37
+ },
38
+ ],
39
+ },
40
+ }),
41
+ ],
42
+ }),
20
43
  ZovaRender.block('basic-page:blockToolbarBulk', {
21
44
  actions: [ZovaRender.tableActionBulk('basic-table:actionCreate')],
22
45
  }),
@@ -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
+ },
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",
@@ -849,7 +849,7 @@ importers:
849
849
  specifier: ^5.1.79
850
850
  version: link:../zova-core
851
851
  zova-suite-a-zova:
852
- specifier: ^5.1.140
852
+ specifier: ^5.1.141
853
853
  version: link:../../src/suite-vendor/a-zova
854
854
  devDependencies:
855
855
  clean-package:
@@ -986,7 +986,7 @@ importers:
986
986
  specifier: ^5.1.24
987
987
  version: link:modules/a-fetch
988
988
  zova-module-a-form:
989
- specifier: ^5.1.45
989
+ specifier: ^5.1.46
990
990
  version: link:modules/a-form
991
991
  zova-module-a-icon:
992
992
  specifier: ^5.1.26
@@ -1004,7 +1004,7 @@ importers:
1004
1004
  specifier: ^5.1.33
1005
1005
  version: link:modules/a-model
1006
1006
  zova-module-a-openapi:
1007
- specifier: ^5.1.46
1007
+ specifier: ^5.1.47
1008
1008
  version: link:modules/a-openapi
1009
1009
  zova-module-a-router:
1010
1010
  specifier: ^5.1.31
@@ -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);