@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,75 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
3
|
+
import { groupCommands, groupSidebar, sortToolbar } from '~/modular/nav-contributions'
|
|
4
|
+
import type {
|
|
5
|
+
AppSlots,
|
|
6
|
+
CommandGroup,
|
|
7
|
+
NavActionId,
|
|
8
|
+
NavContribution,
|
|
9
|
+
SidebarGroup,
|
|
10
|
+
} from '~/modular/nav-contributions'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The single source the three nav shells render from (slice 1 of the modular-vue
|
|
14
|
+
* adoption). Reads the reactively-gated `nav` slot via `useReactiveSlots` — the
|
|
15
|
+
* `slotFilter` has already dropped items the caller lacks permission for, and
|
|
16
|
+
* the computed re-runs when a gate flips — then shapes the surviving items per
|
|
17
|
+
* surface (via the pure helpers in `nav-contributions.ts`) and resolves each
|
|
18
|
+
* item's action.
|
|
19
|
+
*
|
|
20
|
+
* First-party items carry an `action` id resolved here against the `ui` store; a
|
|
21
|
+
* consumer-contributed item may instead carry its own `run` closure, which wins.
|
|
22
|
+
*/
|
|
23
|
+
export function useNavContributions() {
|
|
24
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
25
|
+
const ui = useUiStore()
|
|
26
|
+
|
|
27
|
+
// First-party action ids → host handlers. Typed as an exhaustive
|
|
28
|
+
// `Record<NavActionId, …>`, so a catalog `action` with no handler (or a handler
|
|
29
|
+
// with no catalog entry) is a compile error, not a silently dead button.
|
|
30
|
+
// Consumer items bypass this map entirely via their own `run` closure.
|
|
31
|
+
const actions: Record<NavActionId, () => void> = {
|
|
32
|
+
buildPipeline: () => ui.openBuilder(),
|
|
33
|
+
addFromRepo: () => ui.openAddService(),
|
|
34
|
+
bootstrapRepo: () => ui.openBootstrap(),
|
|
35
|
+
integrationsHub: () => ui.openIntegrations(),
|
|
36
|
+
sandbox: () => ui.openSandbox(),
|
|
37
|
+
kaizen: () => ui.openKaizen(),
|
|
38
|
+
infrastructure: () => ui.openInfrastructure(),
|
|
39
|
+
environmentSetup: () => ui.openEnvironmentSetup(),
|
|
40
|
+
fragmentLibrary: () => ui.openFragmentLibrary(),
|
|
41
|
+
mergeThresholds: () => ui.openWorkspaceSettings('merge'),
|
|
42
|
+
workspaceSettings: () => ui.openWorkspaceSettings(),
|
|
43
|
+
modelConfiguration: () => ui.openModelConfig(),
|
|
44
|
+
serviceFragmentDefaults: () => ui.openWorkspaceSettings('fragments'),
|
|
45
|
+
localModels: () => ui.openLocalModels(),
|
|
46
|
+
accountSettings: () => ui.openAccountSettings(),
|
|
47
|
+
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
48
|
+
shortcuts: () => ui.openShortcutsHelp(),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Run a contribution's action (consumer `run` closure wins over the id map). */
|
|
52
|
+
function invoke(item: NavContribution): void {
|
|
53
|
+
if (item.run) {
|
|
54
|
+
item.run()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
// First-party `action` is a `NavActionId`, so the exhaustive map always has a
|
|
58
|
+
// handler; the optional call only guards a consumer item that mis-uses
|
|
59
|
+
// `action` (its contract is `run`) — it no-ops rather than throwing.
|
|
60
|
+
if (item.action) actions[item.action]?.()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const all = computed<NavContribution[]>(() => slots.value.nav ?? [])
|
|
64
|
+
|
|
65
|
+
/** Grouped + ordered sidebar sections, empty sections dropped. */
|
|
66
|
+
const sidebarGroups = computed<SidebarGroup[]>(() => groupSidebar(all.value))
|
|
67
|
+
|
|
68
|
+
/** Grouped + ordered command-palette entries, empty groups dropped. */
|
|
69
|
+
const commandGroups = computed<CommandGroup[]>(() => groupCommands(all.value))
|
|
70
|
+
|
|
71
|
+
/** Toolbar contributions (the consumer extension point on `BoardToolbar`). */
|
|
72
|
+
const toolbarItems = computed<NavContribution[]>(() => sortToolbar(all.value))
|
|
73
|
+
|
|
74
|
+
return { sidebarGroups, commandGroups, toolbarItems, invoke }
|
|
75
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import enCatalog from '../../i18n/locales/en.json'
|
|
3
|
+
import {
|
|
4
|
+
groupCommands,
|
|
5
|
+
groupSidebar,
|
|
6
|
+
NAV_ACTIONS,
|
|
7
|
+
NAV_CONTRIBUTIONS,
|
|
8
|
+
navSlotFilter,
|
|
9
|
+
sortToolbar,
|
|
10
|
+
} from './nav-contributions'
|
|
11
|
+
import type { AppSlots, NavGates } from './nav-contributions'
|
|
12
|
+
|
|
13
|
+
/** The layer's base i18n catalog, used to prove every referenced key resolves. */
|
|
14
|
+
const en = enCatalog as Record<string, unknown>
|
|
15
|
+
|
|
16
|
+
/** Walk a dotted vue-i18n key path; true when it resolves to a leaf string. */
|
|
17
|
+
function hasKey(path: string): boolean {
|
|
18
|
+
let node: unknown = en
|
|
19
|
+
for (const part of path.split('.')) {
|
|
20
|
+
if (typeof node !== 'object' || node === null || !(part in node)) return false
|
|
21
|
+
node = (node as Record<string, unknown>)[part]
|
|
22
|
+
}
|
|
23
|
+
return typeof node === 'string'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NO_GATES: NavGates = {
|
|
27
|
+
canWriteBoard: false,
|
|
28
|
+
canManageIntegrations: false,
|
|
29
|
+
canManageSettings: false,
|
|
30
|
+
githubAvailable: false,
|
|
31
|
+
libraryAvailable: false,
|
|
32
|
+
infrastructureAvailable: false,
|
|
33
|
+
accountsEnabled: false,
|
|
34
|
+
isAccountAdmin: false,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ALL_GATES: NavGates = {
|
|
38
|
+
canWriteBoard: true,
|
|
39
|
+
canManageIntegrations: true,
|
|
40
|
+
canManageSettings: true,
|
|
41
|
+
githubAvailable: true,
|
|
42
|
+
libraryAvailable: true,
|
|
43
|
+
infrastructureAvailable: true,
|
|
44
|
+
accountsEnabled: true,
|
|
45
|
+
isAccountAdmin: true,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const slots = (): AppSlots => ({ nav: [...NAV_CONTRIBUTIONS] })
|
|
49
|
+
const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
|
|
50
|
+
|
|
51
|
+
describe('navSlotFilter', () => {
|
|
52
|
+
it('drops every gated item when no permission/availability is granted', () => {
|
|
53
|
+
const kept = ids(navSlotFilter(slots(), { gates: NO_GATES }))
|
|
54
|
+
// Only the always-visible destinations survive (no `gate`).
|
|
55
|
+
const alwaysVisible = NAV_CONTRIBUTIONS.filter((i) => !i.gate).map((i) => i.id)
|
|
56
|
+
expect(kept.sort()).toEqual(alwaysVisible.sort())
|
|
57
|
+
expect(kept).toContain('kaizen')
|
|
58
|
+
expect(kept).not.toContain('build-pipeline')
|
|
59
|
+
expect(kept).not.toContain('operator-dashboard')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('keeps every item when all gates pass', () => {
|
|
63
|
+
const kept = ids(navSlotFilter(slots(), { gates: ALL_GATES }))
|
|
64
|
+
expect(kept.sort()).toEqual(NAV_CONTRIBUTIONS.map((i) => i.id).sort())
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reflects a single permission — board.write reveals only its items', () => {
|
|
68
|
+
const gates: NavGates = { ...NO_GATES, canWriteBoard: true, githubAvailable: true }
|
|
69
|
+
const kept = ids(navSlotFilter(slots(), { gates }))
|
|
70
|
+
expect(kept).toContain('build-pipeline')
|
|
71
|
+
expect(kept).toContain('add-from-repo') // needs github + board.write
|
|
72
|
+
expect(kept).not.toContain('bootstrap-repo') // needs integrations.manage
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('passes everything through when no gates service is wired (dev-open parity)', () => {
|
|
76
|
+
const kept = ids(navSlotFilter(slots(), {}))
|
|
77
|
+
expect(kept.sort()).toEqual(NAV_CONTRIBUTIONS.map((i) => i.id).sort())
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('NAV_CONTRIBUTIONS catalog integrity', () => {
|
|
82
|
+
it('has unique ids and every item targets at least one surface', () => {
|
|
83
|
+
const seen = new Set<string>()
|
|
84
|
+
for (const item of NAV_CONTRIBUTIONS) {
|
|
85
|
+
expect(seen.has(item.id), `duplicate id ${item.id}`).toBe(false)
|
|
86
|
+
seen.add(item.id)
|
|
87
|
+
expect(item.surfaces.length, `${item.id} has no surface`).toBeGreaterThan(0)
|
|
88
|
+
// Every surface it targets must carry that surface's placement.
|
|
89
|
+
if (item.surfaces.includes('sidebar')) expect(item.sidebar, `${item.id} sidebar`).toBeTruthy()
|
|
90
|
+
if (item.surfaces.includes('command')) expect(item.command, `${item.id} command`).toBeTruthy()
|
|
91
|
+
// A first-party item is actionable (an id resolved host-side, or a run closure).
|
|
92
|
+
expect(item.action ?? item.run, `${item.id} has no action`).toBeTruthy()
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('every first-party action id is a known NAV_ACTION (no dead buttons)', () => {
|
|
97
|
+
// `useNavContributions` resolves an `action` against an exhaustive
|
|
98
|
+
// `Record<NavActionId, …>` handler map, so a catalog action outside
|
|
99
|
+
// NAV_ACTIONS would be a dead button. The type system already enforces this;
|
|
100
|
+
// this asserts it at runtime too (and that NAV_ACTIONS has no stale ids).
|
|
101
|
+
const declared = new Set<string>(NAV_ACTIONS)
|
|
102
|
+
const used = new Set<string>()
|
|
103
|
+
for (const item of NAV_CONTRIBUTIONS) {
|
|
104
|
+
if (!item.action) continue
|
|
105
|
+
used.add(item.action)
|
|
106
|
+
expect(declared.has(item.action), `${item.id} → unknown action ${item.action}`).toBe(true)
|
|
107
|
+
}
|
|
108
|
+
// No NAV_ACTION is orphaned (every declared handler id is actually used).
|
|
109
|
+
for (const action of NAV_ACTIONS) {
|
|
110
|
+
expect(used.has(action), `NAV_ACTION ${action} is unused`).toBe(true)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('every referenced i18n key exists in the en catalog (no raw-key leak)', () => {
|
|
115
|
+
const missing: string[] = []
|
|
116
|
+
const check = (key: string | undefined) => {
|
|
117
|
+
if (key && !hasKey(key)) missing.push(key)
|
|
118
|
+
}
|
|
119
|
+
for (const group of ['create', 'repositories', 'integrations', 'workspace', 'account']) {
|
|
120
|
+
check(`layout.commandBar.groups.${group}`)
|
|
121
|
+
}
|
|
122
|
+
for (const group of [
|
|
123
|
+
'create',
|
|
124
|
+
'repositories',
|
|
125
|
+
'integrations',
|
|
126
|
+
'infrastructure',
|
|
127
|
+
'workspaceContext',
|
|
128
|
+
'configuration',
|
|
129
|
+
]) {
|
|
130
|
+
check(`nav.${group}`)
|
|
131
|
+
}
|
|
132
|
+
for (const item of NAV_CONTRIBUTIONS) {
|
|
133
|
+
check(item.labelKey)
|
|
134
|
+
if (item.command) {
|
|
135
|
+
// Palette label falls back to the item's default labelKey.
|
|
136
|
+
check(item.command.labelKey ?? item.labelKey)
|
|
137
|
+
check(item.command.keywordsKey)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
expect(missing).toEqual([])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('nav grouping helpers', () => {
|
|
145
|
+
it('groupSidebar orders sections + items and drops empty sections', () => {
|
|
146
|
+
const groups = groupSidebar(NAV_CONTRIBUTIONS)
|
|
147
|
+
expect(groups.map((g) => g.group)).toEqual([
|
|
148
|
+
'create',
|
|
149
|
+
'repositories',
|
|
150
|
+
'integrations',
|
|
151
|
+
'infrastructure',
|
|
152
|
+
'workspaceContext',
|
|
153
|
+
'configuration',
|
|
154
|
+
])
|
|
155
|
+
const configuration = groups.find((g) => g.group === 'configuration')
|
|
156
|
+
expect(configuration?.items.map((i) => i.id)).toEqual([
|
|
157
|
+
'workspace-settings',
|
|
158
|
+
'model-config',
|
|
159
|
+
'account-settings',
|
|
160
|
+
'operator-dashboard',
|
|
161
|
+
])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
|
|
165
|
+
const workspace = groupCommands(NAV_CONTRIBUTIONS).find((g) => g.group === 'workspace')
|
|
166
|
+
// Same order the old CommandBar pushed them in (parity, not a reorder).
|
|
167
|
+
expect(workspace?.items.map((ci) => ci.item.id)).toEqual([
|
|
168
|
+
'fragments',
|
|
169
|
+
'merge-thresholds',
|
|
170
|
+
'workspace-settings',
|
|
171
|
+
'model-config',
|
|
172
|
+
'service-fragment-defaults',
|
|
173
|
+
'local-models',
|
|
174
|
+
'sandbox',
|
|
175
|
+
'keyboard-shortcuts',
|
|
176
|
+
])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('sortToolbar yields nothing first-party (consumer-only extension point)', () => {
|
|
180
|
+
expect(sortToolbar(NAV_CONTRIBUTIONS)).toEqual([])
|
|
181
|
+
})
|
|
182
|
+
})
|