@cat-factory/app 0.199.0 → 0.200.2
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/foundational/FoundationalContractSummary.vue +36 -0
- package/app/components/foundational/FoundationalServiceCatalogList.vue +170 -0
- package/app/components/foundational/FoundationalServiceManager.vue +111 -0
- package/app/components/foundational/FoundationalServicePanel.vue +37 -0
- package/app/components/foundational/FoundationalServiceRegistry.vue +339 -0
- package/app/components/foundational/FoundationalServiceSources.vue +398 -0
- package/app/components/foundational/FoundationalSuppressions.vue +75 -0
- package/app/components/layout/AccountFoundationalSettings.vue +25 -0
- package/app/components/layout/AccountSkillSettings.vue +1 -1
- package/app/components/settings/AccountSettingsPanel.vue +17 -1
- package/app/components/skills/SkillLibraryManager.vue +1 -1
- package/app/composables/api/foundationalServices.ts +131 -0
- package/app/composables/api/skills.ts +1 -1
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/modular/nav-contributions.spec.ts +6 -0
- package/app/modular/nav-contributions.ts +26 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/foundationalServices.spec.ts +121 -0
- package/app/stores/foundationalServices.ts +276 -0
- package/app/stores/skillLibrary.ts +1 -1
- package/app/stores/skills.spec.ts +1 -1
- package/app/stores/skills.ts +1 -1
- package/app/stores/ui/modals.ts +12 -0
- package/app/types/domain.ts +1 -0
- package/app/types/foundationalServices.ts +32 -0
- package/app/types/skills.ts +1 -1
- package/i18n/locales/de.json +146 -6
- package/i18n/locales/en.json +146 -6
- package/i18n/locales/es.json +146 -6
- package/i18n/locales/fr.json +146 -6
- package/i18n/locales/he.json +146 -6
- package/i18n/locales/it.json +146 -6
- package/i18n/locales/ja.json +146 -6
- package/i18n/locales/pl.json +146 -6
- package/i18n/locales/tr.json +146 -6
- package/i18n/locales/uk.json +146 -6
- package/package.json +2 -2
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The services THIS tier registers (backend/docs/adr/0031-foundational-services.md) — the raw list,
|
|
3
|
+
// not the merge — plus the editor that creates and patches one.
|
|
4
|
+
//
|
|
5
|
+
// The editor's shape follows the feature's contract rules rather than plain CRUD convenience:
|
|
6
|
+
// the `id` is what an Architect writes in its design, so it is fixed at creation and shown
|
|
7
|
+
// read-only afterwards; and `contracts`, when submitted, REPLACES the whole uploaded set, which
|
|
8
|
+
// is stated on the form because a partial edit would silently drop a document. A repo-sourced row
|
|
9
|
+
// is not editable here at all — its next sync would overwrite the edit — so it is labelled with
|
|
10
|
+
// its source instead.
|
|
11
|
+
import { computed, reactive, ref } from 'vue'
|
|
12
|
+
import type {
|
|
13
|
+
ApiContractFormat,
|
|
14
|
+
FoundationalService,
|
|
15
|
+
FoundationalServiceOwnerKind,
|
|
16
|
+
UploadApiContract,
|
|
17
|
+
} from '~/types/domain'
|
|
18
|
+
import {
|
|
19
|
+
useFoundationalServices,
|
|
20
|
+
useFoundationalServicesStore,
|
|
21
|
+
} from '~/stores/foundationalServices'
|
|
22
|
+
import FoundationalContractSummary from '~/components/foundational/FoundationalContractSummary.vue'
|
|
23
|
+
|
|
24
|
+
const props = defineProps<{ kind: FoundationalServiceOwnerKind; ownerId: string }>()
|
|
25
|
+
|
|
26
|
+
const catalog =
|
|
27
|
+
props.kind === 'workspace'
|
|
28
|
+
? useFoundationalServicesStore()
|
|
29
|
+
: useFoundationalServices(props.kind, props.ownerId)
|
|
30
|
+
const toast = useToast()
|
|
31
|
+
const { t } = useI18n()
|
|
32
|
+
const { confirm } = useConfirm()
|
|
33
|
+
|
|
34
|
+
// Exhaustive format→label map of literal `t(...)` keys (keeps the typed-key drift guard live).
|
|
35
|
+
const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
|
|
36
|
+
openapi: t('foundational.format.openapi'),
|
|
37
|
+
'toad-contract': t('foundational.format.toadContract'),
|
|
38
|
+
'lokalise-api-contract': t('foundational.format.lokaliseApiContract'),
|
|
39
|
+
}))
|
|
40
|
+
const formatItems = computed(() =>
|
|
41
|
+
(Object.keys(formatLabel.value) as ApiContractFormat[]).map((value) => ({
|
|
42
|
+
value,
|
|
43
|
+
label: formatLabel.value[value],
|
|
44
|
+
})),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
function notifyError(title: string, e: unknown) {
|
|
48
|
+
toast.add({
|
|
49
|
+
title,
|
|
50
|
+
description: e instanceof Error ? e.message : String(e),
|
|
51
|
+
icon: 'i-lucide-triangle-alert',
|
|
52
|
+
color: 'error',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- the editor ------------------------------------------------------------
|
|
57
|
+
/** null = closed; '' = creating; a service id = editing that row. */
|
|
58
|
+
const editing = ref<string | null>(null)
|
|
59
|
+
const saving = ref(false)
|
|
60
|
+
const draft = reactive({
|
|
61
|
+
id: '',
|
|
62
|
+
name: '',
|
|
63
|
+
summary: '',
|
|
64
|
+
description: '',
|
|
65
|
+
capabilities: '',
|
|
66
|
+
/** Left untouched ⇒ the stored set is kept; edited ⇒ the whole set is replaced. */
|
|
67
|
+
contracts: [] as UploadApiContract[],
|
|
68
|
+
contractsTouched: false,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const isCreating = computed(() => editing.value === '')
|
|
72
|
+
|
|
73
|
+
function openCreate() {
|
|
74
|
+
Object.assign(draft, {
|
|
75
|
+
id: '',
|
|
76
|
+
name: '',
|
|
77
|
+
summary: '',
|
|
78
|
+
description: '',
|
|
79
|
+
capabilities: '',
|
|
80
|
+
contracts: [],
|
|
81
|
+
contractsTouched: false,
|
|
82
|
+
})
|
|
83
|
+
editing.value = ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function openEdit(service: FoundationalService) {
|
|
87
|
+
Object.assign(draft, {
|
|
88
|
+
id: service.id,
|
|
89
|
+
name: service.name,
|
|
90
|
+
summary: service.summary,
|
|
91
|
+
description: service.description,
|
|
92
|
+
capabilities: service.capabilities.join(', '),
|
|
93
|
+
// Deliberately NOT prefilled from the manifest: the manifest carries no bodies, so
|
|
94
|
+
// prefilling would submit empty documents over real ones. Untouched ⇒ nothing is sent.
|
|
95
|
+
contracts: [],
|
|
96
|
+
contractsTouched: false,
|
|
97
|
+
})
|
|
98
|
+
editing.value = service.id
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function addContract() {
|
|
102
|
+
draft.contracts.push({ contractId: '', format: 'openapi', title: '', body: '' })
|
|
103
|
+
draft.contractsTouched = true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function removeContract(index: number) {
|
|
107
|
+
draft.contracts.splice(index, 1)
|
|
108
|
+
draft.contractsTouched = true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const capabilityList = computed(() =>
|
|
112
|
+
draft.capabilities
|
|
113
|
+
.split(',')
|
|
114
|
+
.map((c) => c.trim())
|
|
115
|
+
.filter(Boolean),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const draftValid = computed(() => {
|
|
119
|
+
if (!draft.name.trim() || !draft.summary.trim()) return false
|
|
120
|
+
if (isCreating.value && !/^[a-z0-9][a-z0-9-]*$/.test(draft.id.trim())) return false
|
|
121
|
+
return draft.contracts.every((c) => c.contractId.trim() && c.title.trim() && c.body.trim())
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
async function save() {
|
|
125
|
+
if (!draftValid.value) return
|
|
126
|
+
saving.value = true
|
|
127
|
+
try {
|
|
128
|
+
const contracts = draft.contractsTouched ? draft.contracts : undefined
|
|
129
|
+
if (isCreating.value) {
|
|
130
|
+
await catalog.create({
|
|
131
|
+
id: draft.id.trim(),
|
|
132
|
+
name: draft.name.trim(),
|
|
133
|
+
summary: draft.summary.trim(),
|
|
134
|
+
description: draft.description.trim(),
|
|
135
|
+
capabilities: capabilityList.value,
|
|
136
|
+
...(contracts ? { contracts } : {}),
|
|
137
|
+
})
|
|
138
|
+
toast.add({ title: t('foundational.toast.created'), icon: 'i-lucide-check' })
|
|
139
|
+
} else {
|
|
140
|
+
await catalog.update(draft.id, {
|
|
141
|
+
name: draft.name.trim(),
|
|
142
|
+
summary: draft.summary.trim(),
|
|
143
|
+
description: draft.description.trim(),
|
|
144
|
+
capabilities: capabilityList.value,
|
|
145
|
+
...(contracts ? { contracts } : {}),
|
|
146
|
+
})
|
|
147
|
+
toast.add({ title: t('foundational.toast.updated'), icon: 'i-lucide-check' })
|
|
148
|
+
}
|
|
149
|
+
editing.value = null
|
|
150
|
+
} catch (e) {
|
|
151
|
+
notifyError(t('foundational.toast.saveFailed'), e)
|
|
152
|
+
} finally {
|
|
153
|
+
saving.value = false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Deleting at the WORKSPACE tier leaves a tombstone, and that tombstone also suppresses an
|
|
158
|
+
// account service registered under the same id — the board lands in the opt-out list without ever
|
|
159
|
+
// having opted out. That is the intended model (a workspace tombstone IS the suppression), but it
|
|
160
|
+
// is invisible at the point of the click, so the confirmation says it and names where to undo it.
|
|
161
|
+
// An account tier has nothing above it to shadow, so it keeps the plain wording.
|
|
162
|
+
const deleteBody = (service: FoundationalService) =>
|
|
163
|
+
props.kind === 'workspace'
|
|
164
|
+
? t('foundational.confirmDelete.bodyWorkspace', { name: service.name })
|
|
165
|
+
: t('foundational.confirmDelete.body', { name: service.name })
|
|
166
|
+
|
|
167
|
+
async function remove(service: FoundationalService) {
|
|
168
|
+
const ok = await confirm({
|
|
169
|
+
title: t('foundational.confirmDelete.title'),
|
|
170
|
+
description: deleteBody(service),
|
|
171
|
+
variant: 'destructive',
|
|
172
|
+
confirmLabel: t('foundational.confirmDelete.confirm'),
|
|
173
|
+
icon: 'i-lucide-trash-2',
|
|
174
|
+
})
|
|
175
|
+
if (!ok) return
|
|
176
|
+
try {
|
|
177
|
+
await catalog.remove(service.id)
|
|
178
|
+
toast.add({ title: t('foundational.toast.deleted'), icon: 'i-lucide-trash-2' })
|
|
179
|
+
} catch (e) {
|
|
180
|
+
notifyError(t('foundational.toast.deleteFailed'), e)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
<template>
|
|
186
|
+
<div class="flex flex-col gap-3" data-testid="foundational-registry">
|
|
187
|
+
<div
|
|
188
|
+
v-for="s in catalog.services"
|
|
189
|
+
:key="s.id"
|
|
190
|
+
class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
|
|
191
|
+
>
|
|
192
|
+
<div class="flex items-start gap-2">
|
|
193
|
+
<UIcon name="i-lucide-boxes" class="mt-0.5 h-4 w-4 shrink-0 text-sky-400" />
|
|
194
|
+
<div class="min-w-0 flex-1">
|
|
195
|
+
<p class="truncate text-sm font-medium text-slate-100">
|
|
196
|
+
{{ s.name }}
|
|
197
|
+
<code class="ms-1 text-[11px] text-slate-500">{{ s.id }}</code>
|
|
198
|
+
</p>
|
|
199
|
+
<p class="text-xs text-slate-400">{{ s.summary }}</p>
|
|
200
|
+
<div v-if="s.capabilities.length" class="mt-1 flex flex-wrap gap-1">
|
|
201
|
+
<UBadge v-for="c in s.capabilities" :key="c" size="xs" variant="subtle" color="neutral">
|
|
202
|
+
{{ c }}
|
|
203
|
+
</UBadge>
|
|
204
|
+
</div>
|
|
205
|
+
<FoundationalContractSummary :contracts="s.contracts" :format-label="formatLabel" />
|
|
206
|
+
<p v-if="s.sourceId" class="mt-1 text-[11px] text-slate-500">
|
|
207
|
+
{{ t('foundational.registry.fromSource', { path: s.sourcePath ?? '' }) }}
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="flex shrink-0 gap-1">
|
|
211
|
+
<UButton
|
|
212
|
+
v-if="!s.sourceId"
|
|
213
|
+
icon="i-lucide-pencil"
|
|
214
|
+
size="xs"
|
|
215
|
+
variant="ghost"
|
|
216
|
+
:title="t('foundational.registry.edit')"
|
|
217
|
+
@click="openEdit(s)"
|
|
218
|
+
/>
|
|
219
|
+
<UButton
|
|
220
|
+
icon="i-lucide-trash-2"
|
|
221
|
+
size="xs"
|
|
222
|
+
color="error"
|
|
223
|
+
variant="ghost"
|
|
224
|
+
:title="t('foundational.registry.delete')"
|
|
225
|
+
@click="remove(s)"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<p v-if="!catalog.services.length" class="text-sm text-slate-500">
|
|
231
|
+
{{ t('foundational.registry.empty') }}
|
|
232
|
+
</p>
|
|
233
|
+
|
|
234
|
+
<UButton
|
|
235
|
+
v-if="editing === null"
|
|
236
|
+
icon="i-lucide-plus"
|
|
237
|
+
size="sm"
|
|
238
|
+
variant="soft"
|
|
239
|
+
class="self-start"
|
|
240
|
+
data-testid="foundational-add-service"
|
|
241
|
+
@click="openCreate"
|
|
242
|
+
>
|
|
243
|
+
{{ t('foundational.registry.add') }}
|
|
244
|
+
</UButton>
|
|
245
|
+
|
|
246
|
+
<div v-else class="rounded-md border border-slate-800 p-3">
|
|
247
|
+
<p class="mb-2 text-sm font-medium">
|
|
248
|
+
{{
|
|
249
|
+
isCreating ? t('foundational.registry.addTitle') : t('foundational.registry.editTitle')
|
|
250
|
+
}}
|
|
251
|
+
</p>
|
|
252
|
+
<div class="flex flex-col gap-2">
|
|
253
|
+
<!-- The id is what an Architect names in its design, so it is fixed once registered. -->
|
|
254
|
+
<UInput
|
|
255
|
+
v-if="isCreating"
|
|
256
|
+
v-model="draft.id"
|
|
257
|
+
:placeholder="t('foundational.registry.idPlaceholder')"
|
|
258
|
+
/>
|
|
259
|
+
<p v-else class="text-xs text-slate-500">
|
|
260
|
+
<code class="text-slate-300">{{ draft.id }}</code>
|
|
261
|
+
— {{ t('foundational.registry.idFixed') }}
|
|
262
|
+
</p>
|
|
263
|
+
<UInput v-model="draft.name" :placeholder="t('foundational.registry.namePlaceholder')" />
|
|
264
|
+
<UInput
|
|
265
|
+
v-model="draft.summary"
|
|
266
|
+
:placeholder="t('foundational.registry.summaryPlaceholder')"
|
|
267
|
+
/>
|
|
268
|
+
<UTextarea
|
|
269
|
+
v-model="draft.description"
|
|
270
|
+
:rows="4"
|
|
271
|
+
:placeholder="t('foundational.registry.descriptionPlaceholder')"
|
|
272
|
+
/>
|
|
273
|
+
<UInput
|
|
274
|
+
v-model="draft.capabilities"
|
|
275
|
+
:placeholder="t('foundational.registry.capabilitiesPlaceholder')"
|
|
276
|
+
/>
|
|
277
|
+
|
|
278
|
+
<div class="rounded-md border border-slate-800 p-2">
|
|
279
|
+
<p class="text-xs font-medium text-slate-300">
|
|
280
|
+
{{ t('foundational.registry.contractsTitle') }}
|
|
281
|
+
</p>
|
|
282
|
+
<p class="mb-2 text-[11px] text-slate-500">
|
|
283
|
+
{{
|
|
284
|
+
draft.contractsTouched
|
|
285
|
+
? t('foundational.registry.contractsReplace')
|
|
286
|
+
: t('foundational.registry.contractsKeep')
|
|
287
|
+
}}
|
|
288
|
+
</p>
|
|
289
|
+
<div v-for="(c, i) in draft.contracts" :key="i" class="mb-2 flex flex-col gap-1">
|
|
290
|
+
<div class="flex gap-2">
|
|
291
|
+
<UInput
|
|
292
|
+
v-model="c.contractId"
|
|
293
|
+
:placeholder="t('foundational.registry.contractIdPlaceholder')"
|
|
294
|
+
class="flex-1"
|
|
295
|
+
/>
|
|
296
|
+
<USelect v-model="c.format" :items="formatItems" class="w-56" />
|
|
297
|
+
<UButton
|
|
298
|
+
icon="i-lucide-x"
|
|
299
|
+
size="xs"
|
|
300
|
+
color="error"
|
|
301
|
+
variant="ghost"
|
|
302
|
+
:title="t('foundational.registry.removeContract')"
|
|
303
|
+
@click="removeContract(i)"
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
<UInput
|
|
307
|
+
v-model="c.title"
|
|
308
|
+
:placeholder="t('foundational.registry.contractTitlePlaceholder')"
|
|
309
|
+
/>
|
|
310
|
+
<UTextarea
|
|
311
|
+
v-model="c.body"
|
|
312
|
+
:rows="6"
|
|
313
|
+
class="font-mono"
|
|
314
|
+
:placeholder="t('foundational.registry.contractBodyPlaceholder')"
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
<UButton icon="i-lucide-plus" size="xs" variant="ghost" @click="addContract">
|
|
318
|
+
{{ t('foundational.registry.addContract') }}
|
|
319
|
+
</UButton>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div class="flex gap-2">
|
|
323
|
+
<UButton
|
|
324
|
+
size="sm"
|
|
325
|
+
:disabled="!draftValid"
|
|
326
|
+
:loading="saving"
|
|
327
|
+
data-testid="foundational-save-service"
|
|
328
|
+
@click="save"
|
|
329
|
+
>
|
|
330
|
+
{{ t('foundational.registry.save') }}
|
|
331
|
+
</UButton>
|
|
332
|
+
<UButton size="sm" variant="ghost" @click="editing = null">
|
|
333
|
+
{{ t('foundational.registry.cancel') }}
|
|
334
|
+
</UButton>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
</template>
|