@cat-factory/app 0.237.0 → 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/app/components/board/nodes/BlockNode.vue +33 -152
- 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/stores/ui/navigation.ts +8 -31
- package/app/utils/containerTargets.ts +71 -0
- 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 +1 -1
- package/app/utils/taskSources.spec.ts +0 -100
- package/app/utils/taskSources.ts +0 -76
|
@@ -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
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Create a board task from a connected tracker's issue.
|
|
3
|
-
//
|
|
4
|
-
// title, pick an already-imported one, or paste a URL/key) — choosing one opens the
|
|
2
|
+
// Create a board task from a connected tracker's issue. Use the inline picker to find an issue
|
|
3
|
+
// (search by title, pick an already-imported one, or paste a URL/key): choosing one opens the
|
|
5
4
|
// prefilled add-task form (title seeded, issue staged as linked context) where the
|
|
6
5
|
// user confirms the pipeline / presets before it's created. This is the same picker
|
|
7
6
|
// the add-task form uses for "context issues", so the two behave identically. A
|
|
8
7
|
// pasted parent/epic reference can instead be spawned as a whole linked task group.
|
|
8
|
+
//
|
|
9
|
+
// Where the task lands depends on how the modal was opened, and `useContainerTargets` is the shared
|
|
10
|
+
// answer (`<BugHuntModal>` is the same question from the same frame header). From a service frame's
|
|
11
|
+
// own "create task from issue" button that frame settles the SERVICE, so the modal states it rather
|
|
12
|
+
// than asking; a frame with modules still asks frame-or-which-module, scoped to that frame, because
|
|
13
|
+
// the button never answered that half. Opened standalone (the command bar / the Integrations hub)
|
|
14
|
+
// there is no frame behind it and every container on the board is a candidate.
|
|
9
15
|
import type { TaskSourceKind } from '~/types/domain'
|
|
10
16
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
17
|
+
import { type AddSourceLabels, addChoicesOf, buildSourceChoices } from '~/utils/sourcePicker'
|
|
11
18
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
12
19
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
13
20
|
|
|
14
21
|
const { t } = useI18n()
|
|
15
22
|
const ui = useUiStore()
|
|
16
23
|
const tasks = useTasksStore()
|
|
17
|
-
const board = useBoardStore()
|
|
18
24
|
const toast = useToast()
|
|
19
25
|
|
|
20
26
|
const open = computed({
|
|
@@ -32,37 +38,42 @@ const source = ref<TaskSourceKind | undefined>(undefined)
|
|
|
32
38
|
const ref_ = ref('')
|
|
33
39
|
const importing = ref(false)
|
|
34
40
|
|
|
35
|
-
// When opened from a service frame the modal is the "create a task from an issue"
|
|
36
|
-
// surface; opened standalone it's the general tracker-issue browser/importer.
|
|
37
|
-
const title = computed(() =>
|
|
38
|
-
ui.taskImport?.containerId ? t('tasks.import.titleCreate') : t('tasks.import.titleBrowse'),
|
|
39
|
-
)
|
|
40
|
-
|
|
41
41
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The trackers the "nothing connected yet" state offers, worded per add action off the shared
|
|
45
|
+
* builder rather than re-deciding `available ? enable : connect` here. `enable` is connected but
|
|
46
|
+
* toggled off for this workspace, so the user is never told to connect what they already have.
|
|
47
|
+
*/
|
|
48
|
+
const ADD_LABEL: AddSourceLabels<'connect' | 'enable'> = {
|
|
49
|
+
connect: (label) => t('tasks.import.connectSource', { label }),
|
|
50
|
+
enable: (label) => t('tasks.import.enableSource', { label }),
|
|
51
|
+
}
|
|
52
|
+
const addableSources = computed(() => addChoicesOf(buildSourceChoices(tasks.sources, source.value)))
|
|
45
53
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// Where the new task lands. `pinned` is re-resolved through the board on every read, so a frame
|
|
55
|
+
// deleted while the modal sat open widens back to the whole board AND drops the selection that
|
|
56
|
+
// pointed at it (`useContainerTargets`).
|
|
57
|
+
const {
|
|
58
|
+
pinned: pinnedContainer,
|
|
59
|
+
items: containerItems,
|
|
60
|
+
containerId,
|
|
61
|
+
stated: containerStated,
|
|
62
|
+
reset: resetContainer,
|
|
63
|
+
} = useContainerTargets(() => ui.taskImport?.containerId)
|
|
64
|
+
|
|
65
|
+
// Which surface this is. Derived from the RESOLVED frame rather than the id the modal was opened
|
|
66
|
+
// with, so the title cannot claim the frame-scoped surface while the body renders the standalone
|
|
67
|
+
// browser: they answer "was this opened from a frame" from one source.
|
|
68
|
+
const title = computed(() =>
|
|
69
|
+
pinnedContainer.value ? t('tasks.import.titleCreate') : t('tasks.import.titleBrowse'),
|
|
58
70
|
)
|
|
71
|
+
|
|
59
72
|
watch(open, (isOpen) => {
|
|
60
73
|
if (isOpen) {
|
|
61
74
|
ref_.value = ''
|
|
62
75
|
source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
63
|
-
|
|
64
|
-
// fall back to the first container on the board.
|
|
65
|
-
containerId.value = ui.taskImport?.containerId ?? containerItems.value[0]?.value
|
|
76
|
+
resetContainer()
|
|
66
77
|
tasks.loadTasks().catch(() => {})
|
|
67
78
|
}
|
|
68
79
|
})
|
|
@@ -125,18 +136,14 @@ async function doSpawnEpic() {
|
|
|
125
136
|
<p class="text-sm text-slate-400">{{ t('tasks.import.connectFirst') }}</p>
|
|
126
137
|
<div class="flex justify-center gap-2">
|
|
127
138
|
<UButton
|
|
128
|
-
v-for="
|
|
129
|
-
:key="
|
|
139
|
+
v-for="choice in addableSources"
|
|
140
|
+
:key="choice.source"
|
|
130
141
|
color="primary"
|
|
131
142
|
variant="soft"
|
|
132
|
-
:icon="
|
|
133
|
-
@click="ui.openTaskConnect(
|
|
143
|
+
:icon="choice.icon"
|
|
144
|
+
@click="ui.openTaskConnect(choice.source)"
|
|
134
145
|
>
|
|
135
|
-
{{
|
|
136
|
-
s.available
|
|
137
|
-
? t('tasks.import.enableSource', { label: s.label })
|
|
138
|
-
: t('tasks.import.connectSource', { label: s.label })
|
|
139
|
-
}}
|
|
146
|
+
{{ ADD_LABEL[choice.action](choice.label) }}
|
|
140
147
|
</UButton>
|
|
141
148
|
</div>
|
|
142
149
|
</div>
|
|
@@ -148,8 +155,17 @@ async function doSpawnEpic() {
|
|
|
148
155
|
|
|
149
156
|
<!-- Main form -->
|
|
150
157
|
<div v-else class="space-y-4">
|
|
151
|
-
<!-- Where the new task lands
|
|
152
|
-
|
|
158
|
+
<!-- Where the new task lands. Stated when there is one legal target (opened from a service
|
|
159
|
+
frame that has no modules); otherwise a real choice, scoped to that frame when the
|
|
160
|
+
modal was opened from one. -->
|
|
161
|
+
<p v-if="containerStated" class="text-xs text-slate-400">
|
|
162
|
+
<i18n-t keypath="tasks.import.creatingIn" tag="span" scope="global">
|
|
163
|
+
<template #container>
|
|
164
|
+
<span class="font-medium text-slate-200">{{ pinnedContainer!.title }}</span>
|
|
165
|
+
</template>
|
|
166
|
+
</i18n-t>
|
|
167
|
+
</p>
|
|
168
|
+
<UFormField v-else :label="t('tasks.import.createTasksIn')">
|
|
153
169
|
<USelect
|
|
154
170
|
v-model="containerId"
|
|
155
171
|
:items="containerItems"
|
|
@@ -160,7 +176,7 @@ async function doSpawnEpic() {
|
|
|
160
176
|
|
|
161
177
|
<!-- Find an issue and create a task from it. Same picker the add-task form
|
|
162
178
|
uses for context issues: search by title, pick an already-imported one,
|
|
163
|
-
or paste a URL/key
|
|
179
|
+
or paste a URL/key, and choosing one opens the prefilled add-task form. The
|
|
164
180
|
search is scoped to the chosen container's repo (so a GitHub search stays
|
|
165
181
|
in that service's repo and a pasted URL / bare number resolves there). -->
|
|
166
182
|
<UFormField v-if="containerId" :label="t('tasks.import.searchIssues')">
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { nextTick, ref } from 'vue'
|
|
3
|
+
import type { Block } from '~/types/domain'
|
|
4
|
+
import { useBoardStore } from '~/stores/board'
|
|
5
|
+
import { useContainerTargets } from '~/composables/useContainerTargets'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the frame-header authoring surfaces create. Two properties matter and only one of them is
|
|
9
|
+
* about the happy path: the frame a surface was opened from narrows the choice to that service
|
|
10
|
+
* WITHOUT removing its modules as targets, and the answer follows a live board, because the frame
|
|
11
|
+
* can be deleted (by another member, over the socket) while the surface sits open.
|
|
12
|
+
*/
|
|
13
|
+
const block = (over: Partial<Block> & Pick<Block, 'id' | 'level' | 'title'>): Block =>
|
|
14
|
+
({ parentId: null, ...over }) as Block
|
|
15
|
+
|
|
16
|
+
/** A board with two services, one of which has modules, plus a task (never a container). */
|
|
17
|
+
function seedBoard(): void {
|
|
18
|
+
useBoardStore().blocks = [
|
|
19
|
+
block({ id: 'f_auth', level: 'frame', title: 'Auth' }),
|
|
20
|
+
block({ id: 'm_login', level: 'module', title: 'Login', parentId: 'f_auth' }),
|
|
21
|
+
block({ id: 'm_tokens', level: 'module', title: 'Tokens', parentId: 'f_auth' }),
|
|
22
|
+
block({ id: 'f_billing', level: 'frame', title: 'Billing' }),
|
|
23
|
+
block({ id: 't_1', level: 'task', title: 'A task', parentId: 'f_billing' }),
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('useContainerTargets', () => {
|
|
28
|
+
it('scopes to the opening frame and its modules, keeping the module as a target', () => {
|
|
29
|
+
seedBoard()
|
|
30
|
+
const { items, containerId, stated, pinned, reset } = useContainerTargets(() => 'f_auth')
|
|
31
|
+
reset()
|
|
32
|
+
// The button that opened this names the service, so the modules need no parent prefix.
|
|
33
|
+
expect(items.value).toEqual([
|
|
34
|
+
{ label: 'Auth', value: 'f_auth' },
|
|
35
|
+
{ label: 'Login', value: 'm_login' },
|
|
36
|
+
{ label: 'Tokens', value: 'm_tokens' },
|
|
37
|
+
])
|
|
38
|
+
expect(pinned.value?.id).toBe('f_auth')
|
|
39
|
+
// A frame with modules did NOT answer frame-or-which-module, so the picker still asks,
|
|
40
|
+
// preselected to the frame itself.
|
|
41
|
+
expect(stated.value).toBe(false)
|
|
42
|
+
expect(containerId.value).toBe('f_auth')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('states the target rather than asking when the opening frame has no modules', () => {
|
|
46
|
+
seedBoard()
|
|
47
|
+
const { items, containerId, stated, reset } = useContainerTargets(() => 'f_billing')
|
|
48
|
+
reset()
|
|
49
|
+
expect(items.value).toEqual([{ label: 'Billing', value: 'f_billing' }])
|
|
50
|
+
expect(stated.value).toBe(true)
|
|
51
|
+
expect(containerId.value).toBe('f_billing')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('offers every container on the board, parent-labelled, when opened standalone', () => {
|
|
55
|
+
seedBoard()
|
|
56
|
+
const { items, stated, reset } = useContainerTargets(() => null)
|
|
57
|
+
reset()
|
|
58
|
+
expect(items.value).toEqual([
|
|
59
|
+
{ label: 'Auth', value: 'f_auth' },
|
|
60
|
+
{ label: 'Auth › Login', value: 'm_login' },
|
|
61
|
+
{ label: 'Auth › Tokens', value: 'm_tokens' },
|
|
62
|
+
{ label: 'Billing', value: 'f_billing' },
|
|
63
|
+
])
|
|
64
|
+
expect(stated.value).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// The finding this composable exists for: an id is not evidence the block is still there, so the
|
|
68
|
+
// surface widens back to the whole board AND re-derives its selection. Leaving the selection
|
|
69
|
+
// behind is the silent half: the picker renders unselected while the search under it stays scoped
|
|
70
|
+
// to the deleted frame and the create lands on an id the board no longer has.
|
|
71
|
+
it('widens and re-selects when the opening frame is deleted underneath it', async () => {
|
|
72
|
+
seedBoard()
|
|
73
|
+
const openedFrom = ref<string | null>('f_auth')
|
|
74
|
+
const { items, containerId, stated, pinned } = useContainerTargets(() => openedFrom.value)
|
|
75
|
+
containerId.value = 'm_login'
|
|
76
|
+
|
|
77
|
+
const board = useBoardStore()
|
|
78
|
+
board.blocks = board.blocks.filter((b) => !['f_auth', 'm_login', 'm_tokens'].includes(b.id))
|
|
79
|
+
await nextTick()
|
|
80
|
+
|
|
81
|
+
expect(pinned.value).toBeUndefined()
|
|
82
|
+
expect(stated.value).toBe(false)
|
|
83
|
+
expect(items.value).toEqual([{ label: 'Billing', value: 'f_billing' }])
|
|
84
|
+
expect(containerId.value).toBe('f_billing')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('keeps a still-legal selection when a sibling module is added', async () => {
|
|
88
|
+
seedBoard()
|
|
89
|
+
const { containerId } = useContainerTargets(() => 'f_auth')
|
|
90
|
+
containerId.value = 'm_login'
|
|
91
|
+
|
|
92
|
+
const board = useBoardStore()
|
|
93
|
+
board.blocks = [
|
|
94
|
+
...board.blocks,
|
|
95
|
+
block({ id: 'm_sessions', level: 'module', title: 'Sessions', parentId: 'f_auth' }),
|
|
96
|
+
]
|
|
97
|
+
await nextTick()
|
|
98
|
+
|
|
99
|
+
expect(containerId.value).toBe('m_login')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// A block id that resolves to something no task can live in is the same fact as an id that
|
|
103
|
+
// resolves to nothing: the surface has no frame behind it and must not pin to one.
|
|
104
|
+
it('ignores an opening id that is not a legal container', () => {
|
|
105
|
+
seedBoard()
|
|
106
|
+
const { pinned, items, reset, containerId } = useContainerTargets(() => 't_1')
|
|
107
|
+
reset()
|
|
108
|
+
expect(pinned.value).toBeUndefined()
|
|
109
|
+
expect(items.value).toHaveLength(4)
|
|
110
|
+
expect(containerId.value).toBe('f_auth')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { computed, ref, watch, type Ref } from 'vue'
|
|
2
|
+
import type { Block } from '~/types/domain'
|
|
3
|
+
import { useBoardStore } from '~/stores/board'
|
|
4
|
+
import {
|
|
5
|
+
containerTargets,
|
|
6
|
+
isTaskContainer,
|
|
7
|
+
reconcileContainer,
|
|
8
|
+
type ContainerTarget,
|
|
9
|
+
} from '~/utils/containerTargets'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where a board-authoring surface creates, tracking a live board.
|
|
13
|
+
*
|
|
14
|
+
* `openedFrom` is the frame id the surface was opened WITH, and is resolved through the board on
|
|
15
|
+
* every read rather than trusted: an id alone cannot say whether the block still exists or was ever
|
|
16
|
+
* a legal container. When it resolves and the frame holds no modules there is exactly one answer,
|
|
17
|
+
* so `stated` is true and the caller renders a line naming it instead of a picker asking a question
|
|
18
|
+
* the header button already answered. A frame WITH modules did not answer it, so the picker stays,
|
|
19
|
+
* scoped to that frame.
|
|
20
|
+
*
|
|
21
|
+
* Shared by `<TaskImportModal>` and `<BugHuntModal>`, which are opened from the same two frame
|
|
22
|
+
* header buttons with the same payload: as two copies they disagreed about whether the frame was
|
|
23
|
+
* the answer or the question.
|
|
24
|
+
*/
|
|
25
|
+
export function useContainerTargets(openedFrom: () => string | null | undefined): {
|
|
26
|
+
/** The frame or module the surface was opened from, while the board still holds it. */
|
|
27
|
+
pinned: Ref<Block | undefined>
|
|
28
|
+
items: Ref<ContainerTarget[]>
|
|
29
|
+
containerId: Ref<string | undefined>
|
|
30
|
+
/** One legal target, so the surface states where the work lands rather than asking. */
|
|
31
|
+
stated: Ref<boolean>
|
|
32
|
+
/** Re-seed the selection; the caller invokes this when the surface opens. */
|
|
33
|
+
reset: () => void
|
|
34
|
+
} {
|
|
35
|
+
const board = useBoardStore()
|
|
36
|
+
|
|
37
|
+
const pinned = computed<Block | undefined>(() => {
|
|
38
|
+
const id = openedFrom()
|
|
39
|
+
const block = id ? board.getBlock(id) : undefined
|
|
40
|
+
return isTaskContainer(block) ? block : undefined
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const items = computed(() => containerTargets(board.blocks, pinned.value))
|
|
44
|
+
|
|
45
|
+
const containerId = ref<string | undefined>(undefined)
|
|
46
|
+
|
|
47
|
+
function reset() {
|
|
48
|
+
containerId.value = reconcileContainer(items.value, pinned.value?.id)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The board is live, so what is on offer moves under the open surface: a deleted frame, a new
|
|
52
|
+
// module. Watching the TARGETS rather than seeding once is what keeps the selection legal, and
|
|
53
|
+
// it is the reason the pinned frame is re-resolved on every read instead of captured on open.
|
|
54
|
+
watch(items, (next) => {
|
|
55
|
+
const resolved = reconcileContainer(next, containerId.value)
|
|
56
|
+
if (resolved !== containerId.value) containerId.value = resolved
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const stated = computed(() => !!pinned.value && items.value.length === 1)
|
|
60
|
+
|
|
61
|
+
return { pinned, items, containerId, stated, reset }
|
|
62
|
+
}
|