@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.
@@ -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
+ })