@cat-factory/app 0.236.1 → 0.237.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/app/components/board/nodes/BlockNode.vue +33 -152
- package/app/components/common/DescriptorFields.vue +102 -75
- package/app/components/context/ContextAttachmentFields.vue +9 -7
- package/app/components/documents/ContextDocumentPicker.vue +130 -12
- package/app/components/documents/TaskContextDocs.vue +18 -16
- package/app/components/tasks/BugHuntModal.vue +70 -61
- package/app/components/tasks/ContextIssuePicker.vue +52 -28
- package/app/components/tasks/TaskImportModal.vue +55 -39
- package/app/composables/useContainerTargets.spec.ts +112 -0
- package/app/composables/useContainerTargets.ts +62 -0
- package/app/docs/consumer-extensions.md +9 -5
- package/app/stores/ui/navigation.ts +8 -31
- package/app/utils/containerTargets.ts +71 -0
- package/app/utils/descriptorFields.spec.ts +68 -0
- package/app/utils/descriptorFields.ts +40 -1
- package/app/utils/sourcePicker.spec.ts +258 -0
- package/app/utils/sourcePicker.ts +251 -0
- package/i18n/locales/de.json +7 -6
- package/i18n/locales/en.json +7 -6
- package/i18n/locales/es.json +7 -6
- package/i18n/locales/fr.json +7 -6
- package/i18n/locales/he.json +7 -6
- package/i18n/locales/it.json +7 -6
- package/i18n/locales/ja.json +7 -6
- package/i18n/locales/pl.json +7 -6
- package/i18n/locales/tr.json +7 -6
- package/i18n/locales/uk.json +7 -6
- package/package.json +2 -2
- package/app/utils/taskSources.spec.ts +0 -100
- package/app/utils/taskSources.ts +0 -76
|
@@ -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
|
+
}
|
|
@@ -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 `
|
|
189
|
-
value reaches prompts and telemetry). Their values land in the
|
|
190
|
-
`taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is
|
|
191
|
-
server-side on create as well (required answers, option lists, lengths); a code-shipped
|
|
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
|
|
7
|
-
* level-of-detail
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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,
|