@cat-factory/app 0.196.1 → 0.197.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/README.md +39 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
- package/app/components/tutorial/TutorialOverlay.vue +273 -0
- package/app/components/tutorial/TutorialPrompt.vue +102 -0
- package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +6 -164
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +4 -0
- package/app/modular/nav-contributions.ts +35 -0
- package/app/modular/nav-gates.ts +7 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +107 -0
- package/app/modular/tutorial-tours.ts +147 -0
- package/app/pages/index.vue +61 -0
- package/app/stores/board/dependencies.ts +52 -0
- package/app/stores/board/placement.ts +4 -37
- package/app/stores/execution/pendingGates.ts +109 -0
- package/app/stores/execution.ts +7 -94
- package/app/stores/requirements/recommendations.ts +77 -0
- package/app/stores/requirements.ts +17 -43
- package/app/stores/tutorial.spec.ts +135 -0
- package/app/stores/tutorial.ts +145 -0
- package/app/stores/workspace/commands.ts +77 -0
- package/app/stores/workspace.ts +11 -50
- package/app/utils/tutorial.spec.ts +68 -0
- package/app/utils/tutorial.ts +192 -0
- package/i18n/locales/de.json +89 -2
- package/i18n/locales/en.json +92 -2
- package/i18n/locales/es.json +89 -2
- package/i18n/locales/fr.json +89 -2
- package/i18n/locales/he.json +89 -2
- package/i18n/locales/it.json +89 -2
- package/i18n/locales/ja.json +89 -2
- package/i18n/locales/pl.json +89 -2
- package/i18n/locales/tr.json +89 -2
- package/i18n/locales/uk.json +89 -2
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { RequestRecommendationItem, RequirementReview } from '~/types/requirements'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared state + injected dependencies the recommendation slice closes over. Created once in the
|
|
6
|
+
* `requirements` store setup and threaded into {@link createRecommendationCommands} so the split
|
|
7
|
+
* operations stay behaviourally identical to the original single-closure store — a size-only
|
|
8
|
+
* extraction, not a new seam.
|
|
9
|
+
*/
|
|
10
|
+
export interface RecommendationCommandContext {
|
|
11
|
+
api: ReturnType<typeof useApi>
|
|
12
|
+
workspace: ReturnType<typeof useWorkspaceStore>
|
|
13
|
+
/** Block ids whose Requirement Writer is currently producing recommendations. */
|
|
14
|
+
recommending: Ref<Set<string>>
|
|
15
|
+
/** Toggle a block/review id in one of the store's flag sets. */
|
|
16
|
+
withFlag: (set: Ref<Set<string>>, key: string, on: boolean) => void
|
|
17
|
+
/** Commit a server-returned review into the cache. */
|
|
18
|
+
store: (review: RequirementReview) => void
|
|
19
|
+
/** Whether a `pending` recommendation placeholder still exists for the block (server-derived). */
|
|
20
|
+
hasPendingRecommendations: (blockId: string) => boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Ask for, accept, reject and re-request the Requirement Writer's suggested answers. */
|
|
24
|
+
export function createRecommendationCommands(ctx: RecommendationCommandContext) {
|
|
25
|
+
const { api, workspace, recommending, withFlag, store, hasPendingRecommendations } = ctx
|
|
26
|
+
|
|
27
|
+
function isRecommending(blockId: string): boolean {
|
|
28
|
+
return recommending.value.has(blockId) || hasPendingRecommendations(blockId)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ask the Requirement Writer to recommend answers for a batch of findings. Each item carries
|
|
33
|
+
* its finding id plus optional per-finding guidance (the note the human typed before choosing
|
|
34
|
+
* "recommend something"). ASYNCHRONOUS: returns at once with `pending` placeholder
|
|
35
|
+
* recommendations (the Writer runs per finding in the durable driver), which fill in (`ready`)
|
|
36
|
+
* via live `requirements` stream events; a notification calls the user back when the batch is
|
|
37
|
+
* ready. The board shows the `recommending` background stage while any placeholder is pending.
|
|
38
|
+
*/
|
|
39
|
+
async function requestRecommendations(blockId: string, items: RequestRecommendationItem[]) {
|
|
40
|
+
withFlag(recommending, blockId, true)
|
|
41
|
+
try {
|
|
42
|
+
const updated = await api.requestRecommendations(workspace.requireId(), blockId, items)
|
|
43
|
+
if (updated) store(updated)
|
|
44
|
+
return updated
|
|
45
|
+
} finally {
|
|
46
|
+
withFlag(recommending, blockId, false)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Accept a recommendation (becomes the finding's answer, folded into the next incorporation). */
|
|
51
|
+
async function acceptRecommendation(review: RequirementReview, recId: string) {
|
|
52
|
+
store(await api.acceptRecommendation(workspace.requireId(), review.id, recId))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Reject a recommendation (the human then dismisses / answers manually / re-requests). */
|
|
56
|
+
async function rejectRecommendation(review: RequirementReview, recId: string) {
|
|
57
|
+
store(await api.rejectRecommendation(workspace.requireId(), review.id, recId))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Re-request a recommendation with a "do it differently" note. */
|
|
61
|
+
async function reRequestRecommendation(review: RequirementReview, recId: string, note: string) {
|
|
62
|
+
withFlag(recommending, review.blockId, true)
|
|
63
|
+
try {
|
|
64
|
+
store(await api.reRequestRecommendation(workspace.requireId(), review.id, recId, note))
|
|
65
|
+
} finally {
|
|
66
|
+
withFlag(recommending, review.blockId, false)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
isRecommending,
|
|
72
|
+
requestRecommendations,
|
|
73
|
+
acceptRecommendation,
|
|
74
|
+
rejectRecommendation,
|
|
75
|
+
reRequestRecommendation,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import type {
|
|
4
|
-
RequestRecommendationItem,
|
|
5
4
|
RequirementReview,
|
|
6
5
|
ResolveRequirementsExceededChoice,
|
|
7
6
|
ReviewItemStatus,
|
|
8
7
|
} from '~/types/requirements'
|
|
9
8
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
9
|
+
import { createRecommendationCommands } from '~/stores/requirements/recommendations'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Requirements-review state. On the pipeline path the reviewer runs as the first gate
|
|
@@ -204,48 +204,22 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
204
204
|
return updated
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (updated) store(updated)
|
|
224
|
-
return updated
|
|
225
|
-
} finally {
|
|
226
|
-
withFlag(recommending, blockId, false)
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Accept a recommendation (becomes the finding's answer, folded into the next incorporation). */
|
|
231
|
-
async function acceptRecommendation(review: RequirementReview, recId: string) {
|
|
232
|
-
store(await api.acceptRecommendation(workspace.requireId(), review.id, recId))
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** Reject a recommendation (the human then dismisses / answers manually / re-requests). */
|
|
236
|
-
async function rejectRecommendation(review: RequirementReview, recId: string) {
|
|
237
|
-
store(await api.rejectRecommendation(workspace.requireId(), review.id, recId))
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Re-request a recommendation with a "do it differently" note. */
|
|
241
|
-
async function reRequestRecommendation(review: RequirementReview, recId: string, note: string) {
|
|
242
|
-
withFlag(recommending, review.blockId, true)
|
|
243
|
-
try {
|
|
244
|
-
store(await api.reRequestRecommendation(workspace.requireId(), review.id, recId, note))
|
|
245
|
-
} finally {
|
|
246
|
-
withFlag(recommending, review.blockId, false)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
207
|
+
// The Requirement Writer recommendation slice (request / accept / reject / re-request),
|
|
208
|
+
// extracted into a cohesive factory over the state above — a size-only split.
|
|
209
|
+
const {
|
|
210
|
+
isRecommending,
|
|
211
|
+
requestRecommendations,
|
|
212
|
+
acceptRecommendation,
|
|
213
|
+
rejectRecommendation,
|
|
214
|
+
reRequestRecommendation,
|
|
215
|
+
} = createRecommendationCommands({
|
|
216
|
+
api,
|
|
217
|
+
workspace,
|
|
218
|
+
recommending,
|
|
219
|
+
withFlag,
|
|
220
|
+
store,
|
|
221
|
+
hasPendingRecommendations,
|
|
222
|
+
})
|
|
249
223
|
|
|
250
224
|
/** Resolve a capped review: extra-round / proceed / stop-reset. */
|
|
251
225
|
async function resolveExceeded(
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { useTutorialStore } from '~/stores/tutorial'
|
|
3
|
+
|
|
4
|
+
describe('useTutorialStore launch prompt', () => {
|
|
5
|
+
it('offers the tutorial on launch while no decision was ever saved', () => {
|
|
6
|
+
const tutorial = useTutorialStore()
|
|
7
|
+
expect(tutorial.decision).toBeNull()
|
|
8
|
+
tutorial.maybeOfferOnLaunch()
|
|
9
|
+
expect(tutorial.promptOpen).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('auto-opens at most once per session', () => {
|
|
13
|
+
const tutorial = useTutorialStore()
|
|
14
|
+
tutorial.maybeOfferOnLaunch()
|
|
15
|
+
tutorial.closePrompt()
|
|
16
|
+
// The advisory watcher re-fires whenever another modal closes; the guard must hold.
|
|
17
|
+
tutorial.maybeOfferOnLaunch()
|
|
18
|
+
expect(tutorial.promptOpen).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('closing without answering saves nothing, so the next launch asks again', () => {
|
|
22
|
+
const tutorial = useTutorialStore()
|
|
23
|
+
tutorial.maybeOfferOnLaunch()
|
|
24
|
+
tutorial.closePrompt()
|
|
25
|
+
expect(tutorial.decision).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('declining is saved and stops the launch offer for good', () => {
|
|
29
|
+
const tutorial = useTutorialStore()
|
|
30
|
+
tutorial.maybeOfferOnLaunch()
|
|
31
|
+
tutorial.decline()
|
|
32
|
+
expect(tutorial.decision).toBe('declined')
|
|
33
|
+
expect(tutorial.promptOpen).toBe(false)
|
|
34
|
+
// A fresh session (new auto-open guard) still must not offer: decision wins.
|
|
35
|
+
tutorial.promptAutoOpened = false
|
|
36
|
+
tutorial.maybeOfferOnLaunch()
|
|
37
|
+
expect(tutorial.promptOpen).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('defers an offer an advisory landed on top of, and re-offers when it clears', () => {
|
|
41
|
+
const tutorial = useTutorialStore()
|
|
42
|
+
tutorial.maybeOfferOnLaunch()
|
|
43
|
+
// A startup advisory opened after the prompt: withdraw rather than stack.
|
|
44
|
+
tutorial.deferPrompt()
|
|
45
|
+
expect(tutorial.promptOpen).toBe(false)
|
|
46
|
+
expect(tutorial.decision).toBeNull()
|
|
47
|
+
// A deferral is not the user's answer, so it must not consume the session's offer.
|
|
48
|
+
tutorial.maybeOfferOnLaunch()
|
|
49
|
+
expect(tutorial.promptOpen).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('never withdraws a prompt the user opened themselves', () => {
|
|
53
|
+
const tutorial = useTutorialStore()
|
|
54
|
+
tutorial.openPrompt()
|
|
55
|
+
tutorial.deferPrompt()
|
|
56
|
+
expect(tutorial.promptOpen).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('a user-driven open works regardless of a saved decision', () => {
|
|
60
|
+
const tutorial = useTutorialStore()
|
|
61
|
+
tutorial.decline()
|
|
62
|
+
tutorial.openPrompt()
|
|
63
|
+
expect(tutorial.promptOpen).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('useTutorialStore tours', () => {
|
|
68
|
+
it('starting a tour records acceptance, closes the prompt, and resets the cursor', () => {
|
|
69
|
+
const tutorial = useTutorialStore()
|
|
70
|
+
tutorial.openPrompt()
|
|
71
|
+
tutorial.startTour('board-basics')
|
|
72
|
+
expect(tutorial.decision).toBe('accepted')
|
|
73
|
+
expect(tutorial.promptOpen).toBe(false)
|
|
74
|
+
expect(tutorial.activeTourId).toBe('board-basics')
|
|
75
|
+
expect(tutorial.stepIndex).toBe(0)
|
|
76
|
+
expect(tutorial.touring).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('starting a tour after a decline flips the decision to accepted', () => {
|
|
80
|
+
// The user changed their mind via the palette; leaving `declined` in place would
|
|
81
|
+
// misdescribe what happened.
|
|
82
|
+
const tutorial = useTutorialStore()
|
|
83
|
+
tutorial.decline()
|
|
84
|
+
tutorial.startTour('board-basics')
|
|
85
|
+
expect(tutorial.decision).toBe('accepted')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('the step cursor never goes below zero', () => {
|
|
89
|
+
const tutorial = useTutorialStore()
|
|
90
|
+
tutorial.startTour('board-basics')
|
|
91
|
+
tutorial.setStepIndex(-3)
|
|
92
|
+
expect(tutorial.stepIndex).toBe(0)
|
|
93
|
+
tutorial.setStepIndex(4)
|
|
94
|
+
expect(tutorial.stepIndex).toBe(4)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('skipping (stopTour) abandons the tour without marking it complete', () => {
|
|
98
|
+
const tutorial = useTutorialStore()
|
|
99
|
+
tutorial.startTour('board-basics')
|
|
100
|
+
tutorial.setStepIndex(2)
|
|
101
|
+
tutorial.stopTour()
|
|
102
|
+
expect(tutorial.touring).toBe(false)
|
|
103
|
+
expect(tutorial.stepIndex).toBe(0)
|
|
104
|
+
expect(tutorial.isCompleted('board-basics')).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('completing records the tour id once, idempotently', () => {
|
|
108
|
+
const tutorial = useTutorialStore()
|
|
109
|
+
tutorial.startTour('board-basics')
|
|
110
|
+
tutorial.completeTour()
|
|
111
|
+
expect(tutorial.isCompleted('board-basics')).toBe(true)
|
|
112
|
+
expect(tutorial.touring).toBe(false)
|
|
113
|
+
|
|
114
|
+
tutorial.startTour('board-basics')
|
|
115
|
+
tutorial.completeTour()
|
|
116
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics'])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('completing with no active tour records nothing', () => {
|
|
120
|
+
const tutorial = useTutorialStore()
|
|
121
|
+
tutorial.completeTour()
|
|
122
|
+
expect(tutorial.completedTourIds).toEqual([])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('tracks completion per tour id', () => {
|
|
126
|
+
const tutorial = useTutorialStore()
|
|
127
|
+
tutorial.startTour('board-basics')
|
|
128
|
+
tutorial.completeTour()
|
|
129
|
+
tutorial.startTour('first-task')
|
|
130
|
+
tutorial.completeTour()
|
|
131
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics', 'first-task'])
|
|
132
|
+
expect(tutorial.isCompleted('first-task')).toBe(true)
|
|
133
|
+
expect(tutorial.isCompleted('made-up')).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
/** The launch-prompt answer. `null` = never answered, so the app asks again next launch. */
|
|
5
|
+
export type TutorialDecision = 'accepted' | 'declined'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The in-app tutorial state: the launch-prompt decision, per-tour completion, and the
|
|
9
|
+
* live progress of whichever tour is running.
|
|
10
|
+
*
|
|
11
|
+
* Two tiers of state, split on purpose:
|
|
12
|
+
*
|
|
13
|
+
* - PERSISTED (`decision`, `completedTourIds`) — the user's explicit answer to "would you
|
|
14
|
+
* like a tour?" and which tours they have finished. Browser-persisted like the interface
|
|
15
|
+
* tier (`uiMode`): it is a per-person, per-browser preference, and `null` (never asked)
|
|
16
|
+
* is a real state distinct from `declined` — only an explicit answer stops the launch
|
|
17
|
+
* prompt from returning, while closing it without answering merely defers it to the
|
|
18
|
+
* next launch.
|
|
19
|
+
* - SESSION-ONLY (`promptOpen`, `activeTourId`, `stepIndex`) — a tour is anchored to live
|
|
20
|
+
* DOM, so replaying progress across a reload would point step N at a board that hasn't
|
|
21
|
+
* reached that state; a reloaded tour restarts from its beginning instead.
|
|
22
|
+
*
|
|
23
|
+
* The store deliberately knows nothing about WHICH tours exist: the catalog lives in the
|
|
24
|
+
* `tutorialTours` slot (see `modular/tutorial-tours.ts`), so tours ship and evolve — first-
|
|
25
|
+
* party and consumer-contributed alike — without this store changing. It tracks ids and a
|
|
26
|
+
* step cursor; the overlay resolves definitions and drives the cursor.
|
|
27
|
+
*/
|
|
28
|
+
export const useTutorialStore = defineStore(
|
|
29
|
+
'tutorial',
|
|
30
|
+
() => {
|
|
31
|
+
/** The saved launch-prompt answer. Written only by an explicit accept/decline/start. */
|
|
32
|
+
const decision = ref<TutorialDecision | null>(null)
|
|
33
|
+
/** Ids of tours the user finished (reached the last step's Done), persisted. */
|
|
34
|
+
const completedTourIds = ref<string[]>([])
|
|
35
|
+
|
|
36
|
+
const promptOpen = ref(false)
|
|
37
|
+
/** Once-per-session guard for the launch auto-open; later opens are user-driven. */
|
|
38
|
+
const promptAutoOpened = ref(false)
|
|
39
|
+
const activeTourId = ref<string | null>(null)
|
|
40
|
+
const stepIndex = ref(0)
|
|
41
|
+
|
|
42
|
+
/** A tour is currently running (the overlay mounts off this). */
|
|
43
|
+
const touring = computed(() => activeTourId.value !== null)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Auto-open the launch prompt, at most once per session and only while the user has
|
|
47
|
+
* never answered it. Callers gate on the rest of the launch context (board ready, no
|
|
48
|
+
* other startup advisory open) — see `pages/index.vue`.
|
|
49
|
+
*/
|
|
50
|
+
function maybeOfferOnLaunch() {
|
|
51
|
+
if (decision.value !== null || promptAutoOpened.value) return
|
|
52
|
+
promptAutoOpened.value = true
|
|
53
|
+
promptOpen.value = true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** User-driven open (command palette), regardless of any saved decision. */
|
|
57
|
+
function openPrompt() {
|
|
58
|
+
promptOpen.value = true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Withdraw an offer this store made, because something the user actually has to answer
|
|
63
|
+
* (a startup advisory, the GitHub onboarding gate) opened on top of it — and re-arm, so
|
|
64
|
+
* the offer returns once that surface is gone. Distinct from {@link closePrompt}: no
|
|
65
|
+
* decision is written EITHER way, but a deferral was not the user's doing, so it must
|
|
66
|
+
* not consume this session's one offer.
|
|
67
|
+
*
|
|
68
|
+
* Only ever withdraws the auto-opened prompt; a prompt the user opened themselves from
|
|
69
|
+
* the palette is theirs to close.
|
|
70
|
+
*/
|
|
71
|
+
function deferPrompt() {
|
|
72
|
+
if (!promptAutoOpened.value) return
|
|
73
|
+
promptOpen.value = false
|
|
74
|
+
promptAutoOpened.value = false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Close without answering: no decision is written, so the next launch asks again. */
|
|
78
|
+
function closePrompt() {
|
|
79
|
+
promptOpen.value = false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
|
|
83
|
+
function decline() {
|
|
84
|
+
decision.value = 'declined'
|
|
85
|
+
promptOpen.value = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Begin a tour from its first step. Starting one IS accepting the tutorial (also when
|
|
90
|
+
* launched later from the palette after a decline — the user changed their mind, and
|
|
91
|
+
* leaving `declined` in place would misdescribe what happened).
|
|
92
|
+
*/
|
|
93
|
+
function startTour(tourId: string) {
|
|
94
|
+
decision.value = 'accepted'
|
|
95
|
+
promptOpen.value = false
|
|
96
|
+
activeTourId.value = tourId
|
|
97
|
+
stepIndex.value = 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Move the step cursor; the overlay owns bounds/skip logic and never goes below 0. */
|
|
101
|
+
function setStepIndex(index: number) {
|
|
102
|
+
stepIndex.value = Math.max(0, index)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Abandon the running tour without marking it complete (the Skip action). */
|
|
106
|
+
function stopTour() {
|
|
107
|
+
activeTourId.value = null
|
|
108
|
+
stepIndex.value = 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Finish the running tour: record completion (idempotent) and clear the cursor. */
|
|
112
|
+
function completeTour() {
|
|
113
|
+
const id = activeTourId.value
|
|
114
|
+
if (id && !completedTourIds.value.includes(id)) {
|
|
115
|
+
completedTourIds.value = [...completedTourIds.value, id]
|
|
116
|
+
}
|
|
117
|
+
stopTour()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isCompleted(tourId: string): boolean {
|
|
121
|
+
return completedTourIds.value.includes(tourId)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
decision,
|
|
126
|
+
completedTourIds,
|
|
127
|
+
promptOpen,
|
|
128
|
+
promptAutoOpened,
|
|
129
|
+
activeTourId,
|
|
130
|
+
stepIndex,
|
|
131
|
+
touring,
|
|
132
|
+
maybeOfferOnLaunch,
|
|
133
|
+
openPrompt,
|
|
134
|
+
closePrompt,
|
|
135
|
+
deferPrompt,
|
|
136
|
+
decline,
|
|
137
|
+
startTour,
|
|
138
|
+
setStepIndex,
|
|
139
|
+
stopTour,
|
|
140
|
+
completeTour,
|
|
141
|
+
isCompleted,
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
{ persist: { pick: ['decision', 'completedTourIds'] } },
|
|
145
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { WorkspaceListItem, WorkspaceSnapshot } from '~/types/domain'
|
|
3
|
+
import { useAccountsStore } from '~/stores/accounts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared reactive state + injected dependencies the workspace-store board-CRUD factory closes
|
|
7
|
+
* over. Created once in the `workspace` store setup and threaded into
|
|
8
|
+
* {@link createWorkspaceCommands} so the split operations stay behaviourally identical to the
|
|
9
|
+
* original single-closure store — a size-only extraction mirroring `stores/workspace/hydrate.ts`
|
|
10
|
+
* and `stores/workspace/infraSetup.ts`, not a new seam.
|
|
11
|
+
*/
|
|
12
|
+
export interface WorkspaceCommandContext {
|
|
13
|
+
api: ReturnType<typeof useApi>
|
|
14
|
+
workspaceId: Ref<string | null>
|
|
15
|
+
workspaces: Ref<WorkspaceListItem[]>
|
|
16
|
+
hydrate: (snapshot: WorkspaceSnapshot, boardSince?: number) => void
|
|
17
|
+
/** Open one of the active account's boards, creating one when it has none. */
|
|
18
|
+
resolveActiveBoard: () => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Open / create / rename / delete a board, plus switching the active account. */
|
|
22
|
+
export function createWorkspaceCommands(ctx: WorkspaceCommandContext) {
|
|
23
|
+
const { api, workspaceId, workspaces, hydrate, resolveActiveBoard } = ctx
|
|
24
|
+
|
|
25
|
+
/** Switch to another board (within reach of the active account). */
|
|
26
|
+
async function switchTo(id: string) {
|
|
27
|
+
if (id === workspaceId.value) return
|
|
28
|
+
hydrate(await api.getWorkspace(id))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Switch the active account, then open one of its boards (creating one if needed). */
|
|
32
|
+
async function selectAccount(id: string) {
|
|
33
|
+
const accounts = useAccountsStore()
|
|
34
|
+
if (id === accounts.activeAccountId) return
|
|
35
|
+
accounts.switchTo(id)
|
|
36
|
+
workspaceId.value = null
|
|
37
|
+
await resolveActiveBoard()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Create a new board in the active account and open it. */
|
|
41
|
+
async function create(name?: string, description?: string) {
|
|
42
|
+
const accounts = useAccountsStore()
|
|
43
|
+
const snapshot = await api.createWorkspace({
|
|
44
|
+
seed: false,
|
|
45
|
+
name,
|
|
46
|
+
description,
|
|
47
|
+
accountId: accounts.activeAccountId ?? undefined,
|
|
48
|
+
})
|
|
49
|
+
hydrate(snapshot)
|
|
50
|
+
return snapshot.workspace
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Rename a board and/or update its description. */
|
|
54
|
+
async function update(id: string, patch: { name?: string; description?: string | null }) {
|
|
55
|
+
const updated = await api.updateWorkspace(id, patch)
|
|
56
|
+
const i = workspaces.value.findIndex((w) => w.id === id)
|
|
57
|
+
if (i >= 0) workspaces.value[i] = updated
|
|
58
|
+
return updated
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Rename a board (kept for the existing rename callers). */
|
|
62
|
+
async function rename(id: string, name: string) {
|
|
63
|
+
return update(id, { name })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Delete a board; if it was active, fall back to another in the account. */
|
|
67
|
+
async function remove(id: string) {
|
|
68
|
+
await api.deleteWorkspace(id)
|
|
69
|
+
workspaces.value = workspaces.value.filter((w) => w.id !== id)
|
|
70
|
+
if (workspaceId.value === id) {
|
|
71
|
+
workspaceId.value = null
|
|
72
|
+
await resolveActiveBoard()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { switchTo, selectAccount, create, update, rename, remove }
|
|
77
|
+
}
|
package/app/stores/workspace.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
import { useAccountsStore } from '~/stores/accounts'
|
|
11
11
|
import { useBoardStore } from '~/stores/board'
|
|
12
12
|
import { applySnapshotToStores, resetPerBoardCaches } from '~/stores/workspace/hydrate'
|
|
13
|
+
import { createWorkspaceCommands } from '~/stores/workspace/commands'
|
|
13
14
|
import { createInfraSetupState } from '~/stores/workspace/infraSetup'
|
|
14
15
|
import { markBoot } from '~/utils/bootMarks'
|
|
15
16
|
import { retryWhileBackendUnreachable } from '~/utils/backendReady'
|
|
@@ -184,56 +185,16 @@ export const useWorkspaceStore = defineStore(
|
|
|
184
185
|
}
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
accounts.switchTo(id)
|
|
198
|
-
workspaceId.value = null
|
|
199
|
-
await resolveActiveBoard()
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Create a new board in the active account and open it. */
|
|
203
|
-
async function create(name?: string, description?: string) {
|
|
204
|
-
const accounts = useAccountsStore()
|
|
205
|
-
const snapshot = await api.createWorkspace({
|
|
206
|
-
seed: false,
|
|
207
|
-
name,
|
|
208
|
-
description,
|
|
209
|
-
accountId: accounts.activeAccountId ?? undefined,
|
|
210
|
-
})
|
|
211
|
-
hydrate(snapshot)
|
|
212
|
-
return snapshot.workspace
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Rename a board and/or update its description. */
|
|
216
|
-
async function update(id: string, patch: { name?: string; description?: string | null }) {
|
|
217
|
-
const updated = await api.updateWorkspace(id, patch)
|
|
218
|
-
const i = workspaces.value.findIndex((w) => w.id === id)
|
|
219
|
-
if (i >= 0) workspaces.value[i] = updated
|
|
220
|
-
return updated
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Rename a board (kept for the existing rename callers). */
|
|
224
|
-
async function rename(id: string, name: string) {
|
|
225
|
-
return update(id, { name })
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Delete a board; if it was active, fall back to another in the account. */
|
|
229
|
-
async function remove(id: string) {
|
|
230
|
-
await api.deleteWorkspace(id)
|
|
231
|
-
workspaces.value = workspaces.value.filter((w) => w.id !== id)
|
|
232
|
-
if (workspaceId.value === id) {
|
|
233
|
-
workspaceId.value = null
|
|
234
|
-
await resolveActiveBoard()
|
|
235
|
-
}
|
|
236
|
-
}
|
|
188
|
+
// Board CRUD (open / create / rename / delete) + the account switch, extracted into a
|
|
189
|
+
// cohesive factory over the state above — a size-only split mirroring `hydrate.ts` and
|
|
190
|
+
// `infraSetup.ts`.
|
|
191
|
+
const { switchTo, selectAccount, create, update, rename, remove } = createWorkspaceCommands({
|
|
192
|
+
api,
|
|
193
|
+
workspaceId,
|
|
194
|
+
workspaces,
|
|
195
|
+
hydrate,
|
|
196
|
+
resolveActiveBoard,
|
|
197
|
+
})
|
|
237
198
|
|
|
238
199
|
// Monotonic guard for {@link refresh}: `board`-type stream events (and the on-connect resync)
|
|
239
200
|
// each fire a full-snapshot refresh, and {@link hydrate} REPLACES the block list. Without
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { computeCoachMarkLayout, sortTours } from '~/utils/tutorial'
|
|
3
|
+
import type { TutorialTour } from '~/utils/tutorial'
|
|
4
|
+
|
|
5
|
+
const tour = (id: string, order: number): TutorialTour => ({
|
|
6
|
+
id,
|
|
7
|
+
order,
|
|
8
|
+
titleKey: `tutorial.tours.${id}.title`,
|
|
9
|
+
descriptionKey: `tutorial.tours.${id}.description`,
|
|
10
|
+
steps: [],
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('sortTours', () => {
|
|
14
|
+
it('orders by `order`, breaking ties on id, without mutating the input', () => {
|
|
15
|
+
const input = [tour('c', 20), tour('b', 10), tour('a', 20)]
|
|
16
|
+
const sorted = sortTours(input)
|
|
17
|
+
expect(sorted.map((t) => t.id)).toEqual(['b', 'a', 'c'])
|
|
18
|
+
expect(input.map((t) => t.id)).toEqual(['c', 'b', 'a'])
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const viewport = { width: 1000, height: 800 }
|
|
23
|
+
const tooltip = { width: 300, height: 150 }
|
|
24
|
+
|
|
25
|
+
describe('computeCoachMarkLayout', () => {
|
|
26
|
+
it('centers when there is no anchor (intro / wrap-up steps)', () => {
|
|
27
|
+
const layout = computeCoachMarkLayout(null, tooltip, viewport)
|
|
28
|
+
expect(layout.placement).toBe('center')
|
|
29
|
+
expect(layout.left).toBe((1000 - 300) / 2)
|
|
30
|
+
expect(layout.top).toBe((800 - 150) / 2)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('honours a preferred side that fits', () => {
|
|
34
|
+
const target = { top: 400, left: 500, width: 100, height: 40 }
|
|
35
|
+
const layout = computeCoachMarkLayout(target, tooltip, viewport, 'top')
|
|
36
|
+
expect(layout.placement).toBe('top')
|
|
37
|
+
expect(layout.top).toBe(400 - 12 - 150)
|
|
38
|
+
// Cross-axis centered on the anchor.
|
|
39
|
+
expect(layout.left).toBe(500 + 50 - 150)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('falls back when the preferred side has no room', () => {
|
|
43
|
+
// Anchor at the very top: 'top' can't fit a 150px card, so bottom wins.
|
|
44
|
+
const target = { top: 10, left: 500, width: 100, height: 40 }
|
|
45
|
+
const layout = computeCoachMarkLayout(target, tooltip, viewport, 'top')
|
|
46
|
+
expect(layout.placement).toBe('bottom')
|
|
47
|
+
expect(layout.top).toBe(10 + 40 + 12)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('clamps the cross-axis to the viewport instead of overflowing', () => {
|
|
51
|
+
// Anchor hugging the left edge: centering would push the card off-screen.
|
|
52
|
+
const target = { top: 400, left: 0, width: 40, height: 40 }
|
|
53
|
+
const layout = computeCoachMarkLayout(target, tooltip, viewport, 'bottom')
|
|
54
|
+
expect(layout.placement).toBe('bottom')
|
|
55
|
+
expect(layout.left).toBe(8)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('degrades to a clamped bottom position when no side fits at all', () => {
|
|
59
|
+
// A viewport smaller than the card in every direction around the anchor.
|
|
60
|
+
const tinyViewport = { width: 320, height: 200 }
|
|
61
|
+
const target = { top: 80, left: 100, width: 120, height: 40 }
|
|
62
|
+
const layout = computeCoachMarkLayout(target, tooltip, tinyViewport, 'right')
|
|
63
|
+
expect(layout.placement).toBe('bottom')
|
|
64
|
+
// Clamped inside the viewport: overlap beats disappearing off-screen.
|
|
65
|
+
expect(layout.top).toBe(200 - 150 - 8)
|
|
66
|
+
expect(layout.left).toBe(10)
|
|
67
|
+
})
|
|
68
|
+
})
|