@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
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// useContextLinking). A search hit / pasted ref carries `needsImport: true` so
|
|
10
10
|
// it's fetched + persisted before linking. Mirrors ContextIssuePicker.
|
|
11
11
|
//
|
|
12
|
+
// The source being searched is ALWAYS on screen (even when the workspace has exactly one, and
|
|
13
|
+
// even when it has none), and its menu doubles as the "add a source" affordance: which source is
|
|
14
|
+
// selected decides what a pasted ref resolves to and which repository a file pick browses, and
|
|
15
|
+
// attaching context is where a missing integration is discovered. Connecting opens that source's
|
|
16
|
+
// connect modal OVER the caller's form rather than navigating away from it.
|
|
17
|
+
//
|
|
12
18
|
// A PASTED REF IS RESOLVED BEFORE IT CAN BE STAGED, and that is the point of the ref
|
|
13
19
|
// half of this picker. It used to stage whatever text was in the box, so a share link
|
|
14
20
|
// carrying a title segment and `?p=`/`&t=` tracking params (what Figma's own Copy link
|
|
@@ -26,6 +32,7 @@
|
|
|
26
32
|
// stageable with the import as the backstop, because only the source's own refusal is evidence
|
|
27
33
|
// against a link. An outage that made attaching impossible would be a worse failure than the
|
|
28
34
|
// one the pre-flight fixes.
|
|
35
|
+
import { useId } from 'vue'
|
|
29
36
|
import type { DocumentRefReason, DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
|
|
30
37
|
import {
|
|
31
38
|
classifyRefFailure,
|
|
@@ -35,6 +42,13 @@ import {
|
|
|
35
42
|
} from '~/components/documents/ContextDocumentPicker.logic'
|
|
36
43
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
37
44
|
import RepoContextDocPicker from '~/components/documents/RepoContextDocPicker.vue'
|
|
45
|
+
import {
|
|
46
|
+
type AddSourceLabels,
|
|
47
|
+
buildConnectionSourceChoices,
|
|
48
|
+
menuIsPickable,
|
|
49
|
+
reconcileSource,
|
|
50
|
+
sourceMenuItems,
|
|
51
|
+
} from '~/utils/sourcePicker'
|
|
38
52
|
|
|
39
53
|
// Repo-backed document sources pick a FILE out of a repository (repo search → file
|
|
40
54
|
// search / tree browse) instead of the generic free-text catalogue search. Today only
|
|
@@ -49,18 +63,80 @@ const emit = defineEmits<{ pick: [item: PendingContext] }>()
|
|
|
49
63
|
|
|
50
64
|
const { t } = useI18n()
|
|
51
65
|
const documents = useDocumentsStore()
|
|
66
|
+
const ui = useUiStore()
|
|
67
|
+
// Connecting a source stores a workspace credential and is admin-tier, while attaching what it
|
|
68
|
+
// holds is member-tier, so the menu's "add a source" tier is withheld from a member rather than
|
|
69
|
+
// offering a connect that would 403 (see `connectableSources`). The tier also decides which empty
|
|
70
|
+
// state a reader gets, because "connect one" is not advice you can act on without it.
|
|
71
|
+
const { canManageIntegrations } = useWorkspaceAccess()
|
|
52
72
|
|
|
53
73
|
const chosen = computed(() => new Set(props.chosenKeys ?? []))
|
|
54
74
|
|
|
55
|
-
// Source: default to the first connected source
|
|
56
|
-
//
|
|
75
|
+
// Source: default to the first connected source. The selector is always rendered, single source
|
|
76
|
+
// or not: which source is being searched decides what a pasted ref resolves to, so it must never
|
|
77
|
+
// be invisible.
|
|
57
78
|
const source = ref<DocumentSourceKind | undefined>(documents.connectedSources[0]?.source)
|
|
58
|
-
const sourceItems = computed(() =>
|
|
59
|
-
documents.connectedSources.map((s) => ({ label: s.label, value: s.source })),
|
|
60
|
-
)
|
|
61
79
|
const descriptor = computed(() =>
|
|
62
80
|
source.value ? documents.descriptorFor(source.value) : undefined,
|
|
63
81
|
)
|
|
82
|
+
|
|
83
|
+
// The source the user left to connect, so it becomes the selection the moment it turns up
|
|
84
|
+
// connected (the connect modal re-probes on success). Also the reconcile trigger for a source
|
|
85
|
+
// that stops being connected while the caller's form sat open.
|
|
86
|
+
const awaitingConnect = ref<DocumentSourceKind | null>(null)
|
|
87
|
+
function addSource(s: DocumentSourceKind) {
|
|
88
|
+
awaitingConnect.value = s
|
|
89
|
+
ui.openDocumentConnect(s)
|
|
90
|
+
}
|
|
91
|
+
watch(
|
|
92
|
+
() => documents.connectedSources.map((s) => s.source),
|
|
93
|
+
(connected) => {
|
|
94
|
+
const next = reconcileSource(connected, source.value, awaitingConnect.value)
|
|
95
|
+
if (next && next === awaitingConnect.value) awaitingConnect.value = null
|
|
96
|
+
if (next !== source.value) source.value = next
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Two-tier menu: pick a connected source, or connect one the workspace hasn't got yet. A document
|
|
101
|
+
// source carries no per-workspace enable toggle, so `connect` is the ONLY add wording it can need,
|
|
102
|
+
// and `buildConnectionSourceChoices` makes that a fact about the type rather than about this call
|
|
103
|
+
// site: a source that one day gains a toggle fails the typecheck here instead of rendering
|
|
104
|
+
// "Connect X" over something already connected.
|
|
105
|
+
const sourceChoices = computed(() =>
|
|
106
|
+
buildConnectionSourceChoices(documents.sources, {
|
|
107
|
+
isConnected: documents.isConnected,
|
|
108
|
+
canConnect: canManageIntegrations.value,
|
|
109
|
+
available: documents.available,
|
|
110
|
+
selected: source.value,
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
const ADD_LABEL: AddSourceLabels<'connect'> = {
|
|
114
|
+
connect: (label) => t('documents.picker.connectSource', { label }),
|
|
115
|
+
}
|
|
116
|
+
const sourceMenu = computed(() =>
|
|
117
|
+
sourceMenuItems(sourceChoices.value, {
|
|
118
|
+
onSelect: (s) => {
|
|
119
|
+
source.value = s
|
|
120
|
+
},
|
|
121
|
+
onAdd: addSource,
|
|
122
|
+
addLabel: ADD_LABEL,
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether the selector is a CONTROL or just a name. A member's add tier is withheld, so on the
|
|
128
|
+
* common deployment (GitHub docs implicitly connected, nothing else) their menu holds one entry
|
|
129
|
+
* that re-selects what is already selected: a chevron promising a choice that isn't there, which is
|
|
130
|
+
* the same papercut the always-visible selector was meant to remove. The source is still named,
|
|
131
|
+
* which was the point.
|
|
132
|
+
*/
|
|
133
|
+
const sourcePickable = computed(() => menuIsPickable(sourceChoices.value))
|
|
134
|
+
|
|
135
|
+
// The trigger's own content is the source NAME, so the visible "Source" caption is joined to it as
|
|
136
|
+
// the accessible name ("Source Confluence"). Per instance: the add-task form and the inspector can
|
|
137
|
+
// both have a picker mounted, and a hard-coded id would make one claim the other's caption.
|
|
138
|
+
const sourceLabelId = useId()
|
|
139
|
+
const sourceTriggerId = useId()
|
|
64
140
|
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
65
141
|
// A repo-backed source swaps the whole free-text search body for the repo→file picker.
|
|
66
142
|
const isRepoSource = computed(() => !!source.value && REPO_SOURCES.has(source.value))
|
|
@@ -340,17 +416,59 @@ onMounted(() => {
|
|
|
340
416
|
|
|
341
417
|
<template>
|
|
342
418
|
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
419
|
+
<!-- Which source is being searched, always visible, plus the sources the user could add from
|
|
420
|
+
here (each opens the connect modal over the caller's form). Rendered as plain text when
|
|
421
|
+
there is nothing to decide, which for a member is the usual case. -->
|
|
422
|
+
<div class="flex items-center gap-1.5">
|
|
423
|
+
<span
|
|
424
|
+
:id="sourceLabelId"
|
|
425
|
+
class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
|
|
426
|
+
>
|
|
427
|
+
{{ t('documents.picker.sourceLabel') }}
|
|
428
|
+
</span>
|
|
429
|
+
<UDropdownMenu
|
|
430
|
+
v-if="sourcePickable"
|
|
431
|
+
:items="sourceMenu"
|
|
432
|
+
:content="{ side: 'bottom', align: 'start' }"
|
|
433
|
+
>
|
|
434
|
+
<UButton
|
|
435
|
+
:id="sourceTriggerId"
|
|
436
|
+
color="neutral"
|
|
437
|
+
variant="soft"
|
|
438
|
+
size="xs"
|
|
439
|
+
:icon="icon"
|
|
440
|
+
trailing-icon="i-lucide-chevron-down"
|
|
441
|
+
class="max-w-full"
|
|
442
|
+
:aria-labelledby="`${sourceLabelId} ${sourceTriggerId}`"
|
|
443
|
+
>
|
|
444
|
+
<span class="truncate">{{ descriptor?.label ?? t('documents.picker.noSource') }}</span>
|
|
445
|
+
</UButton>
|
|
446
|
+
</UDropdownMenu>
|
|
447
|
+
<span v-else class="flex min-w-0 items-center gap-1 text-xs text-slate-300">
|
|
448
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0" />
|
|
449
|
+
<span class="truncate">{{ descriptor?.label ?? t('documents.picker.noSource') }}</span>
|
|
450
|
+
</span>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
<!-- No source selected: nothing is connected (or the last one was disconnected while this sat
|
|
454
|
+
open), so say so rather than render a search box that can only fail. WHICH sentence depends
|
|
455
|
+
on who is reading: the admin tier is told to connect one, because the menu above is the
|
|
456
|
+
route; a member has no add tier (see `connectableSources`), so telling them to connect a
|
|
457
|
+
source would name an action their own menu withholds. -->
|
|
458
|
+
<EmptyState
|
|
459
|
+
v-if="!source"
|
|
460
|
+
compact
|
|
461
|
+
icon="i-lucide-plug"
|
|
462
|
+
:title="
|
|
463
|
+
canManageIntegrations
|
|
464
|
+
? t('documents.picker.needsSource')
|
|
465
|
+
: t('documents.picker.needsSourceAdmin')
|
|
466
|
+
"
|
|
349
467
|
/>
|
|
350
468
|
|
|
351
469
|
<!-- Repo-backed source (GitHub / GitLab): pick a repository, then a file. -->
|
|
352
470
|
<RepoContextDocPicker
|
|
353
|
-
v-if="isRepoSource
|
|
471
|
+
v-else-if="isRepoSource"
|
|
354
472
|
:source="source!"
|
|
355
473
|
:icon="icon"
|
|
356
474
|
:chosen-keys="chosenKeys"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
3
3
|
import type { Block } from '~/types/domain'
|
|
4
|
+
import { connectableSources } from '~/utils/sourcePicker'
|
|
4
5
|
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
5
6
|
import DocumentOriginLink from '~/components/documents/DocumentOriginLink.vue'
|
|
6
7
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
@@ -52,21 +53,22 @@ const chosenKeys = computed(() =>
|
|
|
52
53
|
)
|
|
53
54
|
|
|
54
55
|
const connected = computed(() => documents.available && documents.anyConnected)
|
|
55
|
-
// Sources the user could connect right now to unlock the picker, when none is
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// Attaching is unaffected, which is the point of the split.
|
|
56
|
+
// Sources the user could connect right now to unlock the picker, when none is connected yet (GitHub
|
|
57
|
+
// docs are implicitly connected via the App, so never here). Through the shared
|
|
58
|
+
// `connectableSources`, which owns both terms: an integration the deployment has not configured has
|
|
59
|
+
// nothing to connect to, and connecting stores a workspace credential, which stays admin-tier while
|
|
60
|
+
// ATTACHING moved to the member tier. Offering the action to a member was a dead end (the connect
|
|
61
|
+
// modal opened, took a token, and 403'd); attaching is unaffected, which is the point of the split.
|
|
62
62
|
const { canManageIntegrations } = useWorkspaceAccess()
|
|
63
|
-
const
|
|
64
|
-
documents.
|
|
65
|
-
|
|
66
|
-
:
|
|
63
|
+
const connectable = computed(() =>
|
|
64
|
+
connectableSources(documents.sources, {
|
|
65
|
+
isConnected: documents.isConnected,
|
|
66
|
+
canConnect: canManageIntegrations.value,
|
|
67
|
+
available: documents.available,
|
|
68
|
+
}),
|
|
67
69
|
)
|
|
68
70
|
const connectMenu = computed<DropdownMenuItem[][]>(() => [
|
|
69
|
-
|
|
71
|
+
connectable.value.map((s) => ({
|
|
70
72
|
label: s.label,
|
|
71
73
|
icon: s.icon,
|
|
72
74
|
onSelect: () => ui.openDocumentConnect(s.source),
|
|
@@ -111,7 +113,7 @@ async function attach(item: PendingContext) {
|
|
|
111
113
|
{{ showPicker ? t('common.done') : t('documents.taskDocs.attach') }}
|
|
112
114
|
</UButton>
|
|
113
115
|
<UDropdownMenu
|
|
114
|
-
v-else-if="
|
|
116
|
+
v-else-if="connectable.length > 1"
|
|
115
117
|
:items="connectMenu"
|
|
116
118
|
:content="{ side: 'bottom', align: 'end' }"
|
|
117
119
|
>
|
|
@@ -120,14 +122,14 @@ async function attach(item: PendingContext) {
|
|
|
120
122
|
</UButton>
|
|
121
123
|
</UDropdownMenu>
|
|
122
124
|
<UButton
|
|
123
|
-
v-else-if="
|
|
125
|
+
v-else-if="connectable.length === 1"
|
|
124
126
|
color="neutral"
|
|
125
127
|
variant="soft"
|
|
126
128
|
size="xs"
|
|
127
129
|
icon="i-lucide-plug"
|
|
128
|
-
@click="ui.openDocumentConnect(
|
|
130
|
+
@click="ui.openDocumentConnect(connectable[0]!.source)"
|
|
129
131
|
>
|
|
130
|
-
{{ t('documents.taskDocs.connectSourceNamed', { source:
|
|
132
|
+
{{ t('documents.taskDocs.connectSourceNamed', { source: connectable[0]!.label }) }}
|
|
131
133
|
</UButton>
|
|
132
134
|
</template>
|
|
133
135
|
|
|
@@ -8,15 +8,18 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Two states this deliberately renders rather than hides: a board whose ranking was
|
|
10
10
|
// unavailable or failed still shows its candidates (flagged as unassessed, since the scan is
|
|
11
|
-
// useful on its own), and a scan that hit its cap says so
|
|
12
|
-
// exactly like an exhaustive one.
|
|
11
|
+
// useful on its own), and a scan that hit its cap says so, because a silently shortened list
|
|
12
|
+
// reads exactly like an exhaustive one.
|
|
13
13
|
//
|
|
14
14
|
// The tracker selector doubles as the "add a tracker" affordance (the same two-tier menu
|
|
15
|
-
// `<ContextIssuePicker>` renders, off the shared `buildSourceChoices`): a hunt
|
|
16
|
-
// place to find out the tracker holding the bugs isn't connected here yet, and the
|
|
17
|
-
// has to be a route to that tracker's own connect screen rather than "go find the
|
|
15
|
+
// `<ContextIssuePicker>` renders, off the shared `buildSourceChoices` + `sourceMenuItems`): a hunt
|
|
16
|
+
// is a common place to find out the tracker holding the bugs isn't connected here yet, and the
|
|
17
|
+
// answer has to be a route to that tracker's own connect screen rather than "go find the
|
|
18
18
|
// Integrations hub". The connect modal opens OVER the hunt, so nothing typed here is lost.
|
|
19
|
-
|
|
19
|
+
//
|
|
20
|
+
// Where an adopted bug lands comes from `useContainerTargets`, shared with `<TaskImportModal>`:
|
|
21
|
+
// both are opened from the same frame-header buttons with the same payload, so the frame either
|
|
22
|
+
// answers "which service" for both or for neither.
|
|
20
23
|
import type { TaskSourceReadReason } from '@cat-factory/contracts'
|
|
21
24
|
import type {
|
|
22
25
|
BugHuntAnalysisStatus,
|
|
@@ -24,13 +27,19 @@ import type {
|
|
|
24
27
|
BugHuntConfidence,
|
|
25
28
|
TaskSourceKind,
|
|
26
29
|
} from '~/types/domain'
|
|
27
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
type AddSourceLabels,
|
|
32
|
+
addChoicesOf,
|
|
33
|
+
buildSourceChoices,
|
|
34
|
+
menuIsPickable,
|
|
35
|
+
reconcileSource,
|
|
36
|
+
sourceMenuItems,
|
|
37
|
+
} from '~/utils/sourcePicker'
|
|
28
38
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
29
39
|
|
|
30
40
|
const { t, d, n } = useI18n()
|
|
31
41
|
const ui = useUiStore()
|
|
32
42
|
const tasks = useTasksStore()
|
|
33
|
-
const board = useBoardStore()
|
|
34
43
|
const hunt = useBugHuntStore()
|
|
35
44
|
const toast = useToast()
|
|
36
45
|
|
|
@@ -46,61 +55,44 @@ const source = ref<TaskSourceKind | undefined>(undefined)
|
|
|
46
55
|
const boardId = ref('')
|
|
47
56
|
const issueType = ref('')
|
|
48
57
|
const labels = ref('')
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
b.level === 'module'
|
|
59
|
-
? `${board.getBlock(b.parentId ?? '')?.title ?? '?'} › ${b.title}`
|
|
60
|
-
: b.title,
|
|
61
|
-
value: b.id,
|
|
62
|
-
})),
|
|
63
|
-
)
|
|
58
|
+
// Where an adopted bug lands. Stated rather than asked when the frame the hunt was opened from is
|
|
59
|
+
// the only legal target; re-resolved through the board, so a frame deleted mid-hunt widens back.
|
|
60
|
+
const {
|
|
61
|
+
pinned: pinnedContainer,
|
|
62
|
+
items: containerItems,
|
|
63
|
+
containerId,
|
|
64
|
+
stated: containerStated,
|
|
65
|
+
reset: resetContainer,
|
|
66
|
+
} = useContainerTargets(() => ui.bugHunt?.containerId)
|
|
64
67
|
|
|
65
68
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
66
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Wording for an addable tracker, as an exhaustive map over the add actions a TRACKER menu can
|
|
72
|
+
* carry: `enable` is connected but toggled off for this workspace, so the user is never told to
|
|
73
|
+
* "connect" something they already connected.
|
|
74
|
+
*/
|
|
75
|
+
const ADD_LABEL: AddSourceLabels<'connect' | 'enable'> = {
|
|
76
|
+
connect: (label) => t('bugHunt.connectSource', { label }),
|
|
77
|
+
enable: (label) => t('bugHunt.enableSource', { label }),
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
// Two-tier tracker menu: pick one the workspace already offers, or add one it doesn't.
|
|
68
81
|
const sourceChoices = computed(() => buildSourceChoices(tasks.sources, source.value))
|
|
69
|
-
const sourceMenu = computed
|
|
70
|
-
sourceChoices.value
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
onSelect: () => {
|
|
78
|
-
source.value = choice.source
|
|
79
|
-
},
|
|
80
|
-
}
|
|
81
|
-
: {
|
|
82
|
-
label: addLabel(choice),
|
|
83
|
-
icon: 'i-lucide-plug',
|
|
84
|
-
onSelect: () => addSource(choice.source),
|
|
85
|
-
},
|
|
86
|
-
),
|
|
87
|
-
),
|
|
82
|
+
const sourceMenu = computed(() =>
|
|
83
|
+
sourceMenuItems(sourceChoices.value, {
|
|
84
|
+
onSelect: (s) => {
|
|
85
|
+
source.value = s
|
|
86
|
+
},
|
|
87
|
+
onAdd: addSource,
|
|
88
|
+
addLabel: ADD_LABEL,
|
|
89
|
+
}),
|
|
88
90
|
)
|
|
91
|
+
/** One entry decides nothing, so the tracker is named as a label rather than as a dead control. */
|
|
92
|
+
const sourcePickable = computed(() => menuIsPickable(sourceChoices.value))
|
|
89
93
|
|
|
90
|
-
/** The trackers that can be added
|
|
91
|
-
const addableSources = computed(() =>
|
|
92
|
-
sourceChoices.value.flat().filter((c) => c.action !== 'select'),
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Wording for an addable tracker: `enable` is connected but toggled off for this workspace,
|
|
97
|
-
* so the user is never told to "connect" something they already connected.
|
|
98
|
-
*/
|
|
99
|
-
function addLabel(choice: SourceChoice): string {
|
|
100
|
-
return choice.action === 'enable'
|
|
101
|
-
? t('bugHunt.enableSource', { label: choice.label })
|
|
102
|
-
: t('bugHunt.connectSource', { label: choice.label })
|
|
103
|
-
}
|
|
94
|
+
/** The trackers that can be added: the empty state's buttons, where none is offered yet. */
|
|
95
|
+
const addableSources = computed(() => addChoicesOf(sourceChoices.value))
|
|
104
96
|
|
|
105
97
|
/**
|
|
106
98
|
* The tracker the user left to add, so it becomes the selection the moment it turns up
|
|
@@ -166,11 +158,11 @@ watch(open, (isOpen) => {
|
|
|
166
158
|
labels.value = ''
|
|
167
159
|
awaitingConnect.value = null
|
|
168
160
|
source.value = ui.bugHunt?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
169
|
-
|
|
161
|
+
resetContainer()
|
|
170
162
|
if (source.value) hunt.loadBoards(source.value)
|
|
171
163
|
})
|
|
172
164
|
|
|
173
|
-
// Switching tracker invalidates both the board list and any ranking already on screen
|
|
165
|
+
// Switching tracker invalidates both the board list and any ranking already on screen: the
|
|
174
166
|
// candidates belong to the previous tracker's board and would otherwise sit there looking current.
|
|
175
167
|
watch(source, (next) => {
|
|
176
168
|
boardId.value = ''
|
|
@@ -267,7 +259,7 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
267
259
|
:icon="choice.icon"
|
|
268
260
|
@click="addSource(choice.source)"
|
|
269
261
|
>
|
|
270
|
-
{{
|
|
262
|
+
{{ ADD_LABEL[choice.action](choice.label) }}
|
|
271
263
|
</UButton>
|
|
272
264
|
</div>
|
|
273
265
|
</div>
|
|
@@ -284,8 +276,11 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
284
276
|
<UFormField :label="t('bugHunt.tracker')">
|
|
285
277
|
<!-- The selector is also the way to ADD a tracker: each entry in the second group
|
|
286
278
|
opens that tracker's own connect screen over this modal, so the hunt (and
|
|
287
|
-
anything typed into it) is still here when the user comes back.
|
|
279
|
+
anything typed into it) is still here when the user comes back. With a single
|
|
280
|
+
entry there is nothing to decide, so the tracker is named as plain text instead:
|
|
281
|
+
a chevron that opens a one-item menu promises a choice that isn't there. -->
|
|
288
282
|
<UDropdownMenu
|
|
283
|
+
v-if="sourcePickable"
|
|
289
284
|
:items="sourceMenu"
|
|
290
285
|
:content="{ side: 'bottom', align: 'start' }"
|
|
291
286
|
class="w-full"
|
|
@@ -300,6 +295,10 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
300
295
|
<span class="truncate">{{ descriptor?.label ?? t('bugHunt.pickTracker') }}</span>
|
|
301
296
|
</UButton>
|
|
302
297
|
</UDropdownMenu>
|
|
298
|
+
<p v-else class="flex items-center gap-1.5 py-1 text-sm text-slate-300">
|
|
299
|
+
<UIcon v-if="descriptor?.icon" :name="descriptor.icon" class="h-4 w-4 shrink-0" />
|
|
300
|
+
<span class="truncate">{{ descriptor?.label ?? t('bugHunt.pickTracker') }}</span>
|
|
301
|
+
</p>
|
|
303
302
|
</UFormField>
|
|
304
303
|
|
|
305
304
|
<UFormField :label="t('bugHunt.board')">
|
|
@@ -335,7 +334,17 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
335
334
|
</UFormField>
|
|
336
335
|
</div>
|
|
337
336
|
|
|
338
|
-
|
|
337
|
+
<!-- Where an adopted bug lands. Stated when the frame this hunt was opened from is the
|
|
338
|
+
only legal target; a choice (scoped to that frame) when it has modules, or over the
|
|
339
|
+
whole board when the hunt was opened standalone. -->
|
|
340
|
+
<p v-if="containerStated" class="text-xs text-slate-400">
|
|
341
|
+
<i18n-t keypath="bugHunt.adoptingInto" tag="span" scope="global">
|
|
342
|
+
<template #container>
|
|
343
|
+
<span class="font-medium text-slate-200">{{ pinnedContainer!.title }}</span>
|
|
344
|
+
</template>
|
|
345
|
+
</i18n-t>
|
|
346
|
+
</p>
|
|
347
|
+
<UFormField v-else :label="t('bugHunt.adoptInto')">
|
|
339
348
|
<USelect v-model="containerId" :items="containerItems" class="w-full" />
|
|
340
349
|
</UFormField>
|
|
341
350
|
|
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
// exactly one), and its menu doubles as the "add a tracker" affordance: attaching a
|
|
17
17
|
// context issue is where a missing integration is discovered, and the connect modal
|
|
18
18
|
// opens over the caller's form rather than navigating away from it.
|
|
19
|
-
import
|
|
19
|
+
import { useId } from 'vue'
|
|
20
20
|
import type { TaskSourceReadReason } from '@cat-factory/contracts'
|
|
21
21
|
import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
22
22
|
import { apiErrorReason } from '~/composables/api/errors'
|
|
23
23
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
type AddSourceLabels,
|
|
26
|
+
buildSourceChoices,
|
|
27
|
+
menuIsPickable,
|
|
28
|
+
reconcileSource,
|
|
29
|
+
sourceMenuItems,
|
|
30
|
+
} from '~/utils/sourcePicker'
|
|
25
31
|
|
|
26
32
|
const props = defineProps<{
|
|
27
33
|
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
@@ -83,30 +89,32 @@ watch(
|
|
|
83
89
|
},
|
|
84
90
|
)
|
|
85
91
|
|
|
92
|
+
/** Wording per add action, exhaustive over what a TRACKER menu can carry. */
|
|
93
|
+
const ADD_LABEL: AddSourceLabels<'connect' | 'enable'> = {
|
|
94
|
+
connect: (label) => t('tasks.picker.connectSource', { label }),
|
|
95
|
+
enable: (label) => t('tasks.picker.enableSource', { label }),
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
// Two-tier menu: pick an offered tracker, or add one that isn't offered yet.
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
source.value = choice.source
|
|
97
|
-
},
|
|
98
|
-
}
|
|
99
|
-
: {
|
|
100
|
-
label:
|
|
101
|
-
choice.action === 'enable'
|
|
102
|
-
? t('tasks.picker.enableSource', { label: choice.label })
|
|
103
|
-
: t('tasks.picker.connectSource', { label: choice.label }),
|
|
104
|
-
icon: 'i-lucide-plug',
|
|
105
|
-
onSelect: () => addSource(choice.source),
|
|
106
|
-
},
|
|
107
|
-
),
|
|
108
|
-
),
|
|
99
|
+
const sourceChoices = computed(() => buildSourceChoices(tasks.sources, source.value))
|
|
100
|
+
const sourceMenu = computed(() =>
|
|
101
|
+
sourceMenuItems(sourceChoices.value, {
|
|
102
|
+
onSelect: (s) => {
|
|
103
|
+
source.value = s
|
|
104
|
+
},
|
|
105
|
+
onAdd: addSource,
|
|
106
|
+
addLabel: ADD_LABEL,
|
|
107
|
+
}),
|
|
109
108
|
)
|
|
109
|
+
/** One entry decides nothing, so the tracker is named as a label rather than as a dead control. */
|
|
110
|
+
const sourcePickable = computed(() => menuIsPickable(sourceChoices.value))
|
|
111
|
+
|
|
112
|
+
// The trigger's own content is the tracker NAME, which says nothing about what the name means, so
|
|
113
|
+
// the visible "Source" caption is joined to it as the accessible name ("Source GitHub"). Per
|
|
114
|
+
// instance, because this picker and the import modal's copy can be mounted at the same time and a
|
|
115
|
+
// hard-coded id would make one trigger claim the other's caption.
|
|
116
|
+
const sourceLabelId = useId()
|
|
117
|
+
const sourceTriggerId = useId()
|
|
110
118
|
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
111
119
|
|
|
112
120
|
const query = ref('')
|
|
@@ -277,24 +285,40 @@ onMounted(() => {
|
|
|
277
285
|
|
|
278
286
|
<template>
|
|
279
287
|
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
|
|
280
|
-
<!-- Which tracker is being searched, always visible, plus the trackers the user
|
|
281
|
-
|
|
288
|
+
<!-- Which tracker is being searched, always visible, plus the trackers the user could add from
|
|
289
|
+
here (each opens the connect modal over the caller's form). With a single entry there is
|
|
290
|
+
nothing to decide, so the tracker is named as plain text: a chevron opening a one-item menu
|
|
291
|
+
promises a choice that isn't there. `id` labels the trigger, whose own content is the
|
|
292
|
+
tracker name rather than what that name means. -->
|
|
282
293
|
<div class="flex items-center gap-1.5">
|
|
283
|
-
<span
|
|
294
|
+
<span
|
|
295
|
+
:id="sourceLabelId"
|
|
296
|
+
class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
|
|
297
|
+
>
|
|
284
298
|
{{ t('tasks.picker.sourceLabel') }}
|
|
285
299
|
</span>
|
|
286
|
-
<UDropdownMenu
|
|
300
|
+
<UDropdownMenu
|
|
301
|
+
v-if="sourcePickable"
|
|
302
|
+
:items="sourceMenu"
|
|
303
|
+
:content="{ side: 'bottom', align: 'start' }"
|
|
304
|
+
>
|
|
287
305
|
<UButton
|
|
306
|
+
:id="sourceTriggerId"
|
|
288
307
|
color="neutral"
|
|
289
308
|
variant="soft"
|
|
290
309
|
size="xs"
|
|
291
310
|
:icon="icon"
|
|
292
311
|
trailing-icon="i-lucide-chevron-down"
|
|
293
312
|
class="max-w-full"
|
|
313
|
+
:aria-labelledby="`${sourceLabelId} ${sourceTriggerId}`"
|
|
294
314
|
>
|
|
295
315
|
<span class="truncate">{{ descriptor?.label ?? t('tasks.picker.noSource') }}</span>
|
|
296
316
|
</UButton>
|
|
297
317
|
</UDropdownMenu>
|
|
318
|
+
<span v-else class="flex min-w-0 items-center gap-1 text-xs text-slate-300">
|
|
319
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0" />
|
|
320
|
+
<span class="truncate">{{ descriptor?.label ?? t('tasks.picker.noSource') }}</span>
|
|
321
|
+
</span>
|
|
298
322
|
</div>
|
|
299
323
|
|
|
300
324
|
<UInput
|