cabloy 5.1.179 → 5.1.180
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cabloy-version +1 -1
- package/.claude/scheduled_tasks.lock +1 -1
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/repo-docs/.vitepress/config.mjs +1 -0
- package/repo-docs/backend/controller-aop-guide.md +1 -1
- package/repo-docs/backend/demonstration-mode-guide.md +130 -0
- package/repo-docs/frontend/page-meta-guide.md +91 -13
- package/repo-docs/frontend/router-view-hosts-guide.md +37 -0
- package/repo-docs/fullstack/framework-performance.md +3 -3
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +7 -7
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/test/{{resourceName}}.test.ts_ +93 -39
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/3-en-us.ts +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/4-zh-cn.ts +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +1862 -2223
- package/vona/src/suite/a-demo/modules/demo-demonstration/package.json +55 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/.metadata/index.ts +68 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/.metadata/locales.ts +18 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/.metadata/this.ts +2 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/bean/guard.demonstration.ts +68 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/config/errors.ts +6 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/config/locale/en-us.ts +3 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/config/locale/zh-cn.ts +3 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/index.ts +3 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/types/index.ts +8 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/test/guardDemonstration.test.ts +121 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/tsconfig.build.json +11 -0
- package/vona/src/suite/a-demo/modules/demo-demonstration/tsconfig.json +7 -0
- package/vona/src/suite/a-demo/package.json +2 -1
- package/vona/src/suite/a-demo/tsconfig.json +3 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +9 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +9 -0
- package/vona/src/suite/a-training/modules/training-student/src/controller/student.ts +19 -17
- package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +47 -27
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/bean.rbacScope.ts +13 -7
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/test/rbacScopeCurrent.test.ts +28 -0
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +1 -1
- package/zova/packages-zova/zova-core/src/bean/type.ts +1 -0
- package/zova/packages-zova/zova-core/src/composables/useController.ts +1 -1
- package/zova/src/suite/a-home/modules/home-indexadmin/src/page/dashboard/controller.tsx +26 -7
- package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/const.ts +1 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/monkey.ts +39 -2
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/pageHost.ts +9 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/router.ts +2 -0
- package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/pageHost.test.ts +53 -0
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +3 -3
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.180
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sessionId":"
|
|
1
|
+
{"sessionId":"f94cb5dc-de70-445c-8874-218b19d56077","pid":47778,"procStart":"Sun Sep 20 12:39:48 2026","acquiredAt":1789961541374}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.1.180
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add support for `$pageHost?.active`.
|
|
8
|
+
- Prepare CRUD resources for RBAC integration.
|
|
9
|
+
- Add demonstration capabilities.
|
|
10
|
+
- Update framework functionality.
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- Fix the RBAC adapter.
|
|
15
|
+
|
|
16
|
+
### Improvements
|
|
17
|
+
|
|
18
|
+
- Refactor the demo demonstration.
|
|
19
|
+
- Update framework performance documentation.
|
|
20
|
+
- Improve page metadata documentation.
|
|
21
|
+
|
|
3
22
|
## 5.1.179
|
|
4
23
|
|
|
5
24
|
### Improvements
|
package/package.json
CHANGED
|
@@ -308,6 +308,7 @@ export default defineConfig({
|
|
|
308
308
|
{ text: 'Auth Guide', link: '/backend/auth-guide' },
|
|
309
309
|
{ text: 'Captcha Guide', link: '/backend/captcha-guide' },
|
|
310
310
|
{ text: 'Rate Limit Guide', link: '/backend/rate-limit-guide' },
|
|
311
|
+
{ text: 'Demonstration Mode Guide', link: '/backend/demonstration-mode-guide' },
|
|
311
312
|
{ text: 'User Access Guide', link: '/backend/user-access-guide' },
|
|
312
313
|
{ text: 'JWT Guide', link: '/backend/jwt-guide' },
|
|
313
314
|
{ text: 'Validation Guide', link: '/backend/validation-guide' },
|
|
@@ -81,7 +81,7 @@ The `@Core.gate(...)` shorthand still maps to `@Aspect.middlewareGlobal('a-core:
|
|
|
81
81
|
|
|
82
82
|
## Guard
|
|
83
83
|
|
|
84
|
-
Guards are used for access control and execution preconditions.
|
|
84
|
+
Guards are used for access control and execution preconditions. For a concrete global Guard that controls write admission in a shared demo deployment, see [Demonstration Mode Guide](/backend/demonstration-mode-guide).
|
|
85
85
|
|
|
86
86
|
Typical jobs include:
|
|
87
87
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Demonstration Mode Guide
|
|
2
|
+
|
|
3
|
+
<Badge type="tip" text="Common" />
|
|
4
|
+
|
|
5
|
+
Demonstration Mode is an application-wide write-admission policy for shared demonstration deployments. It is provided by the first-party `a-demo` suite and uses a global Vona Guard to prevent ordinary visitors from changing demo data.
|
|
6
|
+
|
|
7
|
+
It is disabled by default. When enabled, it protects selected HTTP write methods while leaving the rest of the application's authentication and authorization policies in force.
|
|
8
|
+
|
|
9
|
+
## What Demonstration Mode does—and does not do
|
|
10
|
+
|
|
11
|
+
Demonstration Mode is a safety rail for demo data. It is **not** a replacement for:
|
|
12
|
+
|
|
13
|
+
- Passport authentication
|
|
14
|
+
- RBAC or domain-specific authorization
|
|
15
|
+
- validation and business rules
|
|
16
|
+
- WAF, reverse-proxy, or network controls
|
|
17
|
+
- database and other data-access safeguards
|
|
18
|
+
|
|
19
|
+
Approval by this Guard only lets the request continue to the normal request pipeline; it does not grant the caller any additional permission.
|
|
20
|
+
|
|
21
|
+
The policy applies to HTTP requests only. It does not protect side-effecting `GET` handlers, background jobs, scheduled work, queues, CLI execution, or other non-HTTP code. Model state-changing HTTP APIs with the protected write methods and retain their ordinary authorization rules.
|
|
22
|
+
|
|
23
|
+
## Enable Demonstration Mode
|
|
24
|
+
|
|
25
|
+
Configure the backend environment and restart Vona:
|
|
26
|
+
|
|
27
|
+
```dotenv
|
|
28
|
+
DEMONSTRATION_ENABLED=true
|
|
29
|
+
DEMONSTRATION_USERNAME_WHITELIST=admin,demo-editor
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`DEMONSTRATION_ENABLED` enables the Guard only when its value is the exact lowercase string `true`. An unset value, an empty value, `TRUE`, `True`, and `1` all leave Demonstration Mode disabled.
|
|
33
|
+
|
|
34
|
+
Use [Runtime and Flavors](/backend/runtime-and-flavors#env-file-resolution-and-precedence) to select the active environment files and understand precedence. In a local backend setup, an uncommitted `vona/env/.env.local` is a typical place for local overrides. Deployment-injected environment values may also take precedence. For the broader configuration model, see the [Config Guide](/backend/config-guide).
|
|
35
|
+
|
|
36
|
+
Environment values are evaluated while the backend initializes. Restart Vona after changing either Demonstration Mode variable.
|
|
37
|
+
|
|
38
|
+
## Configure exempt writers
|
|
39
|
+
|
|
40
|
+
`DEMONSTRATION_USERNAME_WHITELIST` is a comma-separated list of usernames that may perform protected writes:
|
|
41
|
+
|
|
42
|
+
```dotenv
|
|
43
|
+
DEMONSTRATION_USERNAME_WHITELIST=admin, demo-editor, content-manager
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The list is normalized as follows:
|
|
47
|
+
|
|
48
|
+
- surrounding whitespace is removed from each entry
|
|
49
|
+
- empty entries are discarded
|
|
50
|
+
- repeated entries are deduplicated
|
|
51
|
+
- usernames match entries exactly and case-sensitively
|
|
52
|
+
|
|
53
|
+
Whitelist membership is necessary but not sufficient. A protected write is admitted only when the current user is all of the following:
|
|
54
|
+
|
|
55
|
+
- authenticated
|
|
56
|
+
- account-active
|
|
57
|
+
- non-anonymous
|
|
58
|
+
- present in the username whitelist
|
|
59
|
+
|
|
60
|
+
If the whitelist is absent or empty while Demonstration Mode is enabled, no user qualifies for protected writes.
|
|
61
|
+
|
|
62
|
+
## Request behavior
|
|
63
|
+
|
|
64
|
+
The Guard protects these HTTP methods:
|
|
65
|
+
|
|
66
|
+
- `POST`
|
|
67
|
+
- `PATCH`
|
|
68
|
+
- `DELETE`
|
|
69
|
+
- `PUT`
|
|
70
|
+
|
|
71
|
+
Other methods pass through this Guard unchanged. A protected request is allowed only for an eligible whitelisted user. All other protected requests are rejected with:
|
|
72
|
+
|
|
73
|
+
| Field | Value |
|
|
74
|
+
| ---------------------- | ------------------------------------------------- |
|
|
75
|
+
| HTTP status | `403 Forbidden` |
|
|
76
|
+
| Application error code | `demo-demonstration:1001` |
|
|
77
|
+
| Meaning | The operation is forbidden in demonstration mode. |
|
|
78
|
+
|
|
79
|
+
The Guard depends on Passport-resolved account state, so it makes this decision only after the current account information is available. Passing the Demonstration Mode check does not bypass later Passport, RBAC, or business-policy checks.
|
|
80
|
+
|
|
81
|
+
## Exempt authentication routes
|
|
82
|
+
|
|
83
|
+
Demonstration Mode deliberately does not apply to the standard authentication and OAuth routes below. This keeps sign-in, sign-out, registration, account association and migration, OAuth callbacks, and token flows available while the write policy is active.
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
/home/user/passport/logout
|
|
87
|
+
/home/user/passport/register
|
|
88
|
+
/home/user/passport/login
|
|
89
|
+
/home/user/passport/login/:module/:providerName/:clientName?
|
|
90
|
+
/home/user/passport/associate/:module/:providerName/:clientName?
|
|
91
|
+
/home/user/passport/migrate/:module/:providerName/:clientName?
|
|
92
|
+
/home/user/passport/refreshAuthToken
|
|
93
|
+
/home/user/passport/createPassportJwtFromOauthCode
|
|
94
|
+
/home/user/passport/createTempAuthToken
|
|
95
|
+
/auth/passport/callback
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
These are route templates used by the built-in authentication flows. They are not a general mechanism for bypassing Demonstration Mode on arbitrary URLs.
|
|
99
|
+
|
|
100
|
+
## Deploy with the `a-demo` suite
|
|
101
|
+
|
|
102
|
+
The `demo-demonstration` module is included in the current project's `a-demo` suite; no separate package installation is required.
|
|
103
|
+
|
|
104
|
+
If an operator intentionally excludes the whole suite:
|
|
105
|
+
|
|
106
|
+
```dotenv
|
|
107
|
+
PROJECT_DISABLED_SUITES=a-demo
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
then `demo-demonstration` and its Guard are not loaded, regardless of `DEMONSTRATION_ENABLED` or the whitelist. This is expected when a deployment does not include demo capabilities.
|
|
111
|
+
|
|
112
|
+
For ordinary policy changes, use `DEMONSTRATION_ENABLED` rather than disabling the suite. Reserve `PROJECT_DISABLED_SUITES=a-demo` for deployments that intentionally exclude all `a-demo` functionality.
|
|
113
|
+
|
|
114
|
+
## Troubleshooting
|
|
115
|
+
|
|
116
|
+
| Symptom | Check |
|
|
117
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
118
|
+
| The policy appears inactive | Confirm `DEMONSTRATION_ENABLED=true` uses exact lowercase spelling, then restart Vona and check active environment-file and deployment-variable precedence. |
|
|
119
|
+
| An intended demo editor receives `403` | Confirm that the user is authenticated, active, non-anonymous, and that their username exactly matches a whitelist entry, including case. |
|
|
120
|
+
| A request is unexpectedly allowed | Confirm whether it uses a method other than `POST`, `PATCH`, `DELETE`, or `PUT`, or whether it belongs to an exempt built-in authentication route. |
|
|
121
|
+
| The policy never loads | Confirm that `a-demo` is not listed in `PROJECT_DISABLED_SUITES`. |
|
|
122
|
+
| A non-HTTP mutation still runs | Demonstration Mode only guards applicable HTTP requests; add the appropriate authorization and execution controls to the relevant job, queue, CLI, or service path. |
|
|
123
|
+
|
|
124
|
+
## Related guides
|
|
125
|
+
|
|
126
|
+
- [Runtime and Flavors](/backend/runtime-and-flavors#env-file-resolution-and-precedence)
|
|
127
|
+
- [Config Guide](/backend/config-guide)
|
|
128
|
+
- [Controller AOP Guide](/backend/controller-aop-guide#guard)
|
|
129
|
+
- [Auth Guide](/backend/auth-guide)
|
|
130
|
+
- [User Access Guide](/backend/user-access-guide)
|
|
@@ -38,8 +38,8 @@ The shortest accurate model is:
|
|
|
38
38
|
2. page code calls `$router.setPageMeta(this.$pageRoute, pageMeta)` when task-level presentation should change
|
|
39
39
|
3. the shared router bean forwards that update to registered router-view hosts
|
|
40
40
|
4. `routerViewTabs` delegates the update to `ModelTabs`
|
|
41
|
-
5. `ModelTabs` stores the metadata on the
|
|
42
|
-
6. the
|
|
41
|
+
5. `ModelTabs` stores the metadata on the level-2 work item matched by the route's `fullPath`
|
|
42
|
+
6. the relevant layout reads that metadata to render task-level title and icon state
|
|
43
43
|
|
|
44
44
|
That is why page metadata is not the same thing as route metadata.
|
|
45
45
|
|
|
@@ -57,13 +57,14 @@ The highest-value current Basic source path is:
|
|
|
57
57
|
5. `zova/src/suite-vendor/a-zova/modules/a-routertabs/src/component/routerViewTabs/controller.tsx` overrides `setPageMeta(...)` and delegates to `ModelTabs`
|
|
58
58
|
6. `zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts` stores and merges `pageMeta` on the routed work item
|
|
59
59
|
7. `zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx` shows the clearest current authoring path
|
|
60
|
-
8. `zova/src/suite/a-home/modules/home-
|
|
60
|
+
8. `zova/src/suite/a-home/modules/home-indexadmin/src/page/dashboard/controller.tsx` shows a current custom-render page-meta call site
|
|
61
|
+
9. `zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx` shows the clearest current visible shell consumer
|
|
61
62
|
|
|
62
63
|
A compact interpretation is:
|
|
63
64
|
|
|
64
65
|
- page code emits page meta through `$router.setPageMeta(...)`
|
|
65
|
-
- the router forwards it to
|
|
66
|
-
- the tabs host stores it on the
|
|
66
|
+
- the router forwards it to all registered routed hosts
|
|
67
|
+
- each host may handle the update or ignore it; the tabs host stores it on the work item matched by the route
|
|
67
68
|
- the shell renders title, dirty, and form-scene signals from that stored metadata
|
|
68
69
|
|
|
69
70
|
## The public page-meta surface
|
|
@@ -81,9 +82,39 @@ export interface IPageMeta {
|
|
|
81
82
|
pageTitle?: string;
|
|
82
83
|
pageDirty?: boolean;
|
|
83
84
|
formMeta?: IFormMeta;
|
|
85
|
+
onCustomRender?: (tabItem: IRouteViewRouteItem) => VNodeChild;
|
|
86
|
+
onCustomRenderIsolate?: (tabCurrent: IRouteViewTabCurrent) => VNodeChild;
|
|
84
87
|
}
|
|
85
88
|
```
|
|
86
89
|
|
|
90
|
+
The two render callbacks are optional shell-rendering hooks. They are useful when a page needs to replace the usual text-only task label with a small reactive presentation, such as a status badge or clock. They are not a replacement for the page's main `render()` output.
|
|
91
|
+
|
|
92
|
+
### `onCustomRender`
|
|
93
|
+
|
|
94
|
+
`onCustomRender` customizes the content of a non-anchor level-2 tab item. The callback receives the corresponding `IRouteViewRouteItem` and returns a Vue `VNodeChild`:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
this.$router.setPageMeta(this.$pageRoute, {
|
|
98
|
+
onCustomRender: tabItem => <span>{tabItem.pageMeta?.pageTitle || 'Working'}</span>,
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
In the current Admin tabs renderer, this callback takes precedence over `pageTitle` for that tab item. The returned content is rendered inside the level-2 tab label. Use the callback argument for stable tab-item metadata; use the page/controller's reactive state when the label itself needs to update.
|
|
103
|
+
|
|
104
|
+
### `onCustomRenderIsolate`
|
|
105
|
+
|
|
106
|
+
`onCustomRenderIsolate` customizes the active anchor item's isolated level-2 presentation. The callback receives the current `IRouteViewTabCurrent` and returns a Vue `VNodeChild`:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
this.$router.setPageMeta(this.$pageRoute, {
|
|
110
|
+
onCustomRenderIsolate: () => <span class="badge">Online</span>,
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
In the current Admin tabs renderer, this callback has precedence over the anchor's `pageTitle` and the ordinary level-2 tab list. It is therefore appropriate for a page-specific shell area that should be shown in place of the default current-item title. The current Basic dashboard uses this hook for its live time badge.
|
|
115
|
+
|
|
116
|
+
These callbacks are currently consumed by the Admin layout's tabs renderer. Do not assume that every routed host or every edition renders them; verify the concrete host and layout before relying on custom shell output.
|
|
117
|
+
|
|
87
118
|
### `pageTitle`
|
|
88
119
|
|
|
89
120
|
`pageTitle` is the task-level title for the current routed work item.
|
|
@@ -224,7 +255,7 @@ setPageMeta(route, pageMeta) {
|
|
|
224
255
|
|
|
225
256
|
This means the router bean is not itself the state owner.
|
|
226
257
|
|
|
227
|
-
It is the forwarding boundary between page code and routed hosts.
|
|
258
|
+
It is the forwarding boundary between page code and routed hosts. The base router-view host provides a no-op `setPageMeta(...)`, so a host that does not override it can ignore the update without changing the page or route record.
|
|
228
259
|
|
|
229
260
|
### 3. `routerViewTabs` delegates to `ModelTabs`
|
|
230
261
|
|
|
@@ -241,7 +272,7 @@ public setPageMeta(route, pageMeta) {
|
|
|
241
272
|
}
|
|
242
273
|
```
|
|
243
274
|
|
|
244
|
-
Inside `ModelTabs`, the update is resolved by `route.fullPath`, then applied to the
|
|
275
|
+
Inside `ModelTabs`, the update is resolved by searching the tab items for the matching `route.fullPath`, then applied to that routed work item. The incoming object is shallow-merged with the existing `pageMeta`, so a later call can update one field without clearing the other fields already stored on that work item.
|
|
245
276
|
|
|
246
277
|
This is the crucial source-level fact:
|
|
247
278
|
|
|
@@ -249,16 +280,29 @@ This is the crucial source-level fact:
|
|
|
249
280
|
|
|
250
281
|
### 4. The active layout consumes the stored page meta
|
|
251
282
|
|
|
252
|
-
|
|
283
|
+
The clearest Basic page-meta consumer is the Admin layout:
|
|
253
284
|
|
|
254
285
|
- `zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx`
|
|
286
|
+
|
|
287
|
+
The Web layout has a related tabs presentation, but its current renderer does not read `pageMeta`:
|
|
288
|
+
|
|
255
289
|
- `zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.tabs.tsx`
|
|
256
290
|
|
|
257
|
-
In the current
|
|
291
|
+
In the current Admin layout, the renderer can use page meta for:
|
|
258
292
|
|
|
259
293
|
- task-level title rendering
|
|
260
294
|
- dirty indicators
|
|
261
295
|
- create/edit icon signals derived from `formMeta.formScene`
|
|
296
|
+
- custom content for level-2 tab items through `onCustomRender`
|
|
297
|
+
- isolated content for the active anchor item through `onCustomRenderIsolate`
|
|
298
|
+
|
|
299
|
+
The current Admin tabs renderer applies custom content in this order:
|
|
300
|
+
|
|
301
|
+
1. if the active anchor item has `onCustomRenderIsolate`, render that isolated result
|
|
302
|
+
2. otherwise, if the anchor item has `pageTitle`, render that title
|
|
303
|
+
3. otherwise, render the other level-2 items; each item uses `onCustomRender(tabItem)` when present, otherwise its `pageTitle`
|
|
304
|
+
|
|
305
|
+
When tabs caching is enabled, this renderer wraps the custom shell output in `ClientOnly`. The current Web layout's tabs renderer does not read these page-meta callbacks, so do not assume that the same custom output appears in every layout or routed host.
|
|
262
306
|
|
|
263
307
|
## The most important authoring pattern
|
|
264
308
|
|
|
@@ -355,13 +399,47 @@ this.$router.setPageMeta(this.$pageRoute, {
|
|
|
355
399
|
|
|
356
400
|
In the current Basic source, this lets the layout derive task-level icon treatment from `formMeta.formScene`.
|
|
357
401
|
|
|
358
|
-
### Scenario 4: I
|
|
402
|
+
### Scenario 4: I need a custom task-level shell presentation
|
|
403
|
+
|
|
404
|
+
Use one of the custom render hooks when the normal title text is not enough:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
this.$router.setPageMeta(this.$pageRoute, {
|
|
408
|
+
onCustomRenderIsolate: () => (
|
|
409
|
+
<span class="badge badge-primary">{this.statusText}</span>
|
|
410
|
+
),
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Use `onCustomRender` for a non-anchor level-2 item and `onCustomRenderIsolate` when the active anchor item's presentation should replace the ordinary level-2 item row. The callback may close over reactive controller state, as in the current dashboard clock example:
|
|
415
|
+
|
|
416
|
+
- `zova/src/suite/a-home/modules/home-indexadmin/src/page/dashboard/controller.tsx`
|
|
417
|
+
|
|
418
|
+
These are routed-shell presentation hooks, not browser document-title or SEO metadata APIs.
|
|
359
419
|
|
|
360
|
-
|
|
420
|
+
### Scenario 5: I only want to change browser document title
|
|
421
|
+
|
|
422
|
+
Use [`$useMeta(...)` in the SSR SEO Meta guide](/frontend/ssr-seo-meta#usemeta), not `$router.setPageMeta(...)`.
|
|
423
|
+
|
|
424
|
+
`pageTitle` is a routed-shell task-title surface first. `$useMeta(...)` owns SSR-aware document-head metadata, including the browser title:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
this.$useMeta({
|
|
428
|
+
title: 'Product catalogue',
|
|
429
|
+
});
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
When the title depends on reactive page state, pass a function so the client metadata surface updates with that state:
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
this.$useMeta(() => ({
|
|
436
|
+
title: this.product?.name ?? 'Product',
|
|
437
|
+
}));
|
|
438
|
+
```
|
|
361
439
|
|
|
362
|
-
`pageTitle`
|
|
440
|
+
Use page meta as well only when the routed shell needs its task label to change independently. For example, a record editor may set `pageTitle` for its Admin tab and `$useMeta(...)` for the browser title; neither API automatically updates the other.
|
|
363
441
|
|
|
364
|
-
|
|
442
|
+
For title templates, descriptions, other head tags, and the static-versus-reactive behavior of `$useMeta(...)`, continue with [SSR SEO Meta](/frontend/ssr-seo-meta).
|
|
365
443
|
|
|
366
444
|
## A compact helper pattern
|
|
367
445
|
|
|
@@ -129,6 +129,43 @@ A compact interpretation is:
|
|
|
129
129
|
|
|
130
130
|
This is the point where route state becomes host state.
|
|
131
131
|
|
|
132
|
+
### Page activation state
|
|
133
|
+
|
|
134
|
+
A routed page and its descendant Zova components can read the optional `$pageHost` shortcut:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
this.$pageHost?.active;
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`$pageHost.active` is a stable, reactive page-host context value. It describes whether this concrete page component instance is currently activated by its Vue host. It is initialized as `true` for SSR and the initial client render, becomes `false` when a cached page is deactivated, and becomes `true` again when that same page instance is reactivated. It becomes `false` before final unmount as a cleanup safeguard.
|
|
141
|
+
|
|
142
|
+
Use it to pause work that does not need to continue while a cached page is inactive, for example timers, polling, subscriptions, media, or observers:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
this.$watch(
|
|
146
|
+
() => this.$pageHost?.active,
|
|
147
|
+
active => {
|
|
148
|
+
if (active) this.startPolling();
|
|
149
|
+
else this.stopPolling();
|
|
150
|
+
},
|
|
151
|
+
{ immediate: true },
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Keep the browser-only work behind the existing SSR and hydration boundary. An initial `active === true` value does not mean that `window`, `document`, or other browser APIs exist during server rendering.
|
|
156
|
+
|
|
157
|
+
`$pageHost.active` is not:
|
|
158
|
+
|
|
159
|
+
- the current route or navigation intent
|
|
160
|
+
- membership in `keepAliveInclude`
|
|
161
|
+
- the selected Tabs or Stack model item
|
|
162
|
+
- browser visibility or window focus
|
|
163
|
+
- data-loading readiness
|
|
164
|
+
|
|
165
|
+
Therefore `active` and `keepAlive` answer different questions: `keepAlive` controls retention eligibility, while `$pageHost.active` reports the lifecycle state of the retained page instance. `__dispose__()` remains the final cleanup hook and is not called merely because a cached page is deactivated.
|
|
166
|
+
|
|
167
|
+
This context is available through the page bean-host hierarchy, so Page Controllers, Render/Style companions, and descendant components can observe the same page-host object. Nested routed pages receive their own nearest page host.
|
|
168
|
+
|
|
132
169
|
## Host 1: `routerViewEmpty`
|
|
133
170
|
|
|
134
171
|
The minimal host controller lives in:
|
|
@@ -42,17 +42,17 @@ For the current public explanation of this backend capability, see [Cache Guide]
|
|
|
42
42
|
|
|
43
43
|
This is the kind of result Cabloy is designed to support: a framework that stays operationally calm even when the system keeps running for long periods.
|
|
44
44
|
|
|
45
|
-
One internally generated project was kept running continuously for **
|
|
45
|
+
One internally generated project was kept running continuously for **3 months**. At one representative PM2 snapshot, the process looked like this:
|
|
46
46
|
|
|
47
47
|
```text
|
|
48
48
|
┌────┬─────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
|
|
49
49
|
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
|
|
50
50
|
├────┼─────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
|
|
51
|
-
│ 0 │ cabloy_*** │ default │ N/A │ cluster │ 226947 │
|
|
51
|
+
│ 0 │ cabloy_*** │ default │ N/A │ cluster │ 226947 │ 3M │ 17 │ online │ 0% │ 381.4mb │ ubuntu │ disabled │
|
|
52
52
|
└────┴─────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
For this
|
|
55
|
+
For this 3-month continuous run, the observed result was **0 memory leak**.
|
|
56
56
|
|
|
57
57
|
The value of this example is not that it is a synthetic micro-benchmark. The value is that it reflects a real long-running project process with a clear, inspectable runtime footprint.
|
|
58
58
|
|
|
@@ -27,42 +27,42 @@ export interface IControllerOptions<%=argv.resourceNameCapitalize%> extends IDec
|
|
|
27
27
|
export class Controller<%=argv.resourceNameCapitalize%> extends BeanBase {
|
|
28
28
|
@Web.post('', { summary: $locale('<%=argv.resourceNameCapitalize%>Create') })
|
|
29
29
|
@Api.body(v.tableIdentity())
|
|
30
|
-
@Passport.
|
|
30
|
+
@Passport.rbac()
|
|
31
31
|
async create(@Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Create): Promise<TableIdentity> {
|
|
32
32
|
return (await this.scope.service.<%=argv.resourceName%>.create(<%=argv.resourceName%>)).id;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
@Web.get('', { summary: $locale('<%=argv.resourceNameCapitalize%>Select') })
|
|
36
36
|
@Api.body(Dto<%=argv.resourceNameCapitalize%>SelectRes)
|
|
37
|
-
@Passport.
|
|
37
|
+
@Passport.rbac()
|
|
38
38
|
async select(@Arg.filter(Dto<%=argv.resourceNameCapitalize%>SelectReq) params: IQueryParams<Model<%=argv.resourceNameCapitalize%>>): Promise<Dto<%=argv.resourceNameCapitalize%>SelectRes> {
|
|
39
39
|
return await this.scope.service.<%=argv.resourceName%>.select(params);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
@Web.get(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>View') })
|
|
43
43
|
@Api.body(v.optional(), v.object(Dto<%=argv.resourceNameCapitalize%>View))
|
|
44
|
-
@Passport.
|
|
44
|
+
@Passport.rbac()
|
|
45
45
|
async view(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<Dto<%=argv.resourceNameCapitalize%>View | undefined> {
|
|
46
46
|
return await this.scope.service.<%=argv.resourceName%>.view(id);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
@Web.patch(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>Update') })
|
|
50
50
|
@Api.body(z.null())
|
|
51
|
-
@Passport.
|
|
51
|
+
@Passport.rbac()
|
|
52
52
|
async update(@Arg.param('id', v.tableIdentity()) id: TableIdentity, @Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Update): Promise<void> {
|
|
53
53
|
await this.scope.service.<%=argv.resourceName%>.update(id, <%=argv.resourceName%>);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
@Web.delete(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>Delete') })
|
|
57
57
|
@Api.body(z.null())
|
|
58
|
-
@Passport.
|
|
58
|
+
@Passport.rbac()
|
|
59
59
|
async delete(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<void> {
|
|
60
60
|
await this.scope.service.<%=argv.resourceName%>.delete(id);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
@Web.post('bulk/delete', { summary: $locale('
|
|
63
|
+
@Web.post('bulk/delete', { summary: $locale('<%=argv.resourceNameCapitalize%>DeleteBulk') })
|
|
64
64
|
@Api.body(z.null())
|
|
65
|
-
@Passport.
|
|
65
|
+
@Passport.rbac({ actionInherit: 'delete' })
|
|
66
66
|
async deleteBulk(@Arg.body() command: Dto<%=argv.resourceNameCapitalize%>DeleteBulk): Promise<void> {
|
|
67
67
|
await this.scope.service.<%=argv.resourceName%>.deleteBulk(command.ids);
|
|
68
68
|
}
|
|
@@ -1,12 +1,74 @@
|
|
|
1
1
|
import type { Dto<%=argv.resourceNameCapitalize%>Create, Dto<%=argv.resourceNameCapitalize%>SelectRes, Dto<%=argv.resourceNameCapitalize%>Update, Entity<%=argv.resourceNameCapitalize%> } from 'vona-module-<%=argv.moduleInfo.relativeName%>';
|
|
2
|
+
|
|
2
3
|
import assert from 'node:assert';
|
|
3
4
|
import { describe, it } from 'node:test';
|
|
5
|
+
import { appMetadata } from 'vona';
|
|
4
6
|
import { app } from 'vona-mock';
|
|
7
|
+
import { SymbolOpenApiOptions } from 'vona-module-a-openapiutils';
|
|
8
|
+
|
|
9
|
+
describe('<%=argv.resourceName%>.test.ts', { concurrency: false }, () => {
|
|
10
|
+
it('action:<%=argv.resourceName%>:openapiMetadata', async () => {
|
|
11
|
+
await app.bean.executor.mockCtx(async () => {
|
|
12
|
+
const controllerBeanFullName = '<%=argv.moduleInfo.relativeName%>.controller.<%=argv.resourceName%>';
|
|
13
|
+
const controller = app.bean.onion.controller
|
|
14
|
+
.getOnionsEnabledCached()
|
|
15
|
+
.find(item => item.beanOptions.beanFullName === controllerBeanFullName)
|
|
16
|
+
?.beanOptions.beanClass;
|
|
17
|
+
if (!controller) throw new Error(`${controllerBeanFullName} not found`);
|
|
18
|
+
|
|
19
|
+
const controllerMetadata = appMetadata.getMetadata<any>(
|
|
20
|
+
SymbolOpenApiOptions,
|
|
21
|
+
controller,
|
|
22
|
+
);
|
|
23
|
+
assert.equal(
|
|
24
|
+
controllerMetadata?.summary?.toString(),
|
|
25
|
+
'<%=argv.moduleInfo.relativeName%>::<%=argv.resourceNameCapitalize%>Controller',
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const expectedSummaries = {
|
|
29
|
+
create: ['post', '<%=argv.moduleActionPathRaw%>', 'Create <%=argv.resourceNameCapitalize%>'],
|
|
30
|
+
select: ['get', '<%=argv.moduleActionPathRaw%>', 'Query <%=argv.resourceNameCapitalize%> List'],
|
|
31
|
+
view: ['get', '<%=argv.moduleActionPathRaw%>/{id}', 'View <%=argv.resourceNameCapitalize%>'],
|
|
32
|
+
update: ['patch', '<%=argv.moduleActionPathRaw%>/{id}', 'Update <%=argv.resourceNameCapitalize%>'],
|
|
33
|
+
delete: ['delete', '<%=argv.moduleActionPathRaw%>/{id}', 'Delete <%=argv.resourceNameCapitalize%>'],
|
|
34
|
+
deleteBulk: ['post', '<%=argv.moduleActionPathRaw%>/bulk/delete', 'Bulk Delete <%=argv.resourceNameCapitalize%>'],
|
|
35
|
+
} as const;
|
|
36
|
+
for (const [action, [method, path, summary]] of Object.entries(expectedSummaries)) {
|
|
37
|
+
const apiJson = await app.bean.openapi.generateJsonOfControllerAction(
|
|
38
|
+
controller,
|
|
39
|
+
action,
|
|
40
|
+
'V31',
|
|
41
|
+
);
|
|
42
|
+
const operation = (apiJson.paths as any)?.[path]?.[method];
|
|
43
|
+
assert.ok(operation, `${action} operation is missing`);
|
|
44
|
+
assert.equal(operation.summary?.toJSON(), summary);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('action:<%=argv.resourceName%>:rbacCatalog', async () => {
|
|
50
|
+
await app.bean.executor.mockCtx(async () => {
|
|
51
|
+
const controllerBeanFullName = '<%=argv.moduleInfo.relativeName%>.controller.<%=argv.resourceName%>';
|
|
52
|
+
const catalog = app.bean.rbacCatalog.getCatalog();
|
|
53
|
+
const expectedInherits = {
|
|
54
|
+
create: undefined,
|
|
55
|
+
select: undefined,
|
|
56
|
+
view: undefined,
|
|
57
|
+
update: undefined,
|
|
58
|
+
delete: undefined,
|
|
59
|
+
deleteBulk: `${controllerBeanFullName}#delete`,
|
|
60
|
+
} as const;
|
|
61
|
+
for (const [action, actionInheritKey] of Object.entries(expectedInherits)) {
|
|
62
|
+
const descriptor = catalog.get(`${controllerBeanFullName}#${action}`);
|
|
63
|
+
assert.ok(descriptor, `${action} RBAC action is missing`);
|
|
64
|
+
assert.equal(descriptor.actionInheritKey, actionInheritKey);
|
|
65
|
+
assert.equal(descriptor.options.dataScope, undefined);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
5
69
|
|
|
6
|
-
describe('<%=argv.resourceName%>.test.ts', () => {
|
|
7
70
|
it('action:<%=argv.resourceName%>', async () => {
|
|
8
71
|
await app.bean.executor.mockCtx(async () => {
|
|
9
|
-
// data
|
|
10
72
|
const data: Dto<%=argv.resourceNameCapitalize%>Create = {
|
|
11
73
|
name: '__Tom__',
|
|
12
74
|
description: 'This is a test',
|
|
@@ -15,48 +77,40 @@ describe('<%=argv.resourceName%>.test.ts', () => {
|
|
|
15
77
|
name: '__TomNew__',
|
|
16
78
|
description: 'This is a test',
|
|
17
79
|
};
|
|
18
|
-
|
|
80
|
+
let <%=argv.resourceName%>Id: Entity<%=argv.resourceNameCapitalize%>['id'] | undefined;
|
|
81
|
+
// sign in as an unrestricted test identity
|
|
19
82
|
await app.bean.passport.signinMock();
|
|
20
83
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
84
|
+
// create
|
|
85
|
+
<%=argv.resourceName%>Id = await app.bean.executor.performAction('post', '<%=argv.moduleActionPathRaw%>', { body: data });
|
|
86
|
+
assert.equal(!!<%=argv.resourceName%>Id, true);
|
|
87
|
+
// findMany
|
|
88
|
+
const selectRes: Dto<%=argv.resourceNameCapitalize%>SelectRes = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>');
|
|
89
|
+
assert.equal(selectRes.list.findIndex(item => item.name === data.name) > -1, true);
|
|
90
|
+
// update
|
|
91
|
+
const updateRes = await app.bean.executor.performAction('patch', '<%=argv.moduleActionPathRaw%>/:id', {
|
|
92
|
+
params: { id: <%=argv.resourceName%>Id },
|
|
93
|
+
body: dataUpdate,
|
|
94
|
+
});
|
|
95
|
+
assert.equal(updateRes, null);
|
|
96
|
+
// findOne
|
|
97
|
+
let <%=argv.resourceName%>: Entity<%=argv.resourceNameCapitalize%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>Id } });
|
|
98
|
+
assert.equal(<%=argv.resourceName%>.name, dataUpdate.name);
|
|
99
|
+
// delete
|
|
100
|
+
const deleteRes = await app.bean.executor.performAction('delete', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
|
|
101
|
+
assert.equal(deleteRes, null);
|
|
102
|
+
// findOne
|
|
103
|
+
<%=argv.resourceName%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
|
|
104
|
+
assert.equal(<%=argv.resourceName%>, undefined);
|
|
32
105
|
} finally {
|
|
106
|
+
if (<%=argv.resourceName%>Id) {
|
|
107
|
+
await app.scope('<%=argv.moduleInfo.relativeName%>').model.<%=argv.resourceName%>.deleteById(
|
|
108
|
+
<%=argv.resourceName%>Id,
|
|
109
|
+
{ disableDeleted: true },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
33
112
|
await app.bean.passport.signout();
|
|
34
113
|
}
|
|
35
|
-
// login as system admin
|
|
36
|
-
await app.bean.passport.signinMock();
|
|
37
|
-
// create
|
|
38
|
-
const <%=argv.resourceName%>Id = await app.bean.executor.performAction('post', '<%=argv.moduleActionPathRaw%>', { body: data });
|
|
39
|
-
assert.equal(!!<%=argv.resourceName%>Id, true);
|
|
40
|
-
// findMany
|
|
41
|
-
const selectRes: Dto<%=argv.resourceNameCapitalize%>SelectRes = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>');
|
|
42
|
-
assert.equal(selectRes.list.findIndex(item => item.name === data.name) > -1, true);
|
|
43
|
-
// update
|
|
44
|
-
const updateRes = await app.bean.executor.performAction('patch', '<%=argv.moduleActionPathRaw%>/:id', {
|
|
45
|
-
params: { id: <%=argv.resourceName%>Id },
|
|
46
|
-
body: dataUpdate,
|
|
47
|
-
});
|
|
48
|
-
assert.equal(updateRes, null);
|
|
49
|
-
// findOne
|
|
50
|
-
let <%=argv.resourceName%>: Entity<%=argv.resourceNameCapitalize%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>Id } });
|
|
51
|
-
assert.equal(<%=argv.resourceName%>.name, dataUpdate.name);
|
|
52
|
-
// delete
|
|
53
|
-
const deleteRes = await app.bean.executor.performAction('delete', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
|
|
54
|
-
assert.equal(deleteRes, null);
|
|
55
|
-
// findOne
|
|
56
|
-
<%=argv.resourceName%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
|
|
57
|
-
assert.equal(<%=argv.resourceName%>, undefined);
|
|
58
|
-
// logout
|
|
59
|
-
await app.bean.passport.signout();
|
|
60
114
|
});
|
|
61
115
|
});
|
|
62
116
|
});
|