@cat-factory/app 0.274.0 → 0.276.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/README.md +158 -4
- package/app/components/board/LaneViewControl.vue +87 -0
- package/app/components/board/nodes/BlockNode.vue +31 -11
- package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
- package/app/components/board/nodes/InitiativeCard.vue +9 -28
- package/app/components/board/nodes/LaneGroup.vue +93 -0
- package/app/components/board/nodes/LaneTask.vue +66 -0
- package/app/components/board/nodes/TaskCard.vue +16 -2
- package/app/components/board/nodes/TaskLane.vue +82 -0
- package/app/components/layout/BoardToolbar.vue +4 -0
- package/app/components/layout/CommandBar.vue +8 -1
- package/app/components/layout/RolePrompt.vue +75 -0
- package/app/components/layout/SideBar.vue +22 -13
- package/app/components/layout/UiRoleSwitcher.vue +73 -0
- package/app/components/panels/InspectorPanel.vue +15 -2
- package/app/components/panels/inspector/TaskStructure.vue +70 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
- package/app/composables/useBlockDrag.ts +47 -17
- package/app/composables/useBlockQueries.ts +27 -24
- package/app/composables/useFrameLanes.ts +177 -0
- package/app/composables/useNavContributions.ts +3 -0
- package/app/composables/useTaskExpansion.ts +1 -1
- package/app/docs/consumer-extensions.md +20 -6
- package/app/modular/external-tools.spec.ts +0 -45
- package/app/modular/external-tools.ts +12 -23
- package/app/modular/nav-contributions.spec.ts +176 -17
- package/app/modular/nav-contributions.ts +106 -23
- package/app/modular/nav-gates.ts +11 -2
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -3
- package/app/modular/tutorial-tours.ts +53 -8
- package/app/pages/index.vue +36 -7
- package/app/stores/board/placement.ts +7 -0
- package/app/stores/board.spec.ts +119 -14
- package/app/stores/laneView.spec.ts +61 -0
- package/app/stores/laneView.ts +85 -0
- package/app/stores/launchPrompt.ts +63 -0
- package/app/stores/taskExpansion.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +1 -1
- package/app/stores/tutorial.ts +4 -4
- package/app/stores/uiMode.spec.ts +11 -0
- package/app/stores/uiMode.ts +14 -2
- package/app/stores/uiRole.spec.ts +185 -0
- package/app/stores/uiRole.ts +86 -0
- package/app/stores/workspaceSettings.ts +4 -0
- package/app/utils/framePlacement.ts +9 -4
- package/app/utils/laneGeometry.spec.ts +69 -0
- package/app/utils/laneGeometry.ts +104 -0
- package/app/utils/laneSort.spec.ts +236 -0
- package/app/utils/laneSort.ts +306 -0
- package/app/utils/swimlanes.spec.ts +259 -0
- package/app/utils/swimlanes.ts +355 -0
- package/app/utils/uiMode.spec.ts +12 -0
- package/app/utils/uiMode.ts +24 -6
- package/app/utils/uiRole.ts +123 -0
- package/i18n/locales/de.json +104 -3
- package/i18n/locales/en.json +110 -3
- package/i18n/locales/es.json +104 -3
- package/i18n/locales/fr.json +104 -3
- package/i18n/locales/he.json +104 -3
- package/i18n/locales/it.json +104 -3
- package/i18n/locales/ja.json +104 -3
- package/i18n/locales/pl.json +104 -3
- package/i18n/locales/tr.json +104 -3
- package/i18n/locales/uk.json +104 -3
- package/package.json +2 -2
- package/app/components/board/nodes/DraggableTask.vue +0 -58
- package/app/components/board/nodes/ModuleFrame.vue +0 -73
- package/app/stores/tutorial.prompt.ts +0 -59
|
@@ -2,13 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
2
2
|
import { missingI18nKeys } from '../../test/i18nKeys'
|
|
3
3
|
import {
|
|
4
4
|
EXTERNAL_TOOL_UNAVAILABLE_KEYS,
|
|
5
|
-
filterExternalTools,
|
|
6
5
|
projectExternalTools,
|
|
7
6
|
resolveExternalToolUrl,
|
|
8
7
|
type ExternalToolContext,
|
|
9
8
|
type ExternalToolContribution,
|
|
10
9
|
} from './external-tools'
|
|
11
|
-
import type { NavGates } from './nav-contributions'
|
|
12
10
|
|
|
13
11
|
const CONTEXT: ExternalToolContext = {
|
|
14
12
|
userId: 'usr_1',
|
|
@@ -27,26 +25,6 @@ const MAP_EDITOR: ExternalToolContribution = {
|
|
|
27
25
|
`https://maps.acme.dev/edit?game=${ctx.metadata.gameId}&ws=${ctx.workspaceId}&user=${ctx.userId ?? ''}`,
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
const GATES: NavGates = {
|
|
31
|
-
canWriteBoard: true,
|
|
32
|
-
canManageIntegrations: true,
|
|
33
|
-
canManageSettings: true,
|
|
34
|
-
githubAvailable: true,
|
|
35
|
-
libraryAvailable: true,
|
|
36
|
-
designSourceConnected: true,
|
|
37
|
-
infrastructureAvailable: true,
|
|
38
|
-
accountsEnabled: true,
|
|
39
|
-
isAccountAdmin: true,
|
|
40
|
-
advancedMode: true,
|
|
41
|
-
boardHasService: true,
|
|
42
|
-
boardHasTask: true,
|
|
43
|
-
boardHasRun: true,
|
|
44
|
-
boardHasOpenDecision: true,
|
|
45
|
-
boardHasPendingApproval: true,
|
|
46
|
-
boardHasFinishedRun: true,
|
|
47
|
-
boardHasFailedRun: true,
|
|
48
|
-
}
|
|
49
|
-
|
|
50
28
|
describe('resolveExternalToolUrl', () => {
|
|
51
29
|
it('folds the invocation context into the resolved URL', () => {
|
|
52
30
|
// The whole point of a resolver over a static link: the tool opens on the right game,
|
|
@@ -250,29 +228,6 @@ describe('projectExternalTools', () => {
|
|
|
250
228
|
})
|
|
251
229
|
})
|
|
252
230
|
|
|
253
|
-
describe('filterExternalTools', () => {
|
|
254
|
-
const tools: ExternalToolContribution[] = [
|
|
255
|
-
{ id: 'a', title: 'A', icon: 'i', url: 'https://a.dev' },
|
|
256
|
-
{ id: 'b', title: 'B', icon: 'i', url: 'https://b.dev', gate: (g) => g.canManageIntegrations },
|
|
257
|
-
{ id: 'c', title: 'C', icon: 'i', url: 'https://c.dev', advanced: true },
|
|
258
|
-
]
|
|
259
|
-
|
|
260
|
-
it('applies the RBAC gate and the interface tier independently', () => {
|
|
261
|
-
expect(filterExternalTools(tools, GATES).map((t) => t.id)).toEqual(['a', 'b', 'c'])
|
|
262
|
-
expect(
|
|
263
|
-
filterExternalTools(tools, { ...GATES, canManageIntegrations: false }).map((t) => t.id),
|
|
264
|
-
).toEqual(['a', 'c'])
|
|
265
|
-
expect(filterExternalTools(tools, { ...GATES, advancedMode: false }).map((t) => t.id)).toEqual([
|
|
266
|
-
'a',
|
|
267
|
-
'b',
|
|
268
|
-
])
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
it('passes everything through with no gates service wired (dev-open parity)', () => {
|
|
272
|
-
expect(filterExternalTools(tools, undefined).map((t) => t.id)).toEqual(['a', 'b', 'c'])
|
|
273
|
-
})
|
|
274
|
-
})
|
|
275
|
-
|
|
276
231
|
describe('EXTERNAL_TOOL_UNAVAILABLE_KEYS', () => {
|
|
277
232
|
it('names copy that exists for every reason', () => {
|
|
278
233
|
// The exhaustive `Record` proves each reason HAS an entry; only this proves the entry still
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { metadataValue } from './workspace-metadata'
|
|
2
|
-
import type { NavContribution,
|
|
2
|
+
import type { NavContribution, NavGatedContribution } from './nav-contributions'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* EXTERNAL TOOLS — a deployment's own web applications, registered programmatically and
|
|
@@ -56,8 +56,17 @@ export interface ExternalToolContext {
|
|
|
56
56
|
*/
|
|
57
57
|
export type ExternalToolUrlResolver = (context: ExternalToolContext) => string | null
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* One registered external tool.
|
|
61
|
+
*
|
|
62
|
+
* The visibility axes are INHERITED from {@link NavGatedContribution} rather than redeclared:
|
|
63
|
+
* a tool is projected onto a nav contribution and filtered by the same `navItemVisible`, so
|
|
64
|
+
* every axis a first-party destination answers to (the interface tier, the role's surface, the
|
|
65
|
+
* RBAC/availability predicate) is one this answers to as well. `intake` defaults the same way
|
|
66
|
+
* it does there, which for a registered application means the full-surface roles only until
|
|
67
|
+
* the deployment says otherwise.
|
|
68
|
+
*/
|
|
69
|
+
export interface ExternalToolContribution extends NavGatedContribution {
|
|
61
70
|
/** Namespaced id (`<ns>:<name>`), like every other consumer contribution. */
|
|
62
71
|
id: string
|
|
63
72
|
/** Display name. Literal copy, not an i18n key: a tool's name is deployment DATA (the same
|
|
@@ -83,10 +92,6 @@ export interface ExternalToolContribution {
|
|
|
83
92
|
requiredMetadata?: readonly string[]
|
|
84
93
|
/** Sidebar/palette order within the External tools section. Defaults to 0. */
|
|
85
94
|
order?: number
|
|
86
|
-
/** Reactive RBAC/availability predicate, exactly as on a {@link NavContribution}. */
|
|
87
|
-
gate?: (gates: NavGates) => boolean
|
|
88
|
-
/** Show only in advanced interface mode. */
|
|
89
|
-
advanced?: boolean
|
|
90
95
|
/** Stable selector for e2e. Defaults to `nav-external-tool-<id>`. */
|
|
91
96
|
testId?: string
|
|
92
97
|
}
|
|
@@ -247,19 +252,3 @@ export function projectExternalTools(
|
|
|
247
252
|
return { tool, resolution, contribution }
|
|
248
253
|
})
|
|
249
254
|
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Drop the tools the caller may not see, on the same two independent axes as `navSlotFilter`
|
|
253
|
-
* applies to `nav`: the interface tier, then the item's own RBAC/availability predicate. With
|
|
254
|
-
* no gates service wired (tests, a bare install) everything passes, matching the dev-open
|
|
255
|
-
* "absent access allows all" parity the nav filter keeps.
|
|
256
|
-
*/
|
|
257
|
-
export function filterExternalTools(
|
|
258
|
-
tools: readonly ExternalToolContribution[],
|
|
259
|
-
gates: NavGates | undefined,
|
|
260
|
-
): ExternalToolContribution[] {
|
|
261
|
-
if (!gates) return [...tools]
|
|
262
|
-
return tools.filter(
|
|
263
|
-
(t) => (t.advanced ? gates.advancedMode : true) && (t.gate ? t.gate(gates) : true),
|
|
264
|
-
)
|
|
265
|
-
}
|
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
SIDEBAR_GROUP_ORDER,
|
|
11
11
|
sortToolbar,
|
|
12
12
|
} from './nav-contributions'
|
|
13
|
-
import type { AppSlots, NavGates } from './nav-contributions'
|
|
13
|
+
import type { AppSlots, NavGatedContribution, NavGates } from './nav-contributions'
|
|
14
|
+
import type { ExternalToolContribution } from './external-tools'
|
|
14
15
|
|
|
15
16
|
/** Prove every referenced key resolves in the layer's base catalog (see `test/i18nKeys`). */
|
|
16
17
|
const hasKey = hasI18nKey
|
|
@@ -25,9 +26,11 @@ const NO_GATES: NavGates = {
|
|
|
25
26
|
infrastructureAvailable: false,
|
|
26
27
|
accountsEnabled: false,
|
|
27
28
|
isAccountAdmin: false,
|
|
28
|
-
// The permission axis is what these cases vary; keep the interface tier at `advanced`
|
|
29
|
-
// so a dropped item is unambiguously an RBAC/availability drop
|
|
29
|
+
// The permission axis is what these cases vary; keep the interface tier at `advanced` and the
|
|
30
|
+
// role at the full surface, so a dropped item is unambiguously an RBAC/availability drop rather
|
|
31
|
+
// than a tier or role drop.
|
|
30
32
|
advancedMode: true,
|
|
33
|
+
fullSurface: true,
|
|
31
34
|
boardHasService: false,
|
|
32
35
|
boardHasTask: false,
|
|
33
36
|
boardHasRun: false,
|
|
@@ -48,6 +51,7 @@ const ALL_GATES: NavGates = {
|
|
|
48
51
|
accountsEnabled: true,
|
|
49
52
|
isAccountAdmin: true,
|
|
50
53
|
advancedMode: true,
|
|
54
|
+
fullSurface: true,
|
|
51
55
|
boardHasService: true,
|
|
52
56
|
boardHasTask: true,
|
|
53
57
|
boardHasRun: true,
|
|
@@ -185,6 +189,76 @@ describe('navSlotFilter', () => {
|
|
|
185
189
|
}
|
|
186
190
|
})
|
|
187
191
|
|
|
192
|
+
it('drops every non-intake destination for a narrowed role', () => {
|
|
193
|
+
const gates: NavGates = { ...ALL_GATES, fullSurface: false }
|
|
194
|
+
const kept = ids(navSlotFilter(slots(), { gates }))
|
|
195
|
+
const intakeOnly = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
|
|
196
|
+
expect(kept.sort()).toEqual(intakeOnly.sort())
|
|
197
|
+
// Fully permitted and on the advanced tier, so every drop here is the ROLE and nothing else:
|
|
198
|
+
// the platform configuration goes, and what teaches the product plus the way out stays.
|
|
199
|
+
expect(kept).toContain('tutorial')
|
|
200
|
+
expect(kept).toContain('ui-role')
|
|
201
|
+
expect(kept).not.toContain('build-pipeline')
|
|
202
|
+
expect(kept).not.toContain('add-from-repo')
|
|
203
|
+
expect(kept).not.toContain('integrations-hub')
|
|
204
|
+
expect(kept).not.toContain('workspace-settings')
|
|
205
|
+
expect(kept).not.toContain('model-providers')
|
|
206
|
+
expect(kept).not.toContain('account-settings')
|
|
207
|
+
// A narrowed sidebar is short by design, but never a shell with nothing in it: the sidebar
|
|
208
|
+
// is where the tutorials live, and `groupSidebar` drops an empty section upstream, so a
|
|
209
|
+
// surface whose every item was palette-only would render as a broken navbar.
|
|
210
|
+
const groups = groupSidebar((navSlotFilter(slots(), { gates }) as AppSlots).nav)
|
|
211
|
+
expect(groups.length).toBeGreaterThan(0)
|
|
212
|
+
for (const group of groups) expect(group.items.length).toBeGreaterThan(0)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('states, per intake item, why the narrowed role keeps it', () => {
|
|
216
|
+
// The mirror of the advanced-item table above, and it earns its place for the opposite
|
|
217
|
+
// reason: `intake` is opt-IN, so the risk is not a silent capability loss but a silent
|
|
218
|
+
// WIDENING: one flag on a destination that configures the platform and the simplified
|
|
219
|
+
// surface has quietly stopped being simple. The table is the claim; adding `intake: true`
|
|
220
|
+
// fails here until the reason is written down.
|
|
221
|
+
const REASON: Record<string, string> = {
|
|
222
|
+
tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
|
|
223
|
+
'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
|
|
224
|
+
'ui-role': 'the way BACK out of the narrowed role',
|
|
225
|
+
}
|
|
226
|
+
const intake = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
|
|
227
|
+
expect(intake.sort()).toEqual(Object.keys(REASON).sort())
|
|
228
|
+
for (const [id, why] of Object.entries(REASON)) {
|
|
229
|
+
expect(why.length, `${id} has no stated reason`).toBeGreaterThan(0)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('keeps the role switch reachable from inside the narrowed role, and the tier switch out', () => {
|
|
234
|
+
// The role decides which surfaces exist at all, so its own entry must survive the narrowing
|
|
235
|
+
// it causes, or a designer who needs a pipeline has no route to say so.
|
|
236
|
+
const roleItem = NAV_CONTRIBUTIONS.find((i) => i.id === 'ui-role')
|
|
237
|
+
expect(roleItem?.intake).toBe(true)
|
|
238
|
+
expect(roleItem?.gate).toBeUndefined()
|
|
239
|
+
// The TIER toggle is deliberately the other way: a narrowed role's tier is capped at basic
|
|
240
|
+
// (`resolveUiMode`), so an entry that flipped it would write a preference nothing honours.
|
|
241
|
+
expect(NAV_CONTRIBUTIONS.find((i) => i.id === 'ui-mode')?.intake).toBeUndefined()
|
|
242
|
+
|
|
243
|
+
const gates: NavGates = { ...NO_GATES, fullSurface: false }
|
|
244
|
+
const kept = ids(navSlotFilter(slots(), { gates }))
|
|
245
|
+
expect(kept).toContain('ui-role')
|
|
246
|
+
expect(kept).not.toContain('ui-mode')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('keeps the tier, role and permission axes independent, and all three must pass', () => {
|
|
250
|
+
// `tutorial` is `intake` and ungated, `sandbox` is advanced + permissioned + not intake:
|
|
251
|
+
// no single axis reveals the second, and narrowing the role cannot reveal anything.
|
|
252
|
+
const narrowedButPermitted: NavGates = { ...ALL_GATES, fullSurface: false }
|
|
253
|
+
expect(ids(navSlotFilter(slots(), { gates: narrowedButPermitted }))).not.toContain('sandbox')
|
|
254
|
+
|
|
255
|
+
const fullButBasic: NavGates = { ...ALL_GATES, advancedMode: false }
|
|
256
|
+
expect(ids(navSlotFilter(slots(), { gates: fullButBasic }))).not.toContain('sandbox')
|
|
257
|
+
|
|
258
|
+
const fullButUnpermitted: NavGates = { ...NO_GATES, canManageIntegrations: false }
|
|
259
|
+
expect(ids(navSlotFilter(slots(), { gates: fullButUnpermitted }))).not.toContain('sandbox')
|
|
260
|
+
})
|
|
261
|
+
|
|
188
262
|
it('keeps the tier switch itself reachable in basic mode', () => {
|
|
189
263
|
// Basic is the shipped default, so this palette entry is how a user who never finds the
|
|
190
264
|
// (icon-only, in the basic rail) sidebar switcher gets to the advanced half at all. Marking
|
|
@@ -226,26 +300,107 @@ describe('navSlotFilter', () => {
|
|
|
226
300
|
})
|
|
227
301
|
|
|
228
302
|
describe('navSlotFilter external tools', () => {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
303
|
+
const tool = (id: string, axes: NavGatedContribution = {}): ExternalToolContribution => ({
|
|
304
|
+
id,
|
|
305
|
+
title: id,
|
|
306
|
+
icon: 'i-lucide-link',
|
|
307
|
+
url: `https://${id}.dev`,
|
|
308
|
+
...axes,
|
|
309
|
+
})
|
|
310
|
+
const tools: ExternalToolContribution[] = [
|
|
311
|
+
tool('acme:open'),
|
|
312
|
+
tool('acme:permissioned', { gate: (g: NavGates) => g.canManageIntegrations }),
|
|
313
|
+
tool('acme:power-user', { advanced: true }),
|
|
314
|
+
tool('acme:intake', { intake: true }),
|
|
238
315
|
]
|
|
316
|
+
const withTools = (): AppSlots => ({ ...slots(), externalTools: [...tools] })
|
|
239
317
|
const toolIds = (s: unknown) => (s as AppSlots).externalTools.map((t) => t.id)
|
|
240
318
|
|
|
241
319
|
it('gates registered tools in the SAME filter as the nav catalog', () => {
|
|
242
320
|
// They become nav items downstream (`useNavContributions` projects them), so gating them
|
|
243
321
|
// anywhere else would let a tool the caller can't use reach the palette while its sidebar
|
|
244
322
|
// twin was correctly hidden.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
expect(toolIds(navSlotFilter(withTools(), {}))).toEqual([
|
|
323
|
+
expect(toolIds(navSlotFilter(withTools(), { gates: ALL_GATES }))).toEqual(
|
|
324
|
+
tools.map((t) => t.id),
|
|
325
|
+
)
|
|
326
|
+
expect(toolIds(navSlotFilter(withTools(), { gates: NO_GATES }))).toEqual([
|
|
327
|
+
'acme:open',
|
|
328
|
+
'acme:power-user',
|
|
329
|
+
'acme:intake',
|
|
330
|
+
])
|
|
331
|
+
// No gates service wired (tests, a bare install): everything passes, matching the dev-open
|
|
332
|
+
// "absent access allows all" parity the nav half keeps.
|
|
333
|
+
expect(toolIds(navSlotFilter(withTools(), {}))).toEqual(tools.map((t) => t.id))
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('drops a tool that has not opted into the intake surface', () => {
|
|
337
|
+
// The axis a separate external-tools filter used to miss entirely: a `designer` kept the
|
|
338
|
+
// deployment's whole External tools section while every first-party destination around it
|
|
339
|
+
// was hidden, which inverts the opt-in default rather than merely leaking one entry.
|
|
340
|
+
const narrowed: NavGates = { ...ALL_GATES, fullSurface: false }
|
|
341
|
+
expect(toolIds(navSlotFilter(withTools(), { gates: narrowed }))).toEqual(['acme:intake'])
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('keeps a tool and a nav entry declaring the same axes in lockstep', () => {
|
|
345
|
+
// The structural half, and the one a per-slot case cannot make: both slots run the same
|
|
346
|
+
// predicate today, so assert the PROPERTY that makes that worth keeping. A future axis
|
|
347
|
+
// wired into one slot and not the other fails here, whatever the axis turns out to be.
|
|
348
|
+
const AXES: NavGatedContribution[] = [
|
|
349
|
+
{},
|
|
350
|
+
{ advanced: true },
|
|
351
|
+
{ intake: true },
|
|
352
|
+
{ gate: (g: NavGates) => g.canManageIntegrations },
|
|
353
|
+
{ advanced: true, intake: true },
|
|
354
|
+
{ advanced: true, gate: (g: NavGates) => g.canManageIntegrations },
|
|
355
|
+
{ intake: true, gate: (g: NavGates) => g.canManageIntegrations },
|
|
356
|
+
{ advanced: true, intake: true, gate: (g: NavGates) => g.canManageIntegrations },
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
for (const [index, axes] of AXES.entries()) {
|
|
360
|
+
const id = `twin-${index}`
|
|
361
|
+
const paired = (gates: NavGates) => {
|
|
362
|
+
const filtered = navSlotFilter(
|
|
363
|
+
{
|
|
364
|
+
...slots(),
|
|
365
|
+
nav: [
|
|
366
|
+
{
|
|
367
|
+
id,
|
|
368
|
+
labelKey: 'nav.kaizen',
|
|
369
|
+
icon: 'i-lucide-link',
|
|
370
|
+
surfaces: ['command'] as const,
|
|
371
|
+
...axes,
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
externalTools: [tool(id, axes)],
|
|
375
|
+
},
|
|
376
|
+
{ gates },
|
|
377
|
+
) as AppSlots
|
|
378
|
+
return {
|
|
379
|
+
nav: filtered.nav.some((i) => i.id === id),
|
|
380
|
+
tool: filtered.externalTools.some((t) => t.id === id),
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Every setting of the three gate fields these axes read; the rest of `NavGates` is
|
|
385
|
+
// invariant here, so varying it would only restate the same eight verdicts.
|
|
386
|
+
for (const advancedMode of [true, false]) {
|
|
387
|
+
for (const fullSurface of [true, false]) {
|
|
388
|
+
for (const canManageIntegrations of [true, false]) {
|
|
389
|
+
const gates: NavGates = {
|
|
390
|
+
...ALL_GATES,
|
|
391
|
+
advancedMode,
|
|
392
|
+
fullSurface,
|
|
393
|
+
canManageIntegrations,
|
|
394
|
+
}
|
|
395
|
+
const verdict = paired(gates)
|
|
396
|
+
expect(
|
|
397
|
+
verdict.tool,
|
|
398
|
+
`${JSON.stringify(axes)} under ${JSON.stringify({ advancedMode, fullSurface, canManageIntegrations })}`,
|
|
399
|
+
).toBe(verdict.nav)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
249
404
|
})
|
|
250
405
|
})
|
|
251
406
|
|
|
@@ -348,7 +503,10 @@ describe('nav grouping helpers', () => {
|
|
|
348
503
|
it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
|
|
349
504
|
const workspace = groupCommands(NAV_CONTRIBUTIONS).find((g) => g.group === 'workspace')
|
|
350
505
|
// Same order the old CommandBar pushed them in (parity, not a reorder), with genuinely
|
|
351
|
-
// new entries appended after it rather than interleaved.
|
|
506
|
+
// new entries appended after it rather than interleaved. `ui-role` is the one deliberate
|
|
507
|
+
// exception, and it changes no existing entry's RELATIVE position: it sits beside `ui-mode`
|
|
508
|
+
// because the two answer one question ("how much of the app do I see"), and a user who finds
|
|
509
|
+
// one has found the other.
|
|
352
510
|
expect(workspace?.items.map((ci) => ci.item.id)).toEqual([
|
|
353
511
|
'fragments',
|
|
354
512
|
'merge-thresholds',
|
|
@@ -359,6 +517,7 @@ describe('nav grouping helpers', () => {
|
|
|
359
517
|
'sandbox',
|
|
360
518
|
'keyboard-shortcuts',
|
|
361
519
|
'ui-mode',
|
|
520
|
+
'ui-role',
|
|
362
521
|
'tutorial',
|
|
363
522
|
'foundational-services',
|
|
364
523
|
])
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { defineModule } from '@modular-vue/core'
|
|
2
|
-
import { filterExternalTools } from './external-tools'
|
|
3
2
|
import type { AppSlots } from './slots'
|
|
4
3
|
|
|
5
4
|
// Re-exported for the slice-1 importers that reach `AppSlots` through this
|
|
@@ -111,6 +110,18 @@ export interface NavGates {
|
|
|
111
110
|
* available to a `gate` predicate that needs to combine it with something else.
|
|
112
111
|
*/
|
|
113
112
|
advancedMode: boolean
|
|
113
|
+
/**
|
|
114
|
+
* The person's ROLE sees the whole product (`stores/uiRole.ts`): true for `engineer` and
|
|
115
|
+
* `product-manager`, false for the `intake` surface a `designer` gets.
|
|
116
|
+
*
|
|
117
|
+
* Read by {@link navSlotFilter} for every item that does NOT declare
|
|
118
|
+
* {@link NavContribution.intake}, i.e. the narrowing is opt-IN for the small surface rather
|
|
119
|
+
* than opt-out for the large one. Stated positively (a role that sees everything, not a
|
|
120
|
+
* "restricted" flag) for the same reason `advancedMode` is: this is the third axis a
|
|
121
|
+
* contribution answers to, and one of the three reading inverted is a destination shown to
|
|
122
|
+
* exactly the person it was hidden from.
|
|
123
|
+
*/
|
|
124
|
+
fullSurface: boolean
|
|
114
125
|
/**
|
|
115
126
|
* The open board has at least one service frame. Availability, not permission: a
|
|
116
127
|
* surface that operates ON a service (today the task-creation tutorial tour) has
|
|
@@ -206,12 +217,50 @@ export const NAV_ACTIONS = [
|
|
|
206
217
|
'shortcuts',
|
|
207
218
|
'tutorial',
|
|
208
219
|
'toggleUiMode',
|
|
220
|
+
'chooseRole',
|
|
209
221
|
] as const
|
|
210
222
|
|
|
211
223
|
export type NavActionId = (typeof NAV_ACTIONS)[number]
|
|
212
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The axes a rendered destination answers to, and the whole of what {@link navItemVisible}
|
|
227
|
+
* reads. Extended by {@link NavContribution} and by an external tool
|
|
228
|
+
* (`modular/external-tools.ts`), which is projected onto a nav item downstream.
|
|
229
|
+
*
|
|
230
|
+
* Shared as an interface rather than restated per contribution kind, so the two cannot declare
|
|
231
|
+
* different axes. That is not hypothetical: the role axis first landed on `NavContribution`
|
|
232
|
+
* alone, and external tools went on rendering for a role every first-party destination was
|
|
233
|
+
* hidden from. A fourth axis added here is one a tool must answer too, and the typecheck plus
|
|
234
|
+
* the single predicate are what say so.
|
|
235
|
+
*/
|
|
236
|
+
export interface NavGatedContribution {
|
|
237
|
+
/** Reactive predicate over {@link NavGates}; absent = always visible. */
|
|
238
|
+
gate?: (g: NavGates) => boolean
|
|
239
|
+
/**
|
|
240
|
+
* A power-user destination: shown only in ADVANCED interface mode (basic mode is the
|
|
241
|
+
* everyday surface — see `stores/uiMode.ts`). Declarative rather than folded into
|
|
242
|
+
* {@link gate}, so an item keeps its RBAC/availability predicate unchanged and the two
|
|
243
|
+
* axes stay independently assertable. Absent = visible in both tiers.
|
|
244
|
+
*/
|
|
245
|
+
advanced?: boolean
|
|
246
|
+
/**
|
|
247
|
+
* Offered on the INTAKE surface: the narrowed set a `designer` role gets (see
|
|
248
|
+
* `utils/uiRole.ts`). Absent = the full-surface roles only.
|
|
249
|
+
*
|
|
250
|
+
* Opt-in, and that direction is the point: a destination added later defaults to the roles
|
|
251
|
+
* that configure the platform, so a new entry can never quietly widen the simplified surface:
|
|
252
|
+
* the cost of getting it wrong is one flag, where the other default is a persona that stopped
|
|
253
|
+
* being simple without anyone deciding to un-simplify it. Independent of {@link advanced} and
|
|
254
|
+
* of {@link gate}: all three must pass.
|
|
255
|
+
*
|
|
256
|
+
* A deployment's own external tool declares it the same way, and defaults the same way: a
|
|
257
|
+
* registered application is platform surface until somebody says it is intake surface.
|
|
258
|
+
*/
|
|
259
|
+
intake?: boolean
|
|
260
|
+
}
|
|
261
|
+
|
|
213
262
|
/** One destination, declared once and rendered per surface. */
|
|
214
|
-
export interface NavContribution {
|
|
263
|
+
export interface NavContribution extends NavGatedContribution {
|
|
215
264
|
id: string
|
|
216
265
|
/** Default (sidebar) label i18n key. */
|
|
217
266
|
labelKey: string
|
|
@@ -225,15 +274,6 @@ export interface NavContribution {
|
|
|
225
274
|
description?: string
|
|
226
275
|
icon: string
|
|
227
276
|
surfaces: readonly NavSurface[]
|
|
228
|
-
/** Reactive predicate over {@link NavGates}; absent = always visible. */
|
|
229
|
-
gate?: (g: NavGates) => boolean
|
|
230
|
-
/**
|
|
231
|
-
* A power-user destination: shown only in ADVANCED interface mode (basic mode is the
|
|
232
|
-
* everyday surface — see `stores/uiMode.ts`). Declarative rather than folded into
|
|
233
|
-
* {@link gate}, so an item keeps its RBAC/availability predicate unchanged and the two
|
|
234
|
-
* axes stay independently assertable. Absent = visible in both tiers.
|
|
235
|
-
*/
|
|
236
|
-
advanced?: boolean
|
|
237
277
|
/**
|
|
238
278
|
* First-party action id, resolved to a `run()` against the host `ui` store by
|
|
239
279
|
* `useNavContributions`. A consumer module that has its own stores instead
|
|
@@ -292,6 +332,13 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
|
|
|
292
332
|
* (`fragments`), the ephemeral-env + runner plumbing (`infrastructure`, which is also the only route
|
|
293
333
|
* to the guided per-service Compose environment setup), and the workspace/model configuration
|
|
294
334
|
* a run actually reads (`workspace-settings`, `model-config`).
|
|
335
|
+
*
|
|
336
|
+
* `intake: true` marks the OTHER axis (see `utils/uiRole.ts`): the three entries a narrowed role
|
|
337
|
+
* keeps. It is opt-in, and the set is short on purpose, because the intake surface brings work IN and
|
|
338
|
+
* watches it, and every destination here that is not `tutorial`, `keyboard-shortcuts` or
|
|
339
|
+
* `ui-role` configures the platform the work runs on. What that role creates instead lives on the
|
|
340
|
+
* BOARD (a frame's add-task / from-ticket / from-design buttons), which is why dropping nearly
|
|
341
|
+
* the whole sidebar costs it nothing it is there to do.
|
|
295
342
|
*/
|
|
296
343
|
export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
297
344
|
{
|
|
@@ -575,6 +622,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
575
622
|
labelKey: 'nav.tutorials',
|
|
576
623
|
icon: 'i-lucide-graduation-cap',
|
|
577
624
|
surfaces: S('sidebar', 'command'),
|
|
625
|
+
// On the intake surface too, and it is the surface that needs it most: a narrowed role has
|
|
626
|
+
// the fewest destinations to learn the product from, and the delivery-loop tours are exactly
|
|
627
|
+
// the ones it can take (the platform half gates on the full surface: see `tutorial-tours.ts`).
|
|
628
|
+
intake: true,
|
|
578
629
|
action: 'tutorial',
|
|
579
630
|
testId: 'nav-tutorial',
|
|
580
631
|
sidebar: { group: 'help', order: 10 },
|
|
@@ -594,6 +645,9 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
594
645
|
labelKey: 'layout.commandBar.cmd.shortcuts',
|
|
595
646
|
icon: 'i-lucide-keyboard',
|
|
596
647
|
surfaces: S('command'),
|
|
648
|
+
// Help, like the tutorials: the cheatsheet describes the board and the palette, which every
|
|
649
|
+
// role has, and nothing it lists is a destination the intake surface hides.
|
|
650
|
+
intake: true,
|
|
597
651
|
action: 'shortcuts',
|
|
598
652
|
command: {
|
|
599
653
|
group: 'workspace',
|
|
@@ -611,6 +665,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
611
665
|
labelKey: 'layout.commandBar.cmd.toggleUiMode',
|
|
612
666
|
icon: 'i-lucide-toggle-right',
|
|
613
667
|
surfaces: S('command'),
|
|
668
|
+
// Deliberately NOT `intake`: a narrowed role's tier is CAPPED at basic (see `resolveUiMode`),
|
|
669
|
+
// so this entry would flip a preference the resolver then ignores, the same lie the sidebar
|
|
670
|
+
// switcher refuses to be under an env pin. The way out of a narrowed role is the entry below,
|
|
671
|
+
// which changes the surface for real.
|
|
614
672
|
action: 'toggleUiMode',
|
|
615
673
|
command: {
|
|
616
674
|
group: 'workspace',
|
|
@@ -618,6 +676,25 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
618
676
|
keywordsKey: 'layout.commandBar.keywords.toggleUiMode',
|
|
619
677
|
},
|
|
620
678
|
},
|
|
679
|
+
{
|
|
680
|
+
// The way BACK from a narrowed role, and the reason the intake surface is safe to ship: the
|
|
681
|
+
// role decides which surfaces exist at all, so a person who picked `designer` and then needs
|
|
682
|
+
// a pipeline has to be able to say so from inside that surface. Palette-only because the
|
|
683
|
+
// sidebar already carries the switcher itself (`UiRoleSwitcher`, above the destinations it
|
|
684
|
+
// gates, in EVERY role) rather than a shortcut to this question, the same relationship
|
|
685
|
+
// `ui-mode` has with the tier switcher.
|
|
686
|
+
id: 'ui-role',
|
|
687
|
+
labelKey: 'layout.commandBar.cmd.chooseRole',
|
|
688
|
+
icon: 'i-lucide-user-round-cog',
|
|
689
|
+
surfaces: S('command'),
|
|
690
|
+
intake: true,
|
|
691
|
+
action: 'chooseRole',
|
|
692
|
+
command: {
|
|
693
|
+
group: 'workspace',
|
|
694
|
+
order: 95,
|
|
695
|
+
keywordsKey: 'layout.commandBar.keywords.chooseRole',
|
|
696
|
+
},
|
|
697
|
+
},
|
|
621
698
|
]
|
|
622
699
|
|
|
623
700
|
/**
|
|
@@ -633,21 +710,25 @@ export const navigationModule = defineModule({
|
|
|
633
710
|
/**
|
|
634
711
|
* Does a shell render this contribution under `gates`?
|
|
635
712
|
*
|
|
636
|
-
* The
|
|
637
|
-
* pass: an `advanced` item is dropped in basic mode,
|
|
638
|
-
* `gate`. Order doesn't matter (it's a
|
|
639
|
-
* the cheaper
|
|
713
|
+
* The three axes a destination is gated on, in ONE place. They are independent and ALL must
|
|
714
|
+
* pass: an `advanced` item is dropped in basic mode, an item that is not `intake` is dropped for
|
|
715
|
+
* a narrowed role, and every item still answers to its own `gate`. Order doesn't matter (it's a
|
|
716
|
+
* conjunction) but the two flag reads come first, since they're the cheaper ones.
|
|
640
717
|
*
|
|
641
|
-
* Named rather than inlined in {@link navSlotFilter} because
|
|
718
|
+
* Named rather than inlined in {@link navSlotFilter} because other readers have to agree with
|
|
642
719
|
* it exactly: a tutorial tour whose step CLICKS a nav entry declares the requirement that
|
|
643
720
|
* renders it, and `tutorial-tours.spec.ts` pairs the two through this function. Spelling the
|
|
644
|
-
* conjunction out
|
|
721
|
+
* conjunction out anywhere else would be a copy that keeps passing while this one changes —
|
|
645
722
|
* and the drift it would miss (an entry gaining a gate clause, or being marked `advanced` and
|
|
646
723
|
* so leaving the DEFAULT interface tier) is precisely a tour offered to a user who then finds
|
|
647
724
|
* no such control.
|
|
648
725
|
*/
|
|
649
|
-
export function navItemVisible(item:
|
|
650
|
-
return (
|
|
726
|
+
export function navItemVisible(item: NavGatedContribution, gates: NavGates): boolean {
|
|
727
|
+
return (
|
|
728
|
+
(item.advanced ? gates.advancedMode : true) &&
|
|
729
|
+
(gates.fullSurface || item.intake === true) &&
|
|
730
|
+
(item.gate ? item.gate(gates) : true)
|
|
731
|
+
)
|
|
651
732
|
}
|
|
652
733
|
|
|
653
734
|
/**
|
|
@@ -675,10 +756,12 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
675
756
|
// richer value than a thinned list, so tour resolution lives in `resolveTourCatalogue`
|
|
676
757
|
// (pure, gates-nullable) and runs once in `useTutorialTours`, whose `tours` is the same
|
|
677
758
|
// gated set the launch prompt and the overlay always saw.
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
759
|
+
// A deployment's external tools run through the SAME predicate as `nav`, on the same line
|
|
760
|
+
// shape, because they become nav items downstream (`useNavContributions` projects them):
|
|
761
|
+
// gating them by any other expression lets a tool reach the palette while its first-party
|
|
762
|
+
// neighbours are correctly hidden. That is exactly what a separate `filterExternalTools`
|
|
763
|
+
// spelling its own conjunction did when the role axis landed.
|
|
764
|
+
externalTools: gates ? externalTools.filter((t) => navItemVisible(t, gates)) : externalTools,
|
|
682
765
|
}
|
|
683
766
|
}
|
|
684
767
|
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -15,8 +15,9 @@ import type { NavGates } from '~/modular/nav-contributions'
|
|
|
15
15
|
*
|
|
16
16
|
* This mirrors the exact gating the pre-slice-1 `SideBar` computeds encoded (see
|
|
17
17
|
* `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity), plus
|
|
18
|
-
* the interface tier (`advancedMode`)
|
|
19
|
-
* re-gates all three shells through the same
|
|
18
|
+
* the interface tier (`advancedMode`) and the person's role (`fullSurface`), which ride
|
|
19
|
+
* the same service so a mode or role flip re-gates all three shells through the same
|
|
20
|
+
* reactive path as a permission flip.
|
|
20
21
|
*/
|
|
21
22
|
export function createNavGates(): NavGates {
|
|
22
23
|
const access = useWorkspaceAccess()
|
|
@@ -27,6 +28,7 @@ export function createNavGates(): NavGates {
|
|
|
27
28
|
const auth = useAuthStore()
|
|
28
29
|
const providerConnections = useProviderConnectionsStore()
|
|
29
30
|
const uiMode = useUiModeStore()
|
|
31
|
+
const uiRole = useUiRoleStore()
|
|
30
32
|
const board = useBoardStore()
|
|
31
33
|
const execution = useExecutionStore()
|
|
32
34
|
const reviews = useReviewStage()
|
|
@@ -108,6 +110,13 @@ export function createNavGates(): NavGates {
|
|
|
108
110
|
get advancedMode() {
|
|
109
111
|
return uiMode.isAdvanced
|
|
110
112
|
},
|
|
113
|
+
// The role axis rides the same reactive service as the tier, so picking a role re-gates all
|
|
114
|
+
// three shells through one path. It is not a permission: every item it keeps is still gated
|
|
115
|
+
// by its own `gate`, and every item it drops is one the caller may well be allowed to open,
|
|
116
|
+
// which is why the switcher out of a narrowed role is itself `intake`.
|
|
117
|
+
get fullSurface() {
|
|
118
|
+
return uiRole.fullSurface
|
|
119
|
+
},
|
|
111
120
|
get boardHasService() {
|
|
112
121
|
return hasService.value
|
|
113
122
|
},
|
|
@@ -24,6 +24,7 @@ const ALL_GATES: NavGates = {
|
|
|
24
24
|
accountsEnabled: true,
|
|
25
25
|
isAccountAdmin: true,
|
|
26
26
|
advancedMode: true,
|
|
27
|
+
fullSurface: true,
|
|
27
28
|
boardHasService: true,
|
|
28
29
|
boardHasTask: true,
|
|
29
30
|
boardHasRun: true,
|
|
@@ -140,9 +141,10 @@ function navAnchoredSteps(): {
|
|
|
140
141
|
* Enumerating rather than reasoning is deliberate. The pairing below is an IMPLICATION over
|
|
141
142
|
* gate sets — anything that satisfies a tour must also render its entry — between two
|
|
142
143
|
* predicates written independently in two files, and nothing about their shape is guaranteed
|
|
143
|
-
* (either may be a conjunction, a disjunction, or read a field the other doesn't).
|
|
144
|
-
*
|
|
145
|
-
*
|
|
144
|
+
* (either may be a conjunction, a disjunction, or read a field the other doesn't). The matrix
|
|
145
|
+
* doubles with every field {@link NavGates} gains, so the cost is worth re-measuring rather than
|
|
146
|
+
* quoting from here: at the field count this spec runs against today it is a fraction of a
|
|
147
|
+
* second, a fair price for a guard that needs no assumption about how either side is spelled.
|
|
146
148
|
*/
|
|
147
149
|
function* everyGateSet(): Generator<NavGates> {
|
|
148
150
|
const keys = Object.keys(ALL_GATES) as (keyof NavGates)[]
|