@cat-factory/app 0.196.1 → 0.198.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 +105 -0
- package/app/components/github/AddServiceFromRepoModal.vue +6 -1
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +164 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +125 -0
- package/app/components/tutorial/TutorialOverlay.vue +307 -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.spec.ts +4 -1
- package/app/composables/usePipelineErrorToast.ts +24 -165
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +14 -0
- package/app/modular/nav-contributions.ts +70 -0
- package/app/modular/nav-gates.logic.spec.ts +41 -0
- package/app/modular/nav-gates.logic.ts +36 -0
- package/app/modular/nav-gates.ts +54 -0
- package/app/modular/registry.spec.ts +6 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +222 -0
- package/app/modular/tutorial-tours.ts +421 -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 +115 -0
- package/app/utils/tutorial.ts +246 -0
- package/i18n/locales/de.json +209 -2
- package/i18n/locales/en.json +215 -2
- package/i18n/locales/es.json +209 -2
- package/i18n/locales/fr.json +209 -2
- package/i18n/locales/he.json +209 -2
- package/i18n/locales/it.json +209 -2
- package/i18n/locales/ja.json +209 -2
- package/i18n/locales/pl.json +209 -2
- package/i18n/locales/tr.json +209 -2
- package/i18n/locales/uk.json +209 -2
- package/package.json +1 -1
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
computeCoachMarkLayout,
|
|
4
|
+
DEFAULT_TARGET_WAIT_MS,
|
|
5
|
+
TARGET_TRACK_INTERVAL_MS,
|
|
6
|
+
} from '~/utils/tutorial'
|
|
7
|
+
import type { CoachMarkLayout, TutorialRect, TutorialStep, TutorialTour } from '~/utils/tutorial'
|
|
8
|
+
import {
|
|
9
|
+
isTargetClickAdvance,
|
|
10
|
+
resolveSkip,
|
|
11
|
+
stepTargetSelectors,
|
|
12
|
+
unexpectedlySkippedSteps,
|
|
13
|
+
waitBudgetMs,
|
|
14
|
+
} from './TutorialOverlay.logic'
|
|
15
|
+
import type { TutorialDirection } from './TutorialOverlay.logic'
|
|
16
|
+
|
|
17
|
+
// The one shared tour runtime: resolves the running tour from the `tutorialTours` slot,
|
|
18
|
+
// anchors a highlight ring + tooltip to the current step's `data-testid`, and advances
|
|
19
|
+
// on Next or on a real click on the highlighted control. Mounted (from `pages/index.vue`)
|
|
20
|
+
// only while `tutorial.touring`, so all the DOM tracking below exists only mid-tour.
|
|
21
|
+
//
|
|
22
|
+
// Anchor tracking is a poll, not a one-shot query: board controls move (canvas pan/zoom,
|
|
23
|
+
// panels opening) and appear asynchronously (a step can point INTO the modal the previous
|
|
24
|
+
// step's click opens), so every tick re-queries and re-measures. A target that never
|
|
25
|
+
// appears within the step's wait SKIPS the step — controls are RBAC/tier/deployment
|
|
26
|
+
// dependent, and a tour is a set of opportunities, not a fixed script. Everything that
|
|
27
|
+
// DECIDES rather than measures lives in `TutorialOverlay.logic.ts`, which is unit-tested.
|
|
28
|
+
const { t } = useI18n()
|
|
29
|
+
const tutorial = useTutorialStore()
|
|
30
|
+
const { tours } = useTutorialTours()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The running tour's script, resolved ONCE from the slot when the tour starts and then HELD
|
|
34
|
+
* for its duration. Gates decide what is OFFERED; they do not get to rewrite a walkthrough
|
|
35
|
+
* that is already under way.
|
|
36
|
+
*
|
|
37
|
+
* Re-reading the gated slot on every flip was fine while the gates were slow-moving facts
|
|
38
|
+
* (a permission, a connection). Gates over live RUN state flip as a DIRECT RESULT of
|
|
39
|
+
* following the tour: `answer-park` is offered while something is waiting for a human, so
|
|
40
|
+
* the moment the user answered — the very thing the tour teaches — its `when` went false,
|
|
41
|
+
* the slot dropped the tour, and the watch below tore the overlay down one step short of its
|
|
42
|
+
* own finish card, with no completion recorded. Holding the script also freezes the branch
|
|
43
|
+
* `resolveTours` picked, so a step can't be swapped underneath a stationary cursor when a
|
|
44
|
+
* board that had both a decision and an approval loses one of them mid-tour.
|
|
45
|
+
*/
|
|
46
|
+
const tour = shallowRef<TutorialTour | null>(null)
|
|
47
|
+
watch(
|
|
48
|
+
() => tutorial.activeTourId,
|
|
49
|
+
(id) => {
|
|
50
|
+
// Read untracked (a watch callback registers no dependencies), which is what pins the
|
|
51
|
+
// script: only starting a DIFFERENT tour re-resolves it.
|
|
52
|
+
tour.value = id ? (tours.value.find((x) => x.id === id) ?? null) : null
|
|
53
|
+
},
|
|
54
|
+
{ immediate: true },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const step = computed<TutorialStep | null>(() => tour.value?.steps[tutorial.stepIndex] ?? null)
|
|
58
|
+
const total = computed(() => tour.value?.steps.length ?? 0)
|
|
59
|
+
const isLast = computed(() => tour.value !== null && tutorial.stepIndex >= total.value - 1)
|
|
60
|
+
|
|
61
|
+
// The tour could not be resolved when it started (a stale persisted id, or a tour this board
|
|
62
|
+
// is not offered at all) or the cursor ran past the end: end it instead of rendering a dead
|
|
63
|
+
// overlay. Since the script is held, this can no longer fire because a gate flipped mid-tour.
|
|
64
|
+
watch(
|
|
65
|
+
() => [tour.value, step.value] as const,
|
|
66
|
+
([tr, st]) => {
|
|
67
|
+
if (tutorial.touring && (!tr || !st)) tutorial.stopTour()
|
|
68
|
+
},
|
|
69
|
+
{ immediate: true },
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const targetRect = ref<TutorialRect | null>(null)
|
|
73
|
+
const cardEl = ref<HTMLElement | null>(null)
|
|
74
|
+
const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Wall-clock deadline for the current step's anchor search. A deadline rather than a
|
|
78
|
+
* per-tick counter: `measure()` also runs on resize and on every step change, so counting
|
|
79
|
+
* invocations let a resize drag burn a "4000 ms" budget in a fraction of that time.
|
|
80
|
+
*/
|
|
81
|
+
const searchDeadline = ref(0)
|
|
82
|
+
/** Which way `skipMissingStep` travels — see `resolveSkip`. */
|
|
83
|
+
const direction = ref<TutorialDirection>('forward')
|
|
84
|
+
/** Steps this run gave up on, so the final card can be honest about an abridged tour. */
|
|
85
|
+
const skippedStepIds = ref<Set<string>>(new Set())
|
|
86
|
+
|
|
87
|
+
/** A targeted step whose anchor hasn't been found yet (renders the waiting note). */
|
|
88
|
+
const searching = computed(() => step.value?.target !== undefined && targetRect.value === null)
|
|
89
|
+
/** The skips the final card must own up to — a branch-gated step's absence is not one. */
|
|
90
|
+
const unexpectedSkips = computed(() =>
|
|
91
|
+
unexpectedlySkippedSteps(skippedStepIds.value, tour.value?.steps ?? []),
|
|
92
|
+
)
|
|
93
|
+
const abridged = computed(() => unexpectedSkips.value.length > 0)
|
|
94
|
+
|
|
95
|
+
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
|
|
96
|
+
/** The tooltip's own size, or a sensible guess before it has rendered once. */
|
|
97
|
+
const cardSize = () => ({
|
|
98
|
+
width: cardEl.value?.offsetWidth ?? 320,
|
|
99
|
+
height: cardEl.value?.offsetHeight ?? 180,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
function queryTarget(s: TutorialStep): HTMLElement | null {
|
|
103
|
+
for (const selector of stepTargetSelectors(s)) {
|
|
104
|
+
const el = document.querySelector<HTMLElement>(selector)
|
|
105
|
+
// `getClientRects().length` distinguishes a mounted-but-hidden control (display:none
|
|
106
|
+
// drawer item) from a visible one; pointing at an invisible control helps nobody.
|
|
107
|
+
if (el && el.getClientRects().length > 0) return el
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Give up on a step whose anchor never appeared, continuing the user's own direction. */
|
|
113
|
+
function skipMissingStep(s: TutorialStep) {
|
|
114
|
+
skippedStepIds.value = new Set(skippedStepIds.value).add(s.id)
|
|
115
|
+
const outcome = resolveSkip(tutorial.stepIndex, direction.value, total.value)
|
|
116
|
+
if (outcome.kind === 'complete') tutorial.completeTour()
|
|
117
|
+
else tutorial.setStepIndex(outcome.index)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function measure() {
|
|
121
|
+
const s = step.value
|
|
122
|
+
if (!s) return
|
|
123
|
+
if (!s.target) {
|
|
124
|
+
targetRect.value = null
|
|
125
|
+
} else {
|
|
126
|
+
const el = queryTarget(s)
|
|
127
|
+
if (!el) {
|
|
128
|
+
targetRect.value = null
|
|
129
|
+
// Centered while searching: the card must not sit at the PREVIOUS step's anchor —
|
|
130
|
+
// nor at its off-screen initial position — pointing at nothing.
|
|
131
|
+
layout.value = computeCoachMarkLayout(null, cardSize(), viewport())
|
|
132
|
+
if (performance.now() >= searchDeadline.value) skipMissingStep(s)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const r = el.getBoundingClientRect()
|
|
136
|
+
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
|
137
|
+
}
|
|
138
|
+
layout.value = computeCoachMarkLayout(
|
|
139
|
+
s.target ? targetRect.value : null,
|
|
140
|
+
cardSize(),
|
|
141
|
+
viewport(),
|
|
142
|
+
s.placement,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Re-arm per step: fresh wait budget, drop the stale anchor immediately (the ring must not
|
|
147
|
+
// linger on the previous control while the next anchor is located), and re-measure once
|
|
148
|
+
// the card has re-rendered its new copy (its size feeds the layout).
|
|
149
|
+
watch(step, async (s) => {
|
|
150
|
+
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
151
|
+
targetRect.value = null
|
|
152
|
+
await nextTick()
|
|
153
|
+
measure()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
function advance() {
|
|
157
|
+
direction.value = 'forward'
|
|
158
|
+
if (isLast.value) tutorial.completeTour()
|
|
159
|
+
else tutorial.setStepIndex(tutorial.stepIndex + 1)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function back() {
|
|
163
|
+
direction.value = 'back'
|
|
164
|
+
tutorial.setStepIndex(tutorial.stepIndex - 1)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// "Now click this" steps: watch real clicks (capture phase, so a stopPropagation inside a
|
|
168
|
+
// control can't hide them) and follow along AFTER the app has reacted — the deferral lets
|
|
169
|
+
// the real handler open its modal/submit its form before the tour moves its anchor.
|
|
170
|
+
function onDocumentClick(event: MouseEvent) {
|
|
171
|
+
if (isTargetClickAdvance(step.value, event.target)) {
|
|
172
|
+
window.setTimeout(advance, 0)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Esc ends the tour, matching every other dismissible surface in the app. */
|
|
177
|
+
function onKeydown(event: KeyboardEvent) {
|
|
178
|
+
if (event.key === 'Escape') tutorial.stopTour()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let trackTimer: ReturnType<typeof setInterval> | undefined
|
|
182
|
+
onMounted(() => {
|
|
183
|
+
const s = step.value
|
|
184
|
+
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
185
|
+
document.addEventListener('click', onDocumentClick, true)
|
|
186
|
+
document.addEventListener('keydown', onKeydown)
|
|
187
|
+
window.addEventListener('resize', measure)
|
|
188
|
+
trackTimer = setInterval(measure, TARGET_TRACK_INTERVAL_MS)
|
|
189
|
+
measure()
|
|
190
|
+
})
|
|
191
|
+
onUnmounted(() => {
|
|
192
|
+
document.removeEventListener('click', onDocumentClick, true)
|
|
193
|
+
document.removeEventListener('keydown', onKeydown)
|
|
194
|
+
window.removeEventListener('resize', measure)
|
|
195
|
+
if (trackTimer !== undefined) clearInterval(trackTimer)
|
|
196
|
+
})
|
|
197
|
+
</script>
|
|
198
|
+
|
|
199
|
+
<template>
|
|
200
|
+
<!-- Teleported so board/panel stacking contexts can't clip the marks; z-[70] sits above
|
|
201
|
+
the app's modals (z-50s), since steps legitimately point INTO an open modal. -->
|
|
202
|
+
<Teleport to="body">
|
|
203
|
+
<div v-if="step" data-testid="tutorial-overlay">
|
|
204
|
+
<div
|
|
205
|
+
v-if="targetRect"
|
|
206
|
+
class="ring-primary-400 outline-primary-400/25 pointer-events-none fixed z-[70] rounded-lg outline-4 ring-2 transition-all duration-150"
|
|
207
|
+
:style="{
|
|
208
|
+
top: `${targetRect.top - 4}px`,
|
|
209
|
+
left: `${targetRect.left - 4}px`,
|
|
210
|
+
width: `${targetRect.width + 8}px`,
|
|
211
|
+
height: `${targetRect.height + 8}px`,
|
|
212
|
+
}"
|
|
213
|
+
data-testid="tutorial-highlight"
|
|
214
|
+
/>
|
|
215
|
+
<!-- `pointer-events-auto` AND the swallowed `pointerdown` are both required for the
|
|
216
|
+
steps that point INSIDE an open modal: Nuxt UI's modal is a reka-ui dismissable
|
|
217
|
+
layer, which sets `body { pointer-events: none }` (leaving this card inert) and
|
|
218
|
+
dismisses on a document-level pointerdown outside its own content (so a press on
|
|
219
|
+
this card would close the user's half-filled form instead of pressing a button). -->
|
|
220
|
+
<div
|
|
221
|
+
ref="cardEl"
|
|
222
|
+
role="dialog"
|
|
223
|
+
aria-live="polite"
|
|
224
|
+
:aria-label="t('tutorial.overlay.ariaLabel')"
|
|
225
|
+
class="pointer-events-auto fixed z-[70] w-80 max-w-[calc(100vw-16px)] rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
|
|
226
|
+
:style="{ top: `${layout.top}px`, left: `${layout.left}px` }"
|
|
227
|
+
data-testid="tutorial-tooltip"
|
|
228
|
+
@pointerdown.stop
|
|
229
|
+
>
|
|
230
|
+
<div class="mb-1 flex items-start justify-between gap-3">
|
|
231
|
+
<h3 class="text-sm font-semibold text-slate-100">{{ t(step.titleKey) }}</h3>
|
|
232
|
+
<span class="shrink-0 text-xs text-slate-500">
|
|
233
|
+
{{ t('tutorial.overlay.progress', { current: tutorial.stepIndex + 1, total }) }}
|
|
234
|
+
</span>
|
|
235
|
+
</div>
|
|
236
|
+
<!-- `bodyParams` carries the fixed proper nouns a step names (a repository slug),
|
|
237
|
+
which live in the catalog's `{named}` placeholders rather than in nine
|
|
238
|
+
translations of the same literal. Absent for most steps. -->
|
|
239
|
+
<p class="text-sm text-slate-300">{{ t(step.bodyKey, step.bodyParams ?? {}) }}</p>
|
|
240
|
+
<p
|
|
241
|
+
v-if="searching"
|
|
242
|
+
class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
|
|
243
|
+
data-testid="tutorial-searching"
|
|
244
|
+
>
|
|
245
|
+
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
|
|
246
|
+
{{ t('tutorial.overlay.searching') }}
|
|
247
|
+
</p>
|
|
248
|
+
<p
|
|
249
|
+
v-else-if="step.advanceOn === 'target-click'"
|
|
250
|
+
class="text-primary-300 mt-2 text-xs"
|
|
251
|
+
data-testid="tutorial-click-hint"
|
|
252
|
+
>
|
|
253
|
+
{{ t('tutorial.overlay.clickHint') }}
|
|
254
|
+
</p>
|
|
255
|
+
<!-- Reaching the end having skipped steps is NOT the same as having been shown the
|
|
256
|
+
whole tour: say so rather than congratulating the user either way. -->
|
|
257
|
+
<p
|
|
258
|
+
v-if="isLast && abridged"
|
|
259
|
+
class="mt-2 text-xs text-amber-300/90"
|
|
260
|
+
data-testid="tutorial-abridged"
|
|
261
|
+
>
|
|
262
|
+
{{
|
|
263
|
+
t(
|
|
264
|
+
'tutorial.overlay.abridged',
|
|
265
|
+
{ count: unexpectedSkips.length },
|
|
266
|
+
unexpectedSkips.length,
|
|
267
|
+
)
|
|
268
|
+
}}
|
|
269
|
+
</p>
|
|
270
|
+
<div class="mt-3 flex items-center justify-between gap-2">
|
|
271
|
+
<UButton
|
|
272
|
+
size="xs"
|
|
273
|
+
variant="ghost"
|
|
274
|
+
color="neutral"
|
|
275
|
+
data-testid="tutorial-skip"
|
|
276
|
+
@click="tutorial.stopTour()"
|
|
277
|
+
>
|
|
278
|
+
{{ t('tutorial.overlay.skip') }}
|
|
279
|
+
</UButton>
|
|
280
|
+
<div class="flex items-center gap-2">
|
|
281
|
+
<UButton
|
|
282
|
+
v-if="tutorial.stepIndex > 0"
|
|
283
|
+
size="xs"
|
|
284
|
+
variant="soft"
|
|
285
|
+
color="neutral"
|
|
286
|
+
data-testid="tutorial-back"
|
|
287
|
+
@click="back()"
|
|
288
|
+
>
|
|
289
|
+
{{ t('tutorial.overlay.back') }}
|
|
290
|
+
</UButton>
|
|
291
|
+
<!-- A click-to-advance step hides Next so the real control is the only way
|
|
292
|
+
forward (its Done form stays on a last step, which must be finishable). -->
|
|
293
|
+
<UButton
|
|
294
|
+
v-if="step.advanceOn !== 'target-click' || isLast"
|
|
295
|
+
size="xs"
|
|
296
|
+
color="primary"
|
|
297
|
+
data-testid="tutorial-next"
|
|
298
|
+
@click="advance()"
|
|
299
|
+
>
|
|
300
|
+
{{ isLast ? t('tutorial.overlay.done') : t('tutorial.overlay.next') }}
|
|
301
|
+
</UButton>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</Teleport>
|
|
307
|
+
</template>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The tutorial launch prompt: asks once on first launch whether the user wants a guided
|
|
3
|
+
// tour, and doubles as the tour picker for later visits (command palette: "Take a tour").
|
|
4
|
+
// Lists whatever the merged `tutorialTours` slot offers this user (first-party + consumer
|
|
5
|
+
// tours, RBAC-gated per tour), so it grows with the catalog rather than hard-coding tours.
|
|
6
|
+
//
|
|
7
|
+
// The decision semantics live in the store: starting a tour or "No thanks" is SAVED (the
|
|
8
|
+
// prompt never auto-opens again), while closing without answering defers to next launch.
|
|
9
|
+
const { t } = useI18n()
|
|
10
|
+
const tutorial = useTutorialStore()
|
|
11
|
+
const { tours } = useTutorialTours()
|
|
12
|
+
|
|
13
|
+
const open = computed({
|
|
14
|
+
get: () => tutorial.promptOpen,
|
|
15
|
+
set: (v: boolean) => (v ? tutorial.openPrompt() : tutorial.closePrompt()),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Only an unanswered prompt offers the persistent "No thanks"; once a decision exists this
|
|
19
|
+
// is just a picker, and the only dismissal left is a plain close.
|
|
20
|
+
const undecided = computed(() => tutorial.decision === null)
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<UModal v-model:open="open" :title="t('tutorial.prompt.title')" :ui="{ content: 'max-w-lg' }">
|
|
25
|
+
<template #body>
|
|
26
|
+
<!-- NOTE for spec authors: this marks the modal's BODY, so it is the "prompt is
|
|
27
|
+
showing" signal, not a container for everything in the prompt. The footer's
|
|
28
|
+
decline/close buttons are in a SIBLING slot — address them from the page by
|
|
29
|
+
their own test ids rather than scoping a locator under this one. -->
|
|
30
|
+
<div class="space-y-4" data-testid="tutorial-prompt">
|
|
31
|
+
<p class="text-sm text-slate-300">{{ t('tutorial.prompt.intro') }}</p>
|
|
32
|
+
<ul class="space-y-2">
|
|
33
|
+
<li
|
|
34
|
+
v-for="tour in tours"
|
|
35
|
+
:key="tour.id"
|
|
36
|
+
class="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
37
|
+
>
|
|
38
|
+
<UIcon
|
|
39
|
+
:name="tour.icon ?? 'i-lucide-compass'"
|
|
40
|
+
class="h-5 w-5 shrink-0 text-primary-400"
|
|
41
|
+
/>
|
|
42
|
+
<div class="min-w-0 flex-1">
|
|
43
|
+
<div class="flex items-center gap-2">
|
|
44
|
+
<span class="text-sm font-medium text-slate-100">{{ t(tour.titleKey) }}</span>
|
|
45
|
+
<UBadge
|
|
46
|
+
v-if="tutorial.isCompleted(tour.id)"
|
|
47
|
+
color="success"
|
|
48
|
+
variant="subtle"
|
|
49
|
+
size="sm"
|
|
50
|
+
data-testid="tutorial-tour-completed"
|
|
51
|
+
>
|
|
52
|
+
{{ t('tutorial.prompt.completed') }}
|
|
53
|
+
</UBadge>
|
|
54
|
+
</div>
|
|
55
|
+
<p class="text-xs text-slate-400">{{ t(tour.descriptionKey) }}</p>
|
|
56
|
+
</div>
|
|
57
|
+
<UButton
|
|
58
|
+
size="sm"
|
|
59
|
+
color="primary"
|
|
60
|
+
:variant="tutorial.isCompleted(tour.id) ? 'soft' : 'solid'"
|
|
61
|
+
:data-testid="`tutorial-start-${tour.id}`"
|
|
62
|
+
@click="tutorial.startTour(tour.id)"
|
|
63
|
+
>
|
|
64
|
+
{{
|
|
65
|
+
tutorial.isCompleted(tour.id)
|
|
66
|
+
? t('tutorial.prompt.restart')
|
|
67
|
+
: t('tutorial.prompt.start')
|
|
68
|
+
}}
|
|
69
|
+
</UButton>
|
|
70
|
+
</li>
|
|
71
|
+
</ul>
|
|
72
|
+
<!-- Every tour gated away (e.g. a viewer on a write-only catalog): say so rather
|
|
73
|
+
than showing an unexplained empty list. -->
|
|
74
|
+
<p v-if="tours.length === 0" class="text-sm text-slate-400">
|
|
75
|
+
{{ t('tutorial.prompt.empty') }}
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
79
|
+
<template #footer>
|
|
80
|
+
<div class="flex w-full items-center justify-between gap-2">
|
|
81
|
+
<UButton
|
|
82
|
+
v-if="undecided"
|
|
83
|
+
color="neutral"
|
|
84
|
+
variant="ghost"
|
|
85
|
+
data-testid="tutorial-decline"
|
|
86
|
+
@click="tutorial.decline()"
|
|
87
|
+
>
|
|
88
|
+
{{ t('tutorial.prompt.decline') }}
|
|
89
|
+
</UButton>
|
|
90
|
+
<span v-else />
|
|
91
|
+
<UButton
|
|
92
|
+
color="neutral"
|
|
93
|
+
variant="soft"
|
|
94
|
+
data-testid="tutorial-close"
|
|
95
|
+
@click="tutorial.closePrompt()"
|
|
96
|
+
>
|
|
97
|
+
{{ undecided ? t('tutorial.prompt.later') : t('common.close') }}
|
|
98
|
+
</UButton>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
101
|
+
</UModal>
|
|
102
|
+
</template>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ParsedConflict } from '~/composables/usePipelineErrorToast'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The bespoke conflict toasts: the five 409 reasons whose copy interpolates runtime detail and
|
|
5
|
+
* whose remedy is a one-click jump into the panel that fixes them. Split out of
|
|
6
|
+
* {@link usePipelineErrorToast} purely for size — it closes over the SAME toast / ui / i18n
|
|
7
|
+
* handles, so behaviour is identical to the former in-composable functions.
|
|
8
|
+
*/
|
|
9
|
+
export function createBespokeConflictToasts(deps: {
|
|
10
|
+
toast: ReturnType<typeof useToast>
|
|
11
|
+
ui: ReturnType<typeof useUiStore>
|
|
12
|
+
t: ReturnType<typeof useI18n>['t']
|
|
13
|
+
te: ReturnType<typeof useI18n>['te']
|
|
14
|
+
}): (conflict: ParsedConflict) => boolean {
|
|
15
|
+
const { toast, ui, t, te } = deps
|
|
16
|
+
// The headline case: a pipeline step's model has no usable provider. Name the
|
|
17
|
+
// offending model(s), explain no provider is available, and offer the one-click jump
|
|
18
|
+
// to the AI setup — the same remedy the startup "No AI model configured" banner gives.
|
|
19
|
+
function presentProvidersUnconfigured(conflict: ParsedConflict): void {
|
|
20
|
+
const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
|
|
21
|
+
const list = models.join(', ')
|
|
22
|
+
toast.add({
|
|
23
|
+
title: t('errors.conflict.providersUnconfigured.title'),
|
|
24
|
+
description: list
|
|
25
|
+
? t('errors.conflict.providersUnconfigured.body', { models: list })
|
|
26
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
27
|
+
color: 'error',
|
|
28
|
+
icon: 'i-lucide-cpu',
|
|
29
|
+
// Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
|
|
30
|
+
// auto-dismiss takes the one-click fix with it before the user can reach it.
|
|
31
|
+
duration: 0,
|
|
32
|
+
actions: [
|
|
33
|
+
{
|
|
34
|
+
label: t('errors.conflict.providersUnconfigured.action'),
|
|
35
|
+
icon: 'i-lucide-settings',
|
|
36
|
+
onClick: () => ui.openAiProviderSetup(),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
|
|
43
|
+
// but the account has none configured. Explain it and offer the jump to the content-storage
|
|
44
|
+
// settings — the same shape as the providers-unconfigured case above. Prefer the localized
|
|
45
|
+
// body (it carries no runtime interpolation) so non-English users see translated copy; the
|
|
46
|
+
// raw backend prose is only the last-resort fallback when the locale lacks the key.
|
|
47
|
+
function presentBinaryStorageUnconfigured(conflict: ParsedConflict): void {
|
|
48
|
+
toast.add({
|
|
49
|
+
title: t('errors.conflict.binaryStorageUnconfigured.title'),
|
|
50
|
+
description: te('errors.conflict.binaryStorageUnconfigured.body')
|
|
51
|
+
? t('errors.conflict.binaryStorageUnconfigured.body')
|
|
52
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
53
|
+
color: 'error',
|
|
54
|
+
icon: 'i-lucide-image',
|
|
55
|
+
// Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
|
|
56
|
+
// remedy reachable instead of letting it auto-dismiss.
|
|
57
|
+
duration: 0,
|
|
58
|
+
actions: [
|
|
59
|
+
{
|
|
60
|
+
label: t('errors.conflict.binaryStorageUnconfigured.action'),
|
|
61
|
+
icon: 'i-lucide-settings',
|
|
62
|
+
onClick: () => ui.openContentStorageSettings(),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
|
|
69
|
+
// "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
|
|
70
|
+
// environment config — the compose wizard for docker-compose, the service inspector otherwise —
|
|
71
|
+
// falling back to the workspace infrastructure window if the frame id wasn't carried.
|
|
72
|
+
function presentDeployerServiceConfig(conflict: ParsedConflict): void {
|
|
73
|
+
const frameId =
|
|
74
|
+
typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
|
|
75
|
+
const provisionType =
|
|
76
|
+
typeof conflict.details.provisionType === 'string'
|
|
77
|
+
? conflict.details.provisionType
|
|
78
|
+
: undefined
|
|
79
|
+
const missing = Array.isArray(conflict.details.missing)
|
|
80
|
+
? conflict.details.missing.join(', ')
|
|
81
|
+
: ''
|
|
82
|
+
toast.add({
|
|
83
|
+
title: t('errors.conflict.deployerServiceConfig.title'),
|
|
84
|
+
description: missing
|
|
85
|
+
? t('errors.conflict.deployerServiceConfig.body', { missing })
|
|
86
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
87
|
+
color: 'error',
|
|
88
|
+
icon: 'i-lucide-server',
|
|
89
|
+
// Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
|
|
90
|
+
duration: 0,
|
|
91
|
+
actions: [
|
|
92
|
+
{
|
|
93
|
+
label: t('errors.conflict.deployerServiceConfig.action'),
|
|
94
|
+
icon: 'i-lucide-settings',
|
|
95
|
+
onClick: () => {
|
|
96
|
+
if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
|
|
97
|
+
else if (frameId) ui.select(frameId)
|
|
98
|
+
else ui.openProviderConnection('environment')
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
|
|
106
|
+
// resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
|
|
107
|
+
// window's Test-environments tab. (Also raised by the Tester start gate — same fix applies.)
|
|
108
|
+
function presentProvisionTypeUnhandled(conflict: ParsedConflict): void {
|
|
109
|
+
const type =
|
|
110
|
+
typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
|
|
111
|
+
toast.add({
|
|
112
|
+
title: t('errors.conflict.provisionTypeUnhandled.title'),
|
|
113
|
+
description: type
|
|
114
|
+
? t('errors.conflict.provisionTypeUnhandled.body', { type })
|
|
115
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
116
|
+
color: 'error',
|
|
117
|
+
icon: 'i-lucide-server-cog',
|
|
118
|
+
duration: 0,
|
|
119
|
+
actions: [
|
|
120
|
+
{
|
|
121
|
+
label: t('errors.conflict.provisionTypeUnhandled.action'),
|
|
122
|
+
icon: 'i-lucide-settings',
|
|
123
|
+
onClick: () => ui.openProviderConnection('environment'),
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A pipeline includes a Deployer, the config is structurally complete, but the live connection
|
|
130
|
+
// probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
|
|
131
|
+
// token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
|
|
132
|
+
function presentDeployerConnectionFailed(conflict: ParsedConflict): void {
|
|
133
|
+
const detail = typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
|
|
134
|
+
toast.add({
|
|
135
|
+
title: t('errors.conflict.deployerConnectionFailed.title'),
|
|
136
|
+
description: detail
|
|
137
|
+
? t('errors.conflict.deployerConnectionFailed.body', { detail })
|
|
138
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
139
|
+
color: 'error',
|
|
140
|
+
icon: 'i-lucide-plug',
|
|
141
|
+
duration: 0,
|
|
142
|
+
actions: [
|
|
143
|
+
{
|
|
144
|
+
label: t('errors.conflict.deployerConnectionFailed.action'),
|
|
145
|
+
icon: 'i-lucide-settings',
|
|
146
|
+
onClick: () => ui.openProviderConnection('environment'),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Dispatch the bespoke conflict reasons (a runtime-interpolated body + a "configure X" action,
|
|
154
|
+
* each with its own key namespace — the ones excluded from `CONFLICT_INFO`). Returns `true` when
|
|
155
|
+
* the reason was one of them (and the toast was raised), `false` to fall through to the generic
|
|
156
|
+
* map. The reason values are mutually exclusive, so dispatch order is irrelevant.
|
|
157
|
+
*/
|
|
158
|
+
function presentBespokeConflict(conflict: ParsedConflict): boolean {
|
|
159
|
+
switch (conflict.reason) {
|
|
160
|
+
case 'providers_unconfigured':
|
|
161
|
+
presentProvidersUnconfigured(conflict)
|
|
162
|
+
return true
|
|
163
|
+
case 'binary_storage_unconfigured':
|
|
164
|
+
presentBinaryStorageUnconfigured(conflict)
|
|
165
|
+
return true
|
|
166
|
+
case 'deployer_service_provisioning_incomplete':
|
|
167
|
+
presentDeployerServiceConfig(conflict)
|
|
168
|
+
return true
|
|
169
|
+
case 'provision_type_unhandled':
|
|
170
|
+
presentProvisionTypeUnhandled(conflict)
|
|
171
|
+
return true
|
|
172
|
+
case 'deployer_connection_test_failed':
|
|
173
|
+
presentDeployerConnectionFailed(conflict)
|
|
174
|
+
return true
|
|
175
|
+
default:
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return presentBespokeConflict
|
|
181
|
+
}
|
|
@@ -47,6 +47,7 @@ export function useNavContributions() {
|
|
|
47
47
|
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
48
48
|
reports: () => ui.openReports(),
|
|
49
49
|
shortcuts: () => ui.openShortcutsHelp(),
|
|
50
|
+
tutorial: () => useTutorialStore().openPrompt(),
|
|
50
51
|
// No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
|
|
51
52
|
// switcher's read-only state rather than pretending to flip a tier the resolver fixes.
|
|
52
53
|
toggleUiMode: () => useUiModeStore().toggleMode(),
|
|
@@ -49,7 +49,10 @@ beforeEach(() => {
|
|
|
49
49
|
}
|
|
50
50
|
vi.stubGlobal('useToast', () => ({ add, update }))
|
|
51
51
|
vi.stubGlobal('useUiStore', () => ui)
|
|
52
|
-
|
|
52
|
+
// Stubbed on the Nuxt app's GLOBAL i18n instance, which is what this composable resolves
|
|
53
|
+
// (never `useI18n()`): it is called from store setup, where no component instance exists.
|
|
54
|
+
// See `frontend/app/README.md` — "A store must be instantiable outside a component setup".
|
|
55
|
+
vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t, te: (key: string) => hasKey(key) } }))
|
|
53
56
|
})
|
|
54
57
|
|
|
55
58
|
function conflict(reason?: string, details: Record<string, unknown> = {}, message?: string) {
|