@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.
@@ -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 a silently shortened list reads
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 is a common
16
- // place to find out the tracker holding the bugs isn't connected here yet, and the answer
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
- import type { DropdownMenuItem } from '@nuxt/ui'
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 { type SourceChoice, buildSourceChoices, reconcileSource } from '~/utils/taskSources'
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
- const containerId = ref<string | undefined>(undefined)
50
-
51
- // Containers an adopted bug can land in: every service frame and module on the board. Modules
52
- // are labelled with their parent frame so the choice is unambiguous (same as the import modal).
53
- const containerItems = computed(() =>
54
- board.blocks
55
- .filter((b) => b.level === 'frame' || b.level === 'module')
56
- .map((b) => ({
57
- label:
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<DropdownMenuItem[][]>(() =>
70
- sourceChoices.value.map((group) =>
71
- group.map((choice) =>
72
- choice.action === 'select'
73
- ? {
74
- label: choice.label,
75
- icon: choice.icon,
76
- trailingIcon: choice.active ? 'i-lucide-check' : undefined,
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 the empty state's buttons, where none is offered yet. */
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
- containerId.value = ui.bugHunt?.containerId ?? containerItems.value[0]?.value
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 the
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
- {{ addLabel(choice) }}
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
- <UFormField :label="t('bugHunt.adoptInto')">
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 type { DropdownMenuItem } from '@nuxt/ui'
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 { buildSourceChoices, reconcileSource } from '~/utils/taskSources'
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 sourceMenu = computed<DropdownMenuItem[][]>(() =>
88
- buildSourceChoices(tasks.sources, source.value).map((group) =>
89
- group.map((choice) =>
90
- choice.action === 'select'
91
- ? {
92
- label: choice.label,
93
- icon: choice.icon,
94
- trailingIcon: choice.active ? 'i-lucide-check' : undefined,
95
- onSelect: () => {
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
- could add from here (each opens the connect modal over the caller's form). -->
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 class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
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 :items="sourceMenu" :content="{ side: 'bottom', align: 'start' }">
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. Pick a container (service
3
- // frame or module), then use the inline picker below to find an issue (search by
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
- // The container (service frame or module) a new task is created in.
44
- const containerId = ref<string | undefined>(undefined)
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
- // Containers a new task can be created in: every service frame and module on the
47
- // board. Modules are labelled with their parent frame so the choice is unambiguous.
48
- const containerItems = computed(() =>
49
- board.blocks
50
- .filter((b) => b.level === 'frame' || b.level === 'module')
51
- .map((b) => ({
52
- label:
53
- b.level === 'module'
54
- ? `${board.getBlock(b.parentId ?? '')?.title ?? '?'} › ${b.title}`
55
- : b.title,
56
- value: b.id,
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
- // Opened from a service frame → preselect it as the create-in target; otherwise
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="s in tasks.sources"
129
- :key="s.source"
139
+ v-for="choice in addableSources"
140
+ :key="choice.source"
130
141
  color="primary"
131
142
  variant="soft"
132
- :icon="s.icon"
133
- @click="ui.openTaskConnect(s.source)"
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 (preselected when opened from a service frame). -->
152
- <UFormField :label="t('tasks.import.createTasksIn')">
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 choosing one opens the prefilled add-task form. The
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
+ }