@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.
@@ -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
+ }
@@ -185,11 +185,15 @@ module). The SPA merges it into the create-task picker and the card-badge catalo
185
185
  `category` groups the picker (below).
186
186
  - **`fields`** are descriptor-driven create-form inputs over the shared descriptor-form vocabulary
187
187
  (`text` / `textarea` / `number` / `select` / `checkbox` / `checkbox-group` / `path`, with
188
- defaults and `showWhen` visibility; `password` is excluded by construction because a task field
189
- value reaches prompts and telemetry). Their values land in the task's sparse
190
- `taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is enforced
191
- server-side on create as well (required answers, option lists, lengths); a code-shipped one is
192
- known only to the SPA, so the create form is its only check (see the Validation note below).
188
+ defaults, `showWhen` visibility and a `section` grouping caption; `password` is excluded by
189
+ construction because a task field value reaches prompts and telemetry). Their values land in the
190
+ task's sparse `taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is
191
+ enforced server-side on create as well (required answers, option lists, lengths); a code-shipped
192
+ one is known only to the SPA, so the create form is its only check (see the Validation note below).
193
+ A `section` groups a long form into captioned runs and changes nothing else; declare a section's
194
+ fields consecutively, since a backend registration whose form could caption one twice fails boot
195
+ and a code-shipped one would simply render the caption twice. Interleaving a section with a
196
+ mutually exclusive `showWhen` branch is not that fault: only one half is ever on screen.
193
197
  - **`formPanel`** optionally names a bespoke create-form section component you contribute to the
194
198
  `taskTypeFormPanels` slot (paired by that id, like `resultViews`); shown INSTEAD of `fields`. An
195
199
  unpaired id degrades to the descriptor fields.
@@ -3,10 +3,14 @@ import type { LodLevel } from '~/types/domain'
3
3
  import { zoomToLod } from '~/composables/useSemanticZoom'
4
4
 
5
5
  /**
6
- * The board-navigation slice of the UI store: selection / focus, canvas zoom + the derived
7
- * level-of-detail, and the (retained) expanded-frame set. Hot paths (zoom/pan/select) live here,
8
- * isolated from the modal + result-view state, per refactoring candidate #4. Composed into
9
- * {@link useUiStore}; the returned refs/actions keep their names, so consumers are unchanged.
6
+ * The board-navigation slice of the UI store: selection / focus, and canvas zoom plus the derived
7
+ * level-of-detail. Hot paths (zoom/pan/select) live here, isolated from the modal + result-view
8
+ * state, per refactoring candidate #4. Composed into {@link useUiStore}; the returned refs/actions
9
+ * keep their names, so consumers are unchanged.
10
+ *
11
+ * There is deliberately no per-frame expanded/collapsed state: a service is always expanded to its
12
+ * task canvas, at every zoom level, so the board layout is fixed. The set that used to hold it
13
+ * outlived the last branch that read it and went with them.
10
14
  */
11
15
  export function createUiNavigation() {
12
16
  const selectedBlockId = ref<string | null>(null)
@@ -17,29 +21,6 @@ export function createUiNavigation() {
17
21
 
18
22
  const lod = computed<LodLevel>(() => zoomToLod(zoom.value))
19
23
 
20
- /** Frames the user has manually expanded to reveal their tasks. */
21
- const expandedFrames = ref<Set<string>>(new Set())
22
-
23
- function toggleFrame(id: string) {
24
- const next = new Set(expandedFrames.value)
25
- if (next.has(id)) next.delete(id)
26
- else next.add(id)
27
- expandedFrames.value = next
28
- }
29
-
30
- function expandFrame(id: string) {
31
- if (expandedFrames.value.has(id)) return
32
- expandedFrames.value = new Set(expandedFrames.value).add(id)
33
- }
34
-
35
- /** Services are always expanded to their task canvas, at every zoom level, so the
36
- * board layout is fixed: panning never changes it and zooming has no expand/collapse
37
- * transition to snap on. (`expandedFrames`/`toggleFrame` are retained for callers but
38
- * no longer gate rendering.) */
39
- function isFrameExpanded(_id: string) {
40
- return true
41
- }
42
-
43
24
  function select(id: string | null) {
44
25
  selectedBlockId.value = id
45
26
  }
@@ -53,10 +34,6 @@ export function createUiNavigation() {
53
34
  focusBlockId,
54
35
  zoom,
55
36
  lod,
56
- expandedFrames,
57
- toggleFrame,
58
- expandFrame,
59
- isFrameExpanded,
60
37
  select,
61
38
  focus,
62
39
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Where a board-authoring surface creates: the containers a new task may land in, and how that
3
+ * answer follows a live board.
4
+ *
5
+ * Pure so the reconcile below can be pinned by a unit test. `<TaskImportModal>` and
6
+ * `<BugHuntModal>` ask the same two-part question (a source to read, a container to land in) and
7
+ * are opened from the same frame-header buttons, so both read this through
8
+ * {@link useContainerTargets} rather than each carrying its own copy.
9
+ */
10
+ import type { Block } from '~/types/domain'
11
+
12
+ /** A container offered as a select item. */
13
+ export interface ContainerTarget {
14
+ label: string
15
+ value: string
16
+ }
17
+
18
+ /** Only a service frame or one of its modules can hold a task. */
19
+ export function isTaskContainer(block: Block | undefined): block is Block {
20
+ return !!block && (block.level === 'frame' || block.level === 'module')
21
+ }
22
+
23
+ /**
24
+ * The containers on offer, given the frame the surface was opened from (if any).
25
+ *
26
+ * Opened from a service frame the answer is SCOPED to that frame: the frame itself plus its
27
+ * modules, each labelled by its own title because the frame is already named on the surface. The
28
+ * frame settles WHICH SERVICE the work belongs to, which is the part a header button can answer;
29
+ * it does not settle frame-or-which-module, so a frame that has modules still owes the user a
30
+ * choice. Opened standalone there is no frame behind it, so every container on the board is a
31
+ * candidate and a module carries its parent's title to keep the choice unambiguous.
32
+ *
33
+ * `pinned` is resolved through the board by the caller rather than trusted as an id, so a frame
34
+ * deleted while the surface sat open widens back to the whole board instead of scoping to
35
+ * something nothing can be created in.
36
+ */
37
+ export function containerTargets(
38
+ blocks: readonly Block[],
39
+ pinned: Block | undefined,
40
+ ): ContainerTarget[] {
41
+ if (pinned) {
42
+ // Modules cannot nest, so a pinned module is already the only answer.
43
+ if (pinned.level === 'module') return [{ label: pinned.title, value: pinned.id }]
44
+ const modules = blocks.filter((b) => b.level === 'module' && b.parentId === pinned.id)
45
+ return [pinned, ...modules].map((b) => ({ label: b.title, value: b.id }))
46
+ }
47
+ const byId = new Map(blocks.map((b) => [b.id, b]))
48
+ return blocks.filter(isTaskContainer).map((b) => ({
49
+ label:
50
+ b.level === 'module' ? `${byId.get(b.parentId ?? '')?.title ?? '?'} › ${b.title}` : b.title,
51
+ value: b.id,
52
+ }))
53
+ }
54
+
55
+ /**
56
+ * The container a surface should hold as the board changes underneath it: the current selection
57
+ * while it is still on offer, else the first one that is.
58
+ *
59
+ * Needed because "where does this land" is answered once, when the surface opens, and the board is
60
+ * live: the frame it was opened from can be deleted, or a module added to it, while it sits there.
61
+ * A selection left pointing at a deleted block is the worst of the three states, because nothing
62
+ * looks wrong: the picker renders with nothing selected while the issue search under it is still
63
+ * scoped to the block that is gone, and the create lands on an id the board no longer has.
64
+ */
65
+ export function reconcileContainer(
66
+ targets: readonly ContainerTarget[],
67
+ selected: string | undefined,
68
+ ): string | undefined {
69
+ if (selected && targets.some((t) => t.value === selected)) return selected
70
+ return targets[0]?.value
71
+ }
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import type { DescriptorField } from '~/types/domain'
3
3
  import {
4
4
  defaultDescriptorValues,
5
+ descriptorFormRows,
5
6
  descriptorGroupValue,
6
7
  setDescriptorCheckbox,
7
8
  setDescriptorValue,
@@ -124,3 +125,70 @@ describe('toggleDescriptorGroupValue', () => {
124
125
  })
125
126
  })
126
127
  })
128
+
129
+ // The layout half of `section` grouping. What a caption SPANS is the shared contracts rule (tested
130
+ // there); what these assert is the property that rule's rendering has to preserve, and the reason the
131
+ // rows are flat at all: a field's identity survives a boundary move, so the keyed diff MOVES the
132
+ // live input instead of remounting it.
133
+ describe('descriptorFormRows', () => {
134
+ const keysOf = (rows: ReturnType<typeof descriptorFormRows>) => rows.map((r) => r.field.key)
135
+
136
+ it('carries each run caption on the field that OPENS it, once', () => {
137
+ const rows = descriptorFormRows(
138
+ [
139
+ field({ key: 'entity' }),
140
+ field({ key: 'style', section: 'Shape' }),
141
+ field({ key: 'verb', section: 'Shape' }),
142
+ field({ key: 'dir', type: 'path', section: 'Placement' }),
143
+ ],
144
+ {},
145
+ )
146
+ expect(rows.map((r) => [r.field.key, r.caption, r.startsGroup])).toEqual([
147
+ ['entity', undefined, false],
148
+ ['style', 'Shape', true],
149
+ ['verb', undefined, false],
150
+ ['dir', 'Placement', true],
151
+ ])
152
+ })
153
+
154
+ it('renders a sectionless form as the flat column, with no caption and no group gap', () => {
155
+ const rows = descriptorFormRows([field({ key: 'entity' }), field({ key: 'notes' })], {})
156
+ expect(rows.map((r) => [r.caption, r.startsGroup])).toEqual([
157
+ [undefined, false],
158
+ [undefined, false],
159
+ ])
160
+ expect(descriptorFormRows([], {})).toEqual([])
161
+ })
162
+
163
+ it('keeps a field key STABLE when a reveal moves it into another run', () => {
164
+ // The regression this shape exists for. `note` is unsectioned and gated, so revealing it splits
165
+ // the `Shape` run in two and re-captions `style`. Boot refuses THIS declaration (the split is
166
+ // reachable), and the renderer still has to be total over it, because a wire descriptor can
167
+ // arrive from a node whose build predates the refusal. Every field key present before is still
168
+ // present after, so Vue's keyed diff moves those nodes rather than unmounting them: typing into
169
+ // `advanced` (the trigger) cannot destroy the input being typed into.
170
+ const fields = [
171
+ field({ key: 'advanced', type: 'checkbox' }),
172
+ field({ key: 'verb', section: 'Shape' }),
173
+ field({ key: 'note', showWhen: { key: 'advanced', equals: true } }),
174
+ field({ key: 'style', section: 'Shape' }),
175
+ ]
176
+ expect(keysOf(descriptorFormRows(fields, {}))).toEqual(['advanced', 'verb', 'style'])
177
+
178
+ const revealed = descriptorFormRows(fields, { advanced: true })
179
+ expect(keysOf(revealed)).toEqual(['advanced', 'verb', 'note', 'style'])
180
+ // The caption moved (the second run needs its own) while the field identities did not.
181
+ expect(revealed.map((r) => r.caption)).toEqual([undefined, 'Shape', undefined, 'Shape'])
182
+ })
183
+
184
+ it('marks a later UNCAPTIONED run as opening a group too, so the gap is not caption-only', () => {
185
+ const rows = descriptorFormRows(
186
+ [field({ key: 'verb', section: 'Shape' }), field({ key: 'notes' })],
187
+ {},
188
+ )
189
+ expect(rows.map((r) => [r.field.key, r.startsGroup])).toEqual([
190
+ ['verb', false],
191
+ ['notes', true],
192
+ ])
193
+ })
194
+ })
@@ -1,4 +1,4 @@
1
- import { descriptorFieldDefaults } from '@cat-factory/contracts'
1
+ import { descriptorFieldDefaults, descriptorFieldSections } from '@cat-factory/contracts'
2
2
  import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
3
3
 
4
4
  // Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
@@ -75,6 +75,45 @@ export function descriptorGroupValue(values: DescriptorFieldValues, key: string)
75
75
  return Array.isArray(value) ? value : []
76
76
  }
77
77
 
78
+ /** One row of a rendered descriptor form: a field, plus the section chrome that precedes it. */
79
+ export interface DescriptorFormRow {
80
+ /** The field to render. Its `key` is the row's identity in the keyed diff. */
81
+ field: DescriptorField
82
+ /** The caption to print above this field, set only on the field that OPENS a captioned run. */
83
+ caption?: string
84
+ /** Whether this field opens a run with another run before it, i.e. needs the between-runs gap. */
85
+ startsGroup: boolean
86
+ }
87
+
88
+ /**
89
+ * A descriptor form reduced to a FLAT list of rows: the shared `descriptorFieldSections` grouping,
90
+ * with each run's caption carried on the field that opens it rather than on a wrapper around it.
91
+ *
92
+ * Flat is the whole point, and it is a correctness rule rather than a layout preference. Run
93
+ * membership is DERIVED state that changes as `showWhen` reveals and hides fields, while a field's
94
+ * identity does not. Rendering the runs as nested `v-for`s re-parents a field the moment a boundary
95
+ * moves, and Vue cannot move a node between two parents: it unmounts and remounts it. The field being
96
+ * remounted is typically the one being TYPED INTO, because typing into a `showWhen` trigger is what
97
+ * moved the boundary, so the input loses focus, caret and any in-flight IME composition mid-keystroke.
98
+ * Keeping every field a sibling under one parent, keyed by `field.key`, makes that a MOVE, which
99
+ * preserves the live input: the behaviour the flat column had before sections existed.
100
+ *
101
+ * Presentation, so it lives here rather than in contracts: what a caption spans is the shared rule,
102
+ * and this is only how the SPA lays that out.
103
+ */
104
+ export function descriptorFormRows(
105
+ fields: readonly DescriptorField[],
106
+ values: DescriptorFieldValues,
107
+ ): DescriptorFormRow[] {
108
+ return descriptorFieldSections(fields, values).flatMap((group, groupIndex) =>
109
+ group.fields.map((field, fieldIndex) => ({
110
+ field,
111
+ ...(fieldIndex === 0 && group.section !== undefined ? { caption: group.section } : {}),
112
+ startsGroup: fieldIndex === 0 && groupIndex > 0,
113
+ })),
114
+ )
115
+ }
116
+
78
117
  /** One option toggled on/off in a `checkbox-group` field's value (deduped, order-preserving). */
79
118
  export function toggleDescriptorGroupValue(
80
119
  values: DescriptorFieldValues,