Skip to content

Commit 028426c

Browse files
vicheeykaranA-aws
authored andcommitted
feat(wizards): support async showWhen(), SkipPrompter backward/forward aws#6166
## Problem Wizard framework does not support: - async `showWhen()` provider. - restoring previous state of WizardPrompter in nested wizard when user clicks BACK button. - skipping prompter in backward direction. ## Solution - introduce `NestedWizard` class that uses child wizards as prompters with support for instantiating and restoring child wizards in backward direction. - update wizard state controller to support `ControlSingal.Skip` and add concept of wizard direction. - support async `showWhen()` provider. fix aws#6094
1 parent 56f1a29 commit 028426c

File tree

10 files changed

+585
-31
lines changed

10 files changed

+585
-31
lines changed

packages/core/src/dev/activation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ async function openStorageFromInput() {
411411
title: 'Enter a key',
412412
})
413413
} else if (target === 'globalsView') {
414-
return new SkipPrompter('')
414+
return new SkipPrompter()
415415
} else if (target === 'globals') {
416416
// List all globalState keys in the quickpick menu.
417417
const items = globalState
@@ -483,7 +483,7 @@ async function resetState() {
483483

484484
this.form.key.bindPrompter(({ target }) => {
485485
if (target && resettableFeatures.some((f) => f.name === target)) {
486-
return new SkipPrompter('')
486+
return new SkipPrompter()
487487
}
488488
throw new Error('invalid feature target')
489489
})

packages/core/src/shared/ui/common/skipPrompter.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,17 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { StepEstimator } from '../../wizards/wizard'
6+
import { StepEstimator, WIZARD_SKIP } from '../../wizards/wizard'
77
import { Prompter, PromptResult } from '../prompter'
88

9-
/** Pseudo-prompter that immediately returns a value (and thus "skips" a step). */
9+
/** Prompter that returns SKIP control signal to parent wizard */
1010
export class SkipPrompter<T> extends Prompter<T> {
11-
/**
12-
* @param val Value immediately returned by the prompter.
13-
*/
14-
constructor(public readonly val: T) {
11+
constructor() {
1512
super()
1613
}
1714

1815
protected async promptUser(): Promise<PromptResult<T>> {
19-
const promptPromise = new Promise<PromptResult<T>>((resolve) => {
20-
resolve(this.val)
21-
})
22-
23-
return await promptPromise
16+
return WIZARD_SKIP
2417
}
2518

2619
public get recentItem(): any {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Wizard, WizardOptions } from '../wizards/wizard'
7+
import { Prompter } from './prompter'
8+
import { WizardPrompter } from './wizardPrompter'
9+
import { createHash } from 'crypto'
10+
11+
/**
12+
* An abstract class that extends the base Wizard class plus the ability to
13+
* use other wizard classes as prompters
14+
*/
15+
export abstract class NestedWizard<T> extends Wizard<T> {
16+
/**
17+
* Map to store memoized wizard instances using SHA-256 hashed keys
18+
*/
19+
private wizardInstances: Map<string, any> = new Map()
20+
21+
public constructor(options?: WizardOptions<T>) {
22+
super(options)
23+
}
24+
25+
/**
26+
* Creates a prompter for a wizard instance with memoization.
27+
*
28+
* @template TWizard - The type of wizard, must extend Wizard<TState>
29+
* @template TState - The type of state managed by the wizard
30+
*
31+
* @param wizardClass - The wizard class constructor
32+
* @param args - Constructor arguments for the wizard instance
33+
*
34+
* @returns A wizard prompter to be used as prompter
35+
*
36+
* @example
37+
* // Create a prompter for SyncWizard
38+
* const prompter = this.createWizardPrompter<SyncWizard, SyncParams>(
39+
* SyncWizard,
40+
* template.uri,
41+
* syncUrl
42+
* )
43+
*
44+
* @remarks
45+
* - Instances are memoized using a SHA-256 hash of the wizard class name and arguments
46+
* - The same wizard instance is reused for identical constructor parameters for restoring wizard prompter
47+
* states during back button click event
48+
*/
49+
protected createWizardPrompter<TWizard extends Wizard<TState>, TState>(
50+
wizardClass: new (...args: any[]) => TWizard,
51+
...args: ConstructorParameters<new (...args: any[]) => TWizard>
52+
): Prompter<TState> {
53+
const memoizeKey = createHash('sha256')
54+
.update(wizardClass.name + JSON.stringify(args))
55+
.digest('hex')
56+
57+
if (!this.wizardInstances.get(memoizeKey)) {
58+
this.wizardInstances.set(memoizeKey, new wizardClass(...args))
59+
}
60+
61+
return new WizardPrompter(this.wizardInstances.get(memoizeKey)) as Prompter<TState>
62+
}
63+
}

packages/core/src/shared/ui/wizardPrompter.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,26 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { StepEstimator, Wizard } from '../wizards/wizard'
6+
import _ from 'lodash'
7+
import { StepEstimator, Wizard, WIZARD_BACK, WIZARD_SKIP } from '../wizards/wizard'
78
import { Prompter, PromptResult } from './prompter'
89

910
/**
10-
* Wraps {@link Wizard} object into its own {@link Prompter}, allowing wizards to use other
11-
* wizards in their flows.
11+
* Wraps {@link Wizard} object into its own {@link Prompter}, allowing wizards to use other wizards in their flows.
12+
* This is meant to be used exclusively in createWizardPrompter() method of {@link NestedWizard} class.
13+
*
14+
* @remarks
15+
* - The WizardPrompter class should never be instantiated with directly.
16+
* - Use createWizardPrompter() method of {@link NestedWizard} when creating a nested wizard prompter for proper state management.
17+
* - See examples:
18+
* - {@link SingleNestedWizard}
19+
* - {@link DoubleNestedWizard}
1220
*/
1321
export class WizardPrompter<T> extends Prompter<T> {
1422
public get recentItem(): any {
1523
return undefined
1624
}
1725
public set recentItem(response: any) {}
18-
1926
private stepOffset: number = 0
2027
private response: T | undefined
2128

@@ -51,6 +58,15 @@ export class WizardPrompter<T> extends Prompter<T> {
5158

5259
protected async promptUser(): Promise<PromptResult<T>> {
5360
this.response = await this.wizard.run()
54-
return this.response
61+
62+
if (this.response === undefined) {
63+
return WIZARD_BACK as PromptResult<T>
64+
} else if (_.isEmpty(this.response)) {
65+
return WIZARD_SKIP as PromptResult<T>
66+
}
67+
68+
return {
69+
...this.response,
70+
} as PromptResult<T>
5571
}
5672
}

packages/core/src/shared/wizards/stateController.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@ export enum ControlSignal {
99
Retry,
1010
Exit,
1111
Back,
12-
Continue,
12+
Skip,
13+
}
14+
15+
/**
16+
* Value for indicating current direction of the wizard
17+
* Created mainly to support skipping prompters
18+
*/
19+
export enum Direction {
20+
Forward,
21+
Backward,
1322
}
1423

1524
export interface StepResult<TState> {
@@ -39,9 +48,11 @@ export class StateMachineController<TState> {
3948
private extraSteps = new Map<number, Branch<TState>>()
4049
private steps: Branch<TState> = []
4150
private internalStep: number = 0
51+
private direction: Direction
4252

4353
public constructor(private state: TState = {} as TState) {
4454
this.previousStates = [_.cloneDeep(state)]
55+
this.direction = Direction.Forward
4556
}
4657

4758
public addStep(step: StepFunction<TState>): void {
@@ -71,12 +82,14 @@ export class StateMachineController<TState> {
7182

7283
this.state = this.previousStates.pop()!
7384
this.internalStep -= 1
85+
this.direction = Direction.Backward
7486
}
7587

7688
protected advanceState(nextState: TState): void {
7789
this.previousStates.push(this.state)
7890
this.state = nextState
7991
this.internalStep += 1
92+
this.direction = Direction.Forward
8093
}
8194

8295
protected detectCycle(step: StepFunction<TState>): TState | undefined {
@@ -105,6 +118,12 @@ export class StateMachineController<TState> {
105118
}
106119

107120
if (isMachineResult(result)) {
121+
if (result.controlSignal === ControlSignal.Skip) {
122+
/**
123+
* Depending on current wizard direction, skip signal get converted to forward or backward control signal
124+
*/
125+
result.controlSignal = this.direction === Direction.Forward ? undefined : ControlSignal.Back
126+
}
108127
return result
109128
} else {
110129
return { nextState: result }

packages/core/src/shared/wizards/wizard.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@ export const WIZARD_RETRY = {
4646
// eslint-disable-next-line @typescript-eslint/naming-convention
4747
export const WIZARD_BACK = { id: WIZARD_CONTROL, type: ControlSignal.Back, toString: () => makeControlString('Back') }
4848
// eslint-disable-next-line @typescript-eslint/naming-convention
49+
export const WIZARD_SKIP = { id: WIZARD_CONTROL, type: ControlSignal.Skip, toString: () => makeControlString('Skip') }
50+
// eslint-disable-next-line @typescript-eslint/naming-convention
4951
export const WIZARD_EXIT = { id: WIZARD_CONTROL, type: ControlSignal.Exit, toString: () => makeControlString('Exit') }
5052

5153
/** Control signals allow for alterations of the normal wizard flow */
52-
export type WizardControl = typeof WIZARD_RETRY | typeof WIZARD_BACK | typeof WIZARD_EXIT
54+
export type WizardControl = typeof WIZARD_RETRY | typeof WIZARD_BACK | typeof WIZARD_EXIT | typeof WIZARD_SKIP
5355

5456
export function isWizardControl(obj: any): obj is WizardControl {
5557
return obj !== undefined && obj.id === WIZARD_CONTROL
@@ -269,9 +271,7 @@ export class Wizard<TState extends Partial<Record<keyof TState, unknown>>> {
269271

270272
if (isValidResponse(answer)) {
271273
state.stepCache.picked = prompter.recentItem
272-
}
273-
274-
if (!isValidResponse(answer)) {
274+
} else {
275275
delete state.stepCache.stepOffset
276276
}
277277

packages/core/src/shared/wizards/wizardForm.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface ContextOptions<TState, TProp> {
2525
* in a single resolution step then they will be added in the order in which they were
2626
* bound.
2727
*/
28-
showWhen?: (state: WizardState<TState>) => boolean
28+
showWhen?: (state: WizardState<TState>) => boolean | Promise<boolean>
2929
/**
3030
* Sets a default value to the target property. This default is applied to the current state
3131
* as long as the property has not been set.
@@ -135,16 +135,30 @@ export class WizardForm<TState extends Partial<Record<keyof TState, unknown>>> {
135135
this.formData.set(key, { ...this.formData.get(key), ...element })
136136
}
137137

138-
public canShowProperty(prop: string, state: TState, defaultState: TState = this.applyDefaults(state)): boolean {
138+
public canShowProperty(
139+
prop: string,
140+
state: TState,
141+
defaultState: TState = this.applyDefaults(state)
142+
): boolean | Promise<boolean> {
139143
const current = _.get(state, prop)
140144
const options = this.formData.get(prop) ?? {}
141145

142146
if (isAssigned(current) || checkParent(prop, state, options)) {
143147
return false
144148
}
145149

146-
if (options.showWhen !== undefined && !options.showWhen(defaultState as WizardState<TState>)) {
147-
return false
150+
if (options.showWhen !== undefined) {
151+
const showStatus = options.showWhen(defaultState as WizardState<TState>)
152+
if (showStatus instanceof Promise) {
153+
return showStatus
154+
.then((result) => {
155+
return result
156+
})
157+
.catch(() => {
158+
return false // Default to not showing if there's an error
159+
})
160+
}
161+
return showStatus
148162
}
149163

150164
return options.provider !== undefined

0 commit comments

Comments
 (0)