@cat-factory/app 0.219.0 → 0.220.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/board/AddTaskModal.vue +61 -25
- package/app/docs/consumer-extensions.md +46 -18
- package/app/utils/taskTypePicker.spec.ts +161 -0
- package/app/utils/taskTypePicker.ts +127 -0
- package/i18n/locales/de.json +2 -1
- package/i18n/locales/en.json +5 -1
- package/i18n/locales/es.json +2 -1
- package/i18n/locales/fr.json +2 -1
- package/i18n/locales/he.json +2 -1
- package/i18n/locales/it.json +2 -1
- package/i18n/locales/ja.json +2 -1
- package/i18n/locales/pl.json +2 -1
- package/i18n/locales/tr.json +2 -1
- package/i18n/locales/uk.json +2 -1
- package/package.json +2 -2
|
@@ -35,6 +35,7 @@ import type { ReviewTargetReason } from '@cat-factory/contracts'
|
|
|
35
35
|
import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
|
|
36
36
|
import { defaultDescriptorValues } from '~/utils/descriptorFields'
|
|
37
37
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
38
|
+
import { buildTaskTypePickerRows } from '~/utils/taskTypePicker'
|
|
38
39
|
|
|
39
40
|
const ui = useUiStore()
|
|
40
41
|
// Interface tier. In BASIC mode this form asks for the task itself (type, title,
|
|
@@ -183,15 +184,15 @@ const customFieldProblems = computed(() => {
|
|
|
183
184
|
if (!custom || customFormPanel.value) return []
|
|
184
185
|
return validateDescriptorFields(custom.fields ?? [], customFieldValues.value)
|
|
185
186
|
})
|
|
186
|
-
// The type picker
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
})
|
|
194
|
-
|
|
187
|
+
// The type picker, laid out as rows (see `buildTaskTypePickerRows`): the built-in choices (i18n
|
|
188
|
+
// labels) first, then the deployment's registered types under their declared `presentation.category`
|
|
189
|
+
// captions, so a catalog of reusable operations reads as sections instead of one wall of buttons.
|
|
190
|
+
// Only the leftovers row's heading is CHROME, so it is the one caption the layer supplies.
|
|
191
|
+
const typeRows = computed(() =>
|
|
192
|
+
buildTaskTypePickerRows(TASK_TYPES.value, customTaskTypes.value, {
|
|
193
|
+
other: t('board.addTask.typeOther'),
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
195
196
|
|
|
196
197
|
// Parse the PR-reference input into the contract fields: a bare positive integer (optionally
|
|
197
198
|
// `#`-prefixed) becomes `prNumber` (a PR on the service's linked repo); anything else is taken
|
|
@@ -768,24 +769,59 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
768
769
|
</p>
|
|
769
770
|
|
|
770
771
|
<UFormField :label="t('board.addTask.typeLabel')">
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
"
|
|
772
|
+
<!-- One row per picker group: the built-ins uncaptioned, then a caption per registered
|
|
773
|
+
category, then the leftovers. Category captions are deployment-authored English
|
|
774
|
+
rendered verbatim (as are the custom labels and their hover descriptions); only the
|
|
775
|
+
leftovers heading is chrome, so only it is i18n. The row gap must stay WIDER than a
|
|
776
|
+
caption's `mb-1`, or a heading sits equidistant between the group above it and its
|
|
777
|
+
own buttons and the grouping stops reading. -->
|
|
778
|
+
<div class="space-y-3">
|
|
779
|
+
<div
|
|
780
|
+
v-for="row in typeRows"
|
|
781
|
+
:key="row.id"
|
|
782
|
+
data-testid="task-type-row"
|
|
783
|
+
:data-task-type-row="row.id"
|
|
785
784
|
>
|
|
786
|
-
|
|
787
|
-
|
|
785
|
+
<p
|
|
786
|
+
v-if="row.caption"
|
|
787
|
+
class="mb-1 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
|
|
788
|
+
data-testid="task-type-category"
|
|
789
|
+
>
|
|
790
|
+
{{ row.caption }}
|
|
791
|
+
</p>
|
|
792
|
+
<div class="flex flex-wrap gap-1">
|
|
793
|
+
<UButton
|
|
794
|
+
v-for="ty in row.choices"
|
|
795
|
+
:key="ty.value"
|
|
796
|
+
:color="taskType === ty.value ? 'primary' : 'neutral'"
|
|
797
|
+
:variant="taskType === ty.value ? 'soft' : 'ghost'"
|
|
798
|
+
:icon="ty.icon"
|
|
799
|
+
size="xs"
|
|
800
|
+
:title="ty.description"
|
|
801
|
+
:data-testid="`task-type-${ty.value}`"
|
|
802
|
+
@click="
|
|
803
|
+
() => {
|
|
804
|
+
taskType = ty.value
|
|
805
|
+
}
|
|
806
|
+
"
|
|
807
|
+
>
|
|
808
|
+
{{ ty.label }}
|
|
809
|
+
</UButton>
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
788
812
|
</div>
|
|
813
|
+
|
|
814
|
+
<!-- What the selected operation is for, in the deployment's own words, in the field's OWN
|
|
815
|
+
help slot (the `:help` seam every other field here uses) rather than a paragraph
|
|
816
|
+
beside it fighting the modal's spacing. The hover title above helps you choose; this
|
|
817
|
+
states the choice you made, which is the half a touch device can reach. Built-in
|
|
818
|
+
types carry no description (their labels are localized and their meaning is fixed),
|
|
819
|
+
so only a custom type is described here. -->
|
|
820
|
+
<template v-if="selectedCustomType?.presentation.description" #help>
|
|
821
|
+
<span data-testid="task-type-description">
|
|
822
|
+
{{ selectedCustomType.presentation.description }}
|
|
823
|
+
</span>
|
|
824
|
+
</template>
|
|
789
825
|
</UFormField>
|
|
790
826
|
|
|
791
827
|
<!-- Recurring tasks are configured as a schedule on the service frame. -->
|
|
@@ -56,18 +56,18 @@ export default defineNuxtPlugin(() => {
|
|
|
56
56
|
|
|
57
57
|
## The landed seams
|
|
58
58
|
|
|
59
|
-
| Seam | Slot key | Entry shape
|
|
60
|
-
| ----------------------------------- | ------------------------- |
|
|
61
|
-
| Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }`
|
|
62
|
-
| Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }`
|
|
63
|
-
| Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }`
|
|
64
|
-
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }`
|
|
65
|
-
| Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`)
|
|
66
|
-
| Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }`
|
|
67
|
-
| External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, order? }`
|
|
68
|
-
| Custom workspace metadata fields | `workspaceMetadataFields` | `{ key, label, description?, placeholder?, type?, options?, order? }`
|
|
69
|
-
| Multi-step wizards | (journeys) | `registerJourney` + step modules
|
|
70
|
-
| Locale strings | (i18n) | `i18n/locales/*.json` in the deployment
|
|
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?, defaultFragmentIds?, 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 |
|
|
71
71
|
|
|
72
72
|
A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
|
|
73
73
|
(the shipped default) exactly as it does for the first-party destinations: see
|
|
@@ -173,20 +173,48 @@ Values are readable anywhere in the SPA via `useWorkspaceSettingsStore().setting
|
|
|
173
173
|
|
|
174
174
|
Model a proprietary work item (an "incident", "pentest", "compliance-audit") as a first-class
|
|
175
175
|
task type, the create-task twin of an agent kind. Contribute `{ taskType: '<ns>:<name>',
|
|
176
|
-
presentation: { label, icon, color, description }, fields?, defaultPipelineId?,
|
|
177
|
-
the `taskTypes` slot (see `acme:incident` in the example
|
|
178
|
-
create-task picker and the card-badge catalog:
|
|
176
|
+
presentation: { label, icon, color, description, category? }, fields?, defaultPipelineId?,
|
|
177
|
+
defaultFragmentIds?, formPanel? }` to the `taskTypes` slot (see `acme:incident` in the example
|
|
178
|
+
module). The SPA merges it into the create-task picker and the card-badge catalog:
|
|
179
179
|
|
|
180
180
|
- **`presentation`** drives the create-task picker entry and the `TaskCard` type badge (resolved
|
|
181
181
|
through the pure `taskTypeMeta` read-model: the `agentKindMeta` twin). An UNREGISTERED
|
|
182
182
|
namespaced type (a stale row after your extension is removed) degrades to the `feature`
|
|
183
|
-
presentation, so a leftover string never breaks a card.
|
|
184
|
-
|
|
185
|
-
`
|
|
183
|
+
presentation, so a leftover string never breaks a card. `description` is rendered verbatim as
|
|
184
|
+
the picker button's tooltip and, once the type is selected, as the type field's help text;
|
|
185
|
+
`category` groups the picker (below).
|
|
186
|
+
- **`fields`** are descriptor-driven create-form inputs over the shared descriptor-form vocabulary
|
|
187
|
+
(`text` / `textarea` / `number` / `select` / `checkbox` / `checkbox-group` / `path`, with
|
|
188
|
+
defaults and `showWhen` visibility; `password` is excluded by construction because a task field
|
|
189
|
+
value reaches prompts and telemetry). Their values land in the task's sparse
|
|
190
|
+
`taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is enforced
|
|
191
|
+
server-side on create as well (required answers, option lists, lengths); a code-shipped one is
|
|
192
|
+
known only to the SPA, so the create form is its only check (see the Validation note below).
|
|
186
193
|
- **`formPanel`** optionally names a bespoke create-form section component you contribute to the
|
|
187
194
|
`taskTypeFormPanels` slot (paired by that id, like `resultViews`); shown INSTEAD of `fields`. An
|
|
188
195
|
unpaired id degrades to the descriptor fields.
|
|
189
196
|
- **`defaultPipelineId`** pre-selects the type's pipeline in the picker.
|
|
197
|
+
- **`defaultFragmentIds`** seed the type's standing context (best-practice fragment ids) onto every
|
|
198
|
+
new task of it, beside whatever it inherits from its service.
|
|
199
|
+
|
|
200
|
+
**The picker is grouped, not flat** (`utils/taskTypePicker.ts`): the built-in types come first in
|
|
201
|
+
one uncaptioned row, then one captioned row per declared `presentation.category` in registration
|
|
202
|
+
order, then any uncategorized types under a translated "Other" heading. Declare a category once you
|
|
203
|
+
ship more than a couple of types, or they pile up behind the everyday `feature` / `bug` choices.
|
|
204
|
+
Categories differing only in case or spacing are ONE row, captioned as you first wrote it, so a
|
|
205
|
+
stray `API delivery` / `API Delivery` pair does not split a category in half.
|
|
206
|
+
|
|
207
|
+
Your own strings (labels, category captions, descriptions) are rendered verbatim and never enter a
|
|
208
|
+
locale catalog; only the platform's own chrome around them is i18n, which is why the "Other" heading
|
|
209
|
+
is the one caption you do not supply. Each row carries `data-testid="task-type-row"` plus
|
|
210
|
+
`data-task-type-row="<id>"`, and each choice `data-testid="task-type-<taskType>"`, so your own e2e
|
|
211
|
+
suite can address a row and the caption inside it.
|
|
212
|
+
|
|
213
|
+
Together, `fields` + `defaultFragmentIds` + `defaultPipelineId` are what turns a task type from a
|
|
214
|
+
badge into a **reusable operation**: a canned unit of work an org runs repeatedly with per-case
|
|
215
|
+
input, whose collected values reach every agent's prompt. See
|
|
216
|
+
[`docs/initiatives/reusable-operations.md`](../../../../docs/initiatives/reusable-operations.md)
|
|
217
|
+
and the `org:introduce-api` worked example in `backend/internal/example-custom-agent`.
|
|
190
218
|
|
|
191
219
|
The **same type can be delivered from the backend** instead of code-shipped: register it on the
|
|
192
220
|
deployment's app-owned `TaskTypeRegistry` and it arrives in the workspace snapshot's
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { CustomTaskType } from '~/types/domain'
|
|
3
|
+
import { buildTaskTypePickerRows, type TaskTypePickerChoice } from './taskTypePicker'
|
|
4
|
+
|
|
5
|
+
const BUILT_INS: TaskTypePickerChoice[] = [
|
|
6
|
+
{ value: 'feature', label: 'Feature', icon: 'i-lucide-sparkles' },
|
|
7
|
+
{ value: 'bug', label: 'Bug', icon: 'i-lucide-bug' },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
/** The localized chrome the caller passes in; the util never authors a display string itself. */
|
|
11
|
+
const CAPTIONS = { other: 'Other' }
|
|
12
|
+
|
|
13
|
+
const custom = (
|
|
14
|
+
taskType: string,
|
|
15
|
+
category?: string,
|
|
16
|
+
description = `What ${taskType} does`,
|
|
17
|
+
): CustomTaskType =>
|
|
18
|
+
({
|
|
19
|
+
taskType,
|
|
20
|
+
presentation: {
|
|
21
|
+
label: taskType.split(':')[1] ?? taskType,
|
|
22
|
+
icon: 'i-lucide-plug',
|
|
23
|
+
color: '#0ea5e9',
|
|
24
|
+
description,
|
|
25
|
+
...(category === undefined ? {} : { category }),
|
|
26
|
+
},
|
|
27
|
+
}) as CustomTaskType
|
|
28
|
+
|
|
29
|
+
describe('buildTaskTypePickerRows', () => {
|
|
30
|
+
it('puts the built-in types first, in one uncaptioned row', () => {
|
|
31
|
+
const rows = buildTaskTypePickerRows(
|
|
32
|
+
BUILT_INS,
|
|
33
|
+
[custom('org:introduce-api', 'API delivery')],
|
|
34
|
+
CAPTIONS,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
expect(rows[0]).toEqual({ id: 'built-in', caption: null, choices: BUILT_INS })
|
|
38
|
+
expect(rows[1]?.caption).toBe('API delivery')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('groups custom types sharing a category under one caption, in first-appearance order', () => {
|
|
42
|
+
const rows = buildTaskTypePickerRows(
|
|
43
|
+
BUILT_INS,
|
|
44
|
+
[
|
|
45
|
+
custom('org:introduce-api', 'API delivery'),
|
|
46
|
+
custom('org:onboard-tenant', 'Tenancy'),
|
|
47
|
+
custom('org:retire-endpoint', 'API delivery'),
|
|
48
|
+
],
|
|
49
|
+
CAPTIONS,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// Registration order is the only order the deployment expressed, so it is the caption order,
|
|
53
|
+
// NOT alphabetical (which would put 'API delivery' first by accident here).
|
|
54
|
+
expect(rows.slice(1).map((r) => r.caption)).toEqual(['API delivery', 'Tenancy'])
|
|
55
|
+
expect(rows[1]?.choices.map((c) => c.value)).toEqual([
|
|
56
|
+
'org:introduce-api',
|
|
57
|
+
'org:retire-endpoint',
|
|
58
|
+
])
|
|
59
|
+
expect(rows[2]?.choices.map((c) => c.value)).toEqual(['org:onboard-tenant'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('carries the verbatim wire presentation onto each custom choice', () => {
|
|
63
|
+
const rows = buildTaskTypePickerRows(
|
|
64
|
+
BUILT_INS,
|
|
65
|
+
[custom('org:introduce-api', 'API delivery', 'Expose functionality over the standard API.')],
|
|
66
|
+
CAPTIONS,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
expect(rows[1]?.choices[0]).toEqual({
|
|
70
|
+
value: 'org:introduce-api',
|
|
71
|
+
label: 'introduce-api',
|
|
72
|
+
icon: 'i-lucide-plug',
|
|
73
|
+
description: 'Expose functionality over the standard API.',
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('folds captions differing only in case or spacing into one row, keeping the first spelling', () => {
|
|
78
|
+
const rows = buildTaskTypePickerRows(
|
|
79
|
+
BUILT_INS,
|
|
80
|
+
[custom('org:introduce-api', 'API delivery'), custom('org:retire-endpoint', 'api DELIVERY')],
|
|
81
|
+
CAPTIONS,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// One row, captioned as the deployment first wrote it: a second heading differing only in case
|
|
85
|
+
// would read as a second category its author never declared.
|
|
86
|
+
expect(rows).toHaveLength(2)
|
|
87
|
+
expect(rows[1]?.caption).toBe('API delivery')
|
|
88
|
+
expect(rows[1]?.id).toBe('category:api delivery')
|
|
89
|
+
expect(rows[1]?.choices.map((c) => c.value)).toEqual([
|
|
90
|
+
'org:introduce-api',
|
|
91
|
+
'org:retire-endpoint',
|
|
92
|
+
])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('keeps captions that differ beyond case and spacing apart, non-ASCII included', () => {
|
|
96
|
+
const rows = buildTaskTypePickerRows(
|
|
97
|
+
[],
|
|
98
|
+
[custom('org:ambito', 'Ámbito'), custom('org:embito', 'Émbito')],
|
|
99
|
+
CAPTIONS,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(rows.map((r) => r.caption)).toEqual(['Ámbito', 'Émbito'])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('captions the trailing uncategorized row with the caller chrome string', () => {
|
|
106
|
+
const rows = buildTaskTypePickerRows(
|
|
107
|
+
BUILT_INS,
|
|
108
|
+
[
|
|
109
|
+
custom('acme:incident'),
|
|
110
|
+
custom('org:introduce-api', 'API delivery'),
|
|
111
|
+
custom('acme:pentest'),
|
|
112
|
+
],
|
|
113
|
+
CAPTIONS,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
expect(rows.map((r) => r.caption)).toEqual([null, 'API delivery', 'Other'])
|
|
117
|
+
expect(rows[2]).toEqual({
|
|
118
|
+
id: 'other',
|
|
119
|
+
caption: 'Other',
|
|
120
|
+
choices: [
|
|
121
|
+
expect.objectContaining({ value: 'acme:incident' }),
|
|
122
|
+
expect.objectContaining({ value: 'acme:pentest' }),
|
|
123
|
+
],
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('leaves the uncategorized row uncaptioned when it is the only row', () => {
|
|
128
|
+
// Nothing precedes it, so a heading would name a distinction the picker does not show.
|
|
129
|
+
const rows = buildTaskTypePickerRows([], [custom('acme:incident')], CAPTIONS)
|
|
130
|
+
|
|
131
|
+
expect(rows).toEqual([
|
|
132
|
+
{
|
|
133
|
+
id: 'other',
|
|
134
|
+
caption: null,
|
|
135
|
+
choices: [expect.objectContaining({ value: 'acme:incident' })],
|
|
136
|
+
},
|
|
137
|
+
])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('treats a blank category as no category rather than an empty caption', () => {
|
|
141
|
+
// A CODE-shipped consumer type skips the wire schema's `v.trim()`, so this is reachable.
|
|
142
|
+
const rows = buildTaskTypePickerRows(BUILT_INS, [custom('acme:incident', ' ')], CAPTIONS)
|
|
143
|
+
|
|
144
|
+
expect(rows.map((r) => r.id)).toEqual(['built-in', 'other'])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('emits no rows for empty inputs, and no custom rows when nothing is registered', () => {
|
|
148
|
+
expect(buildTaskTypePickerRows([], [], CAPTIONS)).toEqual([])
|
|
149
|
+
expect(buildTaskTypePickerRows(BUILT_INS, [], CAPTIONS).map((r) => r.id)).toEqual(['built-in'])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('keeps a row per category even when only custom types are offered', () => {
|
|
153
|
+
const rows = buildTaskTypePickerRows(
|
|
154
|
+
[],
|
|
155
|
+
[custom('org:introduce-api', 'API delivery')],
|
|
156
|
+
CAPTIONS,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
expect(rows.map((r) => r.caption)).toEqual(['API delivery'])
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { CustomTaskType } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The create-task type picker's layout rule (reusable-operations initiative, slice 3).
|
|
5
|
+
*
|
|
6
|
+
* The picker was one flat button row, which is right for the handful of built-in types and wrong
|
|
7
|
+
* the moment a deployment registers a catalog of REUSABLE OPERATIONS: an org with twenty of them
|
|
8
|
+
* ("Introduce API", "Retire endpoint", "Add tenant", …) turns the row into an undifferentiated
|
|
9
|
+
* wall in which the everyday `feature` / `bug` choices are no longer findable. Each custom type
|
|
10
|
+
* may declare a `presentation.category`, and this is where that axis becomes rows.
|
|
11
|
+
*
|
|
12
|
+
* Extracted rather than inlined in `AddTaskModal.vue` for the same reason as
|
|
13
|
+
* `buildFragmentCategoryGroups`: the ORDER is the behaviour worth pinning, and a rule inside an
|
|
14
|
+
* SFC is only reachable by mounting one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One selectable type in the picker. */
|
|
18
|
+
export interface TaskTypePickerChoice<T extends string = string> {
|
|
19
|
+
/** The task type id submitted on create. */
|
|
20
|
+
value: T
|
|
21
|
+
/** Button label: an i18n string for a built-in, the verbatim wire presentation for a custom type. */
|
|
22
|
+
label: string
|
|
23
|
+
/** Icon id (`i-lucide-*`). */
|
|
24
|
+
icon: string
|
|
25
|
+
/**
|
|
26
|
+
* The deployment-authored one-liner from a CUSTOM type's presentation, rendered verbatim (never
|
|
27
|
+
* i18n). Absent for a built-in type, whose meaning is fixed and whose label is already localized.
|
|
28
|
+
*/
|
|
29
|
+
description?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One row of the picker: the choices under an optional caption. */
|
|
33
|
+
export interface TaskTypePickerRow<T extends string = string> {
|
|
34
|
+
/**
|
|
35
|
+
* Stable `v-for` key, also published as the row's `data-task-type-row` attribute so a caption
|
|
36
|
+
* (whose `data-testid` repeats per row, exactly as `pipeline-step`'s does) is addressable through
|
|
37
|
+
* its row instead of by position. Never rendered as text: `built-in`, `other`, or
|
|
38
|
+
* `category:<folded caption>`.
|
|
39
|
+
*/
|
|
40
|
+
id: string
|
|
41
|
+
/**
|
|
42
|
+
* The row's heading, or `null` for an UNCAPTIONED row. Deployment-authored and rendered verbatim
|
|
43
|
+
* for a category row; the caller's localized chrome string for the leftovers row.
|
|
44
|
+
*/
|
|
45
|
+
caption: string | null
|
|
46
|
+
choices: TaskTypePickerChoice<T>[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The localized CHROME captions the picker needs; every other caption is deployment-authored. */
|
|
50
|
+
export interface TaskTypePickerCaptions {
|
|
51
|
+
/** Heading for the trailing row of custom types that declared no category. */
|
|
52
|
+
other: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lay the picker out as rows: the BUILT-IN types first in one uncaptioned row (the everyday
|
|
57
|
+
* delivery loop stays where it has always been), then one captioned row per declared category in
|
|
58
|
+
* first-appearance order (the deployment's own registration order, which is the only order it
|
|
59
|
+
* expressed), then any uncategorized custom types in a trailing row captioned `captions.other`.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately no collapsing, no overflow menu and no alphabetical re-sort: an operation catalog
|
|
62
|
+
* that needs those has outgrown what a create-task dialog should be asking, and each would hide a
|
|
63
|
+
* choice behind a second interaction.
|
|
64
|
+
*/
|
|
65
|
+
export function buildTaskTypePickerRows<T extends string>(
|
|
66
|
+
builtIns: readonly TaskTypePickerChoice<T>[],
|
|
67
|
+
customTypes: readonly CustomTaskType[],
|
|
68
|
+
captions: TaskTypePickerCaptions,
|
|
69
|
+
): TaskTypePickerRow<T>[] {
|
|
70
|
+
const rows: TaskTypePickerRow<T>[] = []
|
|
71
|
+
if (builtIns.length > 0) rows.push({ id: 'built-in', caption: null, choices: [...builtIns] })
|
|
72
|
+
|
|
73
|
+
const byCategory = new Map<string, { caption: string; choices: TaskTypePickerChoice<T>[] }>()
|
|
74
|
+
const uncategorized: TaskTypePickerChoice<T>[] = []
|
|
75
|
+
for (const type of customTypes) {
|
|
76
|
+
const choice = toChoice<T>(type)
|
|
77
|
+
// A category is `v.trim()`-ed on the wire, but a CODE-shipped consumer type is trusted and
|
|
78
|
+
// unvalidated (see `docs/consumer-extensions.md`), so a whitespace-only caption is possible
|
|
79
|
+
// and must read as "no category" rather than render an empty heading.
|
|
80
|
+
const caption = type.presentation.category?.trim()
|
|
81
|
+
if (!caption) {
|
|
82
|
+
uncategorized.push(choice)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const key = categoryKey(caption)
|
|
86
|
+
const bucket = byCategory.get(key)
|
|
87
|
+
if (bucket) bucket.choices.push(choice)
|
|
88
|
+
else byCategory.set(key, { caption, choices: [choice] })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [key, { caption, choices }] of byCategory)
|
|
92
|
+
rows.push({ id: `category:${key}`, caption, choices })
|
|
93
|
+
// The leftovers are captioned only when something PRECEDES them: as the picker's only row they
|
|
94
|
+
// are the whole catalog, and a heading would then name a distinction the user cannot see.
|
|
95
|
+
if (uncategorized.length > 0)
|
|
96
|
+
rows.push({
|
|
97
|
+
id: 'other',
|
|
98
|
+
caption: rows.length > 0 ? captions.other : null,
|
|
99
|
+
choices: uncategorized,
|
|
100
|
+
})
|
|
101
|
+
return rows
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The bucket a caption falls in: its own text folded on CASE and on whitespace runs, so two
|
|
106
|
+
* spellings of one category ("API delivery" / "api delivery") are ONE row rather than
|
|
107
|
+
* near-duplicate headings sitting beside each other. The row still renders the FIRST-SEEN
|
|
108
|
+
* spelling verbatim, because the caption is the deployment's own words.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately NOT slugified: a caption is arbitrary Unicode (a deployment writes it in its own
|
|
111
|
+
* language), so stripping it to an id-safe `[a-z0-9-]` key would fold genuinely distinct captions
|
|
112
|
+
* onto each other ("Ámbito" and "Émbito" both reduce to `mbito`). That is also why the row id
|
|
113
|
+
* carries the folded caption as-is and is addressed as an attribute VALUE rather than a testid.
|
|
114
|
+
*/
|
|
115
|
+
function categoryKey(caption: string): string {
|
|
116
|
+
return caption.toLowerCase().replace(/\s+/g, ' ')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A registered custom type as a picker choice. The id assertion carries the widened `taskType`
|
|
121
|
+
* contract (`<built-in> | <ns>:<name>`): a namespaced registered id IS a legal create-task value,
|
|
122
|
+
* which is what let `AddTaskModal` offer these choices in the first place.
|
|
123
|
+
*/
|
|
124
|
+
function toChoice<T extends string>(type: CustomTaskType): TaskTypePickerChoice<T> {
|
|
125
|
+
const { label, icon, description } = type.presentation
|
|
126
|
+
return { value: type.taskType as T, label, icon, ...(description ? { description } : {}) }
|
|
127
|
+
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -406,7 +406,7 @@
|
|
|
406
406
|
},
|
|
407
407
|
"questionsOnPark": {
|
|
408
408
|
"label": "Offene Fragen bei einem pausierten Headless-Lauf posten",
|
|
409
|
-
"help": "Wenn ein
|
|
409
|
+
"help": "Wenn ein außerhalb der App gestarteter Lauf zur Klärung der Anforderungen pausiert (über die API oder aus einem Tracker-Ticket gestartet), poste seine offenen Fragen am verknüpften Issue, jeweils mit der ID, die eine Antwort nennt. In der App gestartete Aufgaben sind nicht betroffen."
|
|
410
410
|
}
|
|
411
411
|
},
|
|
412
412
|
"probeFailure": {
|
|
@@ -2600,6 +2600,7 @@
|
|
|
2600
2600
|
"title": "Eine Aufgabe hinzufügen",
|
|
2601
2601
|
"newTaskIn": "Neue Aufgabe in {container}",
|
|
2602
2602
|
"typeLabel": "Typ",
|
|
2603
|
+
"typeOther": "Sonstige",
|
|
2603
2604
|
"types": {
|
|
2604
2605
|
"feature": "Feature",
|
|
2605
2606
|
"bug": "Bug",
|
package/i18n/locales/en.json
CHANGED
|
@@ -216,6 +216,10 @@
|
|
|
216
216
|
"title": "Add a task",
|
|
217
217
|
"newTaskIn": "New task in {container}",
|
|
218
218
|
"typeLabel": "Type",
|
|
219
|
+
"typeOther": "Other",
|
|
220
|
+
"@typeOther": {
|
|
221
|
+
"description": "Heading over the trailing group in the task-type picker: the deployment's own registered task types that declared no category of their own. A NOUN-like group heading ('the other ones'), not the adjective describing a single type."
|
|
222
|
+
},
|
|
219
223
|
"types": {
|
|
220
224
|
"feature": "Feature",
|
|
221
225
|
"bug": "Bug",
|
|
@@ -2868,7 +2872,7 @@
|
|
|
2868
2872
|
},
|
|
2869
2873
|
"questionsOnPark": {
|
|
2870
2874
|
"label": "Post open questions on a parked headless run",
|
|
2871
|
-
"help": "When a run started
|
|
2875
|
+
"help": "When a run started outside the app pauses to clarify requirements (through the API, or dispatched from a tracker ticket), post its open questions on the linked issue, each with the id an answer names. Tasks started in the app are unaffected."
|
|
2872
2876
|
}
|
|
2873
2877
|
},
|
|
2874
2878
|
"probeFailure": {
|
package/i18n/locales/es.json
CHANGED
|
@@ -189,6 +189,7 @@
|
|
|
189
189
|
"title": "Añadir una tarea",
|
|
190
190
|
"newTaskIn": "Nueva tarea en {container}",
|
|
191
191
|
"typeLabel": "Tipo",
|
|
192
|
+
"typeOther": "Otros",
|
|
192
193
|
"types": {
|
|
193
194
|
"feature": "Funcionalidad",
|
|
194
195
|
"bug": "Error",
|
|
@@ -2632,7 +2633,7 @@
|
|
|
2632
2633
|
},
|
|
2633
2634
|
"questionsOnPark": {
|
|
2634
2635
|
"label": "Publicar preguntas abiertas cuando un ejecución headless se detiene",
|
|
2635
|
-
"help": "Cuando una ejecución iniciada
|
|
2636
|
+
"help": "Cuando una ejecución iniciada fuera de la aplicación se detiene para aclarar requisitos (por la API o despachada desde una incidencia del rastreador), publica sus preguntas abiertas en la incidencia vinculada, cada una con el id que debe nombrar la respuesta. Las tareas iniciadas en la aplicación no se ven afectadas."
|
|
2636
2637
|
}
|
|
2637
2638
|
},
|
|
2638
2639
|
"probeFailure": {
|
package/i18n/locales/fr.json
CHANGED
|
@@ -189,6 +189,7 @@
|
|
|
189
189
|
"title": "Ajouter une tâche",
|
|
190
190
|
"newTaskIn": "Nouvelle tâche dans {container}",
|
|
191
191
|
"typeLabel": "Type",
|
|
192
|
+
"typeOther": "Autres",
|
|
192
193
|
"types": {
|
|
193
194
|
"feature": "Fonctionnalité",
|
|
194
195
|
"bug": "Bug",
|
|
@@ -2632,7 +2633,7 @@
|
|
|
2632
2633
|
},
|
|
2633
2634
|
"questionsOnPark": {
|
|
2634
2635
|
"label": "Publier les questions ouvertes d'une exécution headless en pause",
|
|
2635
|
-
"help": "Quand une exécution lancée
|
|
2636
|
+
"help": "Quand une exécution lancée hors de l'application se met en pause pour clarifier les exigences (via l'API ou depuis un ticket du traqueur), publie ses questions ouvertes sur le ticket lié, chacune avec l'identifiant qu'une réponse doit nommer. Les tâches lancées dans l'application ne sont pas concernées."
|
|
2636
2637
|
}
|
|
2637
2638
|
},
|
|
2638
2639
|
"probeFailure": {
|
package/i18n/locales/he.json
CHANGED
|
@@ -189,6 +189,7 @@
|
|
|
189
189
|
"title": "הוסף משימה",
|
|
190
190
|
"newTaskIn": "משימה חדשה ב-{container}",
|
|
191
191
|
"typeLabel": "סוג",
|
|
192
|
+
"typeOther": "אחרים",
|
|
192
193
|
"types": {
|
|
193
194
|
"feature": "תכונה",
|
|
194
195
|
"bug": "באג",
|
|
@@ -2773,7 +2774,7 @@
|
|
|
2773
2774
|
},
|
|
2774
2775
|
"questionsOnPark": {
|
|
2775
2776
|
"label": "פרסם שאלות פתוחות בהרצה חסרת-ממשק שהושהתה",
|
|
2776
|
-
"help": "כאשר הרצה שהופעלה
|
|
2777
|
+
"help": "כאשר הרצה שהופעלה מחוץ לאפליקציה עוצרת כדי להבהיר דרישות (דרך ה-API או מכרטיס במערכת המעקב), פרסם את השאלות הפתוחות שלה על הכרטיס המקושר, כל אחת עם המזהה שתשובה צריכה לציין. משימות שהופעלו באפליקציה אינן מושפעות."
|
|
2777
2778
|
}
|
|
2778
2779
|
},
|
|
2779
2780
|
"probeFailure": {
|
package/i18n/locales/it.json
CHANGED
|
@@ -406,7 +406,7 @@
|
|
|
406
406
|
},
|
|
407
407
|
"questionsOnPark": {
|
|
408
408
|
"label": "Pubblica le domande aperte di un'esecuzione headless in pausa",
|
|
409
|
-
"help": "Quando un'esecuzione avviata
|
|
409
|
+
"help": "Quando un'esecuzione avviata fuori dall'app si mette in pausa per chiarire i requisiti (tramite API o da un ticket del tracker), pubblica le sue domande aperte sull'issue collegato, ciascuna con l'id che una risposta deve indicare. Le attività avviate nell'app non sono interessate."
|
|
410
410
|
}
|
|
411
411
|
},
|
|
412
412
|
"probeFailure": {
|
|
@@ -2600,6 +2600,7 @@
|
|
|
2600
2600
|
"title": "Aggiungi un'attività",
|
|
2601
2601
|
"newTaskIn": "Nuova attività in {container}",
|
|
2602
2602
|
"typeLabel": "Tipo",
|
|
2603
|
+
"typeOther": "Altri",
|
|
2603
2604
|
"types": {
|
|
2604
2605
|
"feature": "Feature",
|
|
2605
2606
|
"bug": "Bug",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -189,6 +189,7 @@
|
|
|
189
189
|
"title": "タスクを追加",
|
|
190
190
|
"newTaskIn": "{container} 内の新しいタスク",
|
|
191
191
|
"typeLabel": "種類",
|
|
192
|
+
"typeOther": "その他",
|
|
192
193
|
"types": {
|
|
193
194
|
"feature": "機能",
|
|
194
195
|
"bug": "バグ",
|
|
@@ -2773,7 +2774,7 @@
|
|
|
2773
2774
|
},
|
|
2774
2775
|
"questionsOnPark": {
|
|
2775
2776
|
"label": "ヘッドレス実行が一時停止したら未解決の質問を投稿",
|
|
2776
|
-
"help": "API
|
|
2777
|
+
"help": "アプリの外部で開始した実行が要件確認のため一時停止したとき (API 経由、またはトラッカーのチケットからの起動)、その未解決の質問を、回答が指定する ID を添えてリンクされた課題に投稿します。アプリで開始したタスクには影響しません。"
|
|
2777
2778
|
}
|
|
2778
2779
|
},
|
|
2779
2780
|
"probeFailure": {
|
package/i18n/locales/pl.json
CHANGED
|
@@ -189,6 +189,7 @@
|
|
|
189
189
|
"title": "Dodaj zadanie",
|
|
190
190
|
"newTaskIn": "Nowe zadanie w {container}",
|
|
191
191
|
"typeLabel": "Typ",
|
|
192
|
+
"typeOther": "Inne",
|
|
192
193
|
"types": {
|
|
193
194
|
"feature": "Funkcja",
|
|
194
195
|
"bug": "Błąd",
|
|
@@ -2632,7 +2633,7 @@
|
|
|
2632
2633
|
},
|
|
2633
2634
|
"questionsOnPark": {
|
|
2634
2635
|
"label": "Publikuj otwarte pytania wstrzymanego uruchomienia headless",
|
|
2635
|
-
"help": "Gdy uruchomienie rozpoczęte
|
|
2636
|
+
"help": "Gdy uruchomienie rozpoczęte poza aplikacją wstrzymuje się, aby doprecyzować wymagania (przez API lub wysłane ze zgłoszenia w trackerze), opublikuj jego otwarte pytania w powiązanym zgłoszeniu, każde z identyfikatorem, który ma wskazać odpowiedź. Zadania rozpoczęte w aplikacji pozostają bez zmian."
|
|
2636
2637
|
}
|
|
2637
2638
|
},
|
|
2638
2639
|
"probeFailure": {
|
package/i18n/locales/tr.json
CHANGED
|
@@ -189,6 +189,7 @@
|
|
|
189
189
|
"title": "Görev ekle",
|
|
190
190
|
"newTaskIn": "{container} içinde yeni görev",
|
|
191
191
|
"typeLabel": "Tür",
|
|
192
|
+
"typeOther": "Diğer",
|
|
192
193
|
"types": {
|
|
193
194
|
"feature": "Özellik",
|
|
194
195
|
"bug": "Hata",
|
|
@@ -2773,7 +2774,7 @@
|
|
|
2773
2774
|
},
|
|
2774
2775
|
"questionsOnPark": {
|
|
2775
2776
|
"label": "Duraklatılan başsız çalıştırmanın açık sorularını gönder",
|
|
2776
|
-
"help": "
|
|
2777
|
+
"help": "Uygulama dışında başlatılan bir çalıştırma gereksinimleri netleştirmek için duraklattığında (API üzerinden ya da bir izleyici kaydından gönderilerek), açık sorularını, her biri bir yanıtın belirteceği kimlikle, bağlı soruna gönderir. Uygulamada başlatılan görevler etkilenmez."
|
|
2777
2778
|
}
|
|
2778
2779
|
},
|
|
2779
2780
|
"probeFailure": {
|
package/i18n/locales/uk.json
CHANGED
|
@@ -189,6 +189,7 @@
|
|
|
189
189
|
"title": "Додати завдання",
|
|
190
190
|
"newTaskIn": "Нове завдання в {container}",
|
|
191
191
|
"typeLabel": "Тип",
|
|
192
|
+
"typeOther": "Інші",
|
|
192
193
|
"types": {
|
|
193
194
|
"feature": "Функція",
|
|
194
195
|
"bug": "Помилка",
|
|
@@ -2632,7 +2633,7 @@
|
|
|
2632
2633
|
},
|
|
2633
2634
|
"questionsOnPark": {
|
|
2634
2635
|
"label": "Публікувати відкриті запитання призупиненого headless-запуску",
|
|
2635
|
-
"help": "Коли запуск, розпочатий
|
|
2636
|
+
"help": "Коли запуск, розпочатий поза застосунком, призупиняється для уточнення вимог (через API або надісланий з тикета трекера), опублікуй його відкриті запитання у пов'язаному тикеті, кожне з ідентифікатором, який має назвати відповідь. Завдання, розпочаті в застосунку, лишаються без змін."
|
|
2636
2637
|
}
|
|
2637
2638
|
},
|
|
2638
2639
|
"probeFailure": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.220.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"valibot": "^1.4.2",
|
|
41
41
|
"vue": "3.5.40",
|
|
42
42
|
"wretch": "^3.0.9",
|
|
43
|
-
"@cat-factory/contracts": "0.
|
|
43
|
+
"@cat-factory/contracts": "0.234.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|