@cat-factory/app 0.219.1 → 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 +1 -0
- package/i18n/locales/en.json +4 -0
- package/i18n/locales/es.json +1 -0
- package/i18n/locales/fr.json +1 -0
- package/i18n/locales/he.json +1 -0
- package/i18n/locales/it.json +1 -0
- package/i18n/locales/ja.json +1 -0
- package/i18n/locales/pl.json +1 -0
- package/i18n/locales/tr.json +1 -0
- package/i18n/locales/uk.json +1 -0
- 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
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",
|
package/i18n/locales/es.json
CHANGED
package/i18n/locales/fr.json
CHANGED
package/i18n/locales/he.json
CHANGED
package/i18n/locales/it.json
CHANGED
package/i18n/locales/ja.json
CHANGED
package/i18n/locales/pl.json
CHANGED
package/i18n/locales/tr.json
CHANGED
package/i18n/locales/uk.json
CHANGED
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",
|