cabloy 5.1.208 → 5.1.210

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 (97) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/package.json +1 -1
  4. package/repo-agent-governance/managed-assets.json +9 -9
  5. package/repo-agent-governance/policies/repository.md +1 -1
  6. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +4 -2
  7. package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +13 -1
  8. package/repo-docs/backend/unit-testing.md +1 -1
  9. package/repo-docs/frontend/form-guide.md +17 -1
  10. package/repo-docs/frontend/model-architecture.md +4 -0
  11. package/repo-docs/frontend/model-state-guide.md +23 -2
  12. package/repo-docs/frontend/resource-entry-page-deep-dive.md +119 -0
  13. package/repo-docs/frontend/scripts.md +3 -3
  14. package/repo-docs/frontend/zova-form-under-the-hood.md +5 -0
  15. package/repo-docs/fullstack/parallel-worktree-environment.md +3 -3
  16. package/repo-docs/reference/repo-scripts.md +19 -5
  17. package/repo-e2e/specs/cabloy-basic.spec.ts +1577 -0
  18. package/vona/packages-vona/vona/package.json +1 -1
  19. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  20. package/vona/src/suite/a-home/modules/home-user/src/dto/passport.ts +4 -0
  21. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +172 -0
  22. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +1 -0
  23. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  24. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  25. package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
  26. package/vona/src/suite-vendor/a-vona/modules/a-openapi/src/types/rest.ts +1 -0
  27. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  29. package/zova/packages-cli/cli/package.json +2 -2
  30. package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
  31. package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +9 -0
  32. package/zova/packages-cli/cli-set-front/package.json +1 -1
  33. package/zova/packages-zova/zova/package.json +2 -2
  34. package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +17 -17
  35. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +68 -52
  36. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +524 -343
  37. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +45 -11
  38. package/zova/src/suite/a-home/modules/home-passport/test/lib/persistenceIdentity.test.ts +229 -0
  39. package/zova/src/suite/cabloy-basic/modules/basic-input/src/component/formFieldInput/controller.tsx +2 -1
  40. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/package.json +9 -1
  41. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/.metadata/index.ts +81 -5
  42. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/bean/bean.draftCoordinator.ts +158 -0
  43. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockForm/controller.tsx +11 -3
  44. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +438 -8
  45. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/config.ts +11 -0
  46. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/en-us.ts +9 -0
  47. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/zh-cn.ts +9 -0
  48. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/lib/draftSchema.ts +649 -0
  49. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/model/draft.ts +59 -0
  50. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/monkey.ts +11 -0
  51. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/draft.ts +49 -0
  52. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftDate.test.ts +262 -0
  53. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLifecycle.test.ts +1073 -0
  54. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLoader.mjs +13 -0
  55. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftModel.test.ts +572 -0
  56. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchema.test.ts +427 -0
  57. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchemaSafety.test.ts +200 -0
  58. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  59. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/entry/controller.tsx +6 -1
  60. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  61. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  62. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +86 -1
  63. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/formField/controller.tsx +87 -13
  64. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +751 -0
  65. package/zova/src/suite-vendor/a-zova/modules/a-form/src/types/formField.ts +2 -0
  66. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefresh.test.ts +637 -0
  67. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +1098 -0
  68. package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
  69. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.persister.ts +331 -87
  70. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.query.ts +11 -6
  71. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useQuery.ts +3 -1
  72. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useState.ts +17 -1
  73. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/index.ts +1 -0
  74. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/modelPersistence.ts +204 -0
  75. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/persistenceQueue.ts +50 -0
  76. package/zova/src/suite-vendor/a-zova/modules/a-model/src/monkey.ts +5 -0
  77. package/zova/src/suite-vendor/a-zova/modules/a-model/src/service/storage.ts +47 -2
  78. package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +3 -0
  79. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelLoader.mjs +34 -0
  80. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelPersistence.test.ts +727 -0
  81. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceQueue.test.ts +137 -0
  82. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceTransition.test.ts +129 -0
  83. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  84. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/monkey.ts +7 -5
  85. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +2 -0
  86. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  87. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +14 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routeDiscard.ts +26 -0
  89. package/zova/src/suite-vendor/a-zova/modules/a-router/src/service/routerGuards.ts +6 -3
  90. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
  91. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/routeDiscard.ts +24 -0
  92. package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/routeDiscard.test.ts +105 -0
  93. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/package.json +1 -1
  94. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts +202 -30
  95. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabs.test.ts +524 -0
  96. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabsLoader.mjs +35 -0
  97. package/zova/src/suite-vendor/a-zova/package.json +6 -6
@@ -14,7 +14,7 @@ import { celEnvBase, isNil } from '@cabloy/utils';
14
14
  import { SchemaObject } from 'openapi3-ts/oas31';
15
15
  import { TableIdentity } from 'table-identity';
16
16
  import { VNode } from 'vue';
17
- import { BeanControllerBase, deepEqual } from 'zova';
17
+ import { BeanControllerBase, deepEqual, deepExtend } from 'zova';
18
18
  import { ZovaJsx } from 'zova-jsx';
19
19
  import { Controller } from 'zova-module-a-bean';
20
20
  import {
@@ -23,8 +23,19 @@ import {
23
23
  TypeFormOnSubmitData,
24
24
  } from 'zova-module-a-form';
25
25
  import { $QueriesEnsureLoaded } from 'zova-module-a-model';
26
+ import { ZButton } from 'zova-module-basic-button';
26
27
  import { ModelResource } from 'zova-module-rest-resource';
27
28
 
29
+ import type { BeanDraftCoordinator } from '../../bean/bean.draftCoordinator.js';
30
+ import type { IDraftEditor, IDraftEnvelope, IDraftIdentity } from '../../types/draft.js';
31
+
32
+ import {
33
+ createDraftSchema,
34
+ draftBaselineSignature,
35
+ projectDraftValues,
36
+ restoreDraftValues,
37
+ } from '../../lib/draftSchema.js';
38
+
28
39
  declare module 'zova-module-a-openapi' {
29
40
  export interface IResourceBlockRecord {
30
41
  'basic-pageentry:blockPageEntry'?: ControllerBlockPageEntryProps;
@@ -37,6 +48,7 @@ export interface ControllerBlockPageEntryProps extends IResourceBlockOptionsBase
37
48
  id?: TableIdentity;
38
49
  formScene?: TypeFormScene;
39
50
  pageTitleKey?: string;
51
+ draft?: boolean;
40
52
  }
41
53
 
42
54
  @Controller()
@@ -56,6 +68,24 @@ export class ControllerBlockPageEntry<TData extends {} = {}> extends BeanControl
56
68
  formProvider: IFormProvider;
57
69
  formSchema?: SchemaObject;
58
70
  formData?: TData;
71
+ editorData?: TData;
72
+ draftCandidate?: IDraftEnvelope;
73
+ draftActive = false;
74
+ draftError?: 'storage' | 'incompatible' | 'recover';
75
+ private _baseline?: TData;
76
+ private _dirty = false;
77
+ private _draftStarted = false;
78
+ private _draftDisposed = false;
79
+ private _draftSession = 0;
80
+ private _draftInputRevision = 0;
81
+ private _draftSaveRevision = 0;
82
+ private _draftRecoveryRevision = 0;
83
+ private _draftRemovalPending = false;
84
+ private _draftDiscardState?: { candidate: boolean; inputRevision: number };
85
+ private _draftCoordinator?: BeanDraftCoordinator;
86
+ private _draftIdentity?: IDraftIdentity;
87
+ private _draftEditor?: IDraftEditor;
88
+ private _offDraftDiscard?: () => void;
59
89
 
60
90
  jsxZova: ZovaJsx;
61
91
  jsxCelScope: IPageEntryScope;
@@ -89,14 +119,331 @@ export class ControllerBlockPageEntry<TData extends {} = {}> extends BeanControl
89
119
  () => this.$$modelResource.getFormApiSchemas(this.formMeta)?.sdk,
90
120
  () => this.queryData,
91
121
  );
92
- this.setPageMeta(this.formData, false);
122
+ this._setEditorBaseline(this.formData);
93
123
  this.$watch(
94
124
  () => this.formData,
95
125
  (newValue, oldValue) => {
96
- if (deepEqual(newValue, oldValue)) return;
97
- this.setPageMeta(newValue, false);
126
+ if (
127
+ deepEqual(newValue, oldValue) ||
128
+ this._dirty ||
129
+ (!newValue && this.formMeta.editMode === 'create')
130
+ ) {
131
+ return;
132
+ }
133
+ this._setEditorBaseline(newValue);
98
134
  },
99
135
  );
136
+ this.$ssr.handleDirectOrOnHydrated(() => {
137
+ // Client navigation can finish loading the edit schema after the form mounts.
138
+ this.$watch(
139
+ () => this._draftEnabled && !!this.$pageRoute,
140
+ ready => {
141
+ if (ready) void this._startDraft();
142
+ },
143
+ { immediate: true },
144
+ );
145
+ });
146
+ }
147
+
148
+ protected __dispose__() {
149
+ this._draftDisposed = true;
150
+ ++this._draftSession;
151
+ this._offDraftDiscard?.();
152
+ }
153
+
154
+ private _setEditorBaseline(data: TData | undefined) {
155
+ this._baseline = data ? deepExtend({}, data) : undefined;
156
+ this.editorData = this._baseline;
157
+ this.setPageMeta(data, false);
158
+ }
159
+
160
+ formReady(form: BeanControllerFormBase) {
161
+ this.formRef = form;
162
+ this.$ssr.handleDirectOrOnHydrated(() => {
163
+ void this._startDraft();
164
+ if (form && this._draftEditor && this._dirty && !this.draftCandidate) {
165
+ void this._saveDraft(form.formState.values as TData);
166
+ }
167
+ });
168
+ }
169
+
170
+ formChanged(data: TData) {
171
+ ++this._draftInputRevision;
172
+ ++this._draftRecoveryRevision;
173
+ this._dirty = true;
174
+ this.draftCandidate = undefined;
175
+ if (this.draftError !== 'storage') this.draftError = undefined;
176
+ this.setPageMeta(data, true);
177
+ void this._saveDraft(data);
178
+ }
179
+
180
+ formSchemaRefreshed(data: TData) {
181
+ if (!this._dirty) return;
182
+ this.setPageMeta(data, true);
183
+ if (!this.draftCandidate) void this._saveDraft(data);
184
+ }
185
+
186
+ private _draftCurrent(session: number, editor: IDraftEditor) {
187
+ return (
188
+ !this._draftDisposed &&
189
+ session === this._draftSession &&
190
+ editor === this._draftEditor &&
191
+ !!this._draftCoordinator?.current(editor)
192
+ );
193
+ }
194
+
195
+ async retryDraftStorage() {
196
+ if (this._draftRemovalPending) {
197
+ if (this._draftDiscardState) {
198
+ await this.discardDraft();
199
+ } else if (await this._removeDraft()) {
200
+ if (this._dirty && this.formRef) {
201
+ await this._saveDraft(this.formRef.formState.values as TData);
202
+ }
203
+ }
204
+ } else if (!this._draftEditor) {
205
+ this._draftStarted = false;
206
+ await this._startDraft();
207
+ } else if (this._dirty && !this.draftCandidate && this.formRef) {
208
+ await this._saveDraft(this.formRef.formState.values as TData);
209
+ } else {
210
+ // A failed initial read needs a fresh load, not an empty replacement.
211
+ this._draftEditor = undefined;
212
+ this._draftStarted = false;
213
+ await this._startDraft();
214
+ }
215
+ }
216
+
217
+ private _getDraftSchema() {
218
+ if (!this.formSchema) return;
219
+ return createDraftSchema(this.formSchema, {
220
+ scene: this.schemaScene,
221
+ loadProperties: (schema, scene) => this.$sdk.loadSchemaProperties(schema, scene),
222
+ resolveSchema: reference => this.$sdk.getSchema(reference).data,
223
+ });
224
+ }
225
+
226
+ private get _draftEnabled() {
227
+ const config = this.scope.config.draft;
228
+ return (
229
+ this.formMeta.formMode === 'edit' &&
230
+ config.enabled &&
231
+ this.$props.draft !== false &&
232
+ config.resources[this.resource!] !== false &&
233
+ this._getDraftSchema()?.enabled
234
+ );
235
+ }
236
+
237
+ private async _startDraft() {
238
+ if (
239
+ !process.env.CLIENT ||
240
+ this._draftDisposed ||
241
+ this._draftStarted ||
242
+ !this._draftEnabled ||
243
+ !this.$pageRoute
244
+ ) {
245
+ return;
246
+ }
247
+ this._draftStarted = true;
248
+ const session = ++this._draftSession;
249
+ try {
250
+ const coordinator = await this.app.bean._getBean(
251
+ 'basic-pageentry.bean.draftCoordinator',
252
+ true,
253
+ );
254
+ const identity = coordinator.identity(
255
+ this.$router,
256
+ this.$pageRoute.fullPath,
257
+ this.resource,
258
+ this.formMeta.editMode === 'create' ? 'create' : 'edit',
259
+ this.entryId,
260
+ );
261
+ if (!identity || this._draftDisposed || session !== this._draftSession) return;
262
+ this._draftCoordinator = coordinator;
263
+ this._draftIdentity = identity;
264
+ const editor = await coordinator.open(identity);
265
+ if (!editor || this._draftDisposed || session !== this._draftSession) return;
266
+ this._draftEditor = editor;
267
+ this._offDraftDiscard?.();
268
+ this._offDraftDiscard = this.app.meta.event.on('a-router:routeDiscard', (event, next) => {
269
+ if (event.items.some(item => event.router.resolve(item.fullPath).path === identity.path)) {
270
+ ++this._draftSession;
271
+ this._draftEditor = undefined;
272
+ this.draftCandidate = undefined;
273
+ this.draftActive = false;
274
+ this.draftError = undefined;
275
+ }
276
+ return next();
277
+ });
278
+ this.draftError = editor.owner.failed ? 'storage' : undefined;
279
+ // Input that arrived during discovery owns the draft, not the late snapshot.
280
+ if (this._draftInputRevision > 0) {
281
+ if (this.formRef) await this._saveDraft(this.formRef.formState.values as TData);
282
+ return;
283
+ }
284
+ const snapshot = editor.owner.snapshot;
285
+ if (snapshot) {
286
+ this.draftCandidate = snapshot;
287
+ if (
288
+ snapshot.version !== 1 ||
289
+ !Number.isFinite(snapshot.savedAt) ||
290
+ snapshot.savedAt + this.scope.config.draft.maxAge <= Date.now()
291
+ ) {
292
+ if (await this._removeDraft()) {
293
+ if (this._dirty && this.formRef) {
294
+ await this._saveDraft(this.formRef.formState.values as TData);
295
+ }
296
+ }
297
+ } else if (
298
+ !editor.owner.failed &&
299
+ snapshot.schemaSignature !== this._getDraftSchema()?.signature
300
+ ) {
301
+ this.draftError = 'incompatible';
302
+ }
303
+ }
304
+ } catch {
305
+ if (!this._draftDisposed && session === this._draftSession) {
306
+ this.draftError = 'storage';
307
+ this._draftStarted = false;
308
+ }
309
+ }
310
+ }
311
+
312
+ private async _saveDraft(data: TData) {
313
+ const revision = ++this._draftSaveRevision;
314
+ const session = this._draftSession;
315
+ const editor = this._draftEditor;
316
+ const identity = this._draftIdentity;
317
+ const coordinator = this._draftCoordinator;
318
+ const schema = this._getDraftSchema();
319
+ if (
320
+ !editor ||
321
+ !identity ||
322
+ !coordinator ||
323
+ !schema?.enabled ||
324
+ !this._draftEnabled ||
325
+ !this._draftCurrent(session, editor)
326
+ ) {
327
+ return;
328
+ }
329
+ try {
330
+ const envelope: IDraftEnvelope = {
331
+ version: 1,
332
+ savedAt: Date.now(),
333
+ schemaSignature: schema.signature,
334
+ baselineSignature:
335
+ identity.mode === 'edit' ? draftBaselineSignature(schema, this._baseline) : undefined,
336
+ projection: projectDraftValues(schema, data, this._baseline),
337
+ };
338
+ await coordinator.save(editor, identity, envelope);
339
+ if (!this._draftCurrent(session, editor) || revision !== this._draftSaveRevision) return;
340
+ this.draftError = editor.owner.failed ? 'storage' : undefined;
341
+ this.draftActive = !editor.owner.failed;
342
+ } catch {
343
+ if (this._draftCurrent(session, editor) && revision === this._draftSaveRevision) {
344
+ this.draftError = 'storage';
345
+ }
346
+ }
347
+ }
348
+
349
+ async recoverDraft() {
350
+ const candidate = this.draftCandidate;
351
+ const editor = this._draftEditor;
352
+ const session = this._draftSession;
353
+ const inputRevision = this._draftInputRevision;
354
+ const recoveryRevision = ++this._draftRecoveryRevision;
355
+ if (!candidate || !editor || !this.formRef || !this._draftEnabled) return;
356
+ const current = () =>
357
+ this._draftCurrent(session, editor) &&
358
+ this.draftCandidate === candidate &&
359
+ inputRevision === this._draftInputRevision &&
360
+ recoveryRevision === this._draftRecoveryRevision;
361
+ if (!current()) return;
362
+ try {
363
+ if (
364
+ this._dirty &&
365
+ !(await this.$performCommand('basic-commands:confirm', {
366
+ text: this.scope.locale.DraftReplaceConfirm(),
367
+ }))
368
+ ) {
369
+ return;
370
+ }
371
+ if (!current()) return;
372
+ let baseline = this._baseline;
373
+ if (this._draftIdentity?.mode === 'edit') {
374
+ const result = await this.queryData!.refetch({ throwOnError: true });
375
+ baseline = result.data as TData | undefined;
376
+ }
377
+ if (!current()) return;
378
+ const schema = this._getDraftSchema();
379
+ if (
380
+ !baseline ||
381
+ !schema?.enabled ||
382
+ candidate.schemaSignature !== schema.signature ||
383
+ candidate.savedAt + this.scope.config.draft.maxAge <= Date.now() ||
384
+ (this._draftIdentity?.mode === 'edit' &&
385
+ candidate.baselineSignature !== draftBaselineSignature(schema, baseline))
386
+ ) {
387
+ this.draftError = 'incompatible';
388
+ return;
389
+ }
390
+ const values = restoreDraftValues(schema, candidate.projection, baseline);
391
+ if (!values) {
392
+ this.draftError = 'incompatible';
393
+ return;
394
+ }
395
+ this._baseline = deepExtend({}, baseline);
396
+ this.formRef.reset(values, { keepDefaultValues: true });
397
+ this.draftCandidate = undefined;
398
+ this.draftError = undefined;
399
+ this.draftActive = true;
400
+ this._dirty = true;
401
+ this.setPageMeta(values, true);
402
+ } catch (error: any) {
403
+ if (!current()) return;
404
+ this.draftError = [403, 404].includes(error?.code ?? error?.response?.status)
405
+ ? 'incompatible'
406
+ : 'recover';
407
+ }
408
+ }
409
+
410
+ async discardDraft() {
411
+ const state = this._draftDiscardState ?? {
412
+ candidate: !!this.draftCandidate,
413
+ inputRevision: this._draftInputRevision,
414
+ };
415
+ this._draftDiscardState = state;
416
+ if (!(await this._removeDraft())) return;
417
+ this._draftDiscardState = undefined;
418
+ if (!state.candidate && state.inputRevision === this._draftInputRevision && this._baseline) {
419
+ this.formRef?.reset(this._baseline);
420
+ this._dirty = false;
421
+ this.setPageMeta(this._baseline, false);
422
+ } else if (this._dirty && this.formRef) {
423
+ await this._saveDraft(this.formRef.formState.values as TData);
424
+ }
425
+ }
426
+
427
+ private async _removeDraft() {
428
+ if (!this._draftIdentity || !this._draftCoordinator) return true;
429
+ const session = ++this._draftSession;
430
+ this._draftRemovalPending = true;
431
+ this._draftEditor = undefined;
432
+ try {
433
+ await this._draftCoordinator.remove(this._draftIdentity);
434
+ if (this._draftDisposed || session !== this._draftSession) return false;
435
+ const editor = await this._draftCoordinator.open(this._draftIdentity);
436
+ if (!editor || this._draftDisposed || session !== this._draftSession) return false;
437
+ this._draftEditor = editor;
438
+ this._draftRemovalPending = false;
439
+ this.draftCandidate = undefined;
440
+ this.draftActive = false;
441
+ this.draftError = undefined;
442
+ return true;
443
+ } catch {
444
+ if (!this._draftDisposed && session === this._draftSession) this.draftError = 'storage';
445
+ return false;
446
+ }
100
447
  }
101
448
 
102
449
  get resource() {
@@ -165,7 +512,10 @@ export class ControllerBlockPageEntry<TData extends {} = {}> extends BeanControl
165
512
 
166
513
  async submitData(data: TypeFormOnSubmitData<TData>) {
167
514
  const mutationSubmit = this.$$modelResource.getFormMutationSubmit(this.formMeta, this.entryId);
168
- this.entryIdCreated = (await mutationSubmit?.mutateAsync(data.value as any)) as TableIdentity;
515
+ if (!mutationSubmit) throw new Error('Resource form has no submit mutation');
516
+ this.entryIdCreated = (await mutationSubmit.mutateAsync(data.value as any)) as TableIdentity;
517
+ this._dirty = false;
518
+ await this._removeDraft();
169
519
  this.setPageMeta(data.value, false);
170
520
  }
171
521
 
@@ -176,10 +526,90 @@ export class ControllerBlockPageEntry<TData extends {} = {}> extends BeanControl
176
526
  }
177
527
 
178
528
  protected render() {
179
- if (!this.formData) {
180
- return <div>{this.scope.locale.EntryNotExist()}</div>;
529
+ const data = this.formMeta.editMode === 'create' ? this.editorData : this.formData;
530
+ if (!data) {
531
+ return (
532
+ <div>
533
+ {this._renderDraft()}
534
+ {this._renderDraftStorage()}
535
+ <div>{this.scope.locale.EntryNotExist()}</div>
536
+ </div>
537
+ );
181
538
  }
182
- return <div class={this.$props.class}>{this._renderFormWrapper()}</div>;
539
+ return (
540
+ <div class={this.$props.class}>
541
+ {this._renderDraft()}
542
+ {this._renderDraftStorage()}
543
+ {this._renderFormWrapper()}
544
+ </div>
545
+ );
546
+ }
547
+
548
+ private get _draftVisible() {
549
+ return (
550
+ process.env.CLIENT &&
551
+ !this._draftDisposed &&
552
+ !this.$ssr.isRuntimeSsrPreHydration &&
553
+ this._draftEnabled &&
554
+ this.$passport.isAuthenticated &&
555
+ !!this._draftIdentity &&
556
+ this._draftCoordinator?.isCurrent(this._draftIdentity)
557
+ );
558
+ }
559
+
560
+ private _renderDraft() {
561
+ if (!this._draftVisible || !this.draftCandidate) return;
562
+ const locale = this.scope.locale;
563
+ const error =
564
+ this.draftError === 'incompatible'
565
+ ? locale.DraftIncompatible()
566
+ : this.draftError === 'recover'
567
+ ? locale.DraftRecoverFailed()
568
+ : undefined;
569
+ return (
570
+ <div class="alert mb-3" role="status" data-testid="resource-draft">
571
+ <div>
572
+ <div>{error ?? locale.DraftAvailable()}</div>
573
+ {Number.isFinite(this.draftCandidate.savedAt) && (
574
+ <time datetime={new Date(this.draftCandidate.savedAt).toISOString()}>
575
+ {new Date(this.draftCandidate.savedAt).toLocaleString(this.app.meta.locale.current)}
576
+ </time>
577
+ )}
578
+ </div>
579
+ {this.formData && this.formRef && (
580
+ <ZButton
581
+ type="button"
582
+ class="btn btn-sm btn-primary"
583
+ onPerform={() => this.recoverDraft()}
584
+ >
585
+ {locale.DraftRecover()}
586
+ </ZButton>
587
+ )}
588
+ <ZButton type="button" class="btn btn-sm" onPerform={() => this.discardDraft()}>
589
+ {locale.DraftDiscard()}
590
+ </ZButton>
591
+ </div>
592
+ );
593
+ }
594
+
595
+ private _renderDraftStorage() {
596
+ if (!this._draftVisible || this.draftError !== 'storage') return;
597
+ return (
598
+ <div
599
+ class="mb-3 flex items-center gap-2 text-sm"
600
+ role="status"
601
+ data-testid="resource-draft-storage"
602
+ >
603
+ <span>{this.scope.locale.DraftStorageFailed()}</span>
604
+ <ZButton
605
+ type="button"
606
+ class="btn btn-ghost btn-xs"
607
+ onPerform={() => this.retryDraftStorage()}
608
+ >
609
+ {this.scope.locale.DraftRetry()}
610
+ </ZButton>
611
+ </div>
612
+ );
183
613
  }
184
614
 
185
615
  private _renderFormWrapper() {
@@ -0,0 +1,11 @@
1
+ import type { ZovaSys } from 'zova';
2
+
3
+ export const config = (_sys: ZovaSys) => {
4
+ return {
5
+ draft: {
6
+ enabled: true,
7
+ maxAge: 24 * 60 * 60 * 1000,
8
+ resources: {} as Record<string, boolean>,
9
+ },
10
+ };
11
+ };
@@ -1,3 +1,12 @@
1
1
  export default {
2
2
  EntryNotExist: 'The entry does not exist',
3
+ DraftAvailable: 'Unsaved draft available',
4
+ DraftSaved: 'Draft saved locally',
5
+ DraftRecover: 'Recover draft',
6
+ DraftDiscard: 'Discard draft',
7
+ DraftRetry: 'Retry draft storage',
8
+ DraftStorageFailed: 'Draft storage is unavailable. Your form can still be submitted normally.',
9
+ DraftIncompatible: 'This draft cannot be recovered because the form or record has changed.',
10
+ DraftRecoverFailed: 'The record could not be checked. Try recovery again.',
11
+ DraftReplaceConfirm: 'Recovering this draft will replace your current unsaved input. Continue?',
3
12
  };
@@ -1,3 +1,12 @@
1
1
  export default {
2
2
  EntryNotExist: '该条目不存在',
3
+ DraftAvailable: '发现未提交的草稿',
4
+ DraftSaved: '草稿已保存到本地',
5
+ DraftRecover: '恢复草稿',
6
+ DraftDiscard: '丢弃草稿',
7
+ DraftRetry: '重试草稿存储',
8
+ DraftStorageFailed: '草稿存储不可用,仍可正常编辑和提交表单。',
9
+ DraftIncompatible: '表单或记录已变化,无法恢复此草稿。',
10
+ DraftRecoverFailed: '暂时无法检查记录,请重试恢复。',
11
+ DraftReplaceConfirm: '恢复草稿将替换当前未提交的输入,是否继续?',
3
12
  };