@cat-factory/app 0.134.0 → 0.135.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/environments/EnvironmentSetupWizard.vue +70 -632
- package/app/components/environments/steps/EnvPickStep.vue +40 -0
- package/app/components/environments/steps/EnvPreflightStep.vue +115 -0
- package/app/components/environments/steps/EnvReviewStep.vue +381 -0
- package/app/components/environments/steps/EnvSaveStep.vue +109 -0
- package/app/components/environments/steps/JourneyStepNav.vue +30 -0
- package/app/modular/journeys/environmentSetup.frame.ts +31 -0
- package/app/modular/journeys/environmentSetup.logic.spec.ts +34 -0
- package/app/modular/journeys/environmentSetup.logic.ts +86 -0
- package/app/modular/journeys/environmentSetup.ts +143 -0
- package/app/modular/journeys/persistence.spec.ts +68 -0
- package/app/modular/journeys/persistence.ts +58 -0
- package/app/modular/registry.ts +9 -3
- package/app/plugins/modular.client.ts +37 -4
- package/app/stores/environmentWizard.ts +23 -27
- package/package.json +11 -10
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type MaybeRefOrGetter, toValue, watch } from 'vue'
|
|
2
|
+
import { useEnvironmentWizardStore } from '~/stores/environmentWizard'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridge a journey step's target frame (`ModuleEntryProps.input.frameId`) into
|
|
6
|
+
* the `environmentWizard` data store (slice 3 of the modular-vue adoption).
|
|
7
|
+
*
|
|
8
|
+
* The journey owns per-frame NAVIGATION (its state carries the resolved
|
|
9
|
+
* `frameId`), but the heavy per-step DATA lives in the singleton
|
|
10
|
+
* `environmentWizard` store, which holds exactly ONE frame at a time. Every step
|
|
11
|
+
* that READS that store therefore has to (re)assert the frame it was entered for
|
|
12
|
+
* — otherwise the store can be pointing at a DIFFERENT frame than the one the
|
|
13
|
+
* journey is on, and the step renders (or `save()` persists) the wrong frame's
|
|
14
|
+
* recipe. That desync is reachable within a single session: configure frame A up
|
|
15
|
+
* to the preflight step, open the wizard for frame B (its review step repoints
|
|
16
|
+
* the store to B), then reopen A and RESUME at preflight — a step that never
|
|
17
|
+
* re-bridged would show B's data under A's journey.
|
|
18
|
+
*
|
|
19
|
+
* `beginForFrame` is idempotent per frame (a no-op when the store already targets
|
|
20
|
+
* `frameId`), so calling this from every data step is free on the happy path and
|
|
21
|
+
* only re-seeds when the store actually drifted. `immediate` so the store is
|
|
22
|
+
* targeted before the step's first render reads it.
|
|
23
|
+
*/
|
|
24
|
+
export function useEnvironmentWizardTarget(frameId: MaybeRefOrGetter<string | null>): void {
|
|
25
|
+
const store = useEnvironmentWizardStore()
|
|
26
|
+
watch(
|
|
27
|
+
() => toValue(frameId),
|
|
28
|
+
(id) => store.beginForFrame(id),
|
|
29
|
+
{ immediate: true },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ENV_STEP_ORDER,
|
|
4
|
+
envInitialState,
|
|
5
|
+
envNextAfter,
|
|
6
|
+
envStartStep,
|
|
7
|
+
} from './environmentSetup.logic'
|
|
8
|
+
|
|
9
|
+
// Pure navigation graph for the environment-setup journey (slice 3). `envNextAfter`
|
|
10
|
+
// is the source of truth the journey's `advance` transitions derive their target
|
|
11
|
+
// entries from (see `environmentSetup.ts`), so pinning the order, start branch, and
|
|
12
|
+
// forward edges here means a wiring regression can't silently reorder or drop a step.
|
|
13
|
+
describe('environment-setup journey logic', () => {
|
|
14
|
+
it('orders the steps pick → review → preflight → save', () => {
|
|
15
|
+
expect(ENV_STEP_ORDER).toEqual(['pick', 'review', 'preflight', 'save'])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('seeds state from the launch input', () => {
|
|
19
|
+
expect(envInitialState({ frameId: 'blk_1' })).toEqual({ frameId: 'blk_1' })
|
|
20
|
+
expect(envInitialState({ frameId: null })).toEqual({ frameId: null })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('skips the picker when a frame was preselected, else starts at pick', () => {
|
|
24
|
+
expect(envStartStep({ frameId: 'blk_1' }, { frameId: 'blk_1' })).toBe('review')
|
|
25
|
+
expect(envStartStep({ frameId: null }, { frameId: null })).toBe('pick')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('advances linearly and terminates after save', () => {
|
|
29
|
+
expect(envNextAfter('pick')).toBe('review')
|
|
30
|
+
expect(envNextAfter('review')).toBe('preflight')
|
|
31
|
+
expect(envNextAfter('preflight')).toBe('save')
|
|
32
|
+
expect(envNextAfter('save')).toBe('done')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure navigation logic for the environment-setup journey (slice 3 of the
|
|
3
|
+
* modular-vue adoption — docs/initiatives/modular-vue-adoption.md).
|
|
4
|
+
*
|
|
5
|
+
* The journey owns only the WIZARD NAVIGATION — the ordered steps
|
|
6
|
+
* (pick → review → preflight → save), the forward transitions, and the
|
|
7
|
+
* back/rewind + resume that used to live as a hand-rolled `STEP_ORDER` + `step`
|
|
8
|
+
* ref + `goToStep` in `stores/environmentWizard.ts`. The heavy per-step DATA and
|
|
9
|
+
* async actions (detect / deep-analysis / recipe editing / preflight / save /
|
|
10
|
+
* trial) stay in that Pinia store, which the step components still drive; the
|
|
11
|
+
* journey just decides WHICH step shows and threads the target frame id.
|
|
12
|
+
*
|
|
13
|
+
* This file is deliberately free of Vue/`.vue`/`defineModule` imports so the
|
|
14
|
+
* transition graph is unit-tested directly (`environmentSetup.spec.ts`); the
|
|
15
|
+
* component wiring lives in `environmentSetup.ts`, which feeds these helpers into
|
|
16
|
+
* `defineJourney`'s `transitions` map.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The journey's ordered step entries (also the module's `entryPoints` keys). */
|
|
20
|
+
export const ENV_STEP_ORDER = ['pick', 'review', 'preflight', 'save'] as const
|
|
21
|
+
export type EnvStep = (typeof ENV_STEP_ORDER)[number]
|
|
22
|
+
|
|
23
|
+
/** The single module id every step entry lives under. */
|
|
24
|
+
export const ENV_MODULE_ID = 'cat-factory:environment-setup' as const
|
|
25
|
+
|
|
26
|
+
/** The picker's exit — carries the chosen frame into journey state. */
|
|
27
|
+
export const ENV_SELECT_EXIT = 'select'
|
|
28
|
+
/** Every other step's forward exit — void, just advances. */
|
|
29
|
+
export const ENV_ADVANCE_EXIT = 'advance'
|
|
30
|
+
|
|
31
|
+
/** Payload of the picker's `select` exit. */
|
|
32
|
+
export interface EnvSelectOutput {
|
|
33
|
+
frameId: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The journey's serializable state — just the resolved target frame. Everything
|
|
37
|
+
* else is derived live in the `environmentWizard` store, so this stays tiny and
|
|
38
|
+
* cheap to persist/resume. */
|
|
39
|
+
export interface EnvSetupState {
|
|
40
|
+
frameId: string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The journey's start input — the frame the launcher preselected, or `null` to
|
|
44
|
+
* begin at the pick step. `keyFor` scopes resume to this frame. */
|
|
45
|
+
export interface EnvSetupInput {
|
|
46
|
+
frameId: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Seed the journey state from the launch input. */
|
|
50
|
+
export function envInitialState(input: EnvSetupInput): EnvSetupState {
|
|
51
|
+
return { frameId: input.frameId }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** First step: skip the picker when the launcher already chose a frame. */
|
|
55
|
+
export function envStartStep(_state: EnvSetupState, input: EnvSetupInput): EnvStep {
|
|
56
|
+
return input.frameId ? 'review' : 'pick'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The step reached by advancing from `from` via its forward exit, or `'done'`
|
|
61
|
+
* when the flow completes. The pick step advances via `select` (a distinct
|
|
62
|
+
* exit); the rest via `advance`. Pure and total over `EnvStep`.
|
|
63
|
+
*
|
|
64
|
+
* This is the SINGLE SOURCE OF TRUTH for the linear forward chain: the journey
|
|
65
|
+
* definition (`environmentSetup.ts`) derives its `advance` transitions' target
|
|
66
|
+
* entries from it, so pinning this function in `environmentSetup.logic.spec.ts`
|
|
67
|
+
* genuinely guards the wired graph against a silent reorder/drop. The overloads
|
|
68
|
+
* narrow the return to a concrete entry (or `'done'`) so a transition handler can
|
|
69
|
+
* feed the result straight into a `StepSpec` without a widened `EnvStep | 'done'`.
|
|
70
|
+
*/
|
|
71
|
+
export function envNextAfter(from: 'pick'): 'review'
|
|
72
|
+
export function envNextAfter(from: 'review'): 'preflight'
|
|
73
|
+
export function envNextAfter(from: 'preflight'): 'save'
|
|
74
|
+
export function envNextAfter(from: 'save'): 'done'
|
|
75
|
+
export function envNextAfter(from: EnvStep): EnvStep | 'done' {
|
|
76
|
+
switch (from) {
|
|
77
|
+
case 'pick':
|
|
78
|
+
return 'review'
|
|
79
|
+
case 'review':
|
|
80
|
+
return 'preflight'
|
|
81
|
+
case 'preflight':
|
|
82
|
+
return 'save'
|
|
83
|
+
case 'save':
|
|
84
|
+
return 'done'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { defineModule } from '@modular-vue/core'
|
|
2
|
+
import { defineExit } from '@modular-frontend/core'
|
|
3
|
+
import { defineJourney, defineJourneyHandle } from '@modular-vue/journeys'
|
|
4
|
+
import EnvPickStep from '~/components/environments/steps/EnvPickStep.vue'
|
|
5
|
+
import EnvReviewStep from '~/components/environments/steps/EnvReviewStep.vue'
|
|
6
|
+
import EnvPreflightStep from '~/components/environments/steps/EnvPreflightStep.vue'
|
|
7
|
+
import EnvSaveStep from '~/components/environments/steps/EnvSaveStep.vue'
|
|
8
|
+
import { catFactoryJourneyPersistence } from '~/modular/journeys/persistence'
|
|
9
|
+
import {
|
|
10
|
+
ENV_ADVANCE_EXIT,
|
|
11
|
+
ENV_MODULE_ID,
|
|
12
|
+
ENV_SELECT_EXIT,
|
|
13
|
+
type EnvSelectOutput,
|
|
14
|
+
type EnvSetupInput,
|
|
15
|
+
type EnvSetupState,
|
|
16
|
+
envInitialState,
|
|
17
|
+
envNextAfter,
|
|
18
|
+
envStartStep,
|
|
19
|
+
} from '~/modular/journeys/environmentSetup.logic'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The environment-setup journey — the slice-3 pilot of the modular-vue adoption
|
|
23
|
+
* (docs/initiatives/modular-vue-adoption.md). It replaces the wizard's hand-rolled
|
|
24
|
+
* `STEP_ORDER` + `step` ref + `goToStep` navigation (in `stores/environmentWizard.ts`)
|
|
25
|
+
* with a typed, back/rewind-capable, resumable journey; the per-step data + async
|
|
26
|
+
* actions stay in that Pinia store, driven by the step components below.
|
|
27
|
+
*
|
|
28
|
+
* Authoring notes (co-evolution): `defineModule` comes from the `@modular-vue/core`
|
|
29
|
+
* binding, but the exit helper `defineExit` is only exported from the neutral
|
|
30
|
+
* `@modular-frontend/core` engine (a direct dep) — the Vue binding doesn't re-export
|
|
31
|
+
* the entry/exit authoring helpers yet. That's a small binding-surface gap to close
|
|
32
|
+
* upstream (mirrors slice 2's remote-manifest re-export), not a blocker.
|
|
33
|
+
*
|
|
34
|
+
* Each step's `input` (the target frame) is threaded explicitly on the `next` step
|
|
35
|
+
* specs rather than via an entry `buildInput` factory: the frame is fixed once at the
|
|
36
|
+
* pick step and never changes mid-flow, so the snapshot input a transition places is
|
|
37
|
+
* always current — even on `preserve-state` back-navigation — which keeps the module
|
|
38
|
+
* type simple (no `buildInput`-optionality quirk) and the flow fully explicit.
|
|
39
|
+
*
|
|
40
|
+
* This file imports the step `.vue` components, so — like `result-views.ts` — it's
|
|
41
|
+
* registered from the client plugin, keeping the unit-tested `registry.ts` /
|
|
42
|
+
* `*.logic.ts` import graph free of SFCs.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The step module: one module, four `journey`-mounted entries (the wizard steps),
|
|
47
|
+
* and two exits — `select` (the picker's chosen frame) and `advance` (every other
|
|
48
|
+
* step's void forward). Each non-initial entry opts into `preserve-state` back so
|
|
49
|
+
* the host's Back control rewinds a step without discarding the operator's edits.
|
|
50
|
+
*/
|
|
51
|
+
export const environmentSetupModule = defineModule({
|
|
52
|
+
id: ENV_MODULE_ID,
|
|
53
|
+
version: '1.0.0',
|
|
54
|
+
entryPoints: {
|
|
55
|
+
pick: { mountKinds: ['journey'] as const, component: EnvPickStep },
|
|
56
|
+
review: {
|
|
57
|
+
mountKinds: ['journey'] as const,
|
|
58
|
+
allowBack: 'preserve-state' as const,
|
|
59
|
+
component: EnvReviewStep,
|
|
60
|
+
},
|
|
61
|
+
preflight: {
|
|
62
|
+
mountKinds: ['journey'] as const,
|
|
63
|
+
allowBack: 'preserve-state' as const,
|
|
64
|
+
component: EnvPreflightStep,
|
|
65
|
+
},
|
|
66
|
+
save: {
|
|
67
|
+
mountKinds: ['journey'] as const,
|
|
68
|
+
allowBack: 'preserve-state' as const,
|
|
69
|
+
component: EnvSaveStep,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
exitPoints: {
|
|
73
|
+
[ENV_SELECT_EXIT]: defineExit<EnvSelectOutput>(),
|
|
74
|
+
[ENV_ADVANCE_EXIT]: defineExit(),
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
type EnvModules = { [ENV_MODULE_ID]: typeof environmentSetupModule }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The journey definition. `start` skips the picker when a frame was preselected;
|
|
82
|
+
* transitions are the linear pick → review → preflight → save chain, terminating
|
|
83
|
+
* with `complete` off the save step's `advance`. Back-navigation is handled by the
|
|
84
|
+
* entries' `allowBack`, not explicit transitions.
|
|
85
|
+
*/
|
|
86
|
+
export const environmentSetupJourney = defineJourney<EnvModules, EnvSetupState>()({
|
|
87
|
+
id: 'environment-setup',
|
|
88
|
+
version: '1.0.0',
|
|
89
|
+
initialState: (input: EnvSetupInput) => envInitialState(input),
|
|
90
|
+
start: (state, input: EnvSetupInput) =>
|
|
91
|
+
envStartStep(state, input) === 'review'
|
|
92
|
+
? ({ module: ENV_MODULE_ID, entry: 'review', input: { frameId: state.frameId } } as const)
|
|
93
|
+
: ({ module: ENV_MODULE_ID, entry: 'pick', input: { frameId: state.frameId } } as const),
|
|
94
|
+
transitions: {
|
|
95
|
+
[ENV_MODULE_ID]: {
|
|
96
|
+
pick: {
|
|
97
|
+
[ENV_SELECT_EXIT]: (ctx) => ({
|
|
98
|
+
next: {
|
|
99
|
+
module: ENV_MODULE_ID,
|
|
100
|
+
entry: 'review',
|
|
101
|
+
input: { frameId: ctx.output.frameId },
|
|
102
|
+
} as const,
|
|
103
|
+
state: { frameId: ctx.output.frameId },
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
review: {
|
|
107
|
+
[ENV_ADVANCE_EXIT]: (ctx) => ({
|
|
108
|
+
next: {
|
|
109
|
+
module: ENV_MODULE_ID,
|
|
110
|
+
entry: envNextAfter('review'),
|
|
111
|
+
input: { frameId: ctx.state.frameId },
|
|
112
|
+
} as const,
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
preflight: {
|
|
116
|
+
[ENV_ADVANCE_EXIT]: (ctx) => ({
|
|
117
|
+
next: {
|
|
118
|
+
module: ENV_MODULE_ID,
|
|
119
|
+
entry: envNextAfter('preflight'),
|
|
120
|
+
input: { frameId: ctx.state.frameId },
|
|
121
|
+
} as const,
|
|
122
|
+
}),
|
|
123
|
+
},
|
|
124
|
+
// `save` advances to `envNextAfter('save') === 'done'`, i.e. journey completion.
|
|
125
|
+
save: {
|
|
126
|
+
[ENV_ADVANCE_EXIT]: () => ({ complete: undefined }),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/** Typed launch handle — callers `runtime.start(handle, { frameId })`. */
|
|
133
|
+
export const environmentSetupHandle = defineJourneyHandle(environmentSetupJourney)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pinia-backed persistence keyed by the target frame, so reopening the wizard for
|
|
137
|
+
* the same frame RESUMES at the step it was left on (and a different frame is a
|
|
138
|
+
* fresh flow). A null frame (launched at the picker) is a single transient key.
|
|
139
|
+
*/
|
|
140
|
+
export const environmentSetupPersistence = catFactoryJourneyPersistence<
|
|
141
|
+
EnvSetupInput,
|
|
142
|
+
EnvSetupState
|
|
143
|
+
>(({ journeyId, input }) => `${journeyId}:${input.frameId ?? 'new'}`)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { SerializedJourney } from '@modular-vue/journeys'
|
|
3
|
+
import { catFactoryJourneyPersistence, useJourneyPersistenceStore } from './persistence'
|
|
4
|
+
|
|
5
|
+
// The Pinia-backed journey persistence adapter (slice 3). This is what makes a
|
|
6
|
+
// modal-hosted wizard RESUME on reopen: `keyFor` scopes the stored blob, `save`
|
|
7
|
+
// records it, `load` returns it (so `runtime.start()` rehydrates instead of
|
|
8
|
+
// minting fresh), and `remove` drops it on completion. A fresh Pinia is installed
|
|
9
|
+
// per test by `test/setup.ts`.
|
|
10
|
+
|
|
11
|
+
interface FrameInput {
|
|
12
|
+
frameId: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type FrameState = { frameId: string | null }
|
|
16
|
+
|
|
17
|
+
function blob(step: string): SerializedJourney<FrameState> {
|
|
18
|
+
// Minimal plain-JSON blob — the adapter only stores/round-trips it (JSON clone),
|
|
19
|
+
// it never interprets the shape, so a representative object suffices.
|
|
20
|
+
return {
|
|
21
|
+
journeyId: 'environment-setup',
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
state: { frameId: 'blk_1' },
|
|
24
|
+
step,
|
|
25
|
+
} as unknown as SerializedJourney<FrameState>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('catFactoryJourneyPersistence', () => {
|
|
29
|
+
const persistence = catFactoryJourneyPersistence<FrameInput, { frameId: string | null }>(
|
|
30
|
+
({ journeyId, input }) => `${journeyId}:${input.frameId ?? 'new'}`,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
it('derives a deterministic, frame-scoped key', () => {
|
|
34
|
+
expect(
|
|
35
|
+
persistence.keyFor({ journeyId: 'environment-setup', input: { frameId: 'blk_1' } }),
|
|
36
|
+
).toBe('environment-setup:blk_1')
|
|
37
|
+
expect(persistence.keyFor({ journeyId: 'environment-setup', input: { frameId: null } })).toBe(
|
|
38
|
+
'environment-setup:new',
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('round-trips save → load and detaches the loaded blob from the store', () => {
|
|
43
|
+
const key = 'environment-setup:blk_1'
|
|
44
|
+
persistence.save(key, blob('review'))
|
|
45
|
+
|
|
46
|
+
// Persisted under that key in the Pinia store.
|
|
47
|
+
expect(useJourneyPersistenceStore().journeys[key]).toMatchObject({ step: 'review' })
|
|
48
|
+
|
|
49
|
+
const loaded = persistence.load(key)
|
|
50
|
+
expect(loaded).toMatchObject({ step: 'review', state: { frameId: 'blk_1' } })
|
|
51
|
+
// Cloned, not the live store reference (mutating the load can't corrupt state).
|
|
52
|
+
expect(loaded).not.toBe(useJourneyPersistenceStore().journeys[key])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns null for an unknown key (a fresh start)', () => {
|
|
56
|
+
expect(persistence.load('environment-setup:never-saved')).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('removes a blob on completion so the next start is fresh', () => {
|
|
60
|
+
const key = 'environment-setup:blk_9'
|
|
61
|
+
persistence.save(key, blob('save'))
|
|
62
|
+
expect(persistence.load(key)).not.toBeNull()
|
|
63
|
+
|
|
64
|
+
persistence.remove(key)
|
|
65
|
+
expect(persistence.load(key)).toBeNull()
|
|
66
|
+
expect(useJourneyPersistenceStore().journeys[key]).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { SerializedJourney } from '@modular-vue/journeys'
|
|
4
|
+
import { createPiniaJourneyPersistence } from '@modular-vue/journeys'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pinia-backed journey persistence (slice 3 of the modular-vue adoption —
|
|
8
|
+
* docs/initiatives/modular-vue-adoption.md).
|
|
9
|
+
*
|
|
10
|
+
* A journey's `persistence` adapter is what makes `runtime.start()` mean
|
|
11
|
+
* RESUME: it probes `keyFor(input)` for an in-flight serialized instance and
|
|
12
|
+
* returns it instead of minting a fresh one, so a modal-hosted wizard that is
|
|
13
|
+
* closed and reopened for the same target picks up exactly where it left off
|
|
14
|
+
* (see `JourneyHost`'s start-means-resume contract).
|
|
15
|
+
*
|
|
16
|
+
* We route that through Pinia (via the upstream `createPiniaJourneyPersistence`,
|
|
17
|
+
* the Vue-ecosystem analogue of `createWebStoragePersistence`) rather than a
|
|
18
|
+
* bespoke store: in-flight journeys then participate in the app's existing Pinia
|
|
19
|
+
* devtools/timeline, and one `store.$reset()` drops every persisted journey
|
|
20
|
+
* through a path the app already owns. The binding takes NO `pinia` dependency —
|
|
21
|
+
* the caller (this file) brings the store.
|
|
22
|
+
*
|
|
23
|
+
* Scope is deliberately in-memory / session-only: it survives a modal
|
|
24
|
+
* close→reopen within a session (the demonstrable resume win) but is not wired
|
|
25
|
+
* through `pinia-plugin-persistedstate`, so a full page reload starts fresh.
|
|
26
|
+
* Reload recovery is a later opt-in — a wizard's serialized blob can reference
|
|
27
|
+
* transient run ids that a cross-reload resume would need to re-validate.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The single store that owns every wizard journey's serialized blob, keyed by
|
|
32
|
+
* the per-journey `keyFor`. `SerializedJourney` is plain JSON by construction,
|
|
33
|
+
* so the record is trivially (de)serializable.
|
|
34
|
+
*/
|
|
35
|
+
export const useJourneyPersistenceStore = defineStore('journeyPersistence', () => {
|
|
36
|
+
const journeys = ref<Record<string, SerializedJourney>>({})
|
|
37
|
+
return { journeys }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a Pinia-backed `JourneyPersistence` for a journey, keyed by `keyFor`.
|
|
42
|
+
* Pass the result as a journey registration's `persistence` option. The `store`
|
|
43
|
+
* is a lazy getter so the adapter can be constructed at module-eval time (before
|
|
44
|
+
* any Pinia scope exists) and only resolves the store when the runtime actually
|
|
45
|
+
* loads/saves — inside the client plugin, where Pinia is active.
|
|
46
|
+
*
|
|
47
|
+
* `keyFor` MUST be deterministic for identical input (it's the resume probe
|
|
48
|
+
* key); scope it to the wizard's target (e.g. the service frame id) so reopening
|
|
49
|
+
* the same target resumes and a different target is a fresh flow.
|
|
50
|
+
*/
|
|
51
|
+
export function catFactoryJourneyPersistence<TInput, TState>(
|
|
52
|
+
keyFor: (ctx: { journeyId: string; input: TInput }) => string,
|
|
53
|
+
) {
|
|
54
|
+
return createPiniaJourneyPersistence<TInput, TState>({
|
|
55
|
+
keyFor,
|
|
56
|
+
store: () => useJourneyPersistenceStore(),
|
|
57
|
+
})
|
|
58
|
+
}
|
package/app/modular/registry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AnyModuleDescriptor } from '@modular-vue/core'
|
|
2
2
|
import { createRegistry } from '@modular-vue/runtime'
|
|
3
|
-
import
|
|
3
|
+
import { journeysPlugin } from '@modular-vue/journeys'
|
|
4
4
|
import { navigationModule } from '~/modular/nav-contributions'
|
|
5
5
|
import type { NavGates } from '~/modular/nav-contributions'
|
|
6
6
|
import type { AppSlots } from '~/modular/slots'
|
|
@@ -92,11 +92,17 @@ export function __resetConsumerModulesForTest(): void {
|
|
|
92
92
|
export function createAppRegistry(
|
|
93
93
|
deps: AppDeps,
|
|
94
94
|
extraModules: readonly AnyModuleDescriptor[] = [],
|
|
95
|
-
)
|
|
95
|
+
) {
|
|
96
|
+
// `.use(journeysPlugin())` attaches the journeys extension (slice 3): the returned
|
|
97
|
+
// registry gains `registerJourney(...)` and the resolved manifest exposes the
|
|
98
|
+
// `JourneyRuntime` at `extensions.journeys`, which the client plugin provides to the
|
|
99
|
+
// Vue app. The plugin itself carries no `.vue` imports, so it's safe in this
|
|
100
|
+
// unit-tested import graph; the journeys + their step modules (which DO import SFCs)
|
|
101
|
+
// are registered from the client plugin via `extraModules` + `registerJourney`.
|
|
96
102
|
const registry = createRegistry<AppDeps, AppSlots>({
|
|
97
103
|
services: { gates: deps.gates },
|
|
98
104
|
slots: { nav: [], resultViews: [], agentKinds: [] },
|
|
99
|
-
})
|
|
105
|
+
}).use(journeysPlugin())
|
|
100
106
|
for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
|
|
101
107
|
registry.register(mod)
|
|
102
108
|
}
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { installModularApp } from '@modular-vue/nuxt/runtime'
|
|
2
|
+
import type { ApplicationManifest } from '@modular-vue/runtime'
|
|
3
|
+
import type { NavigationItem } from '@modular-frontend/core'
|
|
2
4
|
import { resolveComponentRegistry } from '@modular-vue/core'
|
|
5
|
+
import { provideJourneyRuntime } from '@modular-vue/journeys'
|
|
6
|
+
import type { JourneyRuntime } from '@modular-vue/journeys'
|
|
3
7
|
import { createAppRegistry } from '~/modular/registry'
|
|
4
8
|
import { navSlotFilter } from '~/modular/nav-contributions'
|
|
5
9
|
import { createNavGates } from '~/modular/nav-gates'
|
|
6
10
|
import { resultViewsModule } from '~/modular/result-views'
|
|
11
|
+
import {
|
|
12
|
+
environmentSetupJourney,
|
|
13
|
+
environmentSetupModule,
|
|
14
|
+
environmentSetupPersistence,
|
|
15
|
+
} from '~/modular/journeys/environmentSetup'
|
|
7
16
|
import type { AppSlots, ResultViewContribution } from '~/modular/slots'
|
|
8
17
|
import type { CustomAgentKind } from '~/types/domain'
|
|
9
18
|
|
|
@@ -43,11 +52,35 @@ export default defineNuxtPlugin({
|
|
|
43
52
|
name: 'cat-factory:modular',
|
|
44
53
|
enforce: 'post',
|
|
45
54
|
setup(nuxtApp) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
// Register the step-module carriers (they import `.vue`, so they enter via
|
|
56
|
+
// `extraModules`, keeping the unit-tested `registry.ts` graph SFC-free) and the
|
|
57
|
+
// journeys themselves (slice 3). `registerJourney` must run BEFORE the manifest
|
|
58
|
+
// resolves (inside `installModularApp`).
|
|
59
|
+
const registry = createAppRegistry({ gates: createNavGates() }, [
|
|
60
|
+
resultViewsModule,
|
|
61
|
+
environmentSetupModule,
|
|
62
|
+
])
|
|
63
|
+
registry.registerJourney(environmentSetupJourney, {
|
|
64
|
+
persistence: environmentSetupPersistence,
|
|
49
65
|
})
|
|
50
|
-
|
|
66
|
+
// The annotation is still required to break `defineNuxtPlugin`'s self-referential
|
|
67
|
+
// return inference (the plugin provides `modular: manifest`, so an un-annotated
|
|
68
|
+
// `manifest` resolves to `any` — TS7022). But since `@modular-vue/nuxt@0.3.0` now
|
|
69
|
+
// FLOWS the registry's plugin-extension type through `installModularApp`, the
|
|
70
|
+
// annotation can name the real `{ journeys: JourneyRuntime }` extension and TS
|
|
71
|
+
// VERIFIES the `journeysPlugin` actually resolved it (pre-0.3.0 erased `TExtensions`
|
|
72
|
+
// to `unknown`, so the extension had to be recovered with an unchecked
|
|
73
|
+
// `as JourneyRuntime` cast). `manifest.journeys` / `manifest.slots` are now typed,
|
|
74
|
+
// so the downstream casts are gone too.
|
|
75
|
+
const manifest: ApplicationManifest<AppSlots, NavigationItem, { journeys: JourneyRuntime }> =
|
|
76
|
+
installModularApp({ vueApp: nuxtApp.vueApp, $router: useRouter() }, registry, {
|
|
77
|
+
slotFilter: navSlotFilter,
|
|
78
|
+
})
|
|
79
|
+
// Provide the resolved `JourneyRuntime` to the Vue app so `<JourneyProvider>` /
|
|
80
|
+
// `<JourneyHost>` / `<JourneyOutlet>` resolve it from context (the wizard hosts
|
|
81
|
+
// don't hand-thread a runtime).
|
|
82
|
+
provideJourneyRuntime(nuxtApp.vueApp, manifest.journeys)
|
|
83
|
+
const slots = manifest.slots
|
|
51
84
|
// Fail FAST on a result-view wiring bug (a duplicate id across the first-party +
|
|
52
85
|
// consumer `resultViews` modules) at BOOT rather than lazily the first time a result
|
|
53
86
|
// window opens: resolve the merged slot once here. `resolveComponentRegistry` throws on
|
|
@@ -23,7 +23,7 @@ import { usePreflightsStore } from '~/stores/preflights'
|
|
|
23
23
|
import { useServicesStore } from '~/stores/services'
|
|
24
24
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
25
25
|
|
|
26
|
-
// The environment setup wizard's cross-step
|
|
26
|
+
// The environment setup wizard's cross-step DATA + actions (shared-stacks slice 7). It backs the
|
|
27
27
|
// guided flow — pick a service frame → review the recommended `docker-compose` recipe (detector
|
|
28
28
|
// facts + the opt-in analyst draft, merged with provenance) → run the machine preflights → save
|
|
29
29
|
// (persist the recipe on the frame AND register the workspace's docker-compose handler so the
|
|
@@ -31,17 +31,19 @@ import { useWorkspaceStore } from '~/stores/workspace'
|
|
|
31
31
|
//
|
|
32
32
|
// The detector + analyst only RECOMMEND; the human confirms/edits the working `recipe` here and the
|
|
33
33
|
// compose provider keys purely on the saved recipe (the build-flag rule). Mirrors the other infra
|
|
34
|
-
// stores' idiom; the flow state is a singleton so the wizard
|
|
34
|
+
// stores' idiom; the flow state is a singleton so the wizard's step children share it.
|
|
35
|
+
//
|
|
36
|
+
// Since slice 3 of the modular-vue adoption (docs/initiatives/modular-vue-adoption.md) the wizard's
|
|
37
|
+
// step NAVIGATION lives in a modular-vue journey (`app/modular/journeys/environmentSetup.ts`), NOT
|
|
38
|
+
// here — this store no longer holds a `step` / `STEP_ORDER` / `goToStep`. It is purely the per-frame
|
|
39
|
+
// data+action layer the journey's step components drive; `beginForFrame` seeds it when a step first
|
|
40
|
+
// targets a frame.
|
|
35
41
|
|
|
36
42
|
/** The seeded analyst-only pipeline the "run deep analysis" trigger starts against the frame. */
|
|
37
43
|
const ANALYSIS_PIPELINE_ID = 'pl_environment_analysis'
|
|
38
44
|
/** The analyst agent kind whose `result.custom` carries the drafted recipe. */
|
|
39
45
|
const ANALYST_AGENT_KIND = 'environment-analyst'
|
|
40
46
|
|
|
41
|
-
/** The wizard's ordered steps. `trial` is an optional post-save action, not a gate. */
|
|
42
|
-
export type EnvWizardStep = 'pick' | 'review' | 'preflight' | 'save'
|
|
43
|
-
export const ENV_WIZARD_STEPS: EnvWizardStep[] = ['pick', 'review', 'preflight', 'save']
|
|
44
|
-
|
|
45
47
|
/** The analyst run's lifecycle as the wizard surfaces it. */
|
|
46
48
|
export type AnalysisStatus = 'idle' | 'running' | 'ready' | 'failed'
|
|
47
49
|
|
|
@@ -70,9 +72,11 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
|
|
|
70
72
|
const pipelines = usePipelinesStore()
|
|
71
73
|
const preflights = usePreflightsStore()
|
|
72
74
|
|
|
73
|
-
// ----
|
|
75
|
+
// ---- Target frame -------------------------------------------------------
|
|
76
|
+
// The frame the flow currently targets. Set by `beginForFrame` when a journey
|
|
77
|
+
// step first mounts against a frame; the journey (not this store) owns which
|
|
78
|
+
// step is showing.
|
|
74
79
|
const frameId = ref<string | null>(null)
|
|
75
|
-
const step = ref<EnvWizardStep>('pick')
|
|
76
80
|
|
|
77
81
|
// ---- Detection ----------------------------------------------------------
|
|
78
82
|
const detecting = ref(false)
|
|
@@ -212,19 +216,18 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
|
|
|
212
216
|
trialStarted.value = false
|
|
213
217
|
}
|
|
214
218
|
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Seed the data layer for a frame the journey's review step is entering. The
|
|
221
|
+
* journey owns navigation, so this is idempotent by frame: it (re)seeds +
|
|
222
|
+
* detects only when the target frame actually changes, so back-navigating to
|
|
223
|
+
* the review step (or a resume) does NOT clobber the operator's in-progress
|
|
224
|
+
* recipe edits. Selecting a different frame resets the flow for it.
|
|
225
|
+
*/
|
|
226
|
+
function beginForFrame(id: string | null) {
|
|
227
|
+
if (frameId.value === id) return
|
|
224
228
|
frameId.value = id
|
|
225
229
|
resetFlowState()
|
|
226
|
-
|
|
227
|
-
void detect()
|
|
230
|
+
if (id) void detect()
|
|
228
231
|
}
|
|
229
232
|
|
|
230
233
|
/** Re-seed the working recipe from the current merge (detector-only, or +analyst after apply). */
|
|
@@ -435,14 +438,9 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
|
|
|
435
438
|
}
|
|
436
439
|
}
|
|
437
440
|
|
|
438
|
-
function goToStep(next: EnvWizardStep) {
|
|
439
|
-
step.value = next
|
|
440
|
-
}
|
|
441
|
-
|
|
442
441
|
return {
|
|
443
442
|
// state
|
|
444
443
|
frameId,
|
|
445
|
-
step,
|
|
446
444
|
detecting,
|
|
447
445
|
detectError,
|
|
448
446
|
recommendation,
|
|
@@ -472,8 +470,7 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
|
|
|
472
470
|
analysisStatus,
|
|
473
471
|
merged,
|
|
474
472
|
// actions
|
|
475
|
-
|
|
476
|
-
selectFrame,
|
|
473
|
+
beginForFrame,
|
|
477
474
|
detect,
|
|
478
475
|
startAnalysis,
|
|
479
476
|
applyAnalystDraft,
|
|
@@ -484,6 +481,5 @@ export const useEnvironmentWizardStore = defineStore('environmentWizard', () =>
|
|
|
484
481
|
runPreflight,
|
|
485
482
|
save,
|
|
486
483
|
trialProvision,
|
|
487
|
-
goToStep,
|
|
488
484
|
}
|
|
489
485
|
})
|