@jskit-ai/ui-generator 0.1.48 → 0.1.50
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/package.descriptor.mjs +117 -24
- package/package.json +3 -3
- package/src/server/buildTemplateContext.js +44 -3
- package/src/server/subcommands/addSubpages.js +89 -2
- package/src/server/subcommands/element.js +42 -10
- package/src/server/subcommands/outlet.js +399 -20
- package/src/server/subcommands/page.js +49 -23
- package/src/server/subcommands/pageSupport.js +115 -37
- package/src/server/subcommands/support.js +128 -23
- package/test/addSubpagesSubcommand.test.js +163 -15
- package/test/buildTemplateContext.test.js +227 -34
- package/test/elementSubcommand.test.js +89 -12
- package/test/outletSubcommand.test.js +305 -14
- package/test/packageDescriptor.test.js +11 -0
- package/test/pageSubcommand.test.js +234 -17
|
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { assertGeneratedUiSourceContract } from "@jskit-ai/kernel/shared/support/generatedUiContract";
|
|
6
7
|
import { runGeneratorSubcommand } from "../src/server/subcommands/element.js";
|
|
7
8
|
|
|
8
9
|
async function withTempApp(run) {
|
|
@@ -14,6 +15,39 @@ async function withTempApp(run) {
|
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function renderTopologyEntry({
|
|
19
|
+
id = "",
|
|
20
|
+
owner = "",
|
|
21
|
+
surfaces = ["*"],
|
|
22
|
+
defaultPlacement = false,
|
|
23
|
+
outlet = "",
|
|
24
|
+
linkRenderer = ""
|
|
25
|
+
} = {}) {
|
|
26
|
+
const ownerLine = owner ? ` owner: "${owner}",\n` : "";
|
|
27
|
+
const defaultLine = defaultPlacement ? " default: true,\n" : "";
|
|
28
|
+
const rendererLines = linkRenderer
|
|
29
|
+
? `,
|
|
30
|
+
renderers: {
|
|
31
|
+
link: "${linkRenderer}"
|
|
32
|
+
}`
|
|
33
|
+
: "";
|
|
34
|
+
return ` {
|
|
35
|
+
id: "${id}",
|
|
36
|
+
${ownerLine} surfaces: ${JSON.stringify(surfaces)},
|
|
37
|
+
${defaultLine} variants: {
|
|
38
|
+
compact: {
|
|
39
|
+
outlet: "${outlet}"${rendererLines}
|
|
40
|
+
},
|
|
41
|
+
medium: {
|
|
42
|
+
outlet: "${outlet}"${rendererLines}
|
|
43
|
+
},
|
|
44
|
+
expanded: {
|
|
45
|
+
outlet: "${outlet}"${rendererLines}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
async function writeAppFixture(appRoot) {
|
|
18
52
|
await mkdir(path.join(appRoot, "config"), { recursive: true });
|
|
19
53
|
await mkdir(path.join(appRoot, "src", "components"), { recursive: true });
|
|
@@ -43,11 +77,39 @@ async function writeAppFixture(appRoot) {
|
|
|
43
77
|
<ShellOutlet
|
|
44
78
|
target="shell-layout:primary-menu"
|
|
45
79
|
default
|
|
46
|
-
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
47
80
|
/>
|
|
48
81
|
<ShellOutlet target="shell-layout:top-right" />
|
|
49
82
|
</div>
|
|
50
83
|
</template>
|
|
84
|
+
`,
|
|
85
|
+
"utf8"
|
|
86
|
+
);
|
|
87
|
+
await writeFile(
|
|
88
|
+
path.join(appRoot, "src", "placementTopology.js"),
|
|
89
|
+
`export default {
|
|
90
|
+
placements: [
|
|
91
|
+
${[
|
|
92
|
+
renderTopologyEntry({
|
|
93
|
+
id: "shell.primary-nav",
|
|
94
|
+
surfaces: ["*"],
|
|
95
|
+
defaultPlacement: true,
|
|
96
|
+
outlet: "shell-layout:primary-menu",
|
|
97
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
98
|
+
}),
|
|
99
|
+
renderTopologyEntry({
|
|
100
|
+
id: "shell.status",
|
|
101
|
+
surfaces: ["*"],
|
|
102
|
+
outlet: "shell-layout:top-right"
|
|
103
|
+
}),
|
|
104
|
+
renderTopologyEntry({
|
|
105
|
+
id: "settings.sections",
|
|
106
|
+
owner: "admin-settings",
|
|
107
|
+
surfaces: ["admin"],
|
|
108
|
+
outlet: "admin-settings:forms"
|
|
109
|
+
})
|
|
110
|
+
].join(",\n")}
|
|
111
|
+
]
|
|
112
|
+
};
|
|
51
113
|
`,
|
|
52
114
|
"utf8"
|
|
53
115
|
);
|
|
@@ -114,9 +176,20 @@ test("ui-generator placed-element subcommand creates component and outlet placem
|
|
|
114
176
|
assert.match(providerSource, /import OpsPanelElement from "\/src\/components\/OpsPanelElement\.vue";/);
|
|
115
177
|
assert.match(providerSource, /registerMainClientComponent\("local\.main\.ui\.element\.ops-panel", \(\) => OpsPanelElement\);/);
|
|
116
178
|
|
|
179
|
+
const componentSource = await readFile(path.join(appRoot, "src", "components", "OpsPanelElement.vue"), "utf8");
|
|
180
|
+
assertGeneratedUiSourceContract(componentSource, {
|
|
181
|
+
profile: "placed-element",
|
|
182
|
+
sourceName: "OpsPanelElement.vue"
|
|
183
|
+
});
|
|
184
|
+
assert.match(componentSource, /class="generated-element-panel"/);
|
|
185
|
+
assert.match(componentSource, /<h2 class="text-subtitle-1 font-weight-medium mb-0">Ops Panel<\/h2>/);
|
|
186
|
+
assert.match(componentSource, /<v-chip color="primary" variant="tonal" size="small">Ready<\/v-chip>/);
|
|
187
|
+
assert.doesNotMatch(componentSource, /Replace this scaffold|Use this area|This is your page/);
|
|
188
|
+
|
|
117
189
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
118
190
|
assert.match(placementSource, /id: "ui-generator\.element\.ops-panel"/);
|
|
119
|
-
assert.match(placementSource, /target: "shell
|
|
191
|
+
assert.match(placementSource, /target: "shell\.status"/);
|
|
192
|
+
assert.match(placementSource, /kind: "component"/);
|
|
120
193
|
assert.match(placementSource, /componentToken: "local\.main\.ui\.element\.ops-panel"/);
|
|
121
194
|
});
|
|
122
195
|
});
|
|
@@ -131,16 +204,16 @@ test("ui-generator placed-element subcommand supports explicit placement overrid
|
|
|
131
204
|
options: {
|
|
132
205
|
name: "Ops Panel",
|
|
133
206
|
surface: "admin",
|
|
134
|
-
placement: "shell
|
|
207
|
+
placement: "shell.primary-nav"
|
|
135
208
|
}
|
|
136
209
|
});
|
|
137
210
|
|
|
138
211
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
139
|
-
assert.match(placementSource, /target: "shell
|
|
212
|
+
assert.match(placementSource, /target: "shell\.primary-nav"/);
|
|
140
213
|
});
|
|
141
214
|
});
|
|
142
215
|
|
|
143
|
-
test("ui-generator placed-element infers surface from
|
|
216
|
+
test("ui-generator placed-element infers surface from an owner-scoped semantic placement", async () => {
|
|
144
217
|
await withTempApp(async (appRoot) => {
|
|
145
218
|
await writeAppFixture(appRoot);
|
|
146
219
|
|
|
@@ -149,12 +222,14 @@ test("ui-generator placed-element infers surface from a page-owned placement tar
|
|
|
149
222
|
subcommand: "placed-element",
|
|
150
223
|
options: {
|
|
151
224
|
name: "Ops Panel",
|
|
152
|
-
placement: "
|
|
225
|
+
placement: "settings.sections",
|
|
226
|
+
owner: "admin-settings"
|
|
153
227
|
}
|
|
154
228
|
});
|
|
155
229
|
|
|
156
230
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
157
|
-
assert.match(placementSource, /target: "
|
|
231
|
+
assert.match(placementSource, /target: "settings\.sections"/);
|
|
232
|
+
assert.match(placementSource, /owner: "admin-settings"/);
|
|
158
233
|
assert.match(placementSource, /surfaces: \["admin"\]/);
|
|
159
234
|
});
|
|
160
235
|
});
|
|
@@ -172,7 +247,7 @@ test("ui-generator placed-element infers the only enabled surface for shared she
|
|
|
172
247
|
});
|
|
173
248
|
|
|
174
249
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
175
|
-
assert.match(placementSource, /target: "shell
|
|
250
|
+
assert.match(placementSource, /target: "shell\.status"/);
|
|
176
251
|
assert.match(placementSource, /surfaces: \["admin"\]/);
|
|
177
252
|
});
|
|
178
253
|
});
|
|
@@ -210,7 +285,7 @@ test("ui-generator placed-element requires explicit surface when a shared shell
|
|
|
210
285
|
name: "Ops Panel"
|
|
211
286
|
}
|
|
212
287
|
}),
|
|
213
|
-
/could not infer a surface for placement target "shell
|
|
288
|
+
/could not infer a surface for placement target "shell.status". Pass --surface explicitly/
|
|
214
289
|
);
|
|
215
290
|
});
|
|
216
291
|
});
|
|
@@ -226,11 +301,12 @@ test("ui-generator placed-element rejects explicit surfaces that conflict with p
|
|
|
226
301
|
subcommand: "placed-element",
|
|
227
302
|
options: {
|
|
228
303
|
name: "Ops Panel",
|
|
229
|
-
placement: "
|
|
304
|
+
placement: "settings.sections",
|
|
305
|
+
owner: "admin-settings",
|
|
230
306
|
surface: "console"
|
|
231
307
|
}
|
|
232
308
|
}),
|
|
233
|
-
/target "
|
|
309
|
+
/target "settings.sections" is not available on surface "console"/
|
|
234
310
|
);
|
|
235
311
|
});
|
|
236
312
|
});
|
|
@@ -285,7 +361,8 @@ test("ui-generator placed-element subcommand overwrites an existing component wh
|
|
|
285
361
|
]);
|
|
286
362
|
|
|
287
363
|
const componentSource = await readFile(path.join(appRoot, "src", "components", "OpsPanelElement.vue"), "utf8");
|
|
288
|
-
assert.match(componentSource, /<h2 class="text-
|
|
364
|
+
assert.match(componentSource, /<h2 class="text-subtitle-1 font-weight-medium mb-0">Ops Panel<\/h2>/);
|
|
365
|
+
assert.doesNotMatch(componentSource, /custom/);
|
|
289
366
|
});
|
|
290
367
|
});
|
|
291
368
|
|
|
@@ -8,6 +8,23 @@ import { runGeneratorSubcommand } from "../src/server/subcommands/outlet.js";
|
|
|
8
8
|
async function withTempApp(run) {
|
|
9
9
|
const appRoot = await mkdtemp(path.join(tmpdir(), "ui-generator-outlet-"));
|
|
10
10
|
try {
|
|
11
|
+
await mkdir(path.join(appRoot, "src"), { recursive: true });
|
|
12
|
+
await writeFile(
|
|
13
|
+
path.join(appRoot, "src", "placementTopology.js"),
|
|
14
|
+
`const placements = [];
|
|
15
|
+
|
|
16
|
+
function addPlacementTopology(value = {}) {
|
|
17
|
+
placements.push(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { addPlacementTopology };
|
|
21
|
+
|
|
22
|
+
export default function getPlacementTopology() {
|
|
23
|
+
return { placements };
|
|
24
|
+
}
|
|
25
|
+
`,
|
|
26
|
+
"utf8"
|
|
27
|
+
);
|
|
11
28
|
return await run(appRoot);
|
|
12
29
|
} finally {
|
|
13
30
|
await rm(appRoot, { recursive: true, force: true });
|
|
@@ -40,24 +57,34 @@ import { computed } from "vue";
|
|
|
40
57
|
subcommand: "outlet",
|
|
41
58
|
args: [targetFile],
|
|
42
59
|
options: {
|
|
43
|
-
target: "contact-view:sub-pages"
|
|
60
|
+
target: "contact-view:sub-pages",
|
|
61
|
+
placement: "page.section-nav"
|
|
44
62
|
}
|
|
45
63
|
});
|
|
46
64
|
|
|
47
|
-
assert.deepEqual(result.touchedFiles, [targetFile]);
|
|
65
|
+
assert.deepEqual(result.touchedFiles, [targetFile, "src/placementTopology.js"]);
|
|
48
66
|
|
|
49
67
|
const output = await readFile(targetPath, "utf8");
|
|
50
68
|
assert.match(output, /import ShellOutlet from "@jskit-ai\/shell-web\/client\/components\/ShellOutlet";/);
|
|
51
69
|
assert.match(output, /<ShellOutlet target="contact-view:sub-pages" \/>/);
|
|
52
70
|
assert.doesNotMatch(output, /RouterView/);
|
|
53
71
|
assert.doesNotMatch(output, /jskit:ui-generator\.outlet:/);
|
|
72
|
+
const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
|
|
73
|
+
assert.match(topologySource, /id: "page\.section-nav"/);
|
|
74
|
+
assert.match(topologySource, /owner: "contact-view"/);
|
|
75
|
+
assert.match(topologySource, /compact: \{/);
|
|
76
|
+
assert.match(topologySource, /medium: \{/);
|
|
77
|
+
assert.match(topologySource, /expanded: \{/);
|
|
78
|
+
assert.match(topologySource, /renderers: \{/);
|
|
79
|
+
assert.match(topologySource, /link: "local\.main\.ui\.surface-aware-menu-link-item"/);
|
|
54
80
|
|
|
55
81
|
const rerun = await runGeneratorSubcommand({
|
|
56
82
|
appRoot,
|
|
57
83
|
subcommand: "outlet",
|
|
58
84
|
args: [targetFile],
|
|
59
85
|
options: {
|
|
60
|
-
target: "contact-view:sub-pages"
|
|
86
|
+
target: "contact-view:sub-pages",
|
|
87
|
+
placement: "page.section-nav"
|
|
61
88
|
}
|
|
62
89
|
});
|
|
63
90
|
|
|
@@ -65,6 +92,224 @@ import { computed } from "vue";
|
|
|
65
92
|
});
|
|
66
93
|
});
|
|
67
94
|
|
|
95
|
+
test("ui-generator outlet can inject a concrete outlet without topology", async () => {
|
|
96
|
+
await withTempApp(async (appRoot) => {
|
|
97
|
+
const targetFile = "src/components/ReportsPanel.vue";
|
|
98
|
+
const targetPath = path.join(appRoot, targetFile);
|
|
99
|
+
|
|
100
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
101
|
+
await writeFile(targetPath, "<template><section /></template>\n", "utf8");
|
|
102
|
+
|
|
103
|
+
const result = await runGeneratorSubcommand({
|
|
104
|
+
appRoot,
|
|
105
|
+
subcommand: "outlet",
|
|
106
|
+
args: [targetFile],
|
|
107
|
+
options: {
|
|
108
|
+
target: "reports:bottom-actions"
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.deepEqual(result.touchedFiles, [targetFile]);
|
|
113
|
+
const output = await readFile(targetPath, "utf8");
|
|
114
|
+
assert.match(output, /<ShellOutlet target="reports:bottom-actions" \/>/);
|
|
115
|
+
const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
|
|
116
|
+
assert.doesNotMatch(topologySource, /reports\.actions/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("ui-generator topology creates adaptive component topology without injecting outlets", async () => {
|
|
121
|
+
await withTempApp(async (appRoot) => {
|
|
122
|
+
const result = await runGeneratorSubcommand({
|
|
123
|
+
appRoot,
|
|
124
|
+
subcommand: "topology",
|
|
125
|
+
args: [],
|
|
126
|
+
options: {
|
|
127
|
+
placement: "reports.actions",
|
|
128
|
+
kind: "component",
|
|
129
|
+
"compact-target": "reports:bottom-actions",
|
|
130
|
+
"medium-target": "reports:toolbar-actions",
|
|
131
|
+
"expanded-target": "reports:side-actions",
|
|
132
|
+
surface: "admin",
|
|
133
|
+
description: "Report action controls."
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
assert.deepEqual(result.touchedFiles, ["src/placementTopology.js"]);
|
|
138
|
+
const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
|
|
139
|
+
assert.match(topologySource, /id: "reports\.actions"/);
|
|
140
|
+
assert.match(topologySource, /description: "Report action controls\."/);
|
|
141
|
+
assert.match(topologySource, /surfaces: \["admin"\]/);
|
|
142
|
+
assert.match(topologySource, /compact: \{\n {6}outlet: "reports:bottom-actions"\n {4}\}/);
|
|
143
|
+
assert.match(topologySource, /medium: \{\n {6}outlet: "reports:toolbar-actions"\n {4}\}/);
|
|
144
|
+
assert.match(topologySource, /expanded: \{\n {6}outlet: "reports:side-actions"\n {4}\}/);
|
|
145
|
+
assert.doesNotMatch(topologySource, /renderers: \{/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("ui-generator topology creates link topology with owner inference for one target", async () => {
|
|
150
|
+
await withTempApp(async (appRoot) => {
|
|
151
|
+
const result = await runGeneratorSubcommand({
|
|
152
|
+
appRoot,
|
|
153
|
+
subcommand: "topology",
|
|
154
|
+
args: [],
|
|
155
|
+
options: {
|
|
156
|
+
placement: "page.section-nav",
|
|
157
|
+
kind: "link",
|
|
158
|
+
target: "report-view:sub-pages",
|
|
159
|
+
surface: "admin"
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(result.touchedFiles, ["src/placementTopology.js"]);
|
|
164
|
+
const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
|
|
165
|
+
assert.match(topologySource, /id: "page\.section-nav"/);
|
|
166
|
+
assert.match(topologySource, /owner: "report-view"/);
|
|
167
|
+
assert.match(topologySource, /compact: \{\n {6}outlet: "report-view:sub-pages",/);
|
|
168
|
+
assert.match(topologySource, /medium: \{\n {6}outlet: "report-view:sub-pages",/);
|
|
169
|
+
assert.match(topologySource, /expanded: \{\n {6}outlet: "report-view:sub-pages",/);
|
|
170
|
+
assert.match(topologySource, /link: "local\.main\.ui\.surface-aware-menu-link-item"/);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("ui-generator topology treats existing app topology id and owner as already present without a marker", async () => {
|
|
175
|
+
await withTempApp(async (appRoot) => {
|
|
176
|
+
await writeFile(
|
|
177
|
+
path.join(appRoot, "src", "placementTopology.js"),
|
|
178
|
+
`export default {
|
|
179
|
+
placements: [
|
|
180
|
+
{
|
|
181
|
+
id: "page.actions",
|
|
182
|
+
owner: "customer-view",
|
|
183
|
+
surfaces: ["admin"],
|
|
184
|
+
variants: {
|
|
185
|
+
compact: { outlet: "customer-view:summary-actions" },
|
|
186
|
+
medium: { outlet: "customer-view:summary-actions" },
|
|
187
|
+
expanded: { outlet: "customer-view:summary-actions" }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
};
|
|
192
|
+
`,
|
|
193
|
+
"utf8"
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const result = await runGeneratorSubcommand({
|
|
197
|
+
appRoot,
|
|
198
|
+
subcommand: "topology",
|
|
199
|
+
args: [],
|
|
200
|
+
options: {
|
|
201
|
+
placement: "page.actions",
|
|
202
|
+
kind: "component",
|
|
203
|
+
target: "customer-view:summary-actions"
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
assert.deepEqual(result.touchedFiles, []);
|
|
208
|
+
const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
|
|
209
|
+
assert.equal((topologySource.match(/id: "page\.actions"/g) || []).length, 1);
|
|
210
|
+
assert.doesNotMatch(topologySource, /jskit:ui-generator\.topology:page\.actions:customer-view/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("ui-generator topology rejects an existing semantic id and owner with different outlets", async () => {
|
|
215
|
+
await withTempApp(async (appRoot) => {
|
|
216
|
+
await writeFile(
|
|
217
|
+
path.join(appRoot, "src", "placementTopology.js"),
|
|
218
|
+
`export default {
|
|
219
|
+
placements: [
|
|
220
|
+
{
|
|
221
|
+
id: "page.actions",
|
|
222
|
+
owner: "customer-view",
|
|
223
|
+
surfaces: ["admin"],
|
|
224
|
+
variants: {
|
|
225
|
+
compact: { outlet: "customer-view:summary-actions" },
|
|
226
|
+
medium: { outlet: "customer-view:summary-actions" },
|
|
227
|
+
expanded: { outlet: "customer-view:summary-actions" }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
};
|
|
232
|
+
`,
|
|
233
|
+
"utf8"
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await assert.rejects(
|
|
237
|
+
runGeneratorSubcommand({
|
|
238
|
+
appRoot,
|
|
239
|
+
subcommand: "topology",
|
|
240
|
+
args: [],
|
|
241
|
+
options: {
|
|
242
|
+
placement: "page.actions",
|
|
243
|
+
kind: "component",
|
|
244
|
+
target: "customer-view:footer-actions"
|
|
245
|
+
}
|
|
246
|
+
}),
|
|
247
|
+
/semantic placement "page\.actions" for owner "customer-view" already exists with different outlet mapping/
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("ui-generator topology requires owner for owner-scoped placements across multiple hosts", async () => {
|
|
253
|
+
await withTempApp(async (appRoot) => {
|
|
254
|
+
await assert.rejects(
|
|
255
|
+
runGeneratorSubcommand({
|
|
256
|
+
appRoot,
|
|
257
|
+
subcommand: "topology",
|
|
258
|
+
args: [],
|
|
259
|
+
options: {
|
|
260
|
+
placement: "page.section-nav",
|
|
261
|
+
kind: "link",
|
|
262
|
+
"compact-target": "report-compact:sub-pages",
|
|
263
|
+
"medium-target": "report-medium:sub-pages",
|
|
264
|
+
"expanded-target": "report-expanded:sub-pages",
|
|
265
|
+
surface: "admin"
|
|
266
|
+
}
|
|
267
|
+
}),
|
|
268
|
+
/ui-generator topology requires --owner because semantic placement "page\.section-nav" maps to multiple outlet hosts/
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("ui-generator outlet validates topology before writing the concrete outlet", async () => {
|
|
274
|
+
await withTempApp(async (appRoot) => {
|
|
275
|
+
const targetFile = "src/components/ContactDetailsPanel.vue";
|
|
276
|
+
const targetPath = path.join(appRoot, targetFile);
|
|
277
|
+
const originalSource = "<template><div /></template>\n";
|
|
278
|
+
|
|
279
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
280
|
+
await writeFile(targetPath, originalSource, "utf8");
|
|
281
|
+
await writeFile(
|
|
282
|
+
path.join(appRoot, "src", "placementTopology.js"),
|
|
283
|
+
`export default {
|
|
284
|
+
placements: [
|
|
285
|
+
{
|
|
286
|
+
id: "page.actions",
|
|
287
|
+
owner: "contact-view",
|
|
288
|
+
variants: {}
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
};
|
|
292
|
+
`,
|
|
293
|
+
"utf8"
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await assert.rejects(
|
|
297
|
+
runGeneratorSubcommand({
|
|
298
|
+
appRoot,
|
|
299
|
+
subcommand: "outlet",
|
|
300
|
+
args: [targetFile],
|
|
301
|
+
options: {
|
|
302
|
+
target: "contact-view:summary-actions",
|
|
303
|
+
placement: "page.actions"
|
|
304
|
+
}
|
|
305
|
+
}),
|
|
306
|
+
/requires compact topology variant/
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
assert.equal(await readFile(targetPath, "utf8"), originalSource);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
68
313
|
test("ui-generator outlet does not inject a second matching outlet", async () => {
|
|
69
314
|
await withTempApp(async (appRoot) => {
|
|
70
315
|
const targetFile = "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue";
|
|
@@ -91,7 +336,8 @@ import ShellOutlet from "@jskit-ai/shell-web/client/components/ShellOutlet";
|
|
|
91
336
|
subcommand: "outlet",
|
|
92
337
|
args: [targetFile],
|
|
93
338
|
options: {
|
|
94
|
-
target: "contact-view:sub-pages"
|
|
339
|
+
target: "contact-view:sub-pages",
|
|
340
|
+
placement: "page.section-nav"
|
|
95
341
|
}
|
|
96
342
|
});
|
|
97
343
|
|
|
@@ -124,7 +370,8 @@ test("ui-generator outlet creates script setup when missing", async () => {
|
|
|
124
370
|
subcommand: "outlet",
|
|
125
371
|
args: [targetFile],
|
|
126
372
|
options: {
|
|
127
|
-
target: "contact-view:sub-pages"
|
|
373
|
+
target: "contact-view:sub-pages",
|
|
374
|
+
placement: "page.section-nav"
|
|
128
375
|
}
|
|
129
376
|
});
|
|
130
377
|
|
|
@@ -135,6 +382,44 @@ test("ui-generator outlet creates script setup when missing", async () => {
|
|
|
135
382
|
});
|
|
136
383
|
});
|
|
137
384
|
|
|
385
|
+
test("ui-generator outlet creates script setup instead of adding template imports to normal script", async () => {
|
|
386
|
+
await withTempApp(async (appRoot) => {
|
|
387
|
+
const targetFile = "src/components/OptionsPanel.vue";
|
|
388
|
+
const targetPath = path.join(appRoot, targetFile);
|
|
389
|
+
|
|
390
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
391
|
+
await writeFile(
|
|
392
|
+
targetPath,
|
|
393
|
+
`<script>
|
|
394
|
+
export default {
|
|
395
|
+
name: "OptionsPanel"
|
|
396
|
+
};
|
|
397
|
+
</script>
|
|
398
|
+
|
|
399
|
+
<template>
|
|
400
|
+
<div>Options</div>
|
|
401
|
+
</template>
|
|
402
|
+
`,
|
|
403
|
+
"utf8"
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
await runGeneratorSubcommand({
|
|
407
|
+
appRoot,
|
|
408
|
+
subcommand: "outlet",
|
|
409
|
+
args: [targetFile],
|
|
410
|
+
options: {
|
|
411
|
+
target: "options:footer-actions",
|
|
412
|
+
placement: "page.actions"
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const output = await readFile(targetPath, "utf8");
|
|
417
|
+
assert.match(output, /<script setup>\nimport ShellOutlet from "@jskit-ai\/shell-web\/client\/components\/ShellOutlet";\n<\/script>/);
|
|
418
|
+
assert.match(output, /<script>\nexport default/);
|
|
419
|
+
assert.match(output, /<ShellOutlet target="options:footer-actions" \/>/);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
138
423
|
test("ui-generator outlet inserts generated script after existing route block", async () => {
|
|
139
424
|
await withTempApp(async (appRoot) => {
|
|
140
425
|
const targetFile = "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue";
|
|
@@ -161,7 +446,8 @@ test("ui-generator outlet inserts generated script after existing route block",
|
|
|
161
446
|
subcommand: "outlet",
|
|
162
447
|
args: [targetFile],
|
|
163
448
|
options: {
|
|
164
|
-
target: "contact-view:sub-pages"
|
|
449
|
+
target: "contact-view:sub-pages",
|
|
450
|
+
placement: "page.section-nav"
|
|
165
451
|
}
|
|
166
452
|
});
|
|
167
453
|
|
|
@@ -203,7 +489,8 @@ test("ui-generator outlet keeps indentation when injected into nested template b
|
|
|
203
489
|
subcommand: "outlet",
|
|
204
490
|
args: [targetFile],
|
|
205
491
|
options: {
|
|
206
|
-
target: "contact-view:sub-pages"
|
|
492
|
+
target: "contact-view:sub-pages",
|
|
493
|
+
placement: "page.section-nav"
|
|
207
494
|
}
|
|
208
495
|
});
|
|
209
496
|
|
|
@@ -229,6 +516,7 @@ test("ui-generator outlet rejects unsupported options", async () => {
|
|
|
229
516
|
args: [targetFile],
|
|
230
517
|
options: {
|
|
231
518
|
target: "contact-view:sub-pages",
|
|
519
|
+
placement: "page.section-nav",
|
|
232
520
|
bogus: "routed"
|
|
233
521
|
}
|
|
234
522
|
}),
|
|
@@ -250,7 +538,8 @@ test("ui-generator outlet supports explicit target host:position", async () => {
|
|
|
250
538
|
subcommand: "outlet",
|
|
251
539
|
args: [targetFile],
|
|
252
540
|
options: {
|
|
253
|
-
target: "customer-view:summary-actions"
|
|
541
|
+
target: "customer-view:summary-actions",
|
|
542
|
+
placement: "page.actions"
|
|
254
543
|
}
|
|
255
544
|
});
|
|
256
545
|
|
|
@@ -272,9 +561,10 @@ test("ui-generator outlet rejects non-vue target files without changing them", a
|
|
|
272
561
|
runGeneratorSubcommand({
|
|
273
562
|
appRoot,
|
|
274
563
|
subcommand: "outlet",
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
target: "vet-view:sub-pages"
|
|
564
|
+
args: [targetFile],
|
|
565
|
+
options: {
|
|
566
|
+
target: "vet-view:sub-pages",
|
|
567
|
+
placement: "page.section-nav"
|
|
278
568
|
}
|
|
279
569
|
}),
|
|
280
570
|
/ui-generator outlet target file must be an existing Vue SFC \(\.vue\): src\/pages\/w\/\[workspaceSlug\]\/admin\/practice\/vets\/_components\/VetAddEditFormFields\.js\./
|
|
@@ -297,9 +587,10 @@ test("ui-generator outlet validates target format", async () => {
|
|
|
297
587
|
runGeneratorSubcommand({
|
|
298
588
|
appRoot,
|
|
299
589
|
subcommand: "outlet",
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
target: "customer-view:"
|
|
590
|
+
args: [targetFile],
|
|
591
|
+
options: {
|
|
592
|
+
target: "customer-view:",
|
|
593
|
+
placement: "page.actions"
|
|
303
594
|
}
|
|
304
595
|
}),
|
|
305
596
|
/ui-generator outlet option "target" must be a target in "host:position" format\./
|
|
@@ -8,4 +8,15 @@ test("ui-generator surface options validate against enabled surface ids", () =>
|
|
|
8
8
|
assert.equal(descriptor.metadata?.generatorSubcommands?.["placed-element"]?.optionNames?.includes("surface"), true);
|
|
9
9
|
assert.equal(descriptor.metadata?.generatorSubcommands?.["placed-element"]?.requiredOptionNames?.includes("surface"), false);
|
|
10
10
|
assert.equal(descriptor.metadata?.generatorSubcommands?.page?.optionNames?.includes("force"), true);
|
|
11
|
+
assert.equal(descriptor.options?.kind?.validationType, "enum");
|
|
12
|
+
assert.equal(descriptor.options?.["navigation-role"]?.validationType, "enum");
|
|
13
|
+
assert.deepEqual(
|
|
14
|
+
descriptor.options?.["navigation-role"]?.allowedValues,
|
|
15
|
+
["primary", "secondary", "utility", "detail", "workflow", "none"]
|
|
16
|
+
);
|
|
17
|
+
assert.equal(descriptor.metadata?.generatorSubcommands?.page?.optionNames?.includes("navigation-role"), true);
|
|
18
|
+
assert.equal(descriptor.metadata?.generatorSubcommands?.outlet?.requiredOptionNames?.includes("placement"), false);
|
|
19
|
+
assert.equal(descriptor.metadata?.generatorSubcommands?.topology?.entrypoint, "src/server/subcommands/outlet.js");
|
|
20
|
+
assert.equal(descriptor.metadata?.generatorSubcommands?.topology?.optionNames?.includes("compact-target"), true);
|
|
21
|
+
assert.equal(descriptor.metadata?.generatorSubcommands?.topology?.requiredOptionNames?.includes("kind"), true);
|
|
11
22
|
});
|