@cat-factory/app 0.198.0 → 0.199.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 +6 -1
- package/app/components/layout/BoardToolbar.vue +1 -1
- package/app/components/layout/CommandBar.vue +8 -2
- package/app/components/layout/SideBar.vue +24 -3
- package/app/components/settings/WorkspaceMetadataSettings.vue +151 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +27 -0
- package/app/composables/useNavContributions.ts +91 -1
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/docs/consumer-extensions.md +76 -10
- package/app/modular/external-tools.spec.ts +281 -0
- package/app/modular/external-tools.ts +265 -0
- package/app/modular/nav-contributions.spec.ts +32 -14
- package/app/modular/nav-contributions.ts +32 -1
- package/app/modular/registry.ts +2 -0
- package/app/modular/slots.ts +15 -0
- package/app/modular/workspace-metadata.spec.ts +160 -0
- package/app/modular/workspace-metadata.ts +173 -0
- package/app/stores/workspaceSettings.ts +3 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +22 -2
- package/i18n/locales/en.json +22 -2
- package/i18n/locales/es.json +22 -2
- package/i18n/locales/fr.json +22 -2
- package/i18n/locales/he.json +22 -2
- package/i18n/locales/it.json +22 -2
- package/i18n/locales/ja.json +22 -2
- package/i18n/locales/pl.json +22 -2
- package/i18n/locales/tr.json +22 -2
- package/i18n/locales/uk.json +22 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -267,7 +267,12 @@ by its `when(gates)` predicate (the same reactive gates service the nav uses, fi
|
|
|
267
267
|
## Extending the layer (consumer modules)
|
|
268
268
|
|
|
269
269
|
A deployment can contribute its own components — result windows, nav entries, inspector
|
|
270
|
-
panels, agent-kind palette data —
|
|
270
|
+
panels, agent-kind palette data — plus two DATA-only seams that need no components at all:
|
|
271
|
+
its own applications in an **External tools** sidebar section (`externalTools`, each
|
|
272
|
+
resolving its URL from the acting user / open workspace / this board's custom fields) and the
|
|
273
|
+
**custom workspace metadata fields** those resolvers read (`workspaceMetadataFields`, edited
|
|
274
|
+
on the Metadata tab of Workspace settings). All of it **without forking**, through the
|
|
275
|
+
auto-imported
|
|
271
276
|
`registerAppModule` seam (the frontend analogue of the backend's `registerAgentKind` /
|
|
272
277
|
`registerGate` registries). The authoring walkthrough, the reusable shared building blocks
|
|
273
278
|
(`ResultWindowShell`, the `StepRunMeta` run-metadata block, `useResultView`, …), and the
|
|
@@ -139,10 +139,14 @@ const commands = computed<Command[]>(() => {
|
|
|
139
139
|
const asCommand = (g: (typeof commandGroups.value)[number]): Command[] =>
|
|
140
140
|
g.items.map((ci) => ({
|
|
141
141
|
id: ci.item.id,
|
|
142
|
-
|
|
142
|
+
// A contribution whose copy is deployment DATA (a registered external tool's title)
|
|
143
|
+
// carries a literal `label`; catalog destinations resolve their key. Running a tool's
|
|
144
|
+
// own name through `t()` would show the raw name plus a missing-key warning.
|
|
145
|
+
label: ci.item.label ?? t(ci.labelKey),
|
|
143
146
|
group: t(g.labelKey),
|
|
144
147
|
icon: ci.item.icon,
|
|
145
|
-
keywords
|
|
148
|
+
// The description doubles as fuzzy-match keywords for a tool, which has no keyword key.
|
|
149
|
+
keywords: ci.keywordsKey ? t(ci.keywordsKey) : ci.item.description,
|
|
146
150
|
run: () => invoke(ci.item),
|
|
147
151
|
}))
|
|
148
152
|
const groupOrEmpty = (name: (typeof commandGroups.value)[number]['group']) => {
|
|
@@ -154,6 +158,8 @@ const commands = computed<Command[]>(() => {
|
|
|
154
158
|
...groupOrEmpty('repositories'),
|
|
155
159
|
...groupOrEmpty('integrations'),
|
|
156
160
|
...dynamicIntegrationCommands.value,
|
|
161
|
+
// The deployment's own applications (the `externalTools` slot, projected onto nav items).
|
|
162
|
+
...groupOrEmpty('externalTools'),
|
|
157
163
|
...groupOrEmpty('workspace'),
|
|
158
164
|
...groupOrEmpty('account'),
|
|
159
165
|
]
|
|
@@ -17,6 +17,7 @@ import LanguageSwitcher from '~/components/layout/LanguageSwitcher.vue'
|
|
|
17
17
|
import UiModeSwitcher from '~/components/layout/UiModeSwitcher.vue'
|
|
18
18
|
import UserMenu from '~/components/auth/UserMenu.vue'
|
|
19
19
|
import { useViewport } from '~/composables/useViewport'
|
|
20
|
+
import type { NavContribution } from '~/modular/nav-contributions'
|
|
20
21
|
|
|
21
22
|
const { t } = useI18n()
|
|
22
23
|
|
|
@@ -37,6 +38,26 @@ const ui = useUiStore()
|
|
|
37
38
|
// or connection flips, so this shell no longer hand-rolls per-item `show*` computeds.
|
|
38
39
|
const { sidebarGroups, invoke } = useNavContributions()
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A destination's visible label: catalog copy for a first-party item, the LITERAL `label` for
|
|
43
|
+
* one whose copy is deployment data (a registered external tool's title). A tool's name is not
|
|
44
|
+
* a catalog key — the deployment ships whatever locales it needs in its own catalog — so
|
|
45
|
+
* running it through `t()` would render the raw name back with a missing-key warning.
|
|
46
|
+
*/
|
|
47
|
+
function navLabel(item: NavContribution): string {
|
|
48
|
+
return item.label ?? t(item.labelKey)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The hover tooltip. In the rail it names the destination (the label is hidden); expanded it
|
|
53
|
+
* carries the item's `description` when it has one, which is how an external tool explains
|
|
54
|
+
* what it is without a second line in the sidebar.
|
|
55
|
+
*/
|
|
56
|
+
function navTitle(item: NavContribution, railed: boolean): string | undefined {
|
|
57
|
+
if (railed) return item.description ? `${navLabel(item)}: ${item.description}` : navLabel(item)
|
|
58
|
+
return item.description
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
// `isCompact` (< lg) is the breakpoint at which the navbar is an off-canvas drawer;
|
|
41
62
|
// above it the aside is static and the drawer flag is inert.
|
|
42
63
|
const { isCompact } = useViewport()
|
|
@@ -235,12 +256,12 @@ watch(
|
|
|
235
256
|
:square="railed"
|
|
236
257
|
class="w-full"
|
|
237
258
|
:class="railed ? 'justify-center' : 'justify-start'"
|
|
238
|
-
:aria-label="railed ?
|
|
239
|
-
:title="
|
|
259
|
+
:aria-label="railed ? navLabel(item) : undefined"
|
|
260
|
+
:title="navTitle(item, railed)"
|
|
240
261
|
:data-testid="item.testId"
|
|
241
262
|
@click="invoke(item)"
|
|
242
263
|
>
|
|
243
|
-
<span v-if="!railed">{{
|
|
264
|
+
<span v-if="!railed">{{ navLabel(item) }}</span>
|
|
244
265
|
</UButton>
|
|
245
266
|
</div>
|
|
246
267
|
</section>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Workspace settings: the values for the CUSTOM metadata fields a deployment declares in code
|
|
3
|
+
// (the `workspaceMetadataFields` slot — see `modular/workspace-metadata.ts`). The fields come
|
|
4
|
+
// from the registry; the values are per workspace and land in the settings row's `metadata`
|
|
5
|
+
// bag, where external-tool URL resolvers read them ("open the map editor on this board's game").
|
|
6
|
+
//
|
|
7
|
+
// A deployment that declares NOTHING never gets here — the panel's tab exists only where fields
|
|
8
|
+
// are declared, so an unwired capability is invisible rather than an empty tab everywhere. The
|
|
9
|
+
// empty state below is therefore the loud one: fields WERE declared and every one was rejected
|
|
10
|
+
// (a malformed key), which must not look like a deployment that declared none.
|
|
11
|
+
import { reactive, watch } from 'vue'
|
|
12
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
13
|
+
import {
|
|
14
|
+
metadataDraftFrom,
|
|
15
|
+
metadataPatchFrom,
|
|
16
|
+
resolveMetadataFields,
|
|
17
|
+
} from '~/modular/workspace-metadata'
|
|
18
|
+
import type { WorkspaceMetadataFieldDefinition } from '~/modular/workspace-metadata'
|
|
19
|
+
import type { AppSlots } from '~/modular/slots'
|
|
20
|
+
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
23
|
+
const store = useWorkspaceSettingsStore()
|
|
24
|
+
const toast = useToast()
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The fields to render. A malformed key is dropped (the store would refuse every save) and
|
|
28
|
+
* NAMED in the console rather than swallowed — the deployment author is the only person who
|
|
29
|
+
* can fix it, and a silently missing field looks exactly like one nobody declared.
|
|
30
|
+
*/
|
|
31
|
+
const fields = computed<WorkspaceMetadataFieldDefinition[]>(() => {
|
|
32
|
+
const { fields: valid, rejected } = resolveMetadataFields(
|
|
33
|
+
(slots.value.workspaceMetadataFields ?? []) as WorkspaceMetadataFieldDefinition[],
|
|
34
|
+
)
|
|
35
|
+
if (import.meta.dev && rejected.length > 0) {
|
|
36
|
+
console.warn(
|
|
37
|
+
'[cat-factory] workspace metadata fields dropped (invalid or duplicate key):',
|
|
38
|
+
rejected.map((f) => f.key),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
return valid
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Local editable copy, re-seeded whenever the stored settings are replaced (the store always
|
|
45
|
+
// reassigns the ref, so tracking the object reference is enough).
|
|
46
|
+
const draft = reactive<Record<string, string>>({})
|
|
47
|
+
watch(
|
|
48
|
+
[() => store.settings, fields],
|
|
49
|
+
() => {
|
|
50
|
+
const next = metadataDraftFrom(fields.value, store.settings.metadata)
|
|
51
|
+
for (const key of Object.keys(draft)) delete draft[key]
|
|
52
|
+
Object.assign(draft, next)
|
|
53
|
+
},
|
|
54
|
+
{ immediate: true },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const saving = ref(false)
|
|
58
|
+
|
|
59
|
+
async function save() {
|
|
60
|
+
saving.value = true
|
|
61
|
+
try {
|
|
62
|
+
// `metadataPatchFrom` carries any stored key this build does not render back into the
|
|
63
|
+
// patch: the update REPLACES the bag, so a value written under a retired field would
|
|
64
|
+
// otherwise be deleted by an unrelated save.
|
|
65
|
+
await store.update({
|
|
66
|
+
metadata: metadataPatchFrom(fields.value, draft, store.settings.metadata),
|
|
67
|
+
})
|
|
68
|
+
toast.add({
|
|
69
|
+
title: t('settings.workspaceSettings.toast.saved'),
|
|
70
|
+
icon: 'i-lucide-check',
|
|
71
|
+
color: 'success',
|
|
72
|
+
})
|
|
73
|
+
} catch (e) {
|
|
74
|
+
toast.add({
|
|
75
|
+
title: t('settings.workspaceSettings.toast.saveFailed'),
|
|
76
|
+
description: e instanceof Error ? e.message : String(e),
|
|
77
|
+
icon: 'i-lucide-triangle-alert',
|
|
78
|
+
color: 'error',
|
|
79
|
+
})
|
|
80
|
+
} finally {
|
|
81
|
+
saving.value = false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A `select` field's items, with an explicit "not set" choice so a value can be cleared. */
|
|
86
|
+
function selectItems(field: WorkspaceMetadataFieldDefinition) {
|
|
87
|
+
return [
|
|
88
|
+
{ label: t('settings.workspaceSettings.metadata.unset'), value: '' },
|
|
89
|
+
...(field.options ?? []).map((o) => ({ label: o.label, value: o.value })),
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<div class="space-y-6" data-testid="workspace-metadata-settings">
|
|
96
|
+
<section class="space-y-2">
|
|
97
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
98
|
+
{{ t('settings.workspaceSettings.metadata.heading') }}
|
|
99
|
+
</h3>
|
|
100
|
+
<p class="text-[11px] text-slate-400">
|
|
101
|
+
{{ t('settings.workspaceSettings.metadata.body') }}
|
|
102
|
+
</p>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<p v-if="fields.length === 0" class="text-[11px] text-slate-500">
|
|
106
|
+
{{ t('settings.workspaceSettings.metadata.empty') }}
|
|
107
|
+
</p>
|
|
108
|
+
|
|
109
|
+
<template v-else>
|
|
110
|
+
<div class="space-y-4">
|
|
111
|
+
<label v-for="field in fields" :key="field.key" class="block">
|
|
112
|
+
<!-- Field labels are deployment DATA, rendered verbatim (see the module docs). -->
|
|
113
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
114
|
+
{{ field.label }}
|
|
115
|
+
</span>
|
|
116
|
+
<USelect
|
|
117
|
+
v-if="field.type === 'select'"
|
|
118
|
+
v-model="draft[field.key]"
|
|
119
|
+
:items="selectItems(field)"
|
|
120
|
+
size="sm"
|
|
121
|
+
:data-testid="`workspace-metadata-${field.key}`"
|
|
122
|
+
/>
|
|
123
|
+
<UInput
|
|
124
|
+
v-else
|
|
125
|
+
v-model="draft[field.key]"
|
|
126
|
+
:type="field.type === 'number' ? 'number' : 'text'"
|
|
127
|
+
:placeholder="field.placeholder"
|
|
128
|
+
size="sm"
|
|
129
|
+
:data-testid="`workspace-metadata-${field.key}`"
|
|
130
|
+
/>
|
|
131
|
+
<span v-if="field.description" class="mt-1 block text-[11px] text-slate-500">
|
|
132
|
+
{{ field.description }}
|
|
133
|
+
</span>
|
|
134
|
+
</label>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div class="flex justify-end">
|
|
138
|
+
<UButton
|
|
139
|
+
color="primary"
|
|
140
|
+
size="sm"
|
|
141
|
+
icon="i-lucide-save"
|
|
142
|
+
:loading="saving"
|
|
143
|
+
data-testid="workspace-metadata-save"
|
|
144
|
+
@click="save"
|
|
145
|
+
>
|
|
146
|
+
{{ t('common.save') }}
|
|
147
|
+
</UButton>
|
|
148
|
+
</div>
|
|
149
|
+
</template>
|
|
150
|
+
</div>
|
|
151
|
+
</template>
|
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
// - Merge thresholds: the auto-merge preset library.
|
|
6
6
|
// - Issue tracker: filing-tracker selection + linking sources + writeback.
|
|
7
7
|
// - Service best practices: the default fragments new services inherit.
|
|
8
|
+
// - Metadata: values for the custom workspace fields the DEPLOYMENT declares in code (read
|
|
9
|
+
// by external-tool URL resolvers); present only where any are declared.
|
|
8
10
|
// The latter three are body-only section components rendered in tabs here (no longer
|
|
9
11
|
// standalone modals).
|
|
10
12
|
import { reactive, ref, watch } from 'vue'
|
|
13
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
11
14
|
import type { ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
|
|
12
15
|
import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
|
|
13
16
|
import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
|
|
@@ -15,7 +18,9 @@ import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentD
|
|
|
15
18
|
import BudgetSettings from '~/components/settings/BudgetSettings.vue'
|
|
16
19
|
import UsageSettings from '~/components/settings/UsageSettings.vue'
|
|
17
20
|
import WorkspaceMembersSettings from '~/components/layout/WorkspaceMembersSettings.vue'
|
|
21
|
+
import WorkspaceMetadataSettings from '~/components/settings/WorkspaceMetadataSettings.vue'
|
|
18
22
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
23
|
+
import type { AppSlots } from '~/modular/slots'
|
|
19
24
|
|
|
20
25
|
const { t, te } = useI18n()
|
|
21
26
|
const ui = useUiStore()
|
|
@@ -23,6 +28,13 @@ const store = useWorkspaceSettingsStore()
|
|
|
23
28
|
const workspace = useWorkspaceStore()
|
|
24
29
|
const access = useWorkspaceAccess()
|
|
25
30
|
const toast = useToast()
|
|
31
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
32
|
+
|
|
33
|
+
// The Metadata tab exists only where the deployment DECLARES custom fields — an unwired
|
|
34
|
+
// capability is invisible, not an empty tab in every deployment. Declared-but-malformed fields
|
|
35
|
+
// still open the tab: it carries the empty state (and the console warning names the keys), so a
|
|
36
|
+
// broken declaration surfaces instead of looking like one nobody wrote.
|
|
37
|
+
const hasMetadataFields = computed(() => (slots.value.workspaceMetadataFields ?? []).length > 0)
|
|
26
38
|
|
|
27
39
|
const open = computed({
|
|
28
40
|
get: () => ui.workspaceSettingsOpen,
|
|
@@ -74,6 +86,16 @@ const tabs = computed(() => [
|
|
|
74
86
|
icon: 'i-lucide-book-open-check',
|
|
75
87
|
slot: 'fragments',
|
|
76
88
|
},
|
|
89
|
+
...(hasMetadataFields.value
|
|
90
|
+
? [
|
|
91
|
+
{
|
|
92
|
+
value: 'metadata',
|
|
93
|
+
label: t('settings.workspaceSettings.tabs.metadata'),
|
|
94
|
+
icon: 'i-lucide-tags',
|
|
95
|
+
slot: 'metadata',
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
: []),
|
|
77
99
|
// Roster + access-mode management is `members.manage` (workspace admins only). Hidden
|
|
78
100
|
// for everyone else — the backend 403s the writes and the tab has nothing to read.
|
|
79
101
|
...(access.canManageMembers.value
|
|
@@ -517,6 +539,11 @@ async function save() {
|
|
|
517
539
|
<ServiceFragmentDefaultsPanel />
|
|
518
540
|
</template>
|
|
519
541
|
|
|
542
|
+
<!-- Custom workspace metadata (only where the deployment declares fields) -->
|
|
543
|
+
<template v-if="hasMetadataFields" #metadata>
|
|
544
|
+
<WorkspaceMetadataSettings />
|
|
545
|
+
</template>
|
|
546
|
+
|
|
520
547
|
<!-- Members (workspace RBAC roster + access mode; admins only) -->
|
|
521
548
|
<template v-if="access.canManageMembers.value && workspace.workspaceId" #members>
|
|
522
549
|
<WorkspaceMembersSettings :workspace-id="workspace.workspaceId" />
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { computed } from 'vue'
|
|
2
2
|
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
3
3
|
import { groupCommands, groupSidebar, sortToolbar } from '~/modular/nav-contributions'
|
|
4
|
+
import { EXTERNAL_TOOL_UNAVAILABLE_KEYS, projectExternalTools } from '~/modular/external-tools'
|
|
5
|
+
import { toMetadataBag } from '~/modular/workspace-metadata'
|
|
6
|
+
import type { ExternalToolContext, ExternalToolContribution } from '~/modular/external-tools'
|
|
4
7
|
import type {
|
|
5
8
|
AppSlots,
|
|
6
9
|
CommandGroup,
|
|
@@ -23,6 +26,14 @@ import type {
|
|
|
23
26
|
export function useNavContributions() {
|
|
24
27
|
const slots = useReactiveSlots<AppSlots>()
|
|
25
28
|
const ui = useUiStore()
|
|
29
|
+
// Resolved through the Nuxt app's global i18n instance rather than `useI18n()` (which needs
|
|
30
|
+
// an active component instance) — the same handle, and the same reason, as
|
|
31
|
+
// `usePipelineErrorToast`: nothing about this composable should depend on WHERE it is called.
|
|
32
|
+
const { t } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
|
|
33
|
+
const toast = useToast()
|
|
34
|
+
const auth = useAuthStore()
|
|
35
|
+
const workspace = useWorkspaceStore()
|
|
36
|
+
const workspaceSettings = useWorkspaceSettingsStore()
|
|
26
37
|
|
|
27
38
|
// First-party action ids → host handlers. Typed as an exhaustive
|
|
28
39
|
// `Record<NavActionId, …>`, so a catalog `action` with no handler (or a handler
|
|
@@ -65,7 +76,86 @@ export function useNavContributions() {
|
|
|
65
76
|
if (item.action) actions[item.action]?.()
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The stored metadata bag, re-hung on a null prototype. A resolver is a DEPLOYMENT'S own code
|
|
81
|
+
* writing `ctx.metadata.gameId`, so the object it reads has to answer `undefined` for an
|
|
82
|
+
* unfilled field whatever that field is called — `constructor` and `toString` both pass the
|
|
83
|
+
* key pattern, and on a plain object both read as an inherited function. A `computed` rather
|
|
84
|
+
* than a copy per read, so the reference stays stable between settings changes.
|
|
85
|
+
*/
|
|
86
|
+
const externalToolMetadata = computed(() => toMetadataBag(workspaceSettings.settings.metadata))
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The invocation context an external tool's resolver reads. GETTERS, not a captured snapshot,
|
|
90
|
+
* so a resolver called at click time sees the workspace/metadata as they are NOW — a teammate
|
|
91
|
+
* can fill in the field the tool needs while this sidebar is open, and the click must then
|
|
92
|
+
* work rather than repeat a message about a fix that already happened. (They also keep the
|
|
93
|
+
* projection below reactive: reading a store inside the computed tracks it.)
|
|
94
|
+
*/
|
|
95
|
+
const externalToolContext: ExternalToolContext = {
|
|
96
|
+
get userId() {
|
|
97
|
+
return auth.user?.id ?? null
|
|
98
|
+
},
|
|
99
|
+
get userEmail() {
|
|
100
|
+
return auth.user?.email ?? null
|
|
101
|
+
},
|
|
102
|
+
get workspaceId() {
|
|
103
|
+
return workspace.workspaceId ?? ''
|
|
104
|
+
},
|
|
105
|
+
get workspaceName() {
|
|
106
|
+
return workspace.activeWorkspace?.name ?? ''
|
|
107
|
+
},
|
|
108
|
+
get metadata() {
|
|
109
|
+
return externalToolMetadata.value
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Registered external tools, projected onto nav contributions. The `externalTools` slot is
|
|
115
|
+
* already RBAC/tier-filtered by `navSlotFilter`, exactly like `nav`.
|
|
116
|
+
*
|
|
117
|
+
* A tool that can't currently resolve stays in the list and explains itself on click: the
|
|
118
|
+
* person looking at the sidebar is usually the one who can fix it (fill in the field), and
|
|
119
|
+
* hiding it would make an unconfigured workspace look like a deployment that never
|
|
120
|
+
* registered the tool.
|
|
121
|
+
*/
|
|
122
|
+
const externalToolItems = computed<NavContribution[]>(() =>
|
|
123
|
+
projectExternalTools(
|
|
124
|
+
(slots.value.externalTools ?? []) as ExternalToolContribution[],
|
|
125
|
+
externalToolContext,
|
|
126
|
+
{
|
|
127
|
+
// A separate browsing context, with `noopener` so the opened page cannot reach back
|
|
128
|
+
// into this one through `window.opener`.
|
|
129
|
+
open: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
|
130
|
+
onUnavailable: (resolution, tool) => {
|
|
131
|
+
// A resolver that threw is the one refusal the toast can't fully explain: the person
|
|
132
|
+
// reading it can't act on a stack trace, and the deployment author who can isn't
|
|
133
|
+
// here. So the message says which tool is broken and the cause goes to the console —
|
|
134
|
+
// unconditionally, not behind `import.meta.dev`, because this is an exception being
|
|
135
|
+
// absorbed and the deployment debugging it is a built one.
|
|
136
|
+
if (resolution.reason === 'resolver-failed') {
|
|
137
|
+
console.error(
|
|
138
|
+
`[cat-factory] external tool "${tool.id}" URL resolver threw`,
|
|
139
|
+
resolution.cause,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
toast.add({
|
|
143
|
+
title: t('externalTools.unavailable.title', { tool: tool.title }),
|
|
144
|
+
description: t(EXTERNAL_TOOL_UNAVAILABLE_KEYS[resolution.reason], {
|
|
145
|
+
fields: resolution.missing.join(', '),
|
|
146
|
+
}),
|
|
147
|
+
icon: 'i-lucide-triangle-alert',
|
|
148
|
+
color: 'warning',
|
|
149
|
+
})
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
).map((item) => item.contribution),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const all = computed<NavContribution[]>(() => [
|
|
156
|
+
...(slots.value.nav ?? []),
|
|
157
|
+
...externalToolItems.value,
|
|
158
|
+
])
|
|
69
159
|
|
|
70
160
|
/** Grouped + ordered sidebar sections, empty sections dropped. */
|
|
71
161
|
const sidebarGroups = computed<SidebarGroup[]>(() => groupSidebar(all.value))
|
|
@@ -220,6 +220,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
220
220
|
titleKey: 'errors.conflict.title.pipeline_schedule_requires_recurring',
|
|
221
221
|
descriptionKey: 'errors.conflict.description.pipeline_schedule_requires_recurring',
|
|
222
222
|
},
|
|
223
|
+
foundational_service_exists: {
|
|
224
|
+
titleKey: 'errors.conflict.title.foundational_service_exists',
|
|
225
|
+
descriptionKey: 'errors.conflict.description.foundational_service_exists',
|
|
226
|
+
},
|
|
223
227
|
pipeline_schedule_intake_unconfigured: {
|
|
224
228
|
titleKey: 'errors.conflict.title.pipeline_schedule_intake_unconfigured',
|
|
225
229
|
descriptionKey: 'errors.conflict.description.pipeline_schedule_intake_unconfigured',
|
|
@@ -56,16 +56,18 @@ export default defineNuxtPlugin(() => {
|
|
|
56
56
|
|
|
57
57
|
## The landed seams
|
|
58
58
|
|
|
59
|
-
| Seam | Slot key
|
|
60
|
-
| ----------------------------------- |
|
|
61
|
-
| Run-detail windows | `resultViews`
|
|
62
|
-
| Agent kinds (palette data) | `agentKinds`
|
|
63
|
-
| Custom task types | `taskTypes`
|
|
64
|
-
| Sidebar / command-palette / toolbar | `nav`
|
|
65
|
-
| Inspector body panels | `inspectorPanels`
|
|
66
|
-
| Top-level overlays | `appOverlays`
|
|
67
|
-
|
|
|
68
|
-
|
|
|
59
|
+
| Seam | Slot key | Entry shape | Host |
|
|
60
|
+
| ----------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
|
61
|
+
| Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
|
|
62
|
+
| Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
|
|
63
|
+
| Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
|
|
64
|
+
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
|
|
65
|
+
| Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
|
|
66
|
+
| Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
|
|
67
|
+
| External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, order? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
|
|
68
|
+
| Custom workspace metadata fields | `workspaceMetadataFields` | `{ key, label, description?, placeholder?, type?, options?, order? }` | the Metadata tab of Workspace settings |
|
|
69
|
+
| Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
|
|
70
|
+
| Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
|
|
69
71
|
|
|
70
72
|
A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
|
|
71
73
|
(the shipped default) exactly as it does for the first-party destinations — see
|
|
@@ -103,6 +105,70 @@ show the panel, and `order` places it among the built-ins. Your panel component
|
|
|
103
105
|
selected block via `usePanelSubject<Block>()` (`@modular-vue/core`). `when` must tolerate a
|
|
104
106
|
nullish subject (the boot-time validation resolve passes `null`).
|
|
105
107
|
|
|
108
|
+
### External tools + workspace metadata (`externalTools`, `workspaceMetadataFields`)
|
|
109
|
+
|
|
110
|
+
Put your OWN web applications — a map editor, an asset pipeline, an admin console — in the
|
|
111
|
+
sidebar's **External tools** section, and open each one _already scoped to what the user is
|
|
112
|
+
looking at_. That second half is the point of the seam; a static link needs no registration.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
externalTools: [
|
|
116
|
+
{
|
|
117
|
+
id: 'acme:map-editor',
|
|
118
|
+
title: 'Map editor', // literal copy: a tool's name is DATA, not a key
|
|
119
|
+
description: 'Edit the level geometry for this project.',
|
|
120
|
+
icon: 'i-lucide-map',
|
|
121
|
+
requiredMetadata: ['gameId'],
|
|
122
|
+
url: (ctx) => {
|
|
123
|
+
// Build, don't splice: every value here is operator-typed text (see below).
|
|
124
|
+
const url = new URL('https://maps.acme.dev/edit')
|
|
125
|
+
url.searchParams.set('game', ctx.metadata.gameId ?? '')
|
|
126
|
+
url.searchParams.set('ws', ctx.workspaceId)
|
|
127
|
+
return url.toString()
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork' }],
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- **`url` is a string or a RESOLVER** `(ctx) => string | null`. The context carries `userId`,
|
|
135
|
+
`userEmail`, `workspaceId`, `workspaceName` and `metadata` — the custom workspace fields you
|
|
136
|
+
declared. It is read at CLICK time, so a value a teammate fills in while the sidebar is open
|
|
137
|
+
takes effect without a reload.
|
|
138
|
+
- **Clicking opens a separate page** (`target=_blank`, `noopener`). The resolved URL must be
|
|
139
|
+
`http(s)`: anything else is refused rather than handed to the browser, because the string
|
|
140
|
+
reaches `window.open` and a `javascript:` URL would run in the SPA's own origin.
|
|
141
|
+
- **Declare `requiredMetadata` for the fields your resolver needs.** An unconfigured workspace
|
|
142
|
+
then gets "fill in `gameId` on the Metadata tab" instead of a generic failure — and the tool
|
|
143
|
+
stays LISTED, because the person looking at the sidebar is usually the one who can fix it. A
|
|
144
|
+
resolver that returns `null` reports separately ("this tool gave no address"), since that one
|
|
145
|
+
is yours to fix, not the operator's.
|
|
146
|
+
- **Treat every `ctx.metadata` value as untrusted input.** A workspace admin types these in, so a
|
|
147
|
+
value is operator-supplied text that happens to be length-bounded — not a constant you chose.
|
|
148
|
+
Set it as a query parameter or an `encodeURIComponent`'d path segment, as above. Never build the
|
|
149
|
+
ORIGIN from one: `` `https://${ctx.metadata.region}.acme.dev` `` with `region` set to
|
|
150
|
+
`evil.com/x?a=` resolves to a URL on someone else's host, and the `http(s)` allow-list cannot
|
|
151
|
+
tell that apart from the link you meant.
|
|
152
|
+
- **A resolver that THROWS costs only its own item.** It is caught and reported as a fourth
|
|
153
|
+
reason (`resolver-failed`) with the cause logged to the console — the sidebar, the palette and
|
|
154
|
+
the toolbar all render from one catalog, so an uncaught throw would otherwise blank all three.
|
|
155
|
+
Do not rely on it: `requiredMetadata` is how you say a field must be there.
|
|
156
|
+
- **`gate` and `advanced`** work exactly as on a `nav` entry; both must pass.
|
|
157
|
+
|
|
158
|
+
**The metadata half** is a deployment-declared FIELD list (here) whose VALUES are per workspace,
|
|
159
|
+
typed in under _Workspace settings → Metadata_ and persisted on the workspace settings row. The
|
|
160
|
+
tab appears only where a deployment declares fields. Keys must be identifier-shaped
|
|
161
|
+
(`^[A-Za-z][A-Za-z0-9_.-]{0,63}$` — the backend refuses anything else); a malformed or duplicate
|
|
162
|
+
key is dropped with a dev-console warning rather than rendered. `type: 'select'` renders a picker
|
|
163
|
+
over your `options`; everything is stored as a string.
|
|
164
|
+
|
|
165
|
+
Two rules the editor keeps, and any other writer of the bag should too: a CLEARED field drops its
|
|
166
|
+
key (so "unset" never reads as "set to nothing" in a resolver), and a save carries through any
|
|
167
|
+
stored key the current build does not declare — the update replaces the whole bag, so a value
|
|
168
|
+
written under a field you have since retired must not be deleted by an unrelated save.
|
|
169
|
+
|
|
170
|
+
Values are readable anywhere in the SPA via `useWorkspaceSettingsStore().settings.metadata`.
|
|
171
|
+
|
|
106
172
|
### Custom task types (`taskTypes`)
|
|
107
173
|
|
|
108
174
|
Model a proprietary work item — an "incident", "pentest", "compliance-audit" — as a first-class
|