cabloy 5.1.157 → 5.1.159

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 (90) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-contract-loop/SKILL.md +24 -7
  3. package/.claude/skills/cabloy-contract-loop/evals/evals.json +12 -0
  4. package/.claude/skills/cabloy-contract-loop/references/contract-loop-map.md +28 -0
  5. package/.claude/skills/cabloy-contract-loop/references/verification-checklist.md +19 -2
  6. package/.claude/skills/cabloy-resource-field-update/SKILL.md +18 -0
  7. package/.claude/skills/cabloy-zova-source-reading/SKILL.md +14 -0
  8. package/.claude/skills/cabloy-zova-source-reading/references/core-reading-paths.md +10 -0
  9. package/.github/workflows/docs-pages.yml +18 -2
  10. package/CHANGELOG.md +26 -0
  11. package/CONTRIBUTING.md +84 -0
  12. package/LICENSES.md +5 -0
  13. package/package.json +2 -1
  14. package/repo-docs/.vitepress/config.mjs +34 -4
  15. package/repo-docs/.vitepress/theme/components/GitHubRepositoriesNav.vue +255 -0
  16. package/repo-docs/.vitepress/theme/custom.css +21 -0
  17. package/repo-docs/.vitepress/theme/index.js +7 -1
  18. package/repo-docs/backend/resource-field-update.md +48 -1
  19. package/repo-docs/blogs/index.md +2 -0
  20. package/repo-docs/frontend/form-layout-guide.md +6 -6
  21. package/repo-docs/frontend/page-guide.md +12 -0
  22. package/repo-docs/frontend/page-meta-guide.md +27 -0
  23. package/repo-docs/frontend/ssr-architecture-overview.md +4 -0
  24. package/repo-docs/frontend/ssr-build-deploy-guide.md +1 -1
  25. package/repo-docs/frontend/zova-form-source-reading-map.md +2 -2
  26. package/repo-docs/frontend/zova-form-under-the-hood.md +1 -1
  27. package/repo-docs/frontend/zova-reactivity-under-the-hood.md +65 -9
  28. package/repo-docs/fullstack/contract-loop-playbook.md +2 -0
  29. package/repo-docs/fullstack/ssr-site-and-flavor-setup.md +198 -0
  30. package/repo-docs/fullstack/vona-zova-integration.md +2 -0
  31. package/repo-e2e/docs/playwright.config.ts +37 -0
  32. package/repo-e2e/docs/specs/blogs-index.spec.ts +180 -0
  33. package/repo-e2e/docs/specs/github-repositories-nav.spec.ts +69 -0
  34. package/repo-e2e/docs/test-results/.last-run.json +4 -0
  35. package/repo-e2e/specs/cabloy-basic.spec.ts +136 -2
  36. package/vona/packages-cli/cli/package.json +1 -1
  37. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/entity/{{resourceName}}.tsx_ +1 -1
  38. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/entity/{{resourceName}}.tsx_ +1 -1
  39. package/vona/packages-cli/cli-set-api/package.json +1 -1
  40. package/vona/packages-vona/vona/package.json +1 -1
  41. package/vona/pnpm-lock.yaml +21 -80
  42. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +3 -0
  43. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +3 -0
  44. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productCreate.tsx +2 -0
  45. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productUpdate.tsx +2 -0
  46. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productView.tsx +2 -0
  47. package/vona/src/suite/a-training/modules/training-record/src/dto/recordCreate.tsx +1 -0
  48. package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectReq.tsx +3 -7
  49. package/vona/src/suite/a-training/modules/training-record/src/dto/recordUpdate.tsx +1 -0
  50. package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +1 -0
  51. package/vona/src/suite/a-training/modules/training-record/src/entity/record.tsx +8 -0
  52. package/vona/src/suite/a-training/modules/training-record/test/record.test.ts +51 -0
  53. package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +3 -0
  54. package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +3 -0
  55. package/vona/src/suite/a-training/modules/training-student/src/dto/studentCreate.tsx +1 -0
  56. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectResItem.tsx +1 -0
  57. package/vona/src/suite/a-training/modules/training-student/src/dto/studentUpdate.tsx +1 -0
  58. package/vona/src/suite/a-training/modules/training-student/src/dto/studentView.tsx +1 -0
  59. package/vona/src/suite/a-training/modules/training-student/src/entity/student.tsx +10 -1
  60. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +99 -1
  61. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  62. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  63. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  64. package/vona/src/suite-vendor/a-vona/modules/a-web/package.json +1 -1
  65. package/vona/src/suite-vendor/a-vona/modules/a-web/src/bean/pipe.filter.ts +85 -57
  66. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  67. package/zova/packages-cli/cli/package.json +2 -2
  68. package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
  69. package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +13 -3
  70. package/zova/packages-cli/cli-set-front/package.json +1 -1
  71. package/zova/packages-zova/zova/package.json +2 -2
  72. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockPage/controller.tsx +31 -11
  73. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockTable/controller.tsx +3 -0
  74. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +1 -1
  75. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +68 -8
  76. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  77. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/model/resource.ts +4 -0
  78. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  79. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  80. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.ts +0 -6
  81. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/formLayout.test.ts +16 -9
  82. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  83. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/model/sdk.ts +4 -0
  84. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +13 -2
  85. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/schema.ts +1 -0
  86. package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
  87. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/controller.tsx +50 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/render.tsx +60 -7
  89. package/zova/src/suite-vendor/a-zova/modules/a-table/src/types/table.ts +6 -4
  90. package/zova/src/suite-vendor/a-zova/package.json +4 -4
@@ -0,0 +1,198 @@
1
+ # Independent SSR Site and Flavor Setup
2
+
3
+ Use this guide when adding a new deployable Zova SSR surface that Vona must dispatch and serve. It covers a new **site + flavor** pair, not merely a page or route inside an existing site.
4
+
5
+ The durable outcome is one aligned identity tuple:
6
+
7
+ ```text
8
+ Zova flavor
9
+ ↕
10
+ flavor env/config + Zova SSR/REST scripts
11
+ ↕
12
+ root paired build wrapper
13
+ ↕
14
+ SSR release directory + generated REST package
15
+ ↕
16
+ Vona @SsrSite registration
17
+ ```
18
+
19
+ Do not substitute identifiers from an existing Admin, Web, or business site. Select and verify every member of the tuple together.
20
+
21
+ ## Before you start
22
+
23
+ ### Detect the active edition
24
+
25
+ Read the repository marker first:
26
+
27
+ - `__CABLOY_BASIC__` → use the current Basic scripts, flavors, UI baselines, site modules, and paths.
28
+ - `__CABLOY_START__` → inspect the active Start repository before naming a flavor, wrapper, generated package, or site module.
29
+ - both markers → stop: the checkout is ambiguous.
30
+
31
+ The framework model is shared, but the exact flavor names, site baselines, UI layer, assets, scripts, and generated output paths can differ by edition. See [Edition Detection](/editions/detection) and [Edition Collaboration Differences](/fullstack/edition-collaboration-differences).
32
+
33
+ ### Confirm that a new site is needed
34
+
35
+ | Change | Normal boundary |
36
+ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
37
+ | New page or route inside an existing SSR site | Page, route, model, and existing site verification; normally no new flavor or `@SsrSite`. |
38
+ | Frontend metadata/resource handoff to an existing backend consumer | Reverse contract loop; rebuild every affected existing flavor pair, then run `npm run deps:vona`. |
39
+ | New deployable URL mount, SSR admission policy, assets, or independent frontend composition | New independent flavor and Vona SSR site; continue with this guide. |
40
+
41
+ A new site is an ownership decision, not a convenient alias for an existing Admin or Web surface.
42
+
43
+ ## Define the identity tuple
44
+
45
+ Choose these values before editing source. Keep their spelling and ownership consistent:
46
+
47
+ | Identity | Role |
48
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------- |
49
+ | `<site-flavor>` | Exact Zova flavor identifier used in env/config, lower-level scripts, build output, and generated REST package. |
50
+ | `<site-id>` | Vona SSR site identity and Zova `SITE_ID`; unique among enabled sites. |
51
+ | `<public-path>` | External mount path; aligned between Zova `APP_PUBLIC_PATH` and Vona `publicPath`; unique among enabled sites. |
52
+ | `<site-module>` | Vona module that owns the site bean and copied SSR assets. |
53
+ | `<bundle-path>` | Copied SSR release directory under `<site-module>/assets/site`; exactly matches Vona `bundlePath`. |
54
+ | `<zova-rest-package>` | Flavor-specific generated REST/type package imported by the Vona site bean. |
55
+ | `<root-build-wrapper>` | Root command that produces the selected SSR bundle **and** REST/type package. |
56
+
57
+ Only one enabled SSR site may own the empty/root public path. A non-root site’s route aliases are relative to the router mount base; do not repeat `<public-path>` inside those aliases.
58
+
59
+ ## Configure Zova
60
+
61
+ ### 1. Add a tracked flavor environment
62
+
63
+ Keep shared defaults in `zova/env/.env`; add the site’s tracked flavor override at:
64
+
65
+ ```text
66
+ zova/env/.env.<site-flavor>
67
+ ```
68
+
69
+ The override must select a non-empty `SITE_ID`, the matching `APP_PUBLIC_PATH`, an SSR rendering profile, and the build-copy targets. For a Vona-integrated SSR site, set `SSR_WITH_VONA=true`.
70
+
71
+ Choose `SSR_PROFILE` from the rendering contract:
72
+
73
+ - use a public profile for cookie-free, cache-safe first paint;
74
+ - choose a session/private profile deliberately for cookie-backed admission, personalized first paint, or private SSR data.
75
+
76
+ Point the build destinations to the selected Vona site module and generated package workspace:
77
+
78
+ ```text
79
+ BUILD_COPY_RELEASE = ../vona/src/.../modules/<site-module>/assets/site
80
+ BUILD_REST_COPY_DIST = ../vona/.zova-rest
81
+ ```
82
+
83
+ Do not change ports or worktree-local environment overrides to make a new site work. Follow the normal worktree-environment workflow only when that separate setup is explicitly required.
84
+
85
+ ### 2. Add flavor-specific frontend configuration only where necessary
86
+
87
+ Use `zova/src/front/config/config/config.<site-flavor>.ts` for site-specific route aliases, route exclusions, layouts, or app configuration. Keep shared API, locale, theme, SSR, and app assembly in base `config.ts`.
88
+
89
+ For mounted routing:
90
+
91
+ - configure an unnamed canonical route through `config.routes.path`;
92
+ - configure a named dynamic route through `config.routes.name`;
93
+ - generate a named alias with `$router.getAliasPath(...)` rather than assuming `$router.getPagePath(...)` selects an alias;
94
+ - do not duplicate the external mount prefix in an alias because `APP_PUBLIC_PATH` supplies that boundary.
95
+
96
+ Choose layouts, title, locales, assets, route exclusions, `requiresAuth`, and redirects from the new site’s contract. Existing Admin and Web sites are specimens, not defaults to copy blindly.
97
+
98
+ ### 3. Add durable scripts and root wrappers
99
+
100
+ Inspect the active root `package.json` and `zova/package.json` first. Add lower-level Zova scripts in the durable source manifest, normally including:
101
+
102
+ ```text
103
+ dev:ssr:<site-flavor>
104
+ build:ssr:<site-flavor>
105
+ build:rest:<site-flavor>
106
+ preview:ssr:<site-flavor>
107
+ ```
108
+
109
+ The SSR and REST scripts must pass the same flavor. Add a paired batch and an explicit root build wrapper that runs both outputs in order:
110
+
111
+ ```bash
112
+ npm run build:ssr:<site-flavor>
113
+ npm run build:rest:<site-flavor>
114
+ ```
115
+
116
+ Use the root wrapper for normal site-level builds and in Vona diagnostics. Add the flavor to an aggregate build only when it belongs to that edition’s default shipped artifact set.
117
+
118
+ Edit durable manifests such as root `package.json` and `zova/package.original.json`; do not hand-edit generated `zova/package.json`, generated REST output, copied bundles, or generated dependency state. Do not run `npm run init` merely to synchronize a manifest.
119
+
120
+ ## Register the Vona SSR site
121
+
122
+ Create or refine an independently packaged Vona site module. It must expose its assets, source entry point, locale metadata, and an SSR-site bean.
123
+
124
+ The site bean should:
125
+
126
+ 1. import `IPagePathRecord` and `IIconRecord` from `<zova-rest-package>`;
127
+ 2. augment the SSR site-ID and public-path type records;
128
+ 3. define typed page/page-data options around the generated route record;
129
+ 4. extend `BeanSsrSiteBase` and register `@SsrSite(...)`;
130
+ 5. provide a localized title, `<site-id>`, `<public-path>`, `<bundle-path>`, and `<root-build-wrapper>` diagnostics command.
131
+
132
+ Conceptually:
133
+
134
+ ```ts
135
+ @SsrSite({
136
+ siteId: '<site-id>',
137
+ publicPath: '<public-path>',
138
+ bundlePath: '<bundle-path>',
139
+ diagnostics: { buildCommand: 'npm run <root-build-wrapper>' },
140
+ })
141
+ export class SsrSiteExample extends BeanSsrSiteBase<...> {}
142
+ ```
143
+
144
+ The default release identity is normally `ssr-<site-flavor>-<app-version>`. Unless you intentionally override it, use that exact copied directory as `<bundle-path>`. Vona resolves the bundle from the owning site module’s `assets/site/<bundle-path>` directory.
145
+
146
+ Ensure the site module is discoverable through its durable suite/module manifest. Let normal tooling generate metadata and dependency closures; do not hand-edit generated metadata.
147
+
148
+ ## Build the artifact pair and sync Vona
149
+
150
+ For every Vona-consumed independent SSR site, this sequence is required:
151
+
152
+ ```bash
153
+ npm run <root-build-wrapper>
154
+ npm run deps:vona
155
+ ```
156
+
157
+ The wrapper must create both:
158
+
159
+ ```text
160
+ SSR bundle and client assets
161
+ + generated flavor REST/type package
162
+ ```
163
+
164
+ `build:rest:<site-flavor>` alone is not a valid Vona SSR handoff: Vona needs the bundle, assets, and typed REST package to move together. Likewise, a default Admin or Web wrapper does not prove an independently named site was rebuilt.
165
+
166
+ After generation, verify that:
167
+
168
+ - the copied release directory matches `<bundle-path>`;
169
+ - the generated package name and Vona import match `<zova-rest-package>`;
170
+ - the Vona local workspace can discover the package after `npm run deps:vona`;
171
+ - the site bean’s diagnostics command names the same root wrapper that produced the artifacts.
172
+
173
+ If the generated artifacts are correct and `npm run deps:vona` completed but Vona still sees stale local types, diagnose [local dependency drift](/fullstack/contract-loop-playbook#recovery-path-for-local-dependency-drift) only then.
174
+
175
+ ## Verify through the Vona boundary
176
+
177
+ A standalone Zova development server can help with page iteration, but it does not prove copied artifacts, Vona site matching, generated type handoff, or production-like hydration.
178
+
179
+ Run the narrowest meaningful checks first:
180
+
181
+ ```bash
182
+ npm run tsc:zova
183
+ npm run <root-build-wrapper>
184
+ npm run deps:vona
185
+ pnpm --dir vona run tsc
186
+ ```
187
+
188
+ Then prove the Vona-served site:
189
+
190
+ 1. request the exact public mount path and assert the intended Vona SSR site handled it;
191
+ 2. inspect raw HTML for server-rendered content and the correct cache/admission behavior;
192
+ 3. verify client assets load from the mounted site;
193
+ 4. open the same route in a browser and assert hydration completes without errors or SSR/client mismatch;
194
+ 5. verify canonical and alias route behavior, including no duplicated mount path;
195
+ 6. exercise the site’s public/private admission or redirect contract;
196
+ 7. retain focused browser/E2E evidence for the new site rather than relying only on compilation.
197
+
198
+ For more detail, read [SSR Architecture Overview](/frontend/ssr-architecture-overview), [SSR Build and Deploy Guide](/frontend/ssr-build-deploy-guide), [Vona + Zova Integration](/fullstack/vona-zova-integration), and [Contract Loop Playbook](/fullstack/contract-loop-playbook).
@@ -64,6 +64,8 @@ Because Start can differ in UI layer, module composition, SSR site baselines, an
64
64
  2. the Start repo’s `package.json`
65
65
  3. the exact Zova flavor names and generated output paths used there
66
66
 
67
+ For a new independently mounted SSR surface, use [Independent SSR Site and Flavor Setup](/fullstack/ssr-site-and-flavor-setup). It defines the linked Zova flavor, paired SSR/REST artifacts, Vona `@SsrSite` registration, and browser proof without assuming that either edition’s default Admin or Web wrapper applies.
68
+
67
69
  ## Recommended integration workflow
68
70
 
69
71
  ### 1. Detect the edition
@@ -0,0 +1,37 @@
1
+ import { defineConfig } from '@playwright/test';
2
+
3
+ const rootDir = new URL('../..', import.meta.url).pathname;
4
+ const baseURL = 'http://127.0.0.1:4173';
5
+
6
+ export default defineConfig({
7
+ testDir: `${rootDir}/repo-e2e/docs/specs`,
8
+ outputDir: `${rootDir}/repo-e2e/docs/test-results`,
9
+ fullyParallel: false,
10
+ workers: 1,
11
+ forbidOnly: !!process.env.CI,
12
+ retries: process.env.CI ? 2 : 0,
13
+ reporter: process.env.CI
14
+ ? [
15
+ ['html', { open: 'never', outputFolder: `${rootDir}/repo-e2e/docs/playwright-report` }],
16
+ ['list'],
17
+ ]
18
+ : 'list',
19
+ use: {
20
+ baseURL,
21
+ viewport: { width: 1440, height: 900 },
22
+ trace: 'on-first-retry',
23
+ },
24
+ webServer: {
25
+ command: 'pnpm --dir repo-docs docs:preview -- --host 127.0.0.1 --port 4173 --strictPort',
26
+ cwd: rootDir,
27
+ url: `${baseURL}/`,
28
+ timeout: 120_000,
29
+ reuseExistingServer: false,
30
+ stdout: 'pipe',
31
+ stderr: 'pipe',
32
+ gracefulShutdown: {
33
+ signal: 'SIGINT',
34
+ timeout: 10_000,
35
+ },
36
+ },
37
+ });
@@ -0,0 +1,180 @@
1
+ import type { Page } from '@playwright/test';
2
+
3
+ import { expect, test } from '@playwright/test';
4
+
5
+ interface IRectangle {
6
+ bottom: number;
7
+ height: number;
8
+ left: number;
9
+ right: number;
10
+ top: number;
11
+ width: number;
12
+ }
13
+
14
+ interface IBlogsGeometry {
15
+ cards: IRectangle[];
16
+ grid: IRectangle & { display: string };
17
+ }
18
+
19
+ interface IArticleGeometry {
20
+ aside: IRectangle;
21
+ container: IRectangle;
22
+ content: IRectangle;
23
+ contentContainer: IRectangle;
24
+ body: IRectangle;
25
+ }
26
+
27
+ const blogArticlePaths = [
28
+ '/blogs/ai-react-nextjs-enterprise-architecture-cabloy/',
29
+ '/blogs/cabloy-fullstack-resource-addressing/',
30
+ '/blogs/nextjs-integrated-fullstack-cabloy-contract-loop/',
31
+ '/blogs/vue-object-oriented-zova-beginner-mental-model/',
32
+ ];
33
+
34
+ function collectPageErrors(page: Page) {
35
+ const errors: Error[] = [];
36
+ page.on('pageerror', error => {
37
+ errors.push(error);
38
+ });
39
+ return errors;
40
+ }
41
+
42
+ function collectConsoleErrors(page: Page) {
43
+ const errors: string[] = [];
44
+ page.on('console', message => {
45
+ const text = message.text();
46
+ if (message.type() === 'error' || /hydration mismatch/i.test(text)) {
47
+ errors.push(text);
48
+ }
49
+ });
50
+ return errors;
51
+ }
52
+
53
+ function getDocumentHorizontalOverflow(page: Page) {
54
+ return page.evaluate(() => {
55
+ return (
56
+ Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - window.innerWidth
57
+ );
58
+ });
59
+ }
60
+
61
+ function getBlogsGeometry(page: Page) {
62
+ return page.locator('.cabloy-blog-grid').evaluate(grid => {
63
+ const toRectangle = (element: Element) => {
64
+ const { bottom, height, left, right, top, width } = element.getBoundingClientRect();
65
+ return { bottom, height, left, right, top, width };
66
+ };
67
+ const cards = Array.from(grid.querySelectorAll('.cabloy-blog-card'), toRectangle);
68
+ return {
69
+ grid: {
70
+ ...toRectangle(grid),
71
+ display: getComputedStyle(grid).display,
72
+ },
73
+ cards,
74
+ };
75
+ }) as Promise<IBlogsGeometry>;
76
+ }
77
+
78
+ function getArticleGeometry(page: Page) {
79
+ return page.locator('.VPDoc').evaluate(doc => {
80
+ const getRequiredChild = (parent: Element, selector: string) => {
81
+ const element = parent.querySelector(`:scope > ${selector}`);
82
+ if (!element) throw new Error(`Could not find ${selector}`);
83
+ return element;
84
+ };
85
+ const toRectangle = (element: Element) => {
86
+ const { bottom, height, left, right, top, width } = element.getBoundingClientRect();
87
+ return { bottom, height, left, right, top, width };
88
+ };
89
+ const container = getRequiredChild(doc, '.container');
90
+ const aside = getRequiredChild(container, '.aside');
91
+ const content = getRequiredChild(container, '.content');
92
+ const contentContainer = getRequiredChild(content, '.content-container');
93
+ const body = getRequiredChild(contentContainer, '.main').querySelector('.vp-doc');
94
+ if (!body) throw new Error('Could not find .vp-doc');
95
+ return {
96
+ aside: toRectangle(aside),
97
+ container: toRectangle(container),
98
+ content: toRectangle(content),
99
+ contentContainer: toRectangle(contentContainer),
100
+ body: toRectangle(body),
101
+ };
102
+ }) as Promise<IArticleGeometry>;
103
+ }
104
+
105
+ test(
106
+ 'DOCS-BLOGS-01: Blogs index uses a wide desktop card grid',
107
+ { tag: '@layout' },
108
+ async ({ page }) => {
109
+ const pageErrors = collectPageErrors(page);
110
+ const consoleErrors = collectConsoleErrors(page);
111
+
112
+ const documentResponse = await page.goto('/blogs/', { waitUntil: 'load' });
113
+ expect(documentResponse?.ok()).toBeTruthy();
114
+
115
+ await expect(page.locator('.Layout.cabloy-blogs-index')).toBeVisible();
116
+ await expect(page.getByRole('heading', { name: 'Blogs', level: 1 })).toBeVisible();
117
+ await expect(page.locator('.VPDoc.has-sidebar')).toHaveCount(0);
118
+ await expect(page.locator('.VPDoc.has-aside')).toHaveCount(0);
119
+
120
+ const cards = page.locator('.cabloy-blog-card');
121
+ await expect(cards).toHaveCount(4);
122
+ for (let index = 0; index < 4; index++) {
123
+ await expect(cards.nth(index)).toBeVisible();
124
+ await expect(cards.nth(index).locator('.cabloy-blog-card__cover img')).toBeVisible();
125
+ }
126
+
127
+ const geometry = await getBlogsGeometry(page);
128
+ expect(geometry.grid.display).toBe('grid');
129
+ expect(geometry.cards).toHaveLength(4);
130
+
131
+ const [firstCard, ...remainingCards] = geometry.cards;
132
+ expect(firstCard.width).toBeGreaterThanOrEqual(279);
133
+ for (const card of remainingCards) {
134
+ expect(Math.abs(card.top - firstCard.top)).toBeLessThanOrEqual(1);
135
+ expect(Math.abs(card.width - firstCard.width)).toBeLessThanOrEqual(1);
136
+ expect(card.left).toBeGreaterThan(firstCard.left);
137
+ expect(card.left).toBeGreaterThanOrEqual(geometry.grid.left - 1);
138
+ expect(card.right).toBeLessThanOrEqual(geometry.grid.right + 1);
139
+ expect(card.right).toBeLessThanOrEqual(1441);
140
+ }
141
+ expect(firstCard.left).toBeGreaterThanOrEqual(geometry.grid.left - 1);
142
+ expect(firstCard.right).toBeLessThanOrEqual(geometry.grid.right + 1);
143
+ expect(firstCard.right).toBeLessThanOrEqual(1441);
144
+
145
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
146
+ expect(pageErrors).toEqual([]);
147
+ expect(consoleErrors).toEqual([]);
148
+ },
149
+ );
150
+
151
+ test(
152
+ 'DOCS-BLOGS-02: Blog articles use a wide desktop body with an outline aside',
153
+ { tag: '@layout' },
154
+ async ({ page }) => {
155
+ const pageErrors = collectPageErrors(page);
156
+ const consoleErrors = collectConsoleErrors(page);
157
+
158
+ for (const path of blogArticlePaths) {
159
+ const documentResponse = await page.goto(path, { waitUntil: 'load' });
160
+ expect(documentResponse?.ok()).toBeTruthy();
161
+
162
+ await expect(page.locator('.Layout.cabloy-blogs-article')).toBeVisible();
163
+ await expect(page.locator('.VPDoc.has-sidebar')).toHaveCount(0);
164
+ await expect(page.locator('.VPDoc.has-aside')).toHaveCount(1);
165
+ await expect(page.locator('.aside')).toBeVisible();
166
+
167
+ const geometry = await getArticleGeometry(page);
168
+ expect(geometry.container.width).toBeCloseTo(1216, 0);
169
+ expect(geometry.content.width).toBeCloseTo(960, 0);
170
+ expect(geometry.contentContainer.width).toBeCloseTo(896, 0);
171
+ expect(geometry.body.width).toBeCloseTo(896, 0);
172
+ expect(geometry.aside.left).toBeGreaterThanOrEqual(geometry.content.right - 1);
173
+ expect(geometry.aside.right).toBeLessThanOrEqual(geometry.container.right + 1);
174
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
175
+ }
176
+
177
+ expect(pageErrors).toEqual([]);
178
+ expect(consoleErrors).toEqual([]);
179
+ },
180
+ );
@@ -0,0 +1,69 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ const repositories = [
4
+ {
5
+ text: 'github.com/cabloy/cabloy',
6
+ link: 'https://github.com/cabloy/cabloy',
7
+ },
8
+ {
9
+ text: 'github.com/cabloy/cabloy-start',
10
+ link: 'https://github.com/cabloy/cabloy-start',
11
+ },
12
+ ];
13
+
14
+ test('DOCS-NAV-01: GitHub icon menu exposes both repository links on desktop', async ({ page }) => {
15
+ const response = await page.goto('/', { waitUntil: 'load' });
16
+ expect(response?.ok()).toBeTruthy();
17
+
18
+ const trigger = page.getByRole('button', { name: 'GitHub repositories' });
19
+ await expect(trigger).toBeVisible();
20
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false');
21
+ await expect(trigger).not.toHaveText(/GitHub/);
22
+
23
+ await trigger.focus();
24
+ await page.keyboard.press('ArrowDown');
25
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true');
26
+
27
+ const menu = page.getByRole('menu', { name: 'GitHub repositories' });
28
+ await expect(menu).toBeVisible();
29
+ for (const repository of repositories) {
30
+ const link = menu.getByRole('menuitem', { name: repository.text, exact: true });
31
+ await expect(link).toHaveAttribute('href', repository.link);
32
+ await expect(link).toHaveAttribute('target', '_blank');
33
+ await expect(link).toHaveAttribute('rel', 'noopener noreferrer');
34
+ }
35
+
36
+ await expect(
37
+ menu.getByRole('menuitem', { name: repositories[0].text, exact: true }),
38
+ ).toBeFocused();
39
+ await page.keyboard.press('ArrowDown');
40
+ await expect(
41
+ menu.getByRole('menuitem', { name: repositories[1].text, exact: true }),
42
+ ).toBeFocused();
43
+ await page.keyboard.press('Escape');
44
+ await expect(menu).toHaveCount(0);
45
+ await expect(trigger).toBeFocused();
46
+
47
+ await trigger.click();
48
+ await expect(menu).toBeVisible();
49
+ await page.mouse.click(10, 200);
50
+ await expect(menu).toHaveCount(0);
51
+ });
52
+
53
+ test('DOCS-NAV-02: GitHub repositories remain readable in mobile navigation', async ({ page }) => {
54
+ await page.setViewportSize({ width: 375, height: 812 });
55
+ const response = await page.goto('/', { waitUntil: 'load' });
56
+ expect(response?.ok()).toBeTruthy();
57
+
58
+ await page.locator('.VPNavBarHamburger').click();
59
+
60
+ const screenMenu = page.locator('.VPNavScreen .cabloy-github-repositories--screen');
61
+ await expect(screenMenu).toBeVisible();
62
+ await expect(screenMenu.getByText('GitHub repositories')).toBeVisible();
63
+ for (const repository of repositories) {
64
+ const link = screenMenu.getByRole('link', { name: repository.text, exact: true });
65
+ await expect(link).toHaveAttribute('href', repository.link);
66
+ await expect(link).toHaveAttribute('target', '_blank');
67
+ await expect(link).toHaveAttribute('rel', 'noopener noreferrer');
68
+ }
69
+ });
@@ -0,0 +1,4 @@
1
+ {
2
+ "status": "passed",
3
+ "failedTests": []
4
+ }
@@ -44,12 +44,12 @@ async function loginAsAdmin(page: Page) {
44
44
  return dashboardResponse;
45
45
  }
46
46
 
47
- function waitForStudentSelect(page: Page) {
47
+ function waitForStudentSelect(page: Page, requireSuccess = true) {
48
48
  return page.waitForResponse(response => {
49
49
  const url = new URL(response.url());
50
50
  return (
51
51
  response.request().method() === 'GET' &&
52
- response.ok() &&
52
+ (!requireSuccess || response.ok()) &&
53
53
  url.pathname === '/api/training/student' &&
54
54
  !response.request().headers()['x-vona-openapi-schema']
55
55
  );
@@ -382,6 +382,140 @@ test(
382
382
  },
383
383
  );
384
384
 
385
+ test(
386
+ 'ATP-BASIC-TABLE-01: Training Student table submits server sorting and pins configured columns',
387
+ { tag: ['@admin', '@flow'] },
388
+ async ({ page }) => {
389
+ await page.setViewportSize({ width: 1440, height: 900 });
390
+ const pageErrors = collectPageErrors(page);
391
+ await loginAsAdmin(page);
392
+
393
+ const studentName = `Table E2E ${Date.now()}`;
394
+ const accessToken = (await page.context().cookies()).find(
395
+ cookie => cookie.name === 'token',
396
+ )?.value;
397
+ expect(accessToken).toBeTruthy();
398
+ const headers = { Authorization: `Bearer ${accessToken}` };
399
+ let studentId: string | number | undefined;
400
+ try {
401
+ const createResponse = await page.request.post('/api/training/student', {
402
+ data: { name: studentName, mobile: '13812345678', level: 1 },
403
+ headers,
404
+ });
405
+ expect(createResponse.ok()).toBeTruthy();
406
+ studentId = (await createResponse.json()).data;
407
+ expect(['string', 'number']).toContain(typeof studentId);
408
+
409
+ const initialSelect = waitForStudentSelect(page);
410
+ await page.getByRole('link', { name: 'Student', exact: true }).click();
411
+ await expect(page).toHaveURL(studentResourceUrl);
412
+ await initialSelect;
413
+ await page.setViewportSize({ width: 800, height: 900 });
414
+
415
+ const nameSort = page.getByRole('button', { name: 'Sort by name', exact: true });
416
+ const nameHeader = nameSort.locator('xpath=ancestor::th');
417
+ const operationsHeader = page.getByRole('columnheader', { name: 'Operations', exact: true });
418
+ await expect(nameSort).toBeVisible();
419
+ await expect(nameHeader).toHaveAttribute('aria-sort', 'none');
420
+ await expect(nameHeader).toHaveCSS('position', 'sticky');
421
+ await expect(nameHeader).toHaveCSS('left', '0px');
422
+ await expect(nameHeader).toHaveCSS('min-width', '240px');
423
+ await expect(nameHeader).toHaveCSS('text-align', 'left');
424
+ await expect(operationsHeader).toHaveCSS('position', 'sticky');
425
+ await expect(operationsHeader).toHaveCSS('right', '0px');
426
+ await expect(operationsHeader).toHaveCSS('min-width', '360px');
427
+ await expect(operationsHeader).toHaveCSS('text-align', 'center');
428
+
429
+ const ascendingResponse = waitForStudentSelect(page, false);
430
+ await nameSort.click();
431
+ await expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
432
+ const ascending = await ascendingResponse;
433
+ expect(ascending.status()).toBe(200);
434
+ const ascendingUrl = new URL(ascending.url());
435
+ expect(JSON.parse(ascendingUrl.searchParams.get('orders')!)).toEqual([['name', 'asc']]);
436
+
437
+ const descendingResponse = waitForStudentSelect(page, false);
438
+ await nameSort.click();
439
+ await expect(nameHeader).toHaveAttribute('aria-sort', 'descending');
440
+ const descending = await descendingResponse;
441
+ expect(descending.status()).toBe(200);
442
+ const descendingUrl = new URL(descending.url());
443
+ expect(JSON.parse(descendingUrl.searchParams.get('orders')!)).toEqual([['name', 'desc']]);
444
+
445
+ const tableWrapper = page
446
+ .locator('div.overflow-x-auto')
447
+ .filter({ has: page.locator('table') })
448
+ .first();
449
+ await expect(tableWrapper).toBeVisible();
450
+ const overflow = await tableWrapper.evaluate(element => {
451
+ return element.scrollWidth > element.clientWidth;
452
+ });
453
+ expect(overflow).toBeTruthy();
454
+ await tableWrapper.evaluate(element => {
455
+ element.scrollLeft = element.scrollWidth;
456
+ });
457
+ await expect(operationsHeader).toHaveCSS('right', '0px');
458
+
459
+ const operationsRow = page
460
+ .locator('tbody tr')
461
+ .filter({
462
+ has: page.getByRole('button', { name: 'Summary', exact: true }),
463
+ })
464
+ .first();
465
+ const operationActions = operationsRow.getByRole('button');
466
+ await expect(operationActions).toHaveCount(4);
467
+ for (const action of await operationActions.all()) {
468
+ await expect(action).toBeVisible();
469
+ }
470
+ const operationsGeometry = await operationActions.evaluateAll(actions => {
471
+ const cell = actions[0]?.closest('td');
472
+ const join = actions[0]?.parentElement;
473
+ const wrapper = cell?.closest('table')?.parentElement;
474
+ if (!cell || !join || !wrapper)
475
+ throw new Error('Could not resolve the Operations action cell');
476
+ const cellRect = cell.getBoundingClientRect();
477
+ const joinRect = join.getBoundingClientRect();
478
+ const wrapperRect = wrapper.getBoundingClientRect();
479
+ const tolerance = 2;
480
+ return {
481
+ fitsCell: join.scrollWidth <= cell.clientWidth,
482
+ actionsFitCell: actions.every(action => {
483
+ const rect = action.getBoundingClientRect();
484
+ return (
485
+ rect.left >= cellRect.left - tolerance && rect.right <= cellRect.right + tolerance
486
+ );
487
+ }),
488
+ joinFitsCell:
489
+ joinRect.left >= cellRect.left - tolerance &&
490
+ joinRect.right <= cellRect.right + tolerance,
491
+ actionsFitWrapper: actions.every(action => {
492
+ const rect = action.getBoundingClientRect();
493
+ return (
494
+ rect.left >= wrapperRect.left - tolerance &&
495
+ rect.right <= wrapperRect.right + tolerance
496
+ );
497
+ }),
498
+ };
499
+ });
500
+ expect(operationsGeometry).toEqual({
501
+ fitsCell: true,
502
+ actionsFitCell: true,
503
+ joinFitsCell: true,
504
+ actionsFitWrapper: true,
505
+ });
506
+ expect(pageErrors).toEqual([]);
507
+ } finally {
508
+ if (studentId !== undefined) {
509
+ const deleteResponse = await page.request.delete(
510
+ `/api/training/student/deleteForce/${studentId}`,
511
+ { headers },
512
+ );
513
+ expect(deleteResponse.ok()).toBeTruthy();
514
+ }
515
+ }
516
+ },
517
+ );
518
+
385
519
  test(
386
520
  'ATP-BASIC-FORM-01: Training Student content remains in the Basic Information tab',
387
521
  { tag: ['@admin', '@flow'] },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli",
3
- "version": "1.1.143",
3
+ "version": "1.1.144",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli",
6
6
  "keywords": [
@@ -11,7 +11,7 @@ export interface IEntityOptions<%=argv.resourceNameCapitalize%> extends IDecorat
11
11
  @Entity<IEntityOptions<%=argv.resourceNameCapitalize%>>('<%=argv.moduleResourceName%>', {
12
12
  openapi: { title: $locale('<%=argv.resourceNameCapitalize%>') },
13
13
  fields: {
14
- id: $makeMetadata(ZovaRender.order(1, 'core')),
14
+ id: $makeMetadata(ZovaRender.order(1, 'core'), ZovaRender.column({ width: 50, fixed: 'left' })),
15
15
  iid: $makeMetadata(ZovaRender.visible(false)),
16
16
  deleted: $makeMetadata(ZovaRender.visible(false)),
17
17
  createdAt: $makeMetadata(