@cat-factory/app 0.82.1 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/initiative/InitiativePlanningWindow.vue +189 -0
- package/app/components/panels/StepResultViewHost.vue +2 -0
- package/app/components/panels/inspector/InitiativeInspector.vue +21 -0
- package/app/composables/api/initiative.ts +29 -0
- package/app/composables/useBlockDeletion.ts +21 -5
- package/app/composables/useBlockQueries.ts +18 -0
- package/app/stores/board.spec.ts +196 -1
- package/app/stores/board.ts +156 -22
- package/app/stores/execution.ts +7 -3
- package/app/stores/initiative.ts +58 -1
- package/app/stores/ui.ts +6 -0
- package/app/utils/catalog.ts +19 -0
- package/i18n/locales/en.json +19 -3
- package/i18n/locales/es.json +19 -3
- package/i18n/locales/fr.json +19 -3
- package/i18n/locales/he.json +19 -3
- package/i18n/locales/ja.json +19 -3
- package/i18n/locales/pl.json +19 -3
- package/i18n/locales/tr.json +19 -3
- package/i18n/locales/uk.json +19 -3
- package/package.json +2 -2
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The interactive-planning Q&A window (slice 2) — the dedicated view of the initiative
|
|
3
|
+
// INTERVIEWER gate. While the planning run is parked, the interviewer's clarifying questions
|
|
4
|
+
// (pending `qa` entries with an empty answer) are shown here; the human answers them, then
|
|
5
|
+
// either CONTINUES (the interviewer re-runs and may ask follow-ups) or PROCEEDS (skip
|
|
6
|
+
// remaining questions — the interviewer converges and the run advances to the analyst/planner).
|
|
7
|
+
// Opened via the universal result-view host: from the inspector / card
|
|
8
|
+
// (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
|
|
9
|
+
// stream events patch the store, so an open window follows the interview as it progresses.
|
|
10
|
+
import { computed, reactive, watch } from 'vue'
|
|
11
|
+
import { INITIATIVE_STATUS_LABEL_KEYS } from '~/utils/initiative'
|
|
12
|
+
|
|
13
|
+
const board = useBoardStore()
|
|
14
|
+
const initiatives = useInitiativesStore()
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
|
|
17
|
+
const { open, blockId, close } = useResultView('initiative-planning', {
|
|
18
|
+
onOpen: (id) => void initiatives.load(id),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
22
|
+
const initiative = computed(() => (blockId.value ? initiatives.forBlock(blockId.value) : null))
|
|
23
|
+
|
|
24
|
+
/** Every interview exchange, with a stable key for the list + draft map. */
|
|
25
|
+
const questions = computed(() =>
|
|
26
|
+
(initiative.value?.qa ?? []).map((q, i) => ({ ...q, key: q.id ?? `q-${i}` })),
|
|
27
|
+
)
|
|
28
|
+
const pending = computed(() => questions.value.filter((q) => !(q.answer ?? '').trim()))
|
|
29
|
+
/** The interview converged (or never started with a model): nothing left to answer. */
|
|
30
|
+
const converged = computed(() => initiative.value?.interview?.status === 'done')
|
|
31
|
+
|
|
32
|
+
// Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive
|
|
33
|
+
// without clobbering an answer the human is mid-edit on.
|
|
34
|
+
const drafts = reactive<Record<string, string>>({})
|
|
35
|
+
watch(
|
|
36
|
+
questions,
|
|
37
|
+
(list) => {
|
|
38
|
+
for (const q of list) {
|
|
39
|
+
if (!(q.key in drafts)) drafts[q.key] = q.answer ?? ''
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{ immediate: true },
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const resuming = computed(() => initiatives.resuming)
|
|
46
|
+
/** Continue is meaningful once every pending question has a drafted answer. */
|
|
47
|
+
const allAnswered = computed(() => pending.value.every((q) => drafts[q.key]?.trim()))
|
|
48
|
+
|
|
49
|
+
/** Persist one answer if its draft differs from what's recorded. */
|
|
50
|
+
async function persist(q: { id?: string; key: string; answer?: string }) {
|
|
51
|
+
const id = q.id
|
|
52
|
+
if (!id || !blockId.value) return
|
|
53
|
+
const next = (drafts[q.key] ?? '').trim()
|
|
54
|
+
if (!next || next === (q.answer ?? '').trim()) return
|
|
55
|
+
await initiatives.answerQuestion(blockId.value, id, next)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Flush all dirty drafts, then run a window action (continue / proceed). */
|
|
59
|
+
async function flushThen(action: (id: string) => Promise<unknown>) {
|
|
60
|
+
if (!blockId.value) return
|
|
61
|
+
for (const q of questions.value) await persist(q)
|
|
62
|
+
await action(blockId.value)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const onContinue = () => flushThen((id) => initiatives.continuePlanning(id))
|
|
66
|
+
const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<Teleport to="body">
|
|
71
|
+
<div
|
|
72
|
+
v-if="open"
|
|
73
|
+
class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
|
|
74
|
+
@click.self="close"
|
|
75
|
+
>
|
|
76
|
+
<div
|
|
77
|
+
class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
78
|
+
role="dialog"
|
|
79
|
+
aria-modal="true"
|
|
80
|
+
data-testid="initiative-planning-window"
|
|
81
|
+
>
|
|
82
|
+
<!-- Header -->
|
|
83
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
84
|
+
<span
|
|
85
|
+
class="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300"
|
|
86
|
+
>
|
|
87
|
+
<UIcon name="i-lucide-messages-square" class="h-4 w-4" />
|
|
88
|
+
</span>
|
|
89
|
+
<div class="min-w-0 flex-1">
|
|
90
|
+
<h2 class="truncate text-sm font-semibold text-slate-100">
|
|
91
|
+
{{ initiative?.title ?? block?.title ?? t('initiative.planning.title') }}
|
|
92
|
+
</h2>
|
|
93
|
+
<p class="truncate text-[11px] text-slate-400">
|
|
94
|
+
{{ t('initiative.planning.subtitle') }}
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
<UBadge v-if="initiative" color="primary" variant="subtle" size="sm">
|
|
98
|
+
{{ t(INITIATIVE_STATUS_LABEL_KEYS[initiative.status]) }}
|
|
99
|
+
</UBadge>
|
|
100
|
+
<button
|
|
101
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
102
|
+
@click="close"
|
|
103
|
+
>
|
|
104
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
105
|
+
</button>
|
|
106
|
+
</header>
|
|
107
|
+
|
|
108
|
+
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
109
|
+
<!-- No entity yet -->
|
|
110
|
+
<div
|
|
111
|
+
v-if="!initiative"
|
|
112
|
+
class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
|
|
113
|
+
>
|
|
114
|
+
<UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
|
|
115
|
+
<p class="text-sm">{{ t('initiative.planning.empty') }}</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<template v-else>
|
|
119
|
+
<p class="mb-4 text-[13px] leading-relaxed text-slate-300">
|
|
120
|
+
{{ t('initiative.planning.intro') }}
|
|
121
|
+
</p>
|
|
122
|
+
|
|
123
|
+
<!-- Converged / no pending questions -->
|
|
124
|
+
<div
|
|
125
|
+
v-if="converged || questions.length === 0"
|
|
126
|
+
class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
|
|
127
|
+
data-testid="initiative-planning-converged"
|
|
128
|
+
>
|
|
129
|
+
{{ t('initiative.planning.converged') }}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Interview questions -->
|
|
133
|
+
<ul v-else class="space-y-4">
|
|
134
|
+
<li
|
|
135
|
+
v-for="q in questions"
|
|
136
|
+
:key="q.key"
|
|
137
|
+
class="rounded-lg border border-slate-800 bg-slate-950/40 p-3"
|
|
138
|
+
data-testid="initiative-planning-question"
|
|
139
|
+
>
|
|
140
|
+
<p class="mb-2 text-[13px] font-medium text-slate-200">{{ q.question }}</p>
|
|
141
|
+
<UTextarea
|
|
142
|
+
v-model="drafts[q.key]"
|
|
143
|
+
:rows="2"
|
|
144
|
+
autoresize
|
|
145
|
+
:placeholder="t('initiative.planning.answerPlaceholder')"
|
|
146
|
+
class="w-full"
|
|
147
|
+
data-testid="initiative-planning-answer"
|
|
148
|
+
@blur="persist(q)"
|
|
149
|
+
/>
|
|
150
|
+
</li>
|
|
151
|
+
</ul>
|
|
152
|
+
</template>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<!-- Action rail -->
|
|
156
|
+
<footer
|
|
157
|
+
v-if="initiative && !converged && questions.length > 0"
|
|
158
|
+
class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
|
|
159
|
+
>
|
|
160
|
+
<p class="text-[11px] text-slate-500">
|
|
161
|
+
{{ t('initiative.planning.hint') }}
|
|
162
|
+
</p>
|
|
163
|
+
<div class="flex items-center gap-2">
|
|
164
|
+
<UButton
|
|
165
|
+
color="neutral"
|
|
166
|
+
variant="ghost"
|
|
167
|
+
size="sm"
|
|
168
|
+
:loading="resuming"
|
|
169
|
+
data-testid="initiative-planning-proceed"
|
|
170
|
+
@click="onProceed"
|
|
171
|
+
>
|
|
172
|
+
{{ t('initiative.planning.proceed') }}
|
|
173
|
+
</UButton>
|
|
174
|
+
<UButton
|
|
175
|
+
color="primary"
|
|
176
|
+
size="sm"
|
|
177
|
+
:loading="resuming"
|
|
178
|
+
:disabled="!allAnswered"
|
|
179
|
+
data-testid="initiative-planning-continue"
|
|
180
|
+
@click="onContinue"
|
|
181
|
+
>
|
|
182
|
+
{{ t('initiative.planning.continue') }}
|
|
183
|
+
</UButton>
|
|
184
|
+
</div>
|
|
185
|
+
</footer>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</Teleport>
|
|
189
|
+
</template>
|
|
@@ -24,6 +24,7 @@ import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
|
|
|
24
24
|
import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
|
|
25
25
|
import MergerResultView from '~/components/panels/MergerResultView.vue'
|
|
26
26
|
import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
|
|
27
|
+
import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
|
|
27
28
|
|
|
28
29
|
const ui = useUiStore()
|
|
29
30
|
|
|
@@ -57,6 +58,7 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
|
|
|
57
58
|
// caveats. Opened from the initiative card / inspector (`ui.openInitiativeTracker`) and
|
|
58
59
|
// as the planner step's result view.
|
|
59
60
|
'initiative-tracker': InitiativeTrackerWindow,
|
|
61
|
+
'initiative-planning': InitiativePlanningWindow,
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
const active = computed<Component | null>(() => {
|
|
@@ -22,12 +22,22 @@ const status = computed<InitiativeStatus>(() => initiative.value?.status ?? 'pla
|
|
|
22
22
|
const planningPipeline = computed(() => pipelines.pipelines.find((p) => p.id === 'pl_initiative'))
|
|
23
23
|
const running = computed(() => !!props.block.executionId)
|
|
24
24
|
|
|
25
|
+
// The interviewer has parked the planning run with questions awaiting answers.
|
|
26
|
+
const awaitingAnswers = computed(
|
|
27
|
+
() =>
|
|
28
|
+
initiative.value?.interview?.status === 'awaiting' &&
|
|
29
|
+
(initiative.value?.qa ?? []).some((q) => !(q.answer ?? '').trim()),
|
|
30
|
+
)
|
|
31
|
+
|
|
25
32
|
function runPlanning() {
|
|
26
33
|
if (planningPipeline.value) void execution.start(props.block.id, planningPipeline.value)
|
|
27
34
|
}
|
|
28
35
|
function openTracker() {
|
|
29
36
|
ui.openInitiativeTracker(props.block.id)
|
|
30
37
|
}
|
|
38
|
+
function openPlanning() {
|
|
39
|
+
ui.openInitiativePlanning(props.block.id)
|
|
40
|
+
}
|
|
31
41
|
|
|
32
42
|
const progress = computed(() => initiativeProgress(initiative.value?.items))
|
|
33
43
|
</script>
|
|
@@ -48,6 +58,17 @@ const progress = computed(() => initiativeProgress(initiative.value?.items))
|
|
|
48
58
|
</p>
|
|
49
59
|
|
|
50
60
|
<div class="flex flex-wrap items-center gap-2">
|
|
61
|
+
<UButton
|
|
62
|
+
v-if="awaitingAnswers"
|
|
63
|
+
data-testid="initiative-answer-planning"
|
|
64
|
+
color="primary"
|
|
65
|
+
variant="solid"
|
|
66
|
+
size="sm"
|
|
67
|
+
icon="i-lucide-messages-square"
|
|
68
|
+
@click="openPlanning"
|
|
69
|
+
>
|
|
70
|
+
{{ t('initiative.inspector.answerPlanning') }}
|
|
71
|
+
</UButton>
|
|
51
72
|
<UButton
|
|
52
73
|
data-testid="initiative-run-planning"
|
|
53
74
|
color="primary"
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
answerInitiativeQuestionContract,
|
|
3
|
+
continueInitiativePlanningContract,
|
|
2
4
|
createInitiativeContract,
|
|
3
5
|
getInitiativeByBlockContract,
|
|
4
6
|
getInitiativeContract,
|
|
5
7
|
listInitiativesContract,
|
|
8
|
+
proceedInitiativePlanningContract,
|
|
6
9
|
} from '@cat-factory/contracts'
|
|
7
10
|
import type { ApiContext } from './context'
|
|
8
11
|
|
|
@@ -24,5 +27,31 @@ export function initiativeApi({ send, ws }: ApiContext) {
|
|
|
24
27
|
// The tracker window's load path: the initiative anchored to a board block.
|
|
25
28
|
getInitiativeByBlock: (workspaceId: string, blockId: string) =>
|
|
26
29
|
send(getInitiativeByBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
30
|
+
|
|
31
|
+
// Interactive planning (slice 2): answer one interview question (no run resume), then
|
|
32
|
+
// continue (interviewer re-runs, may ask more) or proceed (skip remaining, plan now).
|
|
33
|
+
answerInitiativeQuestion: (
|
|
34
|
+
workspaceId: string,
|
|
35
|
+
blockId: string,
|
|
36
|
+
questionId: string,
|
|
37
|
+
answer: string,
|
|
38
|
+
) =>
|
|
39
|
+
send(answerInitiativeQuestionContract, {
|
|
40
|
+
pathPrefix: ws(workspaceId),
|
|
41
|
+
pathParams: { blockId },
|
|
42
|
+
body: { questionId, answer },
|
|
43
|
+
}),
|
|
44
|
+
|
|
45
|
+
continueInitiativePlanning: (workspaceId: string, blockId: string) =>
|
|
46
|
+
send(continueInitiativePlanningContract, {
|
|
47
|
+
pathPrefix: ws(workspaceId),
|
|
48
|
+
pathParams: { blockId },
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
proceedInitiativePlanning: (workspaceId: string, blockId: string) =>
|
|
52
|
+
send(proceedInitiativePlanningContract, {
|
|
53
|
+
pathPrefix: ws(workspaceId),
|
|
54
|
+
pathParams: { blockId },
|
|
55
|
+
}),
|
|
27
56
|
}
|
|
28
57
|
}
|
|
@@ -28,10 +28,23 @@ export function useBlockDeletion() {
|
|
|
28
28
|
: block.level === 'module'
|
|
29
29
|
? 'module'
|
|
30
30
|
: 'service'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const title = t(`panels.inspector.confirmDelete.${kind}.title`)
|
|
32
|
+
// For a container (service/module) state the exact cascade size so the blast radius is
|
|
33
|
+
// explicit — "and everything inside it" hides how many tasks/modules go with it.
|
|
34
|
+
if (kind === 'module' || kind === 'service') {
|
|
35
|
+
const count = board.descendantsOf(block.id).length
|
|
36
|
+
if (count > 0) {
|
|
37
|
+
return {
|
|
38
|
+
title,
|
|
39
|
+
body: t(
|
|
40
|
+
'panels.inspector.confirmDelete.containerBodyWithCount',
|
|
41
|
+
{ name: block.title, count },
|
|
42
|
+
count,
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
34
46
|
}
|
|
47
|
+
return { title, body: t(`panels.inspector.confirmDelete.${kind}.body`, { name: block.title }) }
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
async function deleteBlock(block: Block | undefined | null): Promise<boolean> {
|
|
@@ -54,8 +67,11 @@ export function useBlockDeletion() {
|
|
|
54
67
|
void recurring.remove(schedule.id)
|
|
55
68
|
return true
|
|
56
69
|
}
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
// Cancelling the run is irreversible, so defer it into the delete's commit: it fires only
|
|
71
|
+
// once the (deferred) delete actually lands, so an undo within the window leaves a running
|
|
72
|
+
// pipeline intact rather than restoring a block whose run was already torn down. Target the
|
|
73
|
+
// workspace the block was deleted from in case the user switched mid-window.
|
|
74
|
+
void board.removeBlock(block.id, { onCommit: (wsId) => execution.cancel(block.id, wsId) })
|
|
59
75
|
return true
|
|
60
76
|
}
|
|
61
77
|
|
|
@@ -71,6 +71,23 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
71
71
|
return [...direct, ...nested]
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Every block nested under a container (transitive), excluding the container
|
|
76
|
+
* itself — the exact set a delete cascades over. Used to state the blast radius
|
|
77
|
+
* in the delete confirmation. Structural children only (via `parentId`), so a
|
|
78
|
+
* non-structural epic membership is not counted.
|
|
79
|
+
*/
|
|
80
|
+
function descendantsOf(id: string): Block[] {
|
|
81
|
+
const out: Block[] = []
|
|
82
|
+
const stack = [...childrenOf(id)]
|
|
83
|
+
while (stack.length > 0) {
|
|
84
|
+
const b = stack.pop()!
|
|
85
|
+
out.push(b)
|
|
86
|
+
stack.push(...childrenOf(b.id))
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
/** The top-level service a block ultimately belongs to. */
|
|
75
92
|
function serviceOf(block: Block): Block | undefined {
|
|
76
93
|
let cur: Block | undefined = block
|
|
@@ -200,6 +217,7 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
200
217
|
modulesOf,
|
|
201
218
|
initiativesOf,
|
|
202
219
|
allTasksUnder,
|
|
220
|
+
descendantsOf,
|
|
203
221
|
serviceOf,
|
|
204
222
|
unmetDeps,
|
|
205
223
|
isRunnable,
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
2
|
import { setActivePinia, createPinia } from 'pinia'
|
|
3
3
|
import type { Block, BlockStatus } from '~/types/domain'
|
|
4
4
|
import { useBoardStore } from '~/stores/board'
|
|
@@ -89,6 +89,26 @@ describe('board store read getters', () => {
|
|
|
89
89
|
).toEqual(['t2', 't3'])
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
it('descendantsOf returns the transitive structural subtree, excluding the root', () => {
|
|
93
|
+
store.hydrate([
|
|
94
|
+
frame('f1'),
|
|
95
|
+
moduleBlock('m1', 'f1'),
|
|
96
|
+
task('t1', 'f1'),
|
|
97
|
+
task('t2', 'm1'),
|
|
98
|
+
frame('f2'),
|
|
99
|
+
task('t3', 'f2'),
|
|
100
|
+
])
|
|
101
|
+
expect(
|
|
102
|
+
store
|
|
103
|
+
.descendantsOf('f1')
|
|
104
|
+
.map((b) => b.id)
|
|
105
|
+
.sort(),
|
|
106
|
+
).toEqual(['m1', 't1', 't2'])
|
|
107
|
+
// a leaf task has no descendants; unknown ids are a safe empty
|
|
108
|
+
expect(store.descendantsOf('t1')).toEqual([])
|
|
109
|
+
expect(store.descendantsOf('missing')).toEqual([])
|
|
110
|
+
})
|
|
111
|
+
|
|
92
112
|
it('epicMembers groups blocks by their epicId (indexed lookup)', () => {
|
|
93
113
|
store.hydrate([
|
|
94
114
|
frame('f1'),
|
|
@@ -289,4 +309,179 @@ describe('board store optimistic rollback', () => {
|
|
|
289
309
|
expect(store.getBlock('t1')?.title).toBe('orig')
|
|
290
310
|
expect(store.getBlock('t1')?.description).toBe('keep')
|
|
291
311
|
})
|
|
312
|
+
|
|
313
|
+
it('reparentBlock offers an undo that moves the block back to its previous home', async () => {
|
|
314
|
+
vi.stubGlobal('useApi', () => ({
|
|
315
|
+
reparentBlock: async (
|
|
316
|
+
_ws: string,
|
|
317
|
+
id: string,
|
|
318
|
+
body: { parentId: string; position: unknown },
|
|
319
|
+
) => task(id, body.parentId, { position: body.position as { x: number; y: number } }),
|
|
320
|
+
}))
|
|
321
|
+
interface ToastAction {
|
|
322
|
+
onClick: () => void
|
|
323
|
+
}
|
|
324
|
+
const actions: ToastAction[] = []
|
|
325
|
+
vi.stubGlobal('useToast', () => ({
|
|
326
|
+
add: (t: { actions?: ToastAction[] }) => {
|
|
327
|
+
if (t.actions) actions.push(...t.actions)
|
|
328
|
+
},
|
|
329
|
+
}))
|
|
330
|
+
setActivePinia(createPinia())
|
|
331
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
332
|
+
const store = useBoardStore()
|
|
333
|
+
store.hydrate([
|
|
334
|
+
frame('f1'),
|
|
335
|
+
moduleBlock('m1', 'f1'),
|
|
336
|
+
task('t1', 'f1', { position: { x: 1, y: 2 } }),
|
|
337
|
+
])
|
|
338
|
+
await store.reparentBlock('t1', 'm1', { x: 5, y: 6 })
|
|
339
|
+
expect(store.getBlock('t1')?.parentId).toBe('m1')
|
|
340
|
+
// the undo action returns the block to its original parent + position
|
|
341
|
+
expect(actions).toHaveLength(1)
|
|
342
|
+
actions[0]!.onClick()
|
|
343
|
+
await vi.waitFor(() => {
|
|
344
|
+
expect(store.getBlock('t1')?.parentId).toBe('f1')
|
|
345
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 1, y: 2 })
|
|
346
|
+
})
|
|
347
|
+
// the undo move is itself non-undoable, so no second toast is queued
|
|
348
|
+
expect(actions).toHaveLength(1)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('board store deferred delete + undo', () => {
|
|
353
|
+
interface ToastAction {
|
|
354
|
+
onClick: () => void
|
|
355
|
+
}
|
|
356
|
+
/** Build a store with a stubbed api/toast, capturing the undo action offered on delete. */
|
|
357
|
+
function setup(removeImpl: () => Promise<void>) {
|
|
358
|
+
const removeSpy = vi.fn(removeImpl)
|
|
359
|
+
const addSpy = vi.fn()
|
|
360
|
+
const actions: ToastAction[] = []
|
|
361
|
+
vi.stubGlobal('useApi', () => ({ removeBlock: removeSpy }))
|
|
362
|
+
vi.stubGlobal('useToast', () => ({
|
|
363
|
+
add: (t: { actions?: ToastAction[] }) => {
|
|
364
|
+
addSpy(t)
|
|
365
|
+
if (t.actions) actions.push(...t.actions)
|
|
366
|
+
},
|
|
367
|
+
}))
|
|
368
|
+
setActivePinia(createPinia())
|
|
369
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
370
|
+
return { store: useBoardStore(), removeSpy, addSpy, actions }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
beforeEach(() => {
|
|
374
|
+
vi.useFakeTimers()
|
|
375
|
+
})
|
|
376
|
+
afterEach(() => {
|
|
377
|
+
vi.useRealTimers()
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('hides the subtree immediately but defers the backend delete', () => {
|
|
381
|
+
const { store, removeSpy } = setup(async () => {})
|
|
382
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
|
|
383
|
+
store.removeBlock('f1')
|
|
384
|
+
// the whole subtree disappears at once…
|
|
385
|
+
expect(store.getBlock('f1')).toBeUndefined()
|
|
386
|
+
expect(store.getBlock('m1')).toBeUndefined()
|
|
387
|
+
expect(store.getBlock('t1')).toBeUndefined()
|
|
388
|
+
// …but nothing is deleted server-side yet.
|
|
389
|
+
expect(removeSpy).not.toHaveBeenCalled()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('keeps a pending-delete subtree hidden across a coarse refresh, and prunes its edges', () => {
|
|
393
|
+
const { store } = setup(async () => {})
|
|
394
|
+
store.hydrate([frame('f1'), task('t1', 'f1'), task('t2', 'f1', { dependsOn: ['t1'] })])
|
|
395
|
+
store.removeBlock('t1')
|
|
396
|
+
// A full re-hydrate (e.g. a `board` live event) that still carries the deleted block and
|
|
397
|
+
// the now-dangling dependency edge must not resurrect either.
|
|
398
|
+
store.hydrate([frame('f1'), task('t1', 'f1'), task('t2', 'f1', { dependsOn: ['t1'] })])
|
|
399
|
+
expect(store.getBlock('t1')).toBeUndefined()
|
|
400
|
+
expect(store.getBlock('t2')?.dependsOn).toEqual([])
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('ignores a live upsert for a block awaiting its deferred delete', () => {
|
|
404
|
+
const { store } = setup(async () => {})
|
|
405
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
406
|
+
store.removeBlock('t1')
|
|
407
|
+
store.upsert(task('t1', 'f1', { title: 'resurrected' }))
|
|
408
|
+
expect(store.getBlock('t1')).toBeUndefined()
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('undo cancels the pending delete and restores the subtree', async () => {
|
|
412
|
+
const { store, removeSpy, actions } = setup(async () => {})
|
|
413
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
|
|
414
|
+
store.removeBlock('f1')
|
|
415
|
+
expect(actions).toHaveLength(1)
|
|
416
|
+
actions[0]!.onClick()
|
|
417
|
+
expect(store.getBlock('f1')?.id).toBe('f1')
|
|
418
|
+
expect(store.getBlock('t1')?.id).toBe('t1')
|
|
419
|
+
// the deferred delete never fires after an undo
|
|
420
|
+
await vi.runAllTimersAsync()
|
|
421
|
+
expect(removeSpy).not.toHaveBeenCalled()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('fires the backend delete for the captured workspace once the window elapses', async () => {
|
|
425
|
+
const { store, removeSpy } = setup(async () => {})
|
|
426
|
+
store.hydrate([frame('f1')])
|
|
427
|
+
store.removeBlock('f1')
|
|
428
|
+
await vi.runAllTimersAsync()
|
|
429
|
+
expect(removeSpy).toHaveBeenCalledWith('ws1', 'f1')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('restores the subtree and toasts an error if the deferred delete fails', async () => {
|
|
433
|
+
const { store, addSpy } = setup(() => Promise.reject(new Error('boom')))
|
|
434
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
435
|
+
store.removeBlock('f1')
|
|
436
|
+
await vi.runAllTimersAsync()
|
|
437
|
+
expect(store.getBlock('f1')?.id).toBe('f1')
|
|
438
|
+
expect(store.getBlock('t1')?.id).toBe('t1')
|
|
439
|
+
expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ color: 'error' }))
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('does not reattach a failed deferred delete onto a different workspace', async () => {
|
|
443
|
+
const { store } = setup(() => Promise.reject(new Error('boom')))
|
|
444
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
445
|
+
store.removeBlock('f1')
|
|
446
|
+
// The user switches workspace during the undo window; when the delete then fails, the
|
|
447
|
+
// ws1 subtree must NOT be injected onto the ws2 board now on screen.
|
|
448
|
+
useWorkspaceStore().workspaceId = 'ws2'
|
|
449
|
+
await vi.runAllTimersAsync()
|
|
450
|
+
expect(store.getBlock('f1')).toBeUndefined()
|
|
451
|
+
expect(store.getBlock('t1')).toBeUndefined()
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('runs onCommit with the captured workspace only when the window elapses', async () => {
|
|
455
|
+
const { store } = setup(async () => {})
|
|
456
|
+
const onCommit = vi.fn(async () => {})
|
|
457
|
+
store.hydrate([frame('f1')])
|
|
458
|
+
store.removeBlock('f1', { onCommit })
|
|
459
|
+
await vi.runAllTimersAsync()
|
|
460
|
+
expect(onCommit).toHaveBeenCalledWith('ws1')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('skips onCommit (the irreversible side effect) when the delete is undone', async () => {
|
|
464
|
+
const { store, actions } = setup(async () => {})
|
|
465
|
+
const onCommit = vi.fn(async () => {})
|
|
466
|
+
store.hydrate([frame('f1')])
|
|
467
|
+
store.removeBlock('f1', { onCommit })
|
|
468
|
+
actions[0]!.onClick() // undo before the window elapses
|
|
469
|
+
await vi.runAllTimersAsync()
|
|
470
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('undo re-adds pruned edges without clobbering ones gained during the window', () => {
|
|
474
|
+
const { store, actions } = setup(async () => {})
|
|
475
|
+
store.hydrate([
|
|
476
|
+
frame('f1'),
|
|
477
|
+
task('t1', 'f1'),
|
|
478
|
+
task('t2', 'f1', { dependsOn: ['t1'] }),
|
|
479
|
+
task('t3', 'f1'),
|
|
480
|
+
])
|
|
481
|
+
store.removeBlock('t1')
|
|
482
|
+
// A live event adds a new dependency to the survivor mid-window (t1's edge was pruned).
|
|
483
|
+
store.upsert(task('t2', 'f1', { dependsOn: ['t3'] }))
|
|
484
|
+
actions[0]!.onClick() // undo restores t1 and its edge, keeping the newly-added t3 edge
|
|
485
|
+
expect(store.getBlock('t2')?.dependsOn.slice().sort()).toEqual(['t1', 't3'])
|
|
486
|
+
})
|
|
292
487
|
})
|