@cat-factory/app 0.236.1 → 0.237.1
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 +18 -3
- package/app/components/board/nodes/BlockNode.vue +33 -152
- package/app/components/common/DescriptorFields.vue +102 -75
- package/app/components/context/ContextAttachmentFields.vue +9 -7
- package/app/components/documents/ContextDocumentPicker.vue +130 -12
- package/app/components/documents/TaskContextDocs.vue +18 -16
- package/app/components/tasks/BugHuntModal.vue +70 -61
- package/app/components/tasks/ContextIssuePicker.vue +52 -28
- package/app/components/tasks/TaskImportModal.vue +55 -39
- package/app/composables/useContainerTargets.spec.ts +112 -0
- package/app/composables/useContainerTargets.ts +62 -0
- package/app/docs/consumer-extensions.md +9 -5
- package/app/stores/ui/navigation.ts +8 -31
- package/app/utils/containerTargets.ts +71 -0
- package/app/utils/descriptorFields.spec.ts +68 -0
- package/app/utils/descriptorFields.ts +40 -1
- package/app/utils/sourcePicker.spec.ts +258 -0
- package/app/utils/sourcePicker.ts +251 -0
- package/i18n/locales/de.json +7 -6
- package/i18n/locales/en.json +7 -6
- package/i18n/locales/es.json +7 -6
- package/i18n/locales/fr.json +7 -6
- package/i18n/locales/he.json +7 -6
- package/i18n/locales/it.json +7 -6
- package/i18n/locales/ja.json +7 -6
- package/i18n/locales/pl.json +7 -6
- package/i18n/locales/tr.json +7 -6
- package/i18n/locales/uk.json +7 -6
- package/package.json +2 -2
- package/app/utils/taskSources.spec.ts +0 -100
- package/app/utils/taskSources.ts +0 -76
package/README.md
CHANGED
|
@@ -111,12 +111,27 @@ When the backend declares the fields and the SPA only collects them, render them
|
|
|
111
111
|
surfaces use it: an initiative preset's create form and a reusable operation's per-case form on a
|
|
112
112
|
custom task type (`AddTaskModal`). Adding a third is a `:fields` binding, not a component.
|
|
113
113
|
|
|
114
|
+
**Grouping is the descriptor's own, through `descriptorFieldSections`**, not a wrapper each surface
|
|
115
|
+
builds: consecutive fields sharing a `section` render under one caption, and the reduction applies
|
|
116
|
+
`showWhen` first, so a section whose every field is hidden renders no caption. Never re-group or
|
|
117
|
+
re-order the fields at a call site, or a form renders in an order its author never wrote.
|
|
118
|
+
|
|
119
|
+
**A captioned run is rendered FLAT, never as a per-run wrapper element** (`descriptorFormRows`
|
|
120
|
+
carries each run's caption on the field that opens it). Run membership is derived state that shifts
|
|
121
|
+
as `showWhen` reveals fields, while a field's identity does not: nesting the fields inside a wrapper
|
|
122
|
+
re-parents them when a boundary moves, and Vue can only do that by unmounting and remounting. The
|
|
123
|
+
remounted input is typically the one being TYPED INTO, because typing into the trigger is what moved
|
|
124
|
+
the boundary, so it loses focus, caret and IME composition mid-keystroke. Keep every field a sibling
|
|
125
|
+
keyed by `field.key` and the diff MOVES it instead. The same trap as keying any list by index, with a
|
|
126
|
+
worse symptom: `descriptorFields.spec.ts` pins that a reveal preserves every field key.
|
|
127
|
+
|
|
114
128
|
Four rules travel with it. **Validate with the shared `validateDescriptorFields`** so the submit
|
|
115
129
|
button reflects exactly what the server will refuse, and **submit the shared
|
|
116
130
|
`sanitizeDescriptorFields` result** so a stale answer on a since-hidden `showWhen` field never
|
|
117
|
-
reaches the wire. **
|
|
118
|
-
|
|
119
|
-
|
|
131
|
+
reaches the wire. **Every string a descriptor carries is deployment-authored English rendered
|
|
132
|
+
verbatim**, labels, help, option captions and the `section` grouping captions alike: only the
|
|
133
|
+
platform's own chrome around them (the path-invalid message) is i18n, so no descriptor string enters
|
|
134
|
+
a locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
|
|
120
135
|
(`defaultDescriptorValues` for the initial values, `setDescriptorValue` / `setDescriptorCheckbox` /
|
|
121
136
|
`toggleDescriptorGroupValue` for one edit): what an edit freezes on an entity is what a unit test
|
|
122
137
|
must be able to reach, and a rule inside a component is only reachable by mounting one.
|
|
@@ -32,7 +32,11 @@ const { lod } = useSemanticZoom()
|
|
|
32
32
|
const { isTouch } = useViewport()
|
|
33
33
|
|
|
34
34
|
const block = computed<Block | undefined>(() => board.getBlock(props.id))
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* This service frame is mounted on more than one board in the org, which is a fact about the
|
|
37
|
+
* SERVICE that only its own card can state: nothing else on the board says the work lands in a
|
|
38
|
+
* repo another board also drives.
|
|
39
|
+
*/
|
|
36
40
|
const isShared = computed(() => services.isSharedFrame(props.id))
|
|
37
41
|
const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
|
|
38
42
|
|
|
@@ -46,18 +50,7 @@ const taskCount = computed(() => allTasks.value.length)
|
|
|
46
50
|
const hasTasks = computed(
|
|
47
51
|
() => taskCount.value > 0 || modules.value.length > 0 || initiativeBlocks.value.length > 0,
|
|
48
52
|
)
|
|
49
|
-
|
|
50
|
-
const taskStats = computed(() => {
|
|
51
|
-
let merged = 0
|
|
52
|
-
let prReady = 0
|
|
53
|
-
for (const t of allTasks.value) {
|
|
54
|
-
if (t.status === 'done') merged++
|
|
55
|
-
else if (t.status === 'pr_ready') prReady++
|
|
56
|
-
}
|
|
57
|
-
return { merged, prReady }
|
|
58
|
-
})
|
|
59
|
-
const mergedTasks = computed(() => taskStats.value.merged)
|
|
60
|
-
const prTasks = computed(() => taskStats.value.prReady)
|
|
53
|
+
const prTasks = computed(() => allTasks.value.filter((t) => t.status === 'pr_ready').length)
|
|
61
54
|
const canvas = computed(() => board.containerSize(props.id))
|
|
62
55
|
|
|
63
56
|
// Frame status is derived from its tasks — services never reach "done".
|
|
@@ -92,11 +85,6 @@ const FRAME_STATUS_HINT_KEYS: Record<BlockStatus, string> = {
|
|
|
92
85
|
const statusHint = computed(() => t(FRAME_STATUS_HINT_KEYS[frameStatus.value]))
|
|
93
86
|
|
|
94
87
|
const selected = computed(() => ui.selectedBlockId === props.id)
|
|
95
|
-
// Services are always expanded to their task canvas, at every zoom level: there is no
|
|
96
|
-
// chip/compact collapse, so panning is a fixed layout and zooming has no expand/collapse
|
|
97
|
-
// transition to snap on. The far-chip and compact-summary branches in the template are
|
|
98
|
-
// kept (gated off) so the prior behaviour is one edit away if we want chips back.
|
|
99
|
-
const showExpanded = computed(() => true)
|
|
100
88
|
|
|
101
89
|
// Every child whose parked run the frame badge speaks for: its tasks AND its initiative
|
|
102
90
|
// blocks. An initiative is a frame child like a module and runs an ordinary pipeline (its
|
|
@@ -144,10 +132,6 @@ function openFirstApproval() {
|
|
|
144
132
|
if (a) ui.openApprovalDetail(a.instanceId, a.approval.id)
|
|
145
133
|
}
|
|
146
134
|
|
|
147
|
-
function toggleExpand() {
|
|
148
|
-
ui.toggleFrame(props.id)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
135
|
// Expanded frames are not Vue Flow-draggable (so the pane can pan through them),
|
|
152
136
|
// so they're repositioned by grabbing the header instead. The whole header bar is
|
|
153
137
|
// the grab surface — only the action buttons (marked `.nodrag`) opt out, so a press
|
|
@@ -164,14 +148,12 @@ function onFrameHandle(e: PointerEvent) {
|
|
|
164
148
|
const { enter: enterFrame, leave: leaveFrame } = useFrameStacking()
|
|
165
149
|
|
|
166
150
|
function addTask() {
|
|
167
|
-
ui.expandFrame(props.id)
|
|
168
151
|
ui.openAddTask(props.id)
|
|
169
152
|
}
|
|
170
153
|
|
|
171
|
-
// Open the tracker-issue modal scoped to THIS service: the create-in target and the
|
|
172
|
-
//
|
|
154
|
+
// Open the tracker-issue modal scoped to THIS service: the create-in target and the repo-scoped
|
|
155
|
+
// issue search are narrowed to this frame and its modules (see `useContainerTargets`).
|
|
173
156
|
function createTaskFromIssue() {
|
|
174
|
-
ui.expandFrame(props.id)
|
|
175
157
|
ui.openTaskImport(null, props.id)
|
|
176
158
|
}
|
|
177
159
|
|
|
@@ -182,12 +164,10 @@ function addRecurring() {
|
|
|
182
164
|
// Hunt this service's tracker board for a bug worth picking up. Scoped to THIS frame, so
|
|
183
165
|
// an adopted candidate lands here rather than wherever the board's first frame happens to be.
|
|
184
166
|
function huntBugs() {
|
|
185
|
-
ui.expandFrame(props.id)
|
|
186
167
|
ui.openBugHunt(null, props.id)
|
|
187
168
|
}
|
|
188
169
|
|
|
189
170
|
function createInitiative() {
|
|
190
|
-
ui.expandFrame(props.id)
|
|
191
171
|
ui.openCreateInitiative(props.id)
|
|
192
172
|
}
|
|
193
173
|
|
|
@@ -279,119 +259,13 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
279
259
|
/>
|
|
280
260
|
</div>
|
|
281
261
|
|
|
282
|
-
<!-- =====================
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
:class="[selected ? 'border-white' : '', pulseClass]"
|
|
289
|
-
:style="{ borderColor: accent, backgroundColor: accent + '26' }"
|
|
290
|
-
>
|
|
291
|
-
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: accent }" />
|
|
292
|
-
<span class="truncate text-sm font-semibold text-white">{{ block.title }}</span>
|
|
293
|
-
<UIcon
|
|
294
|
-
v-if="bootstrapping"
|
|
295
|
-
name="i-lucide-loader-circle"
|
|
296
|
-
class="ms-auto h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
|
|
297
|
-
:title="t('board.frame.bootstrapping')"
|
|
298
|
-
/>
|
|
299
|
-
<UIcon
|
|
300
|
-
v-else-if="runFailed"
|
|
301
|
-
name="i-lucide-alert-triangle"
|
|
302
|
-
class="ms-auto h-3.5 w-3.5 shrink-0 text-rose-400"
|
|
303
|
-
:title="t('board.frame.runFailed')"
|
|
304
|
-
/>
|
|
305
|
-
<span v-else-if="hasTasks" class="ms-auto shrink-0 text-[11px] text-slate-300">
|
|
306
|
-
{{ mergedTasks }}/{{ taskCount }}
|
|
307
|
-
</span>
|
|
308
|
-
</div>
|
|
309
|
-
|
|
310
|
-
<!-- ===================== COMPACT: summary (collapsed) ===================== -->
|
|
311
|
-
<div
|
|
312
|
-
v-else-if="!showExpanded"
|
|
313
|
-
class="w-56 overflow-hidden rounded-xl border bg-slate-900/90 shadow-xl backdrop-blur"
|
|
314
|
-
:class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
|
|
315
|
-
>
|
|
316
|
-
<div class="h-1.5 w-full" :style="{ backgroundColor: accent }" />
|
|
317
|
-
<!-- bootstrap-in-progress banner -->
|
|
318
|
-
<div
|
|
319
|
-
v-if="bootstrapping"
|
|
320
|
-
class="border-b border-amber-900/50 bg-amber-950/30 px-3 py-2"
|
|
321
|
-
data-testid="bootstrap-progress"
|
|
322
|
-
>
|
|
323
|
-
<div class="flex items-center gap-1.5 text-[11px]">
|
|
324
|
-
<UIcon
|
|
325
|
-
name="i-lucide-loader-circle"
|
|
326
|
-
class="h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
|
|
327
|
-
/>
|
|
328
|
-
<span class="text-amber-300">{{ t('board.frame.bootstrapping') }}</span>
|
|
329
|
-
<span v-if="bootstrapSubtasks" class="ms-auto text-amber-200/80">
|
|
330
|
-
{{ bootstrapSubtasks.completed }}/{{ bootstrapSubtasks.total }}
|
|
331
|
-
</span>
|
|
332
|
-
</div>
|
|
333
|
-
<div class="mt-1.5 h-1 w-full overflow-hidden rounded bg-amber-900/40">
|
|
334
|
-
<div
|
|
335
|
-
class="h-full rounded bg-amber-400 transition-all"
|
|
336
|
-
:style="{ width: bootstrapPct + '%' }"
|
|
337
|
-
/>
|
|
338
|
-
</div>
|
|
339
|
-
<div v-if="run" class="mt-2 flex justify-end">
|
|
340
|
-
<AgentStopButton :run-id="run.runId" :kind="run.kind" size="xs" variant="ghost" />
|
|
341
|
-
</div>
|
|
342
|
-
</div>
|
|
343
|
-
<!-- failed run: shared failure banner + retry -->
|
|
344
|
-
<div v-else-if="runFailed && run" class="p-2">
|
|
345
|
-
<AgentFailureCard :run="run" variant="compact" />
|
|
346
|
-
</div>
|
|
347
|
-
<div class="space-y-2 p-3">
|
|
348
|
-
<div class="flex items-center gap-2">
|
|
349
|
-
<UIcon
|
|
350
|
-
:name="typeMeta!.icon"
|
|
351
|
-
class="h-4 w-4 shrink-0"
|
|
352
|
-
:style="{ color: typeMeta!.accent }"
|
|
353
|
-
/>
|
|
354
|
-
<span class="truncate text-sm font-semibold text-white">{{ block.title }}</span>
|
|
355
|
-
<UBadge
|
|
356
|
-
v-if="isShared"
|
|
357
|
-
color="info"
|
|
358
|
-
variant="subtle"
|
|
359
|
-
size="sm"
|
|
360
|
-
class="shrink-0"
|
|
361
|
-
:title="t('board.frame.sharedTitle')"
|
|
362
|
-
>
|
|
363
|
-
{{ t('board.frame.shared') }}
|
|
364
|
-
</UBadge>
|
|
365
|
-
</div>
|
|
366
|
-
<div class="flex items-center justify-between">
|
|
367
|
-
<UBadge :color="statusMeta.chip as any" variant="subtle" size="sm" :title="statusHint">{{
|
|
368
|
-
statusLabel
|
|
369
|
-
}}</UBadge>
|
|
370
|
-
<span class="text-[11px] text-slate-400">{{
|
|
371
|
-
t('board.frame.taskCount', { count: taskCount }, taskCount)
|
|
372
|
-
}}</span>
|
|
373
|
-
</div>
|
|
374
|
-
<button
|
|
375
|
-
type="button"
|
|
376
|
-
class="nodrag flex w-full items-center gap-1 rounded-md bg-slate-800/60 px-2 py-1 text-[10px] text-slate-300 hover:bg-slate-800"
|
|
377
|
-
@click.stop="toggleExpand"
|
|
378
|
-
>
|
|
379
|
-
<UIcon name="i-lucide-layers" class="h-3 w-3 text-slate-400" />
|
|
380
|
-
<span v-if="hasTasks">{{
|
|
381
|
-
t('board.frame.mergedOfTotal', { merged: mergedTasks, total: taskCount })
|
|
382
|
-
}}</span>
|
|
383
|
-
<span v-else>{{ t('board.frame.noTasksYet') }}</span>
|
|
384
|
-
<span v-if="prTasks" class="text-emerald-400"
|
|
385
|
-
>· {{ t('board.frame.prCount', { count: prTasks }) }}</span
|
|
386
|
-
>
|
|
387
|
-
<UIcon name="i-lucide-chevron-down" class="ms-auto h-3 w-3" />
|
|
388
|
-
</button>
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
391
|
-
|
|
392
|
-
<!-- ===================== EXPANDED: 2D canvas of tasks + modules ===================== -->
|
|
262
|
+
<!-- ===================== The service card: 2D canvas of tasks + modules =====================
|
|
263
|
+
There is no chip or compact variant: a service is always expanded to its task canvas, at
|
|
264
|
+
every zoom level, so panning is a fixed layout and zooming has no expand/collapse
|
|
265
|
+
transition to snap on. The two gated-off branches that used to sit here (a far-zoom chip
|
|
266
|
+
and a collapsed summary) went with the header's collapse control, since between them they
|
|
267
|
+
held the only render of the "Shared" badge, which now lives in the header below. -->
|
|
393
268
|
<div
|
|
394
|
-
v-else
|
|
395
269
|
class="relative overflow-visible rounded-2xl border bg-slate-900/95 shadow-2xl backdrop-blur"
|
|
396
270
|
:class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
|
|
397
271
|
>
|
|
@@ -484,7 +358,23 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
484
358
|
/>
|
|
485
359
|
</div>
|
|
486
360
|
<div>
|
|
487
|
-
<div class="
|
|
361
|
+
<div class="flex items-center gap-1.5">
|
|
362
|
+
<span class="text-sm font-semibold text-white">{{ block.title }}</span>
|
|
363
|
+
<!-- Mounted on more than one board in the org. On the title row rather than in
|
|
364
|
+
the action strip, because it qualifies the service's NAME: the work lands in
|
|
365
|
+
a repo another board also drives. -->
|
|
366
|
+
<UBadge
|
|
367
|
+
v-if="isShared"
|
|
368
|
+
color="info"
|
|
369
|
+
variant="subtle"
|
|
370
|
+
size="sm"
|
|
371
|
+
class="shrink-0"
|
|
372
|
+
data-testid="frame-shared"
|
|
373
|
+
:title="t('board.frame.sharedTitle')"
|
|
374
|
+
>
|
|
375
|
+
{{ t('board.frame.shared') }}
|
|
376
|
+
</UBadge>
|
|
377
|
+
</div>
|
|
488
378
|
<div class="text-[11px] text-slate-400">{{ typeMeta!.label }}</div>
|
|
489
379
|
</div>
|
|
490
380
|
</div>
|
|
@@ -497,8 +387,8 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
497
387
|
>{{ statusLabel }}</UBadge
|
|
498
388
|
>
|
|
499
389
|
<!-- Board-authoring buttons (create task / from issue / recurring / initiative)
|
|
500
|
-
are `board.write
|
|
501
|
-
|
|
390
|
+
are `board.write`, hidden for a read-only viewer, who keeps the status badge
|
|
391
|
+
(the one view-only affordance here). -->
|
|
502
392
|
<template v-if="access.canWriteBoard.value">
|
|
503
393
|
<UButton
|
|
504
394
|
class="nodrag"
|
|
@@ -562,15 +452,6 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
562
452
|
@click.stop="huntBugs"
|
|
563
453
|
/>
|
|
564
454
|
</template>
|
|
565
|
-
<UButton
|
|
566
|
-
class="nodrag"
|
|
567
|
-
:size="isTouch ? 'sm' : 'xs'"
|
|
568
|
-
variant="ghost"
|
|
569
|
-
color="neutral"
|
|
570
|
-
icon="i-lucide-chevron-up"
|
|
571
|
-
:title="t('board.frame.collapseTitle')"
|
|
572
|
-
@click.stop="toggleExpand"
|
|
573
|
-
/>
|
|
574
455
|
</div>
|
|
575
456
|
</div>
|
|
576
457
|
|
|
@@ -8,16 +8,18 @@
|
|
|
8
8
|
//
|
|
9
9
|
// It extends `ProviderConnectionTab.vue`'s flat-field pattern with the shapes a declared form needs:
|
|
10
10
|
// `checkbox-group` (multi-select whose value is `string[]`), `path` (a repo-relative dir with inline
|
|
11
|
-
// safety validation),
|
|
12
|
-
// backend-supplied English (the `describeConfig`
|
|
11
|
+
// safety validation), single-condition `showWhen` visibility, and `section` grouping captions.
|
|
12
|
+
// Labels/help/option/section captions are backend-supplied English (the `describeConfig`
|
|
13
|
+
// convention); only the chrome is i18n.
|
|
13
14
|
//
|
|
14
15
|
// The model is the typed `DescriptorFieldValues` map (scalars stay strings, `number` a number,
|
|
15
16
|
// `checkbox` a boolean, `checkbox-group` a `string[]`), so it round-trips the wire contract and the
|
|
16
17
|
// shared `validateDescriptorFields` / `sanitizeDescriptorFields` rules unchanged.
|
|
17
18
|
import { computed } from 'vue'
|
|
18
|
-
import {
|
|
19
|
+
import { isSafeRepoDirPath } from '@cat-factory/contracts'
|
|
19
20
|
import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
|
|
20
21
|
import {
|
|
22
|
+
descriptorFormRows,
|
|
21
23
|
descriptorGroupValue,
|
|
22
24
|
setDescriptorCheckbox,
|
|
23
25
|
setDescriptorValue,
|
|
@@ -39,12 +41,20 @@ const props = withDefaults(
|
|
|
39
41
|
const model = defineModel<DescriptorFieldValues>({ required: true })
|
|
40
42
|
const { t } = useI18n()
|
|
41
43
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
// The fields to render, each carrying the section caption that opens its run. Only fields whose
|
|
45
|
+
// `showWhen` holds against the current values are shown; a hidden field's stale value is kept in the
|
|
46
|
+
// model (so re-showing restores it) but the server + client both drop it at sanitize/validate time,
|
|
47
|
+
// so it can never freeze an unvalidated value.
|
|
48
|
+
//
|
|
49
|
+
// The grouping underneath is the shared `descriptorFieldSections`, so a caption spans exactly what
|
|
50
|
+
// one function says it spans, which is the same statement the boot check refuses a declaration
|
|
51
|
+
// against. A form declaring no section is one uncaptioned run, i.e. byte-for-byte the flat column
|
|
52
|
+
// this component always rendered.
|
|
53
|
+
//
|
|
54
|
+
// The rows are FLAT, and the template keeps them siblings keyed by `field.key`, because run
|
|
55
|
+
// membership shifts as `showWhen` reveals fields while a field's identity does not: see
|
|
56
|
+
// `descriptorFormRows` for why a per-run wrapper would remount the input being typed into.
|
|
57
|
+
const rows = computed(() => descriptorFormRows(props.fields, model.value))
|
|
48
58
|
|
|
49
59
|
// The value-mutation rules live in `utils/descriptorFields.ts` as pure functions over the bag (what
|
|
50
60
|
// an edit does to it, including the drop-when-empty rule that keeps an unset answer from freezing),
|
|
@@ -90,76 +100,93 @@ function selectItems(field: DescriptorField) {
|
|
|
90
100
|
</script>
|
|
91
101
|
|
|
92
102
|
<template>
|
|
93
|
-
<div v-if="
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
<div v-if="rows.length" class="space-y-4">
|
|
104
|
+
<!-- One keyed fragment per FIELD, never per run: a run's caption rides the field that opens it,
|
|
105
|
+
so revealing a field re-captions rows in place instead of re-parenting the inputs (which
|
|
106
|
+
Vue can only do by remounting them, destroying the one being typed into). -->
|
|
107
|
+
<template v-for="{ field, caption, startsGroup } in rows" :key="field.key">
|
|
108
|
+
<!-- The section caption, rendered verbatim above its run (deployment-authored English, like
|
|
109
|
+
the field labels themselves). Its testid is the same on every caption, because a caption
|
|
110
|
+
is arbitrary Unicode a deployment writes in its own language: a spec addresses one by the
|
|
111
|
+
TEXT it is asserting about (`getByTestId(...).filter({ hasText })`) rather than by a
|
|
112
|
+
testid that would have to be slugified to be selectable. -->
|
|
113
|
+
<p
|
|
114
|
+
v-if="caption"
|
|
115
|
+
class="-mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
116
|
+
:class="{ 'pt-2': startsGroup }"
|
|
117
|
+
:data-testid="`${testidPrefix}-section`"
|
|
118
|
+
>
|
|
119
|
+
{{ caption }}
|
|
120
|
+
</p>
|
|
121
|
+
<UFormField
|
|
122
|
+
:label="field.label"
|
|
123
|
+
:help="field.help"
|
|
124
|
+
:required="field.required"
|
|
125
|
+
:error="pathInvalid(field) ? t('common.pathInvalid') : undefined"
|
|
126
|
+
:class="{ 'pt-2': startsGroup && !caption }"
|
|
127
|
+
:data-testid="`${testidPrefix}-${field.key}`"
|
|
128
|
+
>
|
|
129
|
+
<!-- checkbox-group: a vertical list of toggles whose value is the checked option set. -->
|
|
130
|
+
<div v-if="field.type === 'checkbox-group'" class="space-y-1.5">
|
|
131
|
+
<UCheckbox
|
|
132
|
+
v-for="opt in field.options ?? []"
|
|
133
|
+
:key="opt.value"
|
|
134
|
+
:model-value="groupValue(field.key).includes(opt.value)"
|
|
135
|
+
:label="opt.label"
|
|
136
|
+
:data-testid="`${testidPrefix}-${field.key}-${opt.value}`"
|
|
137
|
+
@update:model-value="
|
|
138
|
+
(v: boolean | 'indeterminate') => toggleGroup(field.key, opt.value, v === true)
|
|
139
|
+
"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
116
142
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
<USelect
|
|
144
|
+
v-else-if="field.type === 'select'"
|
|
145
|
+
:model-value="stringValue(field.key)"
|
|
146
|
+
:items="selectItems(field)"
|
|
147
|
+
class="w-full"
|
|
148
|
+
:placeholder="field.placeholder"
|
|
149
|
+
@update:model-value="(v: string) => set(field.key, v)"
|
|
150
|
+
/>
|
|
125
151
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
<USwitch
|
|
153
|
+
v-else-if="field.type === 'checkbox'"
|
|
154
|
+
:model-value="boolValue(field.key)"
|
|
155
|
+
@update:model-value="(v: boolean) => setCheckbox(field, v)"
|
|
156
|
+
/>
|
|
131
157
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
158
|
+
<UTextarea
|
|
159
|
+
v-else-if="field.type === 'textarea'"
|
|
160
|
+
:model-value="stringValue(field.key)"
|
|
161
|
+
:rows="3"
|
|
162
|
+
autoresize
|
|
163
|
+
class="w-full"
|
|
164
|
+
:maxlength="field.maxLength"
|
|
165
|
+
:placeholder="field.placeholder"
|
|
166
|
+
@update:model-value="(v: string) => set(field.key, v)"
|
|
167
|
+
/>
|
|
142
168
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
<UInput
|
|
170
|
+
v-else-if="field.type === 'number'"
|
|
171
|
+
:model-value="numberStr(field.key)"
|
|
172
|
+
type="number"
|
|
173
|
+
class="w-full font-mono"
|
|
174
|
+
:placeholder="field.placeholder"
|
|
175
|
+
@update:model-value="(v: string) => set(field.key, v === '' ? undefined : Number(v))"
|
|
176
|
+
/>
|
|
151
177
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
178
|
+
<!-- path + text/password (the untyped default): a single-line input. `path`s stay mono. -->
|
|
179
|
+
<UInput
|
|
180
|
+
v-else
|
|
181
|
+
:model-value="stringValue(field.key)"
|
|
182
|
+
:type="field.type === 'password' ? 'password' : 'text'"
|
|
183
|
+
class="w-full"
|
|
184
|
+
:class="{ 'font-mono': field.type === 'path' }"
|
|
185
|
+
:maxlength="field.maxLength"
|
|
186
|
+
:placeholder="field.placeholder"
|
|
187
|
+
@update:model-value="(v: string) => set(field.key, v)"
|
|
188
|
+
/>
|
|
189
|
+
</UFormField>
|
|
190
|
+
</template>
|
|
164
191
|
</div>
|
|
165
192
|
</template>
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// itself gated on `integrations.manage` for documents, whose ATTACH writes are member-tier while
|
|
16
16
|
// storing the credential is not.
|
|
17
17
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
18
|
+
import { connectableSources } from '~/utils/sourcePicker'
|
|
18
19
|
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
19
20
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
20
21
|
|
|
@@ -48,15 +49,16 @@ const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
|
|
|
48
49
|
// connected via the App, so they never appear here); for issues, every configured tracker not yet
|
|
49
50
|
// available. The connect modals are the same ones the Integrations hub opens.
|
|
50
51
|
//
|
|
51
|
-
// The document half
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// action that opens a modal, takes a token and 403s.
|
|
52
|
+
// The document half goes through the shared `connectableSources`, the ONE answer to "which document
|
|
53
|
+
// sources could I connect", which the picker's own add tier reads too: as three near-copies these
|
|
54
|
+
// disagreed about whether an unavailable integration counted.
|
|
55
55
|
const { canManageIntegrations } = useWorkspaceAccess()
|
|
56
56
|
const connectableDocSources = computed(() =>
|
|
57
|
-
documents.
|
|
58
|
-
|
|
59
|
-
:
|
|
57
|
+
connectableSources(documents.sources, {
|
|
58
|
+
isConnected: documents.isConnected,
|
|
59
|
+
canConnect: canManageIntegrations.value,
|
|
60
|
+
available: documents.available,
|
|
61
|
+
}),
|
|
60
62
|
)
|
|
61
63
|
const connectableIssueSources = computed(() =>
|
|
62
64
|
tasks.available ? tasks.sources.filter((s) => !s.available) : [],
|