@cat-factory/app 0.131.0 → 0.133.0
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/app/components/layout/BoardToolbar.vue +18 -0
- package/app/components/layout/CommandBar.vue +95 -182
- package/app/components/layout/SideBar.vue +18 -266
- package/app/composables/useNavContributions.ts +75 -0
- package/app/modular/nav-contributions.spec.ts +182 -0
- package/app/modular/nav-contributions.ts +483 -0
- package/app/modular/nav-gates.ts +63 -0
- package/app/modular/registry.spec.ts +76 -0
- package/app/modular/registry.ts +93 -0
- package/app/plugins/modular.client.ts +40 -0
- package/app/utils/modular.ts +11 -0
- package/package.json +7 -2
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { defineModule } from '@modular-vue/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single nav/command catalog for the layer (slice 1 of the modular-vue
|
|
5
|
+
* adoption — docs/initiatives/modular-vue-adoption.md).
|
|
6
|
+
*
|
|
7
|
+
* Every destination is declared ONCE here as data and rendered three ways —
|
|
8
|
+
* `SideBar`, `CommandBar`, `BoardToolbar` — instead of each shell hand-rolling
|
|
9
|
+
* its own item list + RBAC gating (the pre-slice-1 triple-maintenance). RBAC /
|
|
10
|
+
* availability gating is a reactive `slotFilter` ({@link navSlotFilter}) over a
|
|
11
|
+
* reactive `gates` service (see `nav-gates.ts`), read through `useReactiveSlots`
|
|
12
|
+
* so an item shows/hides the instant a permission or connection flips — no
|
|
13
|
+
* `recalculateSlots()` call. A consumer deployment contributes its own items to
|
|
14
|
+
* the same `nav` slot via `registerAppModule`, so they light up in all three
|
|
15
|
+
* shells with zero shell edits.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Which shell(s) render a contribution. */
|
|
19
|
+
export type NavSurface = 'sidebar' | 'command' | 'toolbar'
|
|
20
|
+
|
|
21
|
+
/** Sidebar section a contribution lands in (its i18n header is `nav.<group>`). */
|
|
22
|
+
export type NavSidebarGroup =
|
|
23
|
+
| 'create'
|
|
24
|
+
| 'repositories'
|
|
25
|
+
| 'integrations'
|
|
26
|
+
| 'infrastructure'
|
|
27
|
+
| 'workspaceContext'
|
|
28
|
+
| 'configuration'
|
|
29
|
+
|
|
30
|
+
/** Command-palette group (its i18n label is `layout.commandBar.groups.<group>`). */
|
|
31
|
+
export type NavCommandGroup = 'create' | 'repositories' | 'integrations' | 'workspace' | 'account'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The reactive gate inputs a contribution's `gate` predicate reads. Backed by a
|
|
35
|
+
* plain service object whose getters read the host's reactive RBAC/availability
|
|
36
|
+
* state (see `createNavGates`), so reading them inside the `useReactiveSlots`
|
|
37
|
+
* computed tracks them.
|
|
38
|
+
*/
|
|
39
|
+
export interface NavGates {
|
|
40
|
+
/** `board.write` — create pipelines, add repos. */
|
|
41
|
+
canWriteBoard: boolean
|
|
42
|
+
/** `integrations.manage` — bootstrap, connection/infra management, sandbox. */
|
|
43
|
+
canManageIntegrations: boolean
|
|
44
|
+
/** `settings.manage` — workspace/model config, fragment library. */
|
|
45
|
+
canManageSettings: boolean
|
|
46
|
+
/** The GitHub (source-control) integration is enabled on the backend. */
|
|
47
|
+
githubAvailable: boolean
|
|
48
|
+
/** The prompt-fragment library integration is enabled. */
|
|
49
|
+
libraryAvailable: boolean
|
|
50
|
+
/** An execution/test-env backend is reported (runner pool / environment / local). */
|
|
51
|
+
infrastructureAvailable: boolean
|
|
52
|
+
/** Accounts (auth) are enabled on the deployment. */
|
|
53
|
+
accountsEnabled: boolean
|
|
54
|
+
/** The caller is an admin of the active account. */
|
|
55
|
+
isAccountAdmin: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Command-palette placement + copy for a contribution that appears in the palette. */
|
|
59
|
+
export interface NavCommandSpec {
|
|
60
|
+
group: NavCommandGroup
|
|
61
|
+
order: number
|
|
62
|
+
/** Palette label key; falls back to the contribution's `labelKey`. */
|
|
63
|
+
labelKey?: string
|
|
64
|
+
/** Extra fuzzy-match keywords (i18n key). */
|
|
65
|
+
keywordsKey?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The first-party action ids, each resolved to a host `ui`-store handler in
|
|
70
|
+
* `useNavContributions`. Typing {@link NavContribution.action} against this union
|
|
71
|
+
* (and the handler map as an exhaustive `Record<NavActionId, …>`) makes a drift
|
|
72
|
+
* between the catalog and the handler map a compile error instead of a silently
|
|
73
|
+
* dead button. Consumer modules don't use these — they carry their own `run`.
|
|
74
|
+
*/
|
|
75
|
+
export const NAV_ACTIONS = [
|
|
76
|
+
'buildPipeline',
|
|
77
|
+
'addFromRepo',
|
|
78
|
+
'bootstrapRepo',
|
|
79
|
+
'integrationsHub',
|
|
80
|
+
'sandbox',
|
|
81
|
+
'kaizen',
|
|
82
|
+
'infrastructure',
|
|
83
|
+
'environmentSetup',
|
|
84
|
+
'fragmentLibrary',
|
|
85
|
+
'mergeThresholds',
|
|
86
|
+
'workspaceSettings',
|
|
87
|
+
'modelConfiguration',
|
|
88
|
+
'serviceFragmentDefaults',
|
|
89
|
+
'localModels',
|
|
90
|
+
'accountSettings',
|
|
91
|
+
'operatorDashboard',
|
|
92
|
+
'shortcuts',
|
|
93
|
+
] as const
|
|
94
|
+
|
|
95
|
+
export type NavActionId = (typeof NAV_ACTIONS)[number]
|
|
96
|
+
|
|
97
|
+
/** One destination, declared once and rendered per surface. */
|
|
98
|
+
export interface NavContribution {
|
|
99
|
+
id: string
|
|
100
|
+
/** Default (sidebar) label i18n key. */
|
|
101
|
+
labelKey: string
|
|
102
|
+
icon: string
|
|
103
|
+
surfaces: readonly NavSurface[]
|
|
104
|
+
/** Reactive predicate over {@link NavGates}; absent = always visible. */
|
|
105
|
+
gate?: (g: NavGates) => boolean
|
|
106
|
+
/**
|
|
107
|
+
* First-party action id, resolved to a `run()` against the host `ui` store by
|
|
108
|
+
* `useNavContributions`. A consumer module that has its own stores instead
|
|
109
|
+
* supplies {@link run} directly.
|
|
110
|
+
*/
|
|
111
|
+
action?: NavActionId
|
|
112
|
+
/** Direct handler (consumer modules); takes precedence over {@link action}. */
|
|
113
|
+
run?: () => void
|
|
114
|
+
/** Stable selector for e2e / existing specs. */
|
|
115
|
+
testId?: string
|
|
116
|
+
/** Sidebar placement (present when `surfaces` includes `'sidebar'`). */
|
|
117
|
+
sidebar?: { group: NavSidebarGroup; order: number }
|
|
118
|
+
/** Command-palette placement (present when `surfaces` includes `'command'`). */
|
|
119
|
+
command?: NavCommandSpec
|
|
120
|
+
/** Toolbar placement (present when `surfaces` includes `'toolbar'`). */
|
|
121
|
+
toolbar?: { order: number }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The layer's slot map. Consumer modules contribute to the same `nav` key. The
|
|
126
|
+
* index signature is mutable (`unknown[]`) to satisfy the runtime's `SlotMap`
|
|
127
|
+
* constraint; `unknown[]` still satisfies `useReactiveSlots`' `readonly unknown[]`
|
|
128
|
+
* bound, so both the install and the read side accept it.
|
|
129
|
+
*/
|
|
130
|
+
export interface AppSlots {
|
|
131
|
+
nav: NavContribution[]
|
|
132
|
+
[key: string]: unknown[]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const S = (...s: NavSurface[]) => s as readonly NavSurface[]
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The first-party catalog. Mapped 1:1 from the pre-slice-1 `SideBar` + `CommandBar`
|
|
139
|
+
* gating. Two deliberate consistency unifications noted in the tracker:
|
|
140
|
+
* - `account-settings` gates on `accountsEnabled` in BOTH shells (the palette
|
|
141
|
+
* previously showed it unconditionally; the account modal only makes sense
|
|
142
|
+
* with accounts enabled).
|
|
143
|
+
* - one icon per destination across shells.
|
|
144
|
+
*/
|
|
145
|
+
export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
146
|
+
{
|
|
147
|
+
id: 'build-pipeline',
|
|
148
|
+
labelKey: 'nav.buildPipeline',
|
|
149
|
+
icon: 'i-lucide-workflow',
|
|
150
|
+
surfaces: S('sidebar', 'command'),
|
|
151
|
+
gate: (g) => g.canWriteBoard,
|
|
152
|
+
action: 'buildPipeline',
|
|
153
|
+
testId: 'nav-build-pipeline',
|
|
154
|
+
sidebar: { group: 'create', order: 10 },
|
|
155
|
+
command: {
|
|
156
|
+
group: 'create',
|
|
157
|
+
order: 10,
|
|
158
|
+
labelKey: 'layout.commandBar.cmd.newPipeline',
|
|
159
|
+
keywordsKey: 'layout.commandBar.keywords.newPipeline',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'add-from-repo',
|
|
164
|
+
labelKey: 'nav.addFromRepo',
|
|
165
|
+
icon: 'i-lucide-folder-git-2',
|
|
166
|
+
surfaces: S('sidebar', 'command'),
|
|
167
|
+
gate: (g) => g.githubAvailable && g.canWriteBoard,
|
|
168
|
+
action: 'addFromRepo',
|
|
169
|
+
testId: 'nav-add-from-repo',
|
|
170
|
+
sidebar: { group: 'repositories', order: 10 },
|
|
171
|
+
command: {
|
|
172
|
+
group: 'repositories',
|
|
173
|
+
order: 10,
|
|
174
|
+
labelKey: 'layout.commandBar.cmd.addFromRepo',
|
|
175
|
+
keywordsKey: 'layout.commandBar.keywords.addFromRepo',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'bootstrap-repo',
|
|
180
|
+
labelKey: 'nav.bootstrapRepo',
|
|
181
|
+
icon: 'i-lucide-git-branch-plus',
|
|
182
|
+
surfaces: S('sidebar', 'command'),
|
|
183
|
+
gate: (g) => g.canManageIntegrations,
|
|
184
|
+
action: 'bootstrapRepo',
|
|
185
|
+
testId: 'nav-bootstrap-repo',
|
|
186
|
+
sidebar: { group: 'repositories', order: 20 },
|
|
187
|
+
command: {
|
|
188
|
+
group: 'repositories',
|
|
189
|
+
order: 20,
|
|
190
|
+
labelKey: 'layout.commandBar.cmd.bootstrapRepo',
|
|
191
|
+
keywordsKey: 'layout.commandBar.keywords.bootstrapRepo',
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 'integrations-hub',
|
|
196
|
+
labelKey: 'nav.integrations',
|
|
197
|
+
icon: 'i-lucide-blocks',
|
|
198
|
+
surfaces: S('sidebar'),
|
|
199
|
+
gate: (g) => g.canManageIntegrations,
|
|
200
|
+
action: 'integrationsHub',
|
|
201
|
+
testId: 'nav-integrations',
|
|
202
|
+
sidebar: { group: 'integrations', order: 10 },
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'sandbox',
|
|
206
|
+
labelKey: 'nav.sandbox',
|
|
207
|
+
icon: 'i-lucide-flask-conical',
|
|
208
|
+
surfaces: S('sidebar', 'command'),
|
|
209
|
+
gate: (g) => g.canManageIntegrations,
|
|
210
|
+
action: 'sandbox',
|
|
211
|
+
testId: 'nav-sandbox',
|
|
212
|
+
sidebar: { group: 'integrations', order: 20 },
|
|
213
|
+
command: {
|
|
214
|
+
group: 'workspace',
|
|
215
|
+
order: 70,
|
|
216
|
+
labelKey: 'layout.commandBar.cmd.sandbox',
|
|
217
|
+
keywordsKey: 'layout.commandBar.keywords.sandbox',
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: 'kaizen',
|
|
222
|
+
labelKey: 'nav.kaizen',
|
|
223
|
+
icon: 'i-lucide-sparkles',
|
|
224
|
+
surfaces: S('sidebar'),
|
|
225
|
+
action: 'kaizen',
|
|
226
|
+
testId: 'nav-kaizen',
|
|
227
|
+
sidebar: { group: 'integrations', order: 30 },
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'infrastructure',
|
|
231
|
+
labelKey: 'nav.infrastructure',
|
|
232
|
+
icon: 'i-lucide-server-cog',
|
|
233
|
+
surfaces: S('sidebar'),
|
|
234
|
+
gate: (g) => g.infrastructureAvailable,
|
|
235
|
+
action: 'infrastructure',
|
|
236
|
+
testId: 'nav-infrastructure',
|
|
237
|
+
sidebar: { group: 'infrastructure', order: 10 },
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'environment-setup',
|
|
241
|
+
labelKey: 'nav.environmentSetup',
|
|
242
|
+
icon: 'i-lucide-flask-conical',
|
|
243
|
+
surfaces: S('sidebar'),
|
|
244
|
+
gate: (g) => g.infrastructureAvailable,
|
|
245
|
+
action: 'environmentSetup',
|
|
246
|
+
testId: 'nav-environment-setup',
|
|
247
|
+
sidebar: { group: 'infrastructure', order: 20 },
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: 'fragments',
|
|
251
|
+
labelKey: 'nav.contextFragments',
|
|
252
|
+
icon: 'i-lucide-book-marked',
|
|
253
|
+
surfaces: S('sidebar', 'command'),
|
|
254
|
+
gate: (g) => g.libraryAvailable && g.canManageSettings,
|
|
255
|
+
action: 'fragmentLibrary',
|
|
256
|
+
testId: 'nav-fragments',
|
|
257
|
+
sidebar: { group: 'workspaceContext', order: 10 },
|
|
258
|
+
command: {
|
|
259
|
+
group: 'workspace',
|
|
260
|
+
order: 10,
|
|
261
|
+
labelKey: 'layout.commandBar.cmd.fragments',
|
|
262
|
+
keywordsKey: 'layout.commandBar.keywords.fragments',
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: 'merge-thresholds',
|
|
267
|
+
labelKey: 'layout.commandBar.cmd.mergeThresholds',
|
|
268
|
+
icon: 'i-lucide-git-merge',
|
|
269
|
+
surfaces: S('command'),
|
|
270
|
+
gate: (g) => g.canManageSettings,
|
|
271
|
+
action: 'mergeThresholds',
|
|
272
|
+
command: {
|
|
273
|
+
group: 'workspace',
|
|
274
|
+
order: 20,
|
|
275
|
+
keywordsKey: 'layout.commandBar.keywords.mergeThresholds',
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: 'workspace-settings',
|
|
280
|
+
labelKey: 'nav.workspaceSettings',
|
|
281
|
+
icon: 'i-lucide-sliders-horizontal',
|
|
282
|
+
surfaces: S('sidebar', 'command'),
|
|
283
|
+
gate: (g) => g.canManageSettings,
|
|
284
|
+
action: 'workspaceSettings',
|
|
285
|
+
testId: 'nav-workspace-settings',
|
|
286
|
+
sidebar: { group: 'configuration', order: 10 },
|
|
287
|
+
command: {
|
|
288
|
+
group: 'workspace',
|
|
289
|
+
order: 30,
|
|
290
|
+
labelKey: 'layout.commandBar.cmd.workspaceSettings',
|
|
291
|
+
keywordsKey: 'layout.commandBar.keywords.workspaceSettings',
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: 'model-config',
|
|
296
|
+
labelKey: 'nav.modelConfiguration',
|
|
297
|
+
icon: 'i-lucide-cpu',
|
|
298
|
+
surfaces: S('sidebar', 'command'),
|
|
299
|
+
gate: (g) => g.canManageSettings,
|
|
300
|
+
action: 'modelConfiguration',
|
|
301
|
+
testId: 'nav-model-config',
|
|
302
|
+
sidebar: { group: 'configuration', order: 20 },
|
|
303
|
+
command: {
|
|
304
|
+
group: 'workspace',
|
|
305
|
+
order: 40,
|
|
306
|
+
labelKey: 'layout.commandBar.cmd.modelConfiguration',
|
|
307
|
+
keywordsKey: 'layout.commandBar.keywords.modelConfiguration',
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: 'service-fragment-defaults',
|
|
312
|
+
labelKey: 'layout.commandBar.cmd.serviceFragmentDefaults',
|
|
313
|
+
icon: 'i-lucide-book-open-check',
|
|
314
|
+
surfaces: S('command'),
|
|
315
|
+
gate: (g) => g.canManageSettings,
|
|
316
|
+
action: 'serviceFragmentDefaults',
|
|
317
|
+
command: {
|
|
318
|
+
group: 'workspace',
|
|
319
|
+
order: 50,
|
|
320
|
+
keywordsKey: 'layout.commandBar.keywords.serviceFragmentDefaults',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: 'local-models',
|
|
325
|
+
labelKey: 'layout.commandBar.cmd.localModels',
|
|
326
|
+
icon: 'i-lucide-server',
|
|
327
|
+
surfaces: S('command'),
|
|
328
|
+
action: 'localModels',
|
|
329
|
+
command: {
|
|
330
|
+
group: 'workspace',
|
|
331
|
+
order: 60,
|
|
332
|
+
keywordsKey: 'layout.commandBar.keywords.localModels',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: 'account-settings',
|
|
337
|
+
labelKey: 'nav.accountSettings',
|
|
338
|
+
icon: 'i-lucide-users',
|
|
339
|
+
surfaces: S('sidebar', 'command'),
|
|
340
|
+
gate: (g) => g.accountsEnabled,
|
|
341
|
+
action: 'accountSettings',
|
|
342
|
+
testId: 'nav-account-settings',
|
|
343
|
+
sidebar: { group: 'configuration', order: 30 },
|
|
344
|
+
command: {
|
|
345
|
+
group: 'account',
|
|
346
|
+
order: 10,
|
|
347
|
+
labelKey: 'layout.commandBar.cmd.accountSettings',
|
|
348
|
+
keywordsKey: 'layout.commandBar.keywords.accountSettings',
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: 'operator-dashboard',
|
|
353
|
+
labelKey: 'nav.operatorDashboard',
|
|
354
|
+
icon: 'i-lucide-gauge',
|
|
355
|
+
surfaces: S('sidebar'),
|
|
356
|
+
gate: (g) => g.accountsEnabled && g.isAccountAdmin,
|
|
357
|
+
action: 'operatorDashboard',
|
|
358
|
+
testId: 'nav-operator-dashboard',
|
|
359
|
+
sidebar: { group: 'configuration', order: 40 },
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: 'keyboard-shortcuts',
|
|
363
|
+
labelKey: 'layout.commandBar.cmd.shortcuts',
|
|
364
|
+
icon: 'i-lucide-keyboard',
|
|
365
|
+
surfaces: S('command'),
|
|
366
|
+
action: 'shortcuts',
|
|
367
|
+
command: {
|
|
368
|
+
group: 'workspace',
|
|
369
|
+
order: 80,
|
|
370
|
+
keywordsKey: 'layout.commandBar.keywords.shortcuts',
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The first-party navigation module: contributes the whole catalog to the `nav`
|
|
377
|
+
* slot. Registered by `createAppRegistry`.
|
|
378
|
+
*/
|
|
379
|
+
export const navigationModule = defineModule({
|
|
380
|
+
id: 'cat-factory:navigation',
|
|
381
|
+
version: '1.0.0',
|
|
382
|
+
slots: { nav: [...NAV_CONTRIBUTIONS] },
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Reactive RBAC/availability filter over the merged `nav` slot. Reads
|
|
387
|
+
* `deps.gates.*` (the reactive gate service) per item, so evaluated inside
|
|
388
|
+
* `useReactiveSlots` it re-runs when a permission or connection flips. Passed to
|
|
389
|
+
* `installModularApp` as the global `slotFilter`.
|
|
390
|
+
*
|
|
391
|
+
* Typed against `AppSlots` (not the generic `SlotFilter`) so it matches the
|
|
392
|
+
* filter shape the runtime infers for this registry. `deps` is widened to an
|
|
393
|
+
* optional `gates` to avoid importing `AppDeps` (which would be circular).
|
|
394
|
+
*/
|
|
395
|
+
export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppSlots {
|
|
396
|
+
const gates = deps.gates
|
|
397
|
+
const nav = slots.nav ?? []
|
|
398
|
+
return {
|
|
399
|
+
...slots,
|
|
400
|
+
// No gates service wired (tests / bare install) ⇒ show everything, matching
|
|
401
|
+
// the dev-open "absent access allows all" backend parity.
|
|
402
|
+
nav: gates ? nav.filter((i) => (i.gate ? i.gate(gates) : true)) : nav,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Sidebar sections, in render order; each header is `nav.<group>`. */
|
|
407
|
+
export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
|
|
408
|
+
'create',
|
|
409
|
+
'repositories',
|
|
410
|
+
'integrations',
|
|
411
|
+
'infrastructure',
|
|
412
|
+
'workspaceContext',
|
|
413
|
+
'configuration',
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
/** Command-palette groups, in render order; each label is `layout.commandBar.groups.<group>`. */
|
|
417
|
+
export const COMMAND_GROUP_ORDER: readonly NavCommandGroup[] = [
|
|
418
|
+
'create',
|
|
419
|
+
'repositories',
|
|
420
|
+
'integrations',
|
|
421
|
+
'workspace',
|
|
422
|
+
'account',
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
export interface SidebarGroup {
|
|
426
|
+
group: NavSidebarGroup
|
|
427
|
+
/** i18n key for the section header. */
|
|
428
|
+
labelKey: string
|
|
429
|
+
items: NavContribution[]
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export interface CommandItem {
|
|
433
|
+
item: NavContribution
|
|
434
|
+
/** Resolved palette label i18n key. */
|
|
435
|
+
labelKey: string
|
|
436
|
+
/** Resolved palette keywords i18n key, if any. */
|
|
437
|
+
keywordsKey?: string
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export interface CommandGroup {
|
|
441
|
+
group: NavCommandGroup
|
|
442
|
+
/** i18n key for the group label. */
|
|
443
|
+
labelKey: string
|
|
444
|
+
items: CommandItem[]
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Pure grouping/ordering helpers over an already-gated item list. Kept here (not
|
|
449
|
+
* in the composable) so they're unit-testable without a Vue/Nuxt runtime — the
|
|
450
|
+
* composable just feeds them the reactive `nav` slot. Each returns groups in
|
|
451
|
+
* canonical order with empty groups dropped and items sorted by their per-surface
|
|
452
|
+
* `order`.
|
|
453
|
+
*/
|
|
454
|
+
export function groupSidebar(items: readonly NavContribution[]): SidebarGroup[] {
|
|
455
|
+
return SIDEBAR_GROUP_ORDER.map((group) => ({
|
|
456
|
+
group,
|
|
457
|
+
labelKey: `nav.${group}`,
|
|
458
|
+
items: items
|
|
459
|
+
.filter((i) => i.surfaces.includes('sidebar') && i.sidebar?.group === group)
|
|
460
|
+
.sort((a, b) => (a.sidebar?.order ?? 0) - (b.sidebar?.order ?? 0)),
|
|
461
|
+
})).filter((g) => g.items.length > 0)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function groupCommands(items: readonly NavContribution[]): CommandGroup[] {
|
|
465
|
+
return COMMAND_GROUP_ORDER.map((group) => ({
|
|
466
|
+
group,
|
|
467
|
+
labelKey: `layout.commandBar.groups.${group}`,
|
|
468
|
+
items: items
|
|
469
|
+
.filter((i) => i.surfaces.includes('command') && i.command?.group === group)
|
|
470
|
+
.sort((a, b) => (a.command?.order ?? 0) - (b.command?.order ?? 0))
|
|
471
|
+
.map<CommandItem>((item) => ({
|
|
472
|
+
item,
|
|
473
|
+
labelKey: item.command?.labelKey ?? item.labelKey,
|
|
474
|
+
keywordsKey: item.command?.keywordsKey,
|
|
475
|
+
})),
|
|
476
|
+
})).filter((g) => g.items.length > 0)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function sortToolbar(items: readonly NavContribution[]): NavContribution[] {
|
|
480
|
+
return items
|
|
481
|
+
.filter((i) => i.surfaces.includes('toolbar'))
|
|
482
|
+
.sort((a, b) => (a.toolbar?.order ?? 0) - (b.toolbar?.order ?? 0))
|
|
483
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import type { NavGates } from '~/modular/nav-contributions'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build the reactive `gates` service the nav `slotFilter` reads (slice 1 of the
|
|
6
|
+
* modular-vue adoption). Called once from the modular install plugin, where
|
|
7
|
+
* Pinia + composables are available.
|
|
8
|
+
*
|
|
9
|
+
* Returns a plain object whose getters read the host's reactive RBAC /
|
|
10
|
+
* availability state. Passed to the registry as a `service` (by reference, not
|
|
11
|
+
* snapshotted), so when `navSlotFilter` reads `gates.canWriteBoard` inside the
|
|
12
|
+
* `useReactiveSlots` computed the underlying reactive source is tracked — a
|
|
13
|
+
* permission or connection flip re-gates every shell with no `recalculateSlots()`.
|
|
14
|
+
*
|
|
15
|
+
* This mirrors the exact gating the pre-slice-1 `SideBar` computeds encoded (see
|
|
16
|
+
* `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity).
|
|
17
|
+
*/
|
|
18
|
+
export function createNavGates(): NavGates {
|
|
19
|
+
const access = useWorkspaceAccess()
|
|
20
|
+
const github = useGitHubStore()
|
|
21
|
+
const library = useFragmentLibraryStore()
|
|
22
|
+
const accounts = useAccountsStore()
|
|
23
|
+
const auth = useAuthStore()
|
|
24
|
+
const providerConnections = useProviderConnectionsStore()
|
|
25
|
+
|
|
26
|
+
const infrastructureAvailable = computed(
|
|
27
|
+
() =>
|
|
28
|
+
auth.infrastructure != null ||
|
|
29
|
+
auth.localMode?.enabled === true ||
|
|
30
|
+
providerConnections.isAvailable('runner-pool') ||
|
|
31
|
+
providerConnections.isAvailable('environment'),
|
|
32
|
+
)
|
|
33
|
+
const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
get canWriteBoard() {
|
|
37
|
+
return access.canWriteBoard.value
|
|
38
|
+
},
|
|
39
|
+
get canManageIntegrations() {
|
|
40
|
+
return access.canManageIntegrations.value
|
|
41
|
+
},
|
|
42
|
+
get canManageSettings() {
|
|
43
|
+
return access.canManageSettings.value
|
|
44
|
+
},
|
|
45
|
+
get githubAvailable() {
|
|
46
|
+
return github.available === true
|
|
47
|
+
},
|
|
48
|
+
get libraryAvailable() {
|
|
49
|
+
return library.available === true
|
|
50
|
+
},
|
|
51
|
+
get infrastructureAvailable() {
|
|
52
|
+
// `integrations.manage` is required to provision/manage infrastructure, so
|
|
53
|
+
// gate the whole section on it too (a member/viewer would only 403 inside).
|
|
54
|
+
return access.canManageIntegrations.value && infrastructureAvailable.value
|
|
55
|
+
},
|
|
56
|
+
get accountsEnabled() {
|
|
57
|
+
return accounts.enabled
|
|
58
|
+
},
|
|
59
|
+
get isAccountAdmin() {
|
|
60
|
+
return isAccountAdmin.value
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { defineModule } from '@modular-vue/core'
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { __resetConsumerModulesForTest, createAppRegistry, registerAppModule } from './registry'
|
|
4
|
+
import type { NavGates } from './nav-contributions'
|
|
5
|
+
|
|
6
|
+
const NO_GATES: NavGates = {
|
|
7
|
+
canWriteBoard: false,
|
|
8
|
+
canManageIntegrations: false,
|
|
9
|
+
canManageSettings: false,
|
|
10
|
+
githubAvailable: false,
|
|
11
|
+
libraryAvailable: false,
|
|
12
|
+
infrastructureAvailable: false,
|
|
13
|
+
accountsEnabled: false,
|
|
14
|
+
isAccountAdmin: false,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('app modular registry', () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
__resetConsumerModulesForTest()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('registers the first-party navigation module', () => {
|
|
23
|
+
const manifest = createAppRegistry({ gates: NO_GATES }).resolveManifest()
|
|
24
|
+
expect(manifest.modules.map((m) => m.id)).toContain('cat-factory:navigation')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('includes consumer modules contributed via registerAppModule', () => {
|
|
28
|
+
registerAppModule(defineModule({ id: 'consumer:example', version: '1.0.0' }))
|
|
29
|
+
|
|
30
|
+
const ids = createAppRegistry({ gates: NO_GATES })
|
|
31
|
+
.resolveManifest()
|
|
32
|
+
.modules.map((m) => m.id)
|
|
33
|
+
|
|
34
|
+
expect(ids).toContain('cat-factory:navigation')
|
|
35
|
+
expect(ids).toContain('consumer:example')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('rejects a module whose id collides with an already-registered one', () => {
|
|
39
|
+
registerAppModule(defineModule({ id: 'cat-factory:navigation', version: '2.0.0' }))
|
|
40
|
+
|
|
41
|
+
expect(() => createAppRegistry({ gates: NO_GATES }).resolveManifest()).toThrow(/duplicate/i)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('merges a consumer-contributed nav item into the resolved nav slot', () => {
|
|
45
|
+
// A deployment extending the layer contributes its own nav destination to the
|
|
46
|
+
// SAME `nav` slot the first-party module fills — it then renders in every shell
|
|
47
|
+
// with no shell edits (the slice-1 extensibility promise).
|
|
48
|
+
registerAppModule(
|
|
49
|
+
defineModule({
|
|
50
|
+
id: 'consumer:nav',
|
|
51
|
+
version: '1.0.0',
|
|
52
|
+
slots: {
|
|
53
|
+
nav: [
|
|
54
|
+
{
|
|
55
|
+
id: 'consumer:reports',
|
|
56
|
+
labelKey: 'consumer.reports',
|
|
57
|
+
icon: 'i-lucide-bar-chart',
|
|
58
|
+
surfaces: ['sidebar', 'toolbar'],
|
|
59
|
+
action: 'openReports',
|
|
60
|
+
sidebar: { group: 'configuration', order: 99 },
|
|
61
|
+
toolbar: { order: 10 },
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const slots = createAppRegistry({ gates: NO_GATES }).resolveManifest().slots as {
|
|
69
|
+
nav: { id: string }[]
|
|
70
|
+
}
|
|
71
|
+
const ids = slots.nav.map((i) => i.id)
|
|
72
|
+
// First-party catalog + the consumer item both present in one merged slot.
|
|
73
|
+
expect(ids).toContain('build-pipeline')
|
|
74
|
+
expect(ids).toContain('consumer:reports')
|
|
75
|
+
})
|
|
76
|
+
})
|