@cat-factory/app 0.110.2 → 0.110.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/board/AddTaskModal.vue +30 -2
- package/app/components/board/RecurringPipelineModal.vue +29 -2
- package/app/components/bootstrap/BootstrapModal.vue +18 -1
- package/app/components/github/AddServiceFromRepoModal.vue +11 -5
- package/app/components/layout/BoardToolbar.vue +43 -0
- package/app/components/panels/DecisionModal.vue +33 -5
- package/app/components/panels/InspectorPanel.vue +22 -2
- package/app/composables/api/board.ts +9 -0
- package/app/composables/useBlockDeletion.ts +49 -1
- package/app/composables/useUnsavedGuard.spec.ts +104 -0
- package/app/composables/useUnsavedGuard.ts +66 -0
- package/app/stores/board.ts +42 -1
- package/app/stores/services.ts +18 -0
- package/app/stores/workspace.ts +1 -0
- package/i18n/locales/de.json +22 -5
- package/i18n/locales/en.json +22 -5
- package/i18n/locales/es.json +21 -4
- package/i18n/locales/fr.json +21 -4
- package/i18n/locales/he.json +21 -4
- package/i18n/locales/it.json +22 -5
- package/i18n/locales/ja.json +21 -4
- package/i18n/locales/pl.json +21 -4
- package/i18n/locales/tr.json +21 -4
- package/i18n/locales/uk.json +21 -4
- package/package.json +2 -2
|
@@ -40,7 +40,7 @@ const { linkPending } = useContextLinking()
|
|
|
40
40
|
const open = computed({
|
|
41
41
|
get: () => ui.addTaskContainerId !== null,
|
|
42
42
|
set: (v: boolean) => {
|
|
43
|
-
if (!v)
|
|
43
|
+
if (!v) void requestClose()
|
|
44
44
|
},
|
|
45
45
|
})
|
|
46
46
|
|
|
@@ -405,6 +405,34 @@ watch(open, (isOpen) => {
|
|
|
405
405
|
resolvePendingIssueBodies().catch(() => {})
|
|
406
406
|
})
|
|
407
407
|
|
|
408
|
+
// UX-18: prompt before discarding typed input on Escape / backdrop / Cancel. Registered
|
|
409
|
+
// after the reset watcher so the baseline is the seeded form (a prefill is the clean
|
|
410
|
+
// starting point, not a spurious edit). The snapshot covers the user-owned fields — the
|
|
411
|
+
// cheap task-type / technical toggles are excluded, and only the *stable* context keys are
|
|
412
|
+
// compared so the async issue-body resolution never reads as a change.
|
|
413
|
+
const { requestClose } = useUnsavedGuard({
|
|
414
|
+
open,
|
|
415
|
+
close: () => ui.closeAddTask(),
|
|
416
|
+
saving: () => saving.value,
|
|
417
|
+
snapshot: () => ({
|
|
418
|
+
title: title.value.trim(),
|
|
419
|
+
description: description.value.trim(),
|
|
420
|
+
severity: severity.value,
|
|
421
|
+
stepsToReproduce: stepsToReproduce.value.trim(),
|
|
422
|
+
timeboxHours: timeboxHours.value ?? null,
|
|
423
|
+
docKind: docKind.value,
|
|
424
|
+
docAudience: docAudience.value.trim(),
|
|
425
|
+
docTargetPath: docTargetPath.value.trim(),
|
|
426
|
+
docOutlineHints: docOutlineHints.value.trim(),
|
|
427
|
+
docKindFieldValues: { ...docKindFieldValues },
|
|
428
|
+
riskPolicyId: riskPolicyId.value,
|
|
429
|
+
modelPresetId: modelPresetId.value,
|
|
430
|
+
pipelineId: pipelineId.value,
|
|
431
|
+
agentConfig: { ...agentConfigValues.value },
|
|
432
|
+
context: pendingContext.value.map(contextKey),
|
|
433
|
+
}),
|
|
434
|
+
})
|
|
435
|
+
|
|
408
436
|
// A recurring task only needs a target frame (its details are filled in the schedule
|
|
409
437
|
// modal); every other type needs a title.
|
|
410
438
|
const canAdd = computed(() =>
|
|
@@ -921,7 +949,7 @@ async function add() {
|
|
|
921
949
|
|
|
922
950
|
<template #footer>
|
|
923
951
|
<div class="flex w-full justify-end gap-2">
|
|
924
|
-
<UButton color="neutral" variant="ghost" @click="
|
|
952
|
+
<UButton color="neutral" variant="ghost" @click="requestClose()">{{
|
|
925
953
|
t('common.cancel')
|
|
926
954
|
}}</UButton>
|
|
927
955
|
<UButton
|
|
@@ -21,7 +21,7 @@ const { t } = useI18n()
|
|
|
21
21
|
const open = computed({
|
|
22
22
|
get: () => ui.addRecurringFrameId !== null,
|
|
23
23
|
set: (v: boolean) => {
|
|
24
|
-
if (!v)
|
|
24
|
+
if (!v) void requestClose()
|
|
25
25
|
},
|
|
26
26
|
})
|
|
27
27
|
|
|
@@ -131,6 +131,33 @@ watch(open, (isOpen) => {
|
|
|
131
131
|
void tasks.probe()
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
// UX-18: prompt before discarding typed input on Escape / backdrop / Cancel. Registered
|
|
135
|
+
// after the reset watcher so the baseline is the seeded form (the default pipeline + the
|
|
136
|
+
// workspace tracker settings are the clean starting point, not a spurious edit).
|
|
137
|
+
const { requestClose } = useUnsavedGuard({
|
|
138
|
+
open,
|
|
139
|
+
close: () => ui.closeAddRecurring(),
|
|
140
|
+
saving: () => saving.value,
|
|
141
|
+
snapshot: () => ({
|
|
142
|
+
name: name.value.trim(),
|
|
143
|
+
description: description.value.trim(),
|
|
144
|
+
pipelineId: pipelineId.value,
|
|
145
|
+
onDemand: onDemand.value,
|
|
146
|
+
recurrence: recurrence.value,
|
|
147
|
+
trackerKind: trackerKind.value,
|
|
148
|
+
jiraProjectKey: jiraProjectKey.value.trim(),
|
|
149
|
+
linearTeamId: linearTeamId.value.trim(),
|
|
150
|
+
intakeSource: intakeSource.value,
|
|
151
|
+
intakeJiraProjectKey: intakeJiraProjectKey.value.trim(),
|
|
152
|
+
intakeLinearTeamId: intakeLinearTeamId.value.trim(),
|
|
153
|
+
intakeGithubRepo: intakeGithubRepo.value.trim(),
|
|
154
|
+
intakeTitleFragment: intakeTitleFragment.value.trim(),
|
|
155
|
+
intakeLabels: intakeLabels.value.trim(),
|
|
156
|
+
intakeIssueType: intakeIssueType.value.trim(),
|
|
157
|
+
intakeInProgressLabel: intakeInProgressLabel.value.trim(),
|
|
158
|
+
}),
|
|
159
|
+
})
|
|
160
|
+
|
|
134
161
|
// The board field required for the picked source must be filled before a bug-intake schedule saves.
|
|
135
162
|
const intakeReady = computed(() => {
|
|
136
163
|
if (!isBugIntake.value) return true
|
|
@@ -423,7 +450,7 @@ async function add() {
|
|
|
423
450
|
|
|
424
451
|
<template #footer>
|
|
425
452
|
<div class="flex w-full justify-end gap-2">
|
|
426
|
-
<UButton color="neutral" variant="ghost" @click="
|
|
453
|
+
<UButton color="neutral" variant="ghost" @click="requestClose()">{{
|
|
427
454
|
t('common.cancel')
|
|
428
455
|
}}</UButton>
|
|
429
456
|
<UButton
|
|
@@ -22,7 +22,7 @@ const { confirmAction, toastDone } = useConfirmAction()
|
|
|
22
22
|
const open = computed({
|
|
23
23
|
get: () => ui.bootstrapOpen,
|
|
24
24
|
set: (v: boolean) => {
|
|
25
|
-
if (!v)
|
|
25
|
+
if (!v) void requestClose()
|
|
26
26
|
},
|
|
27
27
|
})
|
|
28
28
|
|
|
@@ -84,6 +84,23 @@ const typeItems = useFrameRepoTypeItems()
|
|
|
84
84
|
|
|
85
85
|
const usingReference = computed(() => mode.value === 'reference')
|
|
86
86
|
|
|
87
|
+
// UX-18: prompt before discarding a half-filled launch form on Escape / backdrop / the X.
|
|
88
|
+
// The modal keeps its fields across opens (no reset watcher), so the baseline is whatever
|
|
89
|
+
// the form held when it opened — a close only prompts once the user has typed something
|
|
90
|
+
// new. Only the typed launch fields are guarded; the reference-architecture sub-form has
|
|
91
|
+
// its own Cancel/Save. Declared here (below the launch-form refs) because the guard reads
|
|
92
|
+
// its initial baseline synchronously.
|
|
93
|
+
const { requestClose } = useUnsavedGuard({
|
|
94
|
+
open,
|
|
95
|
+
close: () => ui.closeBootstrap(),
|
|
96
|
+
saving: () => launching.value,
|
|
97
|
+
snapshot: () => ({
|
|
98
|
+
repoName: repoName.value.trim(),
|
|
99
|
+
description: description.value.trim(),
|
|
100
|
+
instructions: instructions.value.trim(),
|
|
101
|
+
}),
|
|
102
|
+
})
|
|
103
|
+
|
|
87
104
|
// Mirror of the backend `slugField` rule (@cat-factory/contracts bootstrap
|
|
88
105
|
// schema): the new repo name is a SINGLE GitHub name segment — no "owner/"
|
|
89
106
|
// prefix — so reject a bad value inline before we hit the API. Kept in sync with
|
|
@@ -65,13 +65,19 @@ watch(
|
|
|
65
65
|
// The integration is on but this workspace isn't bound yet — connect first.
|
|
66
66
|
const needsGitHub = computed(() => github.available === true && !github.connected)
|
|
67
67
|
|
|
68
|
-
// Repos
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
71
|
-
//
|
|
68
|
+
// Repos whose service is ALREADY mounted on THIS board can't be added again — adding here would
|
|
69
|
+
// be a no-op. A repo whose service lives on ANOTHER board in the org stays addable: adding it
|
|
70
|
+
// MOUNTS the shared service onto this board (backend `addServiceFromRepo`). Monorepos are exempt
|
|
71
|
+
// (each subdirectory is its own service). Derived from the account's catalog cross-referenced with
|
|
72
|
+
// this board's mounts (`byServiceId`), since the repo projection carries no repo→block link.
|
|
72
73
|
const onBoardIds = computed(
|
|
73
74
|
() =>
|
|
74
|
-
new Set(
|
|
75
|
+
new Set(
|
|
76
|
+
services.catalog
|
|
77
|
+
.filter((s) => services.byServiceId[s.id])
|
|
78
|
+
.map((s) => s.repoGithubId)
|
|
79
|
+
.filter((id): id is number => id != null),
|
|
80
|
+
),
|
|
75
81
|
)
|
|
76
82
|
|
|
77
83
|
// Map an available repo to a combobox item. The label carries the private/monorepo/
|
|
@@ -44,6 +44,34 @@ const mountableItems = computed(() =>
|
|
|
44
44
|
}),
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
+
async function restoreService(id: string, title: string) {
|
|
48
|
+
try {
|
|
49
|
+
await board.restoreService(id)
|
|
50
|
+
toast.add({
|
|
51
|
+
title: t('board.toast.restored', { name: title }),
|
|
52
|
+
icon: 'i-lucide-archive-restore',
|
|
53
|
+
color: 'neutral',
|
|
54
|
+
})
|
|
55
|
+
} catch (e) {
|
|
56
|
+
toast.add({
|
|
57
|
+
title: t('board.toast.restoreFailed'),
|
|
58
|
+
description: e instanceof Error ? e.message : String(e),
|
|
59
|
+
color: 'error',
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Archived services — hidden from the board but restorable here with no expiry.
|
|
65
|
+
const archivedItems = computed(() =>
|
|
66
|
+
board.archived.map((b) => ({
|
|
67
|
+
label: b.title,
|
|
68
|
+
icon: 'i-lucide-box',
|
|
69
|
+
onSelect: () => {
|
|
70
|
+
void restoreService(b.id, b.title)
|
|
71
|
+
},
|
|
72
|
+
})),
|
|
73
|
+
)
|
|
74
|
+
|
|
47
75
|
const zoomPct = computed(() => Math.round(ui.zoom * 100))
|
|
48
76
|
// Disable the zoom buttons once the camera hits a clamp (Vue Flow pins the zoom exactly
|
|
49
77
|
// at the limit, so a small epsilon guards against float drift). UX-16.
|
|
@@ -186,6 +214,21 @@ const decisionItems = computed(() =>
|
|
|
186
214
|
</UButton>
|
|
187
215
|
</UDropdownMenu>
|
|
188
216
|
|
|
217
|
+
<!-- archived services: hidden from the board, restorable with no expiry -->
|
|
218
|
+
<UDropdownMenu v-if="archivedItems.length" :items="archivedItems">
|
|
219
|
+
<UButton
|
|
220
|
+
color="neutral"
|
|
221
|
+
variant="ghost"
|
|
222
|
+
size="sm"
|
|
223
|
+
icon="i-lucide-archive"
|
|
224
|
+
:title="t('board.toolbar.archivedServices')"
|
|
225
|
+
data-testid="board-archived-services"
|
|
226
|
+
>
|
|
227
|
+
<span class="hidden sm:inline">{{ t('board.toolbar.archivedServices') }}</span>
|
|
228
|
+
<span>{{ n(archivedItems.length) }}</span>
|
|
229
|
+
</UButton>
|
|
230
|
+
</UDropdownMenu>
|
|
231
|
+
|
|
189
232
|
<!-- human-actionable notifications (merge review, pipeline complete, CI failed) -->
|
|
190
233
|
<NotificationsInbox />
|
|
191
234
|
|
|
@@ -4,6 +4,7 @@ import { agentKindMeta } from '~/utils/catalog'
|
|
|
4
4
|
const execution = useExecutionStore()
|
|
5
5
|
const board = useBoardStore()
|
|
6
6
|
const ui = useUiStore()
|
|
7
|
+
const toast = useToast()
|
|
7
8
|
const { t } = useI18n()
|
|
8
9
|
|
|
9
10
|
const ctx = computed(() => ui.decisionContext)
|
|
@@ -16,17 +17,42 @@ const decision = computed(() => step.value?.decision ?? null)
|
|
|
16
17
|
const block = computed(() => (instance.value ? board.getBlock(instance.value.blockId) : undefined))
|
|
17
18
|
const agent = computed(() => (step.value ? agentKindMeta(step.value.agentKind) : null))
|
|
18
19
|
|
|
20
|
+
// UX-25: which option is being resolved (null = idle). Guards against a fire-and-forget
|
|
21
|
+
// double-submit — the resolve is awaited, all options disable while it runs, and a failed
|
|
22
|
+
// resolve keeps the modal open with an error toast instead of closing silently.
|
|
23
|
+
const resolvingOption = ref<string | null>(null)
|
|
24
|
+
|
|
19
25
|
const open = computed({
|
|
20
26
|
get: () => !!ctx.value && !!decision.value,
|
|
21
27
|
set: (v: boolean) => {
|
|
22
|
-
|
|
28
|
+
// While a resolve is in flight the options are disabled, so the dismiss affordances
|
|
29
|
+
// (Escape / backdrop) are locked too — the awaited resolve settles the modal itself.
|
|
30
|
+
if (!v && !resolvingOption.value) ui.closeDecision()
|
|
23
31
|
},
|
|
24
32
|
})
|
|
25
33
|
|
|
26
|
-
function choose(option: string) {
|
|
27
|
-
if (!ctx.value) return
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
async function choose(option: string) {
|
|
35
|
+
if (!ctx.value || resolvingOption.value) return
|
|
36
|
+
resolvingOption.value = option
|
|
37
|
+
try {
|
|
38
|
+
// `resolveDecision` returns false when a required-credential prompt is cancelled — keep
|
|
39
|
+
// the modal open in that case so the choice isn't silently dropped.
|
|
40
|
+
const resolved = await execution.resolveDecision(
|
|
41
|
+
ctx.value.instanceId,
|
|
42
|
+
ctx.value.decisionId,
|
|
43
|
+
option,
|
|
44
|
+
)
|
|
45
|
+
if (resolved) ui.closeDecision()
|
|
46
|
+
} catch (e) {
|
|
47
|
+
toast.add({
|
|
48
|
+
title: t('panels.decision.resolveFailed'),
|
|
49
|
+
description: e instanceof Error ? e.message : String(e),
|
|
50
|
+
icon: 'i-lucide-triangle-alert',
|
|
51
|
+
color: 'error',
|
|
52
|
+
})
|
|
53
|
+
} finally {
|
|
54
|
+
resolvingOption.value = null
|
|
55
|
+
}
|
|
30
56
|
}
|
|
31
57
|
</script>
|
|
32
58
|
|
|
@@ -65,6 +91,8 @@ function choose(option: string) {
|
|
|
65
91
|
block
|
|
66
92
|
data-testid="decision-option"
|
|
67
93
|
class="justify-start"
|
|
94
|
+
:loading="resolvingOption === opt"
|
|
95
|
+
:disabled="resolvingOption !== null && resolvingOption !== opt"
|
|
68
96
|
@click="choose(opt)"
|
|
69
97
|
>
|
|
70
98
|
{{ opt }}
|
|
@@ -186,10 +186,17 @@ const runMenu = computed(() => {
|
|
|
186
186
|
|
|
187
187
|
// Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
|
|
188
188
|
// (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
|
|
189
|
-
const { deleteBlock } = useBlockDeletion()
|
|
189
|
+
const { deleteBlock, archiveBlock } = useBlockDeletion()
|
|
190
190
|
function remove() {
|
|
191
191
|
void deleteBlock(block.value)
|
|
192
192
|
}
|
|
193
|
+
function archive() {
|
|
194
|
+
void archiveBlock(block.value)
|
|
195
|
+
}
|
|
196
|
+
// A service (top-level frame) can be archived — hidden but restorable with no expiry.
|
|
197
|
+
const isServiceFrame = computed(
|
|
198
|
+
() => block.value?.level === 'frame' && block.value?.parentId === null,
|
|
199
|
+
)
|
|
193
200
|
|
|
194
201
|
// ---- failed agent run (bootstrap or execution) ------------------------------
|
|
195
202
|
// A block whose current run failed surfaces the shared failure banner + retry,
|
|
@@ -545,12 +552,25 @@ const showOriginalDescription = ref(false)
|
|
|
545
552
|
>
|
|
546
553
|
{{ t('panels.inspector.focus') }}
|
|
547
554
|
</UButton>
|
|
555
|
+
<UButton
|
|
556
|
+
v-if="isServiceFrame"
|
|
557
|
+
color="neutral"
|
|
558
|
+
variant="ghost"
|
|
559
|
+
size="sm"
|
|
560
|
+
icon="i-lucide-archive"
|
|
561
|
+
:class="isServiceFrame ? 'ms-auto' : ''"
|
|
562
|
+
data-testid="inspector-archive"
|
|
563
|
+
:title="t('panels.inspector.archiveService')"
|
|
564
|
+
@click="archive"
|
|
565
|
+
>
|
|
566
|
+
{{ t('panels.inspector.archiveService') }}
|
|
567
|
+
</UButton>
|
|
548
568
|
<UButton
|
|
549
569
|
color="error"
|
|
550
570
|
variant="ghost"
|
|
551
571
|
size="sm"
|
|
552
572
|
icon="i-lucide-trash-2"
|
|
553
|
-
class="ms-auto"
|
|
573
|
+
:class="isServiceFrame ? '' : 'ms-auto'"
|
|
554
574
|
data-testid="inspector-delete"
|
|
555
575
|
:title="deleteLabel"
|
|
556
576
|
@click="remove"
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
addModuleContract,
|
|
5
5
|
addServiceFromRepoContract,
|
|
6
6
|
addTaskContract,
|
|
7
|
+
archiveBlockContract,
|
|
7
8
|
assignEpicContract,
|
|
8
9
|
clonePipelineContract,
|
|
9
10
|
createPipelineContract,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
removeBlockContract,
|
|
15
16
|
reparentBlockContract,
|
|
16
17
|
reseedPipelineContract,
|
|
18
|
+
restoreBlockContract,
|
|
17
19
|
toggleDependencyContract,
|
|
18
20
|
updateBlockContract,
|
|
19
21
|
updatePipelineContract,
|
|
@@ -87,6 +89,13 @@ export function boardApi({ send, ws }: ApiContext) {
|
|
|
87
89
|
removeBlock: (workspaceId: string, blockId: string) =>
|
|
88
90
|
send(removeBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
89
91
|
|
|
92
|
+
// Archive a service (hide it + its subtree, restorable with no expiry) instead of deleting.
|
|
93
|
+
archiveBlock: (workspaceId: string, blockId: string) =>
|
|
94
|
+
send(archiveBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
95
|
+
|
|
96
|
+
restoreBlock: (workspaceId: string, blockId: string) =>
|
|
97
|
+
send(restoreBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
98
|
+
|
|
90
99
|
toggleDependency: (workspaceId: string, blockId: string, body: { sourceId: string }) =>
|
|
91
100
|
send(toggleDependencyContract, {
|
|
92
101
|
pathPrefix: ws(workspaceId),
|
|
@@ -15,9 +15,54 @@ export function useBlockDeletion() {
|
|
|
15
15
|
const execution = useExecutionStore()
|
|
16
16
|
const ui = useUiStore()
|
|
17
17
|
const recurring = useRecurringPipelinesStore()
|
|
18
|
+
const toast = useToast()
|
|
18
19
|
const { confirm } = useConfirm()
|
|
19
20
|
const { t } = useI18n()
|
|
20
21
|
|
|
22
|
+
/** A service is any top-level frame; only services are archivable. */
|
|
23
|
+
function isService(block: Block): boolean {
|
|
24
|
+
return block.level === 'frame' && block.parentId === null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Unfinished (`status !== 'done'`) task descendants of a service — the delete blocker. */
|
|
28
|
+
function unfinishedTaskCount(block: Block): number {
|
|
29
|
+
return board.descendantsOf(block.id).filter((b) => b.level === 'task' && b.status !== 'done')
|
|
30
|
+
.length
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Archive a service: hide it (restorable with no expiry) instead of deleting. Used both as the
|
|
35
|
+
* explicit inspector action and as the automatic fallback when a service that still has
|
|
36
|
+
* unfinished work can't be deleted.
|
|
37
|
+
*/
|
|
38
|
+
async function archiveBlock(block: Block | undefined | null): Promise<boolean> {
|
|
39
|
+
if (!block || !isService(block)) return false
|
|
40
|
+
const ok = await confirm({
|
|
41
|
+
title: t('panels.inspector.confirmArchive.title'),
|
|
42
|
+
description: t('panels.inspector.confirmArchive.body', { name: block.title }),
|
|
43
|
+
confirmLabel: t('panels.inspector.archiveService'),
|
|
44
|
+
icon: 'i-lucide-archive',
|
|
45
|
+
})
|
|
46
|
+
if (!ok) return false
|
|
47
|
+
ui.select(null)
|
|
48
|
+
try {
|
|
49
|
+
await board.archiveService(block.id)
|
|
50
|
+
toast.add({
|
|
51
|
+
title: t('board.toast.archived', { name: block.title }),
|
|
52
|
+
icon: 'i-lucide-archive',
|
|
53
|
+
color: 'neutral',
|
|
54
|
+
})
|
|
55
|
+
} catch (e) {
|
|
56
|
+
toast.add({
|
|
57
|
+
title: t('board.toast.archiveFailed'),
|
|
58
|
+
description: e instanceof Error ? e.message : String(e),
|
|
59
|
+
icon: 'i-lucide-triangle-alert',
|
|
60
|
+
color: 'error',
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
21
66
|
/** Resolve the confirm title/body for a block, matching the inspector's delete-label kinds. */
|
|
22
67
|
function copyFor(block: Block): { title: string; body: string } {
|
|
23
68
|
const schedule = recurring.byBlock(block.id)
|
|
@@ -49,6 +94,9 @@ export function useBlockDeletion() {
|
|
|
49
94
|
|
|
50
95
|
async function deleteBlock(block: Block | undefined | null): Promise<boolean> {
|
|
51
96
|
if (!block) return false
|
|
97
|
+
// A service with unfinished work can't be deleted (the backend rejects it) — archive it
|
|
98
|
+
// instead. Route straight to the archive flow so the user is never handed a dead-end error.
|
|
99
|
+
if (isService(block) && unfinishedTaskCount(block) > 0) return archiveBlock(block)
|
|
52
100
|
const { title, body } = copyFor(block)
|
|
53
101
|
const ok = await confirm({
|
|
54
102
|
title,
|
|
@@ -75,5 +123,5 @@ export function useBlockDeletion() {
|
|
|
75
123
|
return true
|
|
76
124
|
}
|
|
77
125
|
|
|
78
|
-
return { deleteBlock }
|
|
126
|
+
return { deleteBlock, archiveBlock }
|
|
79
127
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { nextTick, ref } from 'vue'
|
|
3
|
+
import { useUnsavedGuard } from '~/composables/useUnsavedGuard'
|
|
4
|
+
|
|
5
|
+
// Control the shared confirm dialog: `confirm()` resolves to the user's choice (discard
|
|
6
|
+
// vs keep). `useI18n` is already stubbed to a passthrough in test/setup.ts.
|
|
7
|
+
function stubConfirm(result: boolean) {
|
|
8
|
+
const confirm = vi.fn().mockResolvedValue(result)
|
|
9
|
+
vi.stubGlobal('useConfirm', () => ({ confirm }))
|
|
10
|
+
return confirm
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('useUnsavedGuard', () => {
|
|
14
|
+
it('closes immediately without prompting when the form is unchanged', async () => {
|
|
15
|
+
const confirm = stubConfirm(true)
|
|
16
|
+
const close = vi.fn()
|
|
17
|
+
const value = ref('hello')
|
|
18
|
+
const { requestClose } = useUnsavedGuard({
|
|
19
|
+
open: ref(true),
|
|
20
|
+
close,
|
|
21
|
+
snapshot: () => ({ value: value.value }),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
await requestClose()
|
|
25
|
+
|
|
26
|
+
expect(confirm).not.toHaveBeenCalled()
|
|
27
|
+
expect(close).toHaveBeenCalledOnce()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('prompts and closes when dirty and the user confirms discard', async () => {
|
|
31
|
+
const confirm = stubConfirm(true)
|
|
32
|
+
const close = vi.fn()
|
|
33
|
+
const value = ref('hello')
|
|
34
|
+
const { requestClose, isDirty } = useUnsavedGuard({
|
|
35
|
+
open: ref(true),
|
|
36
|
+
close,
|
|
37
|
+
snapshot: () => ({ value: value.value }),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
value.value = 'changed'
|
|
41
|
+
expect(isDirty()).toBe(true)
|
|
42
|
+
await requestClose()
|
|
43
|
+
|
|
44
|
+
expect(confirm).toHaveBeenCalledOnce()
|
|
45
|
+
expect(close).toHaveBeenCalledOnce()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('prompts and keeps the modal open when the user cancels discard', async () => {
|
|
49
|
+
const confirm = stubConfirm(false)
|
|
50
|
+
const close = vi.fn()
|
|
51
|
+
const value = ref('hello')
|
|
52
|
+
const { requestClose } = useUnsavedGuard({
|
|
53
|
+
open: ref(true),
|
|
54
|
+
close,
|
|
55
|
+
snapshot: () => ({ value: value.value }),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
value.value = 'changed'
|
|
59
|
+
await requestClose()
|
|
60
|
+
|
|
61
|
+
expect(confirm).toHaveBeenCalledOnce()
|
|
62
|
+
expect(close).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('never prompts or closes while a submit is in flight', async () => {
|
|
66
|
+
const confirm = stubConfirm(true)
|
|
67
|
+
const close = vi.fn()
|
|
68
|
+
const value = ref('hello')
|
|
69
|
+
const { requestClose } = useUnsavedGuard({
|
|
70
|
+
open: ref(true),
|
|
71
|
+
close,
|
|
72
|
+
saving: () => true,
|
|
73
|
+
snapshot: () => ({ value: value.value }),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
value.value = 'changed'
|
|
77
|
+
await requestClose()
|
|
78
|
+
|
|
79
|
+
expect(confirm).not.toHaveBeenCalled()
|
|
80
|
+
expect(close).not.toHaveBeenCalled()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('re-baselines the seeded form each time the modal opens', async () => {
|
|
84
|
+
stubConfirm(true)
|
|
85
|
+
const open = ref(false)
|
|
86
|
+
const value = ref('')
|
|
87
|
+
const { isDirty } = useUnsavedGuard({
|
|
88
|
+
open,
|
|
89
|
+
close: vi.fn(),
|
|
90
|
+
snapshot: () => ({ value: value.value }),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// A reset watcher seeds a prefill; the guard re-snapshots on open, so the seeded value
|
|
94
|
+
// is the clean baseline rather than a spurious edit.
|
|
95
|
+
value.value = 'seeded prefill'
|
|
96
|
+
open.value = true
|
|
97
|
+
await nextTick()
|
|
98
|
+
expect(isDirty()).toBe(false)
|
|
99
|
+
|
|
100
|
+
// A genuine edit after the modal is open is dirty.
|
|
101
|
+
value.value = 'user typed more'
|
|
102
|
+
expect(isDirty()).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { watch } from 'vue'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guard a content-heavy modal against silently discarding unsaved input when the user
|
|
6
|
+
* dismisses it (Escape, backdrop click, or a Cancel button). Wire it into a controlled
|
|
7
|
+
* `UModal` whose `open` is a store-backed writable computed: route the setter's close and
|
|
8
|
+
* the Cancel button through `requestClose()` instead of closing directly.
|
|
9
|
+
*
|
|
10
|
+
* `snapshot()` returns a serialisable view of the user-facing form state. The baseline is
|
|
11
|
+
* captured every time the modal opens, so register this AFTER the component's own reset
|
|
12
|
+
* watcher — it then snapshots the *seeded* form (a prefill or an existing edit is the
|
|
13
|
+
* clean baseline, not a spurious change). A close request only prompts when the current
|
|
14
|
+
* snapshot diverges from that baseline; when nothing changed — or a submit is in flight —
|
|
15
|
+
* the close proceeds immediately, so the common path is unchanged.
|
|
16
|
+
*
|
|
17
|
+
* Keep `snapshot()` to stable, user-owned values: exclude fields mutated by async loads
|
|
18
|
+
* (they would read as dirty the instant a background fetch settles) and prefer stable ids
|
|
19
|
+
* over objects that a best-effort resolve rewrites in place.
|
|
20
|
+
*/
|
|
21
|
+
export function useUnsavedGuard(opts: {
|
|
22
|
+
/** The modal's open state (the writable computed's underlying getter). */
|
|
23
|
+
open: Ref<boolean>
|
|
24
|
+
/** A serialisable view of the current form state. */
|
|
25
|
+
snapshot: () => unknown
|
|
26
|
+
/** Actually close the modal (the store close action). */
|
|
27
|
+
close: () => void
|
|
28
|
+
/** True while a submit is in flight — a close is then a no-op (the submit closes itself). */
|
|
29
|
+
saving?: () => boolean
|
|
30
|
+
}) {
|
|
31
|
+
const { confirm } = useConfirm()
|
|
32
|
+
const { t } = useI18n()
|
|
33
|
+
|
|
34
|
+
let baseline = serialize(opts.snapshot())
|
|
35
|
+
watch(opts.open, (isOpen) => {
|
|
36
|
+
if (isOpen) baseline = serialize(opts.snapshot())
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function isDirty(): boolean {
|
|
40
|
+
return serialize(opts.snapshot()) !== baseline
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function requestClose(): Promise<void> {
|
|
44
|
+
// A submit in flight closes itself on success — don't interrupt it or prompt.
|
|
45
|
+
if (opts.saving?.()) return
|
|
46
|
+
if (!isDirty()) {
|
|
47
|
+
opts.close()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
const discard = await confirm({
|
|
51
|
+
title: t('common.discard.title'),
|
|
52
|
+
description: t('common.discard.body'),
|
|
53
|
+
confirmLabel: t('common.discard.confirm'),
|
|
54
|
+
cancelLabel: t('common.discard.keep'),
|
|
55
|
+
variant: 'destructive',
|
|
56
|
+
icon: 'i-lucide-triangle-alert',
|
|
57
|
+
})
|
|
58
|
+
if (discard) opts.close()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { requestClose, isDirty }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function serialize(value: unknown): string {
|
|
65
|
+
return JSON.stringify(value ?? null)
|
|
66
|
+
}
|