From 7912123f892a8976d5cd5086b55dd2efa0b35a15 Mon Sep 17 00:00:00 2001 From: Vichym Date: Thu, 5 Dec 2024 22:15:12 -0800 Subject: [PATCH 1/7] add support for skipping prompter backward and forward direction --- packages/core/src/dev/activation.ts | 4 +-- .../core/src/shared/ui/common/skipPrompter.ts | 15 +++-------- .../src/shared/ui/nestedWizardPrompter.ts | 27 +++++++++++++++++++ packages/core/src/shared/ui/wizardPrompter.ts | 19 ++++++++++--- .../src/shared/wizards/stateController.ts | 21 ++++++++++++++- packages/core/src/shared/wizards/wizard.ts | 8 +++--- .../core/src/shared/wizards/wizardForm.ts | 22 ++++++++++++--- .../src/test/shared/wizards/wizard.test.ts | 4 +-- .../test/shared/wizards/wizardTestUtils.ts | 7 +++-- 9 files changed, 98 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/shared/ui/nestedWizardPrompter.ts diff --git a/packages/core/src/dev/activation.ts b/packages/core/src/dev/activation.ts index 7f37b0552eb..b4100b191ce 100644 --- a/packages/core/src/dev/activation.ts +++ b/packages/core/src/dev/activation.ts @@ -411,7 +411,7 @@ async function openStorageFromInput() { title: 'Enter a key', }) } else if (target === 'globalsView') { - return new SkipPrompter('') + return new SkipPrompter() } else if (target === 'globals') { // List all globalState keys in the quickpick menu. const items = globalState @@ -483,7 +483,7 @@ async function resetState() { this.form.key.bindPrompter(({ target }) => { if (target && resettableFeatures.some((f) => f.name === target)) { - return new SkipPrompter('') + return new SkipPrompter() } throw new Error('invalid feature target') }) diff --git a/packages/core/src/shared/ui/common/skipPrompter.ts b/packages/core/src/shared/ui/common/skipPrompter.ts index ab638ee7e18..8162c25c4a2 100644 --- a/packages/core/src/shared/ui/common/skipPrompter.ts +++ b/packages/core/src/shared/ui/common/skipPrompter.ts @@ -3,24 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { StepEstimator } from '../../wizards/wizard' +import { StepEstimator, WIZARD_SKIP } from '../../wizards/wizard' import { Prompter, PromptResult } from '../prompter' -/** Pseudo-prompter that immediately returns a value (and thus "skips" a step). */ +/** Prompter that return SKIP control signal to parent wizard */ export class SkipPrompter extends Prompter { - /** - * @param val Value immediately returned by the prompter. - */ - constructor(public readonly val: T) { + constructor() { super() } protected async promptUser(): Promise> { - const promptPromise = new Promise>((resolve) => { - resolve(this.val) - }) - - return await promptPromise + return WIZARD_SKIP } public get recentItem(): any { diff --git a/packages/core/src/shared/ui/nestedWizardPrompter.ts b/packages/core/src/shared/ui/nestedWizardPrompter.ts new file mode 100644 index 00000000000..9c6ac0835d2 --- /dev/null +++ b/packages/core/src/shared/ui/nestedWizardPrompter.ts @@ -0,0 +1,27 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Wizard, WizardOptions } from '../wizards/wizard' +import { WizardPrompter } from './wizardPrompter' +import { createHash } from 'crypto' + +export abstract class NestedWizard extends Wizard { + // Map to store wizard instances + private wizardInstances: Map = new Map() + + protected constructor(options: WizardOptions) { + super(options) + } + + protected createWizardPrompter(constructor: new (...args: any[]) => T, ...args: any[]): WizardPrompter { + const memoizeKey = createHash('sha256') + .update(constructor.name + JSON.stringify(args)) + .digest('hex') + if (!this.wizardInstances.get(memoizeKey) as T) { + this.wizardInstances.set(memoizeKey, new constructor(...args)) + } + return new WizardPrompter(this.wizardInstances.get(memoizeKey)) + } +} diff --git a/packages/core/src/shared/ui/wizardPrompter.ts b/packages/core/src/shared/ui/wizardPrompter.ts index 76256403375..0e0c1943550 100644 --- a/packages/core/src/shared/ui/wizardPrompter.ts +++ b/packages/core/src/shared/ui/wizardPrompter.ts @@ -3,19 +3,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { StepEstimator, Wizard } from '../wizards/wizard' +import _ from 'lodash' +import { StepEstimator, Wizard, WIZARD_BACK, WIZARD_SKIP } from '../wizards/wizard' import { Prompter, PromptResult } from './prompter' /** * Wraps {@link Wizard} object into its own {@link Prompter}, allowing wizards to use other * wizards in their flows. */ + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const WIZARD_PROMPTER = 'WIZARD_PROMPTER' export class WizardPrompter extends Prompter { public get recentItem(): any { return undefined } public set recentItem(response: any) {} - private stepOffset: number = 0 private response: T | undefined @@ -49,8 +52,18 @@ export class WizardPrompter extends Prompter { } } + // eslint-disable-next-line @typescript-eslint/naming-convention protected async promptUser(): Promise> { this.response = await this.wizard.run() - return this.response + + if (this.response === undefined) { + return WIZARD_BACK as PromptResult + } else if (_.isEmpty(this.response)) { + return WIZARD_SKIP as PromptResult + } + + return { + ...this.response, + } as PromptResult } } diff --git a/packages/core/src/shared/wizards/stateController.ts b/packages/core/src/shared/wizards/stateController.ts index fc84a4ce13f..d96c6030436 100644 --- a/packages/core/src/shared/wizards/stateController.ts +++ b/packages/core/src/shared/wizards/stateController.ts @@ -9,7 +9,16 @@ export enum ControlSignal { Retry, Exit, Back, - Continue, + Skip, +} + +/** + * Value for indicating current direction of the wizard + * Created mainly to support skipping prompters + */ +export enum DIRECTON { + Forward, + Backward, } export interface StepResult { @@ -39,9 +48,11 @@ export class StateMachineController { private extraSteps = new Map>() private steps: Branch = [] private internalStep: number = 0 + private direction: DIRECTON public constructor(private state: TState = {} as TState) { this.previousStates = [_.cloneDeep(state)] + this.direction = DIRECTON.Forward } public addStep(step: StepFunction): void { @@ -71,12 +82,14 @@ export class StateMachineController { this.state = this.previousStates.pop()! this.internalStep -= 1 + this.direction = DIRECTON.Backward } protected advanceState(nextState: TState): void { this.previousStates.push(this.state) this.state = nextState this.internalStep += 1 + this.direction = DIRECTON.Forward } protected detectCycle(step: StepFunction): TState | undefined { @@ -105,6 +118,12 @@ export class StateMachineController { } if (isMachineResult(result)) { + if (result.controlSignal === ControlSignal.Skip) { + /** + * Depending on current wizard direction, skip signal get converted to forward or backward control signal + */ + result.controlSignal = this.direction === DIRECTON.Forward ? undefined : ControlSignal.Back + } return result } else { return { nextState: result } diff --git a/packages/core/src/shared/wizards/wizard.ts b/packages/core/src/shared/wizards/wizard.ts index d50399535bb..05d45584802 100644 --- a/packages/core/src/shared/wizards/wizard.ts +++ b/packages/core/src/shared/wizards/wizard.ts @@ -46,10 +46,12 @@ export const WIZARD_RETRY = { // eslint-disable-next-line @typescript-eslint/naming-convention export const WIZARD_BACK = { id: WIZARD_CONTROL, type: ControlSignal.Back, toString: () => makeControlString('Back') } // eslint-disable-next-line @typescript-eslint/naming-convention +export const WIZARD_SKIP = { id: WIZARD_CONTROL, type: ControlSignal.Skip, toString: () => makeControlString('Skip') } +// eslint-disable-next-line @typescript-eslint/naming-convention export const WIZARD_EXIT = { id: WIZARD_CONTROL, type: ControlSignal.Exit, toString: () => makeControlString('Exit') } /** Control signals allow for alterations of the normal wizard flow */ -export type WizardControl = typeof WIZARD_RETRY | typeof WIZARD_BACK | typeof WIZARD_EXIT +export type WizardControl = typeof WIZARD_RETRY | typeof WIZARD_BACK | typeof WIZARD_EXIT | typeof WIZARD_SKIP export function isWizardControl(obj: any): obj is WizardControl { return obj !== undefined && obj.id === WIZARD_CONTROL @@ -269,9 +271,7 @@ export class Wizard>> { if (isValidResponse(answer)) { state.stepCache.picked = prompter.recentItem - } - - if (!isValidResponse(answer)) { + } else { delete state.stepCache.stepOffset } diff --git a/packages/core/src/shared/wizards/wizardForm.ts b/packages/core/src/shared/wizards/wizardForm.ts index 23ca89b2273..7d3a55a2635 100644 --- a/packages/core/src/shared/wizards/wizardForm.ts +++ b/packages/core/src/shared/wizards/wizardForm.ts @@ -25,7 +25,7 @@ interface ContextOptions { * in a single resolution step then they will be added in the order in which they were * bound. */ - showWhen?: (state: WizardState) => boolean + showWhen?: (state: WizardState) => boolean | Promise /** * Sets a default value to the target property. This default is applied to the current state * as long as the property has not been set. @@ -135,7 +135,11 @@ export class WizardForm>> { this.formData.set(key, { ...this.formData.get(key), ...element }) } - public canShowProperty(prop: string, state: TState, defaultState: TState = this.applyDefaults(state)): boolean { + public canShowProperty( + prop: string, + state: TState, + defaultState: TState = this.applyDefaults(state) + ): boolean | Promise { const current = _.get(state, prop) const options = this.formData.get(prop) ?? {} @@ -143,8 +147,18 @@ export class WizardForm>> { return false } - if (options.showWhen !== undefined && !options.showWhen(defaultState as WizardState)) { - return false + if (options.showWhen !== undefined) { + const showStatus = options.showWhen(defaultState as WizardState) + if (showStatus instanceof Promise) { + return showStatus + .then((result) => { + return result + }) + .catch(() => { + return false // Default to not showing if there's an error + }) + } + return showStatus } return options.provider !== undefined diff --git a/packages/core/src/test/shared/wizards/wizard.test.ts b/packages/core/src/test/shared/wizards/wizard.test.ts index 0aee4f66502..3644c6050c8 100644 --- a/packages/core/src/test/shared/wizards/wizard.test.ts +++ b/packages/core/src/test/shared/wizards/wizard.test.ts @@ -211,11 +211,11 @@ describe('Wizard', function () { it('binds prompter to (sync AND async) property', async function () { wizard.form.prop1.bindPrompter(() => helloPrompter) - wizard.form.prop3.bindPrompter(async () => new SkipPrompter('helloooo (async)')) + wizard.form.prop3.bindPrompter(async () => new SkipPrompter()) const result = await wizard.run() assert.strictEqual(result?.prop1, 'hello') - assert.strictEqual(result?.prop3, 'helloooo (async)') + assert(!result?.prop3) }) it('initializes state to empty object if not provided', async function () { diff --git a/packages/core/src/test/shared/wizards/wizardTestUtils.ts b/packages/core/src/test/shared/wizards/wizardTestUtils.ts index 5b9f7003b08..76c50f6ae94 100644 --- a/packages/core/src/test/shared/wizards/wizardTestUtils.ts +++ b/packages/core/src/test/shared/wizards/wizardTestUtils.ts @@ -156,8 +156,11 @@ export async function createWizardTester>(wizard: Wizard `No properties of "${propPath}" would be shown` ) case NOT_ASSERT_SHOW: - return () => - failIf(form.canShowProperty(propPath, state), `Property "${propPath}" would be shown`) + return async () => + failIf( + await form.canShowProperty(propPath, state), + `Property "${propPath}" would be shown` + ) case NOT_ASSERT_SHOW_ANY: return assertShowNone(propPath) case ASSERT_VALUE: From 6b41090194728484d11b9f994ded20239cbc5add Mon Sep 17 00:00:00 2001 From: Vichym Date: Fri, 6 Dec 2024 14:03:38 -0800 Subject: [PATCH 2/7] address comment --- .../src/shared/ui/nestedWizardPrompter.ts | 36 +++++++++++++++++-- packages/core/src/shared/ui/wizardPrompter.ts | 5 +++ .../src/shared/wizards/stateController.ts | 12 +++---- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/core/src/shared/ui/nestedWizardPrompter.ts b/packages/core/src/shared/ui/nestedWizardPrompter.ts index 9c6ac0835d2..54b5076f48e 100644 --- a/packages/core/src/shared/ui/nestedWizardPrompter.ts +++ b/packages/core/src/shared/ui/nestedWizardPrompter.ts @@ -7,21 +7,51 @@ import { Wizard, WizardOptions } from '../wizards/wizard' import { WizardPrompter } from './wizardPrompter' import { createHash } from 'crypto' +/** + * An abstract class that extends the base Wizard class plus the ability to + * use other wizard classes as prompters + */ export abstract class NestedWizard extends Wizard { - // Map to store wizard instances + /** + * Map to store memoized wizard instances using SHA-256 hashed keys + */ private wizardInstances: Map = new Map() protected constructor(options: WizardOptions) { super(options) } - protected createWizardPrompter(constructor: new (...args: any[]) => T, ...args: any[]): WizardPrompter { + /** + * Creates or retrieves a memoized wizard prompter instance + * + * @param {new (...args: any[]) => T} constructor - The constructor function for creating the wizard instance + * @param {...any[]} args - Arguments to pass to the constructor + * @returns {WizardPrompter} A wrapped wizard to be used as prompter in parent wizard class + * + * @remarks + * This method uses memoization to cache wizard instances based on their constructor + * name and arguments, allowing for restoring wizard state for back button. + * + * @example + * this.createWizardPrompter( + * TemplateParametersWizard, + * template!.uri, + * samSyncUrl, + * syncMementoRootKey + * ), + */ + protected createWizardPrompter>( + constructor: new (...args: any[]) => T, + ...args: ConstructorParameters T> + ): WizardPrompter { const memoizeKey = createHash('sha256') .update(constructor.name + JSON.stringify(args)) .digest('hex') - if (!this.wizardInstances.get(memoizeKey) as T) { + + if (!this.wizardInstances.get(memoizeKey)) { this.wizardInstances.set(memoizeKey, new constructor(...args)) } + return new WizardPrompter(this.wizardInstances.get(memoizeKey)) } } diff --git a/packages/core/src/shared/ui/wizardPrompter.ts b/packages/core/src/shared/ui/wizardPrompter.ts index 0e0c1943550..291b8dfb35f 100644 --- a/packages/core/src/shared/ui/wizardPrompter.ts +++ b/packages/core/src/shared/ui/wizardPrompter.ts @@ -10,10 +10,15 @@ import { Prompter, PromptResult } from './prompter' /** * Wraps {@link Wizard} object into its own {@link Prompter}, allowing wizards to use other * wizards in their flows. + * + * @remarks + * - This class should not use direclty inside a parent class. + * - Consider extending {@link NestedWizard} instead. Example: {@link SyncWizard} */ // eslint-disable-next-line @typescript-eslint/naming-convention export const WIZARD_PROMPTER = 'WIZARD_PROMPTER' + export class WizardPrompter extends Prompter { public get recentItem(): any { return undefined diff --git a/packages/core/src/shared/wizards/stateController.ts b/packages/core/src/shared/wizards/stateController.ts index d96c6030436..8c9ecbecca9 100644 --- a/packages/core/src/shared/wizards/stateController.ts +++ b/packages/core/src/shared/wizards/stateController.ts @@ -16,7 +16,7 @@ export enum ControlSignal { * Value for indicating current direction of the wizard * Created mainly to support skipping prompters */ -export enum DIRECTON { +export enum DIRECTION { Forward, Backward, } @@ -48,11 +48,11 @@ export class StateMachineController { private extraSteps = new Map>() private steps: Branch = [] private internalStep: number = 0 - private direction: DIRECTON + private direction: DIRECTION public constructor(private state: TState = {} as TState) { this.previousStates = [_.cloneDeep(state)] - this.direction = DIRECTON.Forward + this.direction = DIRECTION.Forward } public addStep(step: StepFunction): void { @@ -82,14 +82,14 @@ export class StateMachineController { this.state = this.previousStates.pop()! this.internalStep -= 1 - this.direction = DIRECTON.Backward + this.direction = DIRECTION.Backward } protected advanceState(nextState: TState): void { this.previousStates.push(this.state) this.state = nextState this.internalStep += 1 - this.direction = DIRECTON.Forward + this.direction = DIRECTION.Forward } protected detectCycle(step: StepFunction): TState | undefined { @@ -122,7 +122,7 @@ export class StateMachineController { /** * Depending on current wizard direction, skip signal get converted to forward or backward control signal */ - result.controlSignal = this.direction === DIRECTON.Forward ? undefined : ControlSignal.Back + result.controlSignal = this.direction === DIRECTION.Forward ? undefined : ControlSignal.Back } return result } else { From 71ad18df44c32fe6dae7091928a9a492d49b6422 Mon Sep 17 00:00:00 2001 From: Vichym Date: Fri, 6 Dec 2024 15:06:53 -0800 Subject: [PATCH 3/7] Add test for NestedWizard class --- .../src/shared/ui/nestedWizardPrompter.ts | 48 +- packages/core/src/shared/ui/wizardPrompter.ts | 1 - .../test/shared/wizards/nestedWizard.test.ts | 447 ++++++++++++++++++ 3 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/test/shared/wizards/nestedWizard.test.ts diff --git a/packages/core/src/shared/ui/nestedWizardPrompter.ts b/packages/core/src/shared/ui/nestedWizardPrompter.ts index 54b5076f48e..cb3e91c8747 100644 --- a/packages/core/src/shared/ui/nestedWizardPrompter.ts +++ b/packages/core/src/shared/ui/nestedWizardPrompter.ts @@ -4,6 +4,7 @@ */ import { Wizard, WizardOptions } from '../wizards/wizard' +import { Prompter } from './prompter' import { WizardPrompter } from './wizardPrompter' import { createHash } from 'crypto' @@ -17,41 +18,46 @@ export abstract class NestedWizard extends Wizard { */ private wizardInstances: Map = new Map() - protected constructor(options: WizardOptions) { + public constructor(options?: WizardOptions) { super(options) } /** - * Creates or retrieves a memoized wizard prompter instance + * Creates a prompter for a wizard instance with memoization. * - * @param {new (...args: any[]) => T} constructor - The constructor function for creating the wizard instance - * @param {...any[]} args - Arguments to pass to the constructor - * @returns {WizardPrompter} A wrapped wizard to be used as prompter in parent wizard class + * @template TWizard - The type of wizard, must extend Wizard + * @template TState - The type of state managed by the wizard * - * @remarks - * This method uses memoization to cache wizard instances based on their constructor - * name and arguments, allowing for restoring wizard state for back button. + * @param wizardClass - The wizard class constructor + * @param args - Constructor arguments for the wizard instance + * + * @returns A wizard prompter to be used as prompter * * @example - * this.createWizardPrompter( - * TemplateParametersWizard, - * template!.uri, - * samSyncUrl, - * syncMementoRootKey - * ), + * // Create a prompter for SyncWizard + * const prompter = this.createWizardPrompter( + * SyncWizard, + * template.uri, + * syncUrl + * ) + * + * @remarks + * - Instances are memoized using a SHA-256 hash of the wizard class name and arguments + * - The same wizard instance is reused for identical constructor parameters for restoring wizard prompter + * states during back button click event */ - protected createWizardPrompter>( - constructor: new (...args: any[]) => T, - ...args: ConstructorParameters T> - ): WizardPrompter { + protected createWizardPrompter, TState>( + wizardClass: new (...args: any[]) => TWizard, + ...args: ConstructorParameters TWizard> + ): Prompter { const memoizeKey = createHash('sha256') - .update(constructor.name + JSON.stringify(args)) + .update(wizardClass.name + JSON.stringify(args)) .digest('hex') if (!this.wizardInstances.get(memoizeKey)) { - this.wizardInstances.set(memoizeKey, new constructor(...args)) + this.wizardInstances.set(memoizeKey, new wizardClass(...args)) } - return new WizardPrompter(this.wizardInstances.get(memoizeKey)) + return new WizardPrompter(this.wizardInstances.get(memoizeKey)) as Prompter } } diff --git a/packages/core/src/shared/ui/wizardPrompter.ts b/packages/core/src/shared/ui/wizardPrompter.ts index 291b8dfb35f..3bc03e50ec1 100644 --- a/packages/core/src/shared/ui/wizardPrompter.ts +++ b/packages/core/src/shared/ui/wizardPrompter.ts @@ -57,7 +57,6 @@ export class WizardPrompter extends Prompter { } } - // eslint-disable-next-line @typescript-eslint/naming-convention protected async promptUser(): Promise> { this.response = await this.wizard.run() diff --git a/packages/core/src/test/shared/wizards/nestedWizard.test.ts b/packages/core/src/test/shared/wizards/nestedWizard.test.ts new file mode 100644 index 00000000000..977dca33a9c --- /dev/null +++ b/packages/core/src/test/shared/wizards/nestedWizard.test.ts @@ -0,0 +1,447 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { createCommonButtons } from '../../../shared/ui/buttons' +import { NestedWizard } from '../../../shared/ui/nestedWizardPrompter' +import { createQuickPick, DataQuickPickItem } from '../../../shared/ui/pickerPrompter' +import { Wizard } from '../../../shared/wizards/wizard' +import * as assert from 'assert' +import { PrompterTester } from './prompterTester' +import { TestQuickPick } from '../vscode/quickInput' + +interface ChildWizardForm { + childWizardProp1: string + childWizardProp2: string + childWizardProp3: string +} + +interface SingleNestedWizardForm { + singleNestedWizardProp1: string + singleNestedWizardProp2: string + singleNestedWizardNestedProp: any + singleNestedWizardProp3: string +} + +interface DoubleNestedWizardForm { + doubleNestedWizardProp1: string + doubleNestedWizardProp2: string + doubleNestedWizardNestedProp: any + singleNestedWizardConditionalSkipProps: any + doubleNestedWizardProp3: string +} + +export function createTestPrompter(title: string, itemsString: string[]) { + const items: DataQuickPickItem[] = itemsString.map((s) => ({ + label: s, + data: s, + })) + + return createQuickPick(items, { title: title, buttons: createCommonButtons() }) +} + +describe('NestedWizard2', () => { + class ChildWizard extends Wizard { + constructor() { + super() + this.form.childWizardProp1.bindPrompter(() => + createTestPrompter('ChildWizard Prompter 1', ['c1p1', '**', '***']) + ) + this.form.childWizardProp2.bindPrompter(() => + createTestPrompter('ChildWizard Prompter 2', ['c2p1', '**', '***']) + ) + this.form.childWizardProp3.bindPrompter(() => + createTestPrompter('ChildWizard Prompter 3', ['c3p1', '**', '***']) + ) + } + } + + class SingleNestedWizard extends NestedWizard { + constructor() { + super() + + this.form.singleNestedWizardProp1.bindPrompter(() => + createTestPrompter('SingleNestedWizard Prompter 1', ['s1p1', '**', '***']) + ) + this.form.singleNestedWizardProp2.bindPrompter(() => + createTestPrompter('SingleNestedWizard Prompter 2', ['s2p1', '**', '***']) + ) + this.form.singleNestedWizardNestedProp.bindPrompter(() => + this.createWizardPrompter(ChildWizard) + ) + this.form.singleNestedWizardProp3.bindPrompter(() => + createTestPrompter('SingleNestedWizard Prompter 3', ['s3p1', '**', '***']) + ) + } + } + + class DoubleNestedWizard extends NestedWizard { + constructor() { + super() + + this.form.doubleNestedWizardProp1.bindPrompter(() => + createTestPrompter('DoubleNestedWizard Prompter 1', ['d1p1', '**', '***']) + ) + this.form.doubleNestedWizardProp2.bindPrompter(() => + createTestPrompter('DoubleNestedWizard Prompter 2', ['d2p1', '**', '***']) + ) + this.form.doubleNestedWizardNestedProp.bindPrompter(() => + this.createWizardPrompter(SingleNestedWizard) + ) + this.form.doubleNestedWizardProp3.bindPrompter(() => + createTestPrompter('DoubleNestedWizard Prompter 3', ['d3p1', '**', '***']) + ) + } + } + + it('return the correct output from nested child wizard', async () => { + /** + * SingleNestedWizard + | + +-- Prompter 1 + | + +-- Prompter 2 + | + +-- ChildWizard + | | + | +-- Prompter 1 + | | + | +-- Prompter 2 + | | + | +-- Prompter 3 + | + +-- Prompter 3 + */ + const expectedCallOrders = [ + 'SingleNestedWizard Prompter 1', + 'SingleNestedWizard Prompter 2', + 'ChildWizard Prompter 1', + 'ChildWizard Prompter 2', + 'ChildWizard Prompter 3', + 'SingleNestedWizard Prompter 3', + ] + const expectedOutput = { + singleNestedWizardProp1: 's1p1', + singleNestedWizardProp2: 's2p1', + singleNestedWizardNestedProp: { + childWizardProp1: 'c1p1', + childWizardProp2: 'c2p1', + childWizardProp3: 'c3p1', + }, + singleNestedWizardProp3: 's3p1', + } + + const prompterTester = setupPrompterTester(expectedCallOrders) + + const parentWizard = new SingleNestedWizard() + const result = await parentWizard.run() + assertWizardOutput(prompterTester, expectedCallOrders, result, expectedOutput) + }) + + it('return the correct output from double nested child wizard', async () => { + /** + * DoubleNestedWizard + | + +-- Prompter 1 + | + +-- Prompter 2 + | + +-- SingleNestedWizard + | | + | +-- Prompter 1 + | | + | +-- Prompter 2 + | | + | +-- ChildWizard + | | | + | | +-- Prompter 1 + | | | + | | +-- Prompter 2 + | | | + | | +-- Prompter 3 + | | + | +-- Prompter 3 + | + +-- Prompter 3 + */ + const expectedCallOrders = [ + 'DoubleNestedWizard Prompter 1', + 'DoubleNestedWizard Prompter 2', + 'SingleNestedWizard Prompter 1', + 'SingleNestedWizard Prompter 2', + 'ChildWizard Prompter 1', + 'ChildWizard Prompter 2', + 'ChildWizard Prompter 3', + 'SingleNestedWizard Prompter 3', + 'DoubleNestedWizard Prompter 3', + ] + const expectedOutput = { + doubleNestedWizardProp1: 'd1p1', + doubleNestedWizardProp2: 'd2p1', + doubleNestedWizardNestedProp: { + singleNestedWizardProp1: 's1p1', + singleNestedWizardProp2: 's2p1', + singleNestedWizardNestedProp: { + childWizardProp1: 'c1p1', + childWizardProp2: 'c2p1', + childWizardProp3: 'c3p1', + }, + singleNestedWizardProp3: 's3p1', + }, + doubleNestedWizardProp3: 'd3p1', + } + + const prompterTester = setupPrompterTester(expectedCallOrders) + + const parentWizard = new DoubleNestedWizard() + const result = await parentWizard.run() + + assertWizardOutput(prompterTester, expectedCallOrders, result, expectedOutput) + }) + + it('regenerates child wizard prompters in correct reverse order when going backward (back button)', async () => { + /** + * DoubleNestedWizard + | + +--> Prompter 1 (1) + | + +--> Prompter 2 (2) + | + | SingleNestedWizard + | | + | +--> Prompter 1 (3) + | | + | +--> Prompter 2 (4) + | | + | | ChildWizard + | | | + | | +--> Prompter 1 (5) + | | | + | | +--> Prompter 2 (6) + | | | + | | +--> Prompter 3 (7) + | | | + | | +--> Prompter 2 (8) <-- Back + | | | + | | +--> Prompter 1 (9) <-- Back + | | | + | | +--> Prompter 2 (10) + | | | + | | +--> Prompter 3 (11) + | | + | +--> Prompter 3 (12) + | + +--> Prompter 3 (13) <-- Back + | + | SingleNestedWizard + | | + | +--> Prompter 3 (14) <-- Back + | | + | | ChildWizard + | | | + | | +--> Prompter 3 (15) + | | + | +--> Prompter 3 (16) + | + +--> Prompter 3 (17) + + */ + const expectedCallOrders = [ + 'DoubleNestedWizard Prompter 1', // 1 + 'DoubleNestedWizard Prompter 2', // 2 + 'SingleNestedWizard Prompter 1', // 3 + 'SingleNestedWizard Prompter 2', // 4 + 'ChildWizard Prompter 1', // 5 + 'ChildWizard Prompter 2', // 6 + 'ChildWizard Prompter 3', // 7 (Back button) + 'ChildWizard Prompter 2', // 8 (Back button) + 'ChildWizard Prompter 1', // 9 + 'ChildWizard Prompter 2', // 10 + 'ChildWizard Prompter 3', // 11 + 'SingleNestedWizard Prompter 3', // 12 + 'DoubleNestedWizard Prompter 3', // 13 (Back button) + 'SingleNestedWizard Prompter 3', // 14 (Back button) + 'ChildWizard Prompter 3', // 15 + 'SingleNestedWizard Prompter 3', // 16 + 'DoubleNestedWizard Prompter 3', // 17 + ] + const prompterTester = PrompterTester.init() + .handleQuickPick('DoubleNestedWizard Prompter 1', (quickPick) => { + // 1st + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick('DoubleNestedWizard Prompter 2', (quickPick) => { + // 2nd + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick('SingleNestedWizard Prompter 1', (quickPick) => { + // 3rd + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick('SingleNestedWizard Prompter 2', (quickPick) => { + // 4th + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick('ChildWizard Prompter 1', (quickPick) => { + // 5th + // 9th + quickPick.acceptItem(quickPick.items[0]) + }) + .handleQuickPick( + 'ChildWizard Prompter 2', + (() => { + const generator = (function* () { + // 6th + // First call, choose '**' + yield async (picker: TestQuickPick) => { + await picker.untilReady() + assert.strictEqual(picker.items[1].label, '**') + picker.acceptItem(picker.items[1]) + } + // 8th + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.pressButton(vscode.QuickInputButtons.Back) + } + // 10th + // Second call and subsequent call, should restore previously selected option (**) + while (true) { + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.acceptItem(picker.items[1]) + } + } + })() + + return (picker: TestQuickPick) => { + const next = generator.next().value + return next(picker) + } + })() + ) + .handleQuickPick( + 'ChildWizard Prompter 3', + (() => { + const generator = (function* () { + // 7th + // First call, check Back Button + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.pressButton(vscode.QuickInputButtons.Back) + } + // 11th + // 15th + while (true) { + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.acceptItem(picker.items[0]) + } + } + })() + + return (picker: TestQuickPick) => { + const next = generator.next().value + return next(picker) + } + })() + ) + .handleQuickPick( + 'SingleNestedWizard Prompter 3', + (() => { + const generator = (function* () { + // 12th + // First call, choose '***' + yield async (picker: TestQuickPick) => { + await picker.untilReady() + assert.strictEqual(picker.items[2].label, '***') + picker.acceptItem(picker.items[2]) + } + // 14th + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.pressButton(vscode.QuickInputButtons.Back) + } + // 16th + // Second call and after should restore previously selected option (**) + while (true) { + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.acceptItem(picker.items[2]) + } + } + })() + + return (picker: TestQuickPick) => { + const next = generator.next().value + return next(picker) + } + })() + ) + .handleQuickPick( + 'DoubleNestedWizard Prompter 3', + (() => { + const generator = (function* () { + // 13th + // First call, check Back Button + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.pressButton(vscode.QuickInputButtons.Back) + } + // 17th + // Default behavior for any subsequent calls + while (true) { + yield async (picker: TestQuickPick) => { + await picker.untilReady() + picker.acceptItem(picker.items[0]) + } + } + })() + + return (picker: TestQuickPick) => { + const next = generator.next().value + return next(picker) + } + })() + ) + .build() + + const parentWizard = new DoubleNestedWizard() + + const result = await parentWizard.run() + + assertWizardOutput(prompterTester, expectedCallOrders, result, { + doubleNestedWizardProp1: 'd1p1', + doubleNestedWizardProp2: 'd2p1', + doubleNestedWizardNestedProp: { + singleNestedWizardProp1: 's1p1', + singleNestedWizardProp2: 's2p1', + singleNestedWizardNestedProp: { + childWizardProp1: 'c1p1', + childWizardProp2: '**', + childWizardProp3: 'c3p1', + }, + singleNestedWizardProp3: '***', + }, + doubleNestedWizardProp3: 'd3p1', + }) + }) +}) + +function setupPrompterTester(titles: string[]) { + const prompterTester = PrompterTester.init() + titles.forEach((title) => { + prompterTester.handleQuickPick(title, (quickPick) => { + quickPick.acceptItem(quickPick.items[0]) + }) + }) + prompterTester.build() + return prompterTester +} + +function assertWizardOutput(prompterTester: PrompterTester, orderedTitle: string[], result: any, output: any) { + assert.deepStrictEqual(result, output) + orderedTitle.forEach((title, index) => { + prompterTester.assertCallOrder(title, index + 1) + }) +} From 4d8ff987880ba6ecf6717bb3afd4043bd472c288 Mon Sep 17 00:00:00 2001 From: Vichym Date: Tue, 10 Dec 2024 17:49:49 -0800 Subject: [PATCH 4/7] correct typo --- packages/core/src/shared/ui/wizardPrompter.ts | 6 +- .../test/shared/wizards/nestedWizard.test.ts | 104 +++++++++--------- 2 files changed, 56 insertions(+), 54 deletions(-) diff --git a/packages/core/src/shared/ui/wizardPrompter.ts b/packages/core/src/shared/ui/wizardPrompter.ts index 3bc03e50ec1..f1103426c8f 100644 --- a/packages/core/src/shared/ui/wizardPrompter.ts +++ b/packages/core/src/shared/ui/wizardPrompter.ts @@ -12,8 +12,10 @@ import { Prompter, PromptResult } from './prompter' * wizards in their flows. * * @remarks - * - This class should not use direclty inside a parent class. - * - Consider extending {@link NestedWizard} instead. Example: {@link SyncWizard} + * - This class should only be used inside wizard classes that extend {@link NestedWizard}. + * - See examples: + * - {@link SingleNestedWizard} + * - {@link DoubleNestedWizard} */ // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/packages/core/src/test/shared/wizards/nestedWizard.test.ts b/packages/core/src/test/shared/wizards/nestedWizard.test.ts index 977dca33a9c..6cdf0f3bd79 100644 --- a/packages/core/src/test/shared/wizards/nestedWizard.test.ts +++ b/packages/core/src/test/shared/wizards/nestedWizard.test.ts @@ -41,60 +41,60 @@ export function createTestPrompter(title: string, itemsString: string[]) { return createQuickPick(items, { title: title, buttons: createCommonButtons() }) } -describe('NestedWizard2', () => { - class ChildWizard extends Wizard { - constructor() { - super() - this.form.childWizardProp1.bindPrompter(() => - createTestPrompter('ChildWizard Prompter 1', ['c1p1', '**', '***']) - ) - this.form.childWizardProp2.bindPrompter(() => - createTestPrompter('ChildWizard Prompter 2', ['c2p1', '**', '***']) - ) - this.form.childWizardProp3.bindPrompter(() => - createTestPrompter('ChildWizard Prompter 3', ['c3p1', '**', '***']) - ) - } +class ChildWizard extends Wizard { + constructor() { + super() + this.form.childWizardProp1.bindPrompter(() => + createTestPrompter('ChildWizard Prompter 1', ['c1p1', '**', '***']) + ) + this.form.childWizardProp2.bindPrompter(() => + createTestPrompter('ChildWizard Prompter 2', ['c2p1', '**', '***']) + ) + this.form.childWizardProp3.bindPrompter(() => + createTestPrompter('ChildWizard Prompter 3', ['c3p1', '**', '***']) + ) } +} - class SingleNestedWizard extends NestedWizard { - constructor() { - super() +class SingleNestedWizard extends NestedWizard { + constructor() { + super() - this.form.singleNestedWizardProp1.bindPrompter(() => - createTestPrompter('SingleNestedWizard Prompter 1', ['s1p1', '**', '***']) - ) - this.form.singleNestedWizardProp2.bindPrompter(() => - createTestPrompter('SingleNestedWizard Prompter 2', ['s2p1', '**', '***']) - ) - this.form.singleNestedWizardNestedProp.bindPrompter(() => - this.createWizardPrompter(ChildWizard) - ) - this.form.singleNestedWizardProp3.bindPrompter(() => - createTestPrompter('SingleNestedWizard Prompter 3', ['s3p1', '**', '***']) - ) - } + this.form.singleNestedWizardProp1.bindPrompter(() => + createTestPrompter('SingleNestedWizard Prompter 1', ['s1p1', '**', '***']) + ) + this.form.singleNestedWizardProp2.bindPrompter(() => + createTestPrompter('SingleNestedWizard Prompter 2', ['s2p1', '**', '***']) + ) + this.form.singleNestedWizardNestedProp.bindPrompter(() => + this.createWizardPrompter(ChildWizard) + ) + this.form.singleNestedWizardProp3.bindPrompter(() => + createTestPrompter('SingleNestedWizard Prompter 3', ['s3p1', '**', '***']) + ) } +} - class DoubleNestedWizard extends NestedWizard { - constructor() { - super() +class DoubleNestedWizard extends NestedWizard { + constructor() { + super() - this.form.doubleNestedWizardProp1.bindPrompter(() => - createTestPrompter('DoubleNestedWizard Prompter 1', ['d1p1', '**', '***']) - ) - this.form.doubleNestedWizardProp2.bindPrompter(() => - createTestPrompter('DoubleNestedWizard Prompter 2', ['d2p1', '**', '***']) - ) - this.form.doubleNestedWizardNestedProp.bindPrompter(() => - this.createWizardPrompter(SingleNestedWizard) - ) - this.form.doubleNestedWizardProp3.bindPrompter(() => - createTestPrompter('DoubleNestedWizard Prompter 3', ['d3p1', '**', '***']) - ) - } + this.form.doubleNestedWizardProp1.bindPrompter(() => + createTestPrompter('DoubleNestedWizard Prompter 1', ['d1p1', '**', '***']) + ) + this.form.doubleNestedWizardProp2.bindPrompter(() => + createTestPrompter('DoubleNestedWizard Prompter 2', ['d2p1', '**', '***']) + ) + this.form.doubleNestedWizardNestedProp.bindPrompter(() => + this.createWizardPrompter(SingleNestedWizard) + ) + this.form.doubleNestedWizardProp3.bindPrompter(() => + createTestPrompter('DoubleNestedWizard Prompter 3', ['d3p1', '**', '***']) + ) } +} +describe('NestedWizard', () => { it('return the correct output from nested child wizard', async () => { /** * SingleNestedWizard @@ -106,12 +106,12 @@ describe('NestedWizard2', () => { +-- ChildWizard | | | +-- Prompter 1 - | | - | +-- Prompter 2 - | | - | +-- Prompter 3 - | - +-- Prompter 3 + | | + | +-- Prompter 2 + | | + | +-- Prompter 3 + | + +-- Prompter 3 */ const expectedCallOrders = [ 'SingleNestedWizard Prompter 1', From 4c5e48d3d706a72358dc3b874f1874c7881cf040 Mon Sep 17 00:00:00 2001 From: Vichym Date: Wed, 11 Dec 2024 16:40:14 -0800 Subject: [PATCH 5/7] move NestedWizard logic to Wizzard class --- .../src/shared/ui/nestedWizardPrompter.ts | 63 ------------------- packages/core/src/shared/ui/wizardPrompter.ts | 17 ++--- .../src/shared/wizards/stateController.ts | 12 ++-- packages/core/src/shared/wizards/wizard.ts | 45 +++++++++++++ .../test/shared/wizards/nestedWizard.test.ts | 5 +- 5 files changed, 58 insertions(+), 84 deletions(-) delete mode 100644 packages/core/src/shared/ui/nestedWizardPrompter.ts diff --git a/packages/core/src/shared/ui/nestedWizardPrompter.ts b/packages/core/src/shared/ui/nestedWizardPrompter.ts deleted file mode 100644 index cb3e91c8747..00000000000 --- a/packages/core/src/shared/ui/nestedWizardPrompter.ts +++ /dev/null @@ -1,63 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Wizard, WizardOptions } from '../wizards/wizard' -import { Prompter } from './prompter' -import { WizardPrompter } from './wizardPrompter' -import { createHash } from 'crypto' - -/** - * An abstract class that extends the base Wizard class plus the ability to - * use other wizard classes as prompters - */ -export abstract class NestedWizard extends Wizard { - /** - * Map to store memoized wizard instances using SHA-256 hashed keys - */ - private wizardInstances: Map = new Map() - - public constructor(options?: WizardOptions) { - super(options) - } - - /** - * Creates a prompter for a wizard instance with memoization. - * - * @template TWizard - The type of wizard, must extend Wizard - * @template TState - The type of state managed by the wizard - * - * @param wizardClass - The wizard class constructor - * @param args - Constructor arguments for the wizard instance - * - * @returns A wizard prompter to be used as prompter - * - * @example - * // Create a prompter for SyncWizard - * const prompter = this.createWizardPrompter( - * SyncWizard, - * template.uri, - * syncUrl - * ) - * - * @remarks - * - Instances are memoized using a SHA-256 hash of the wizard class name and arguments - * - The same wizard instance is reused for identical constructor parameters for restoring wizard prompter - * states during back button click event - */ - protected createWizardPrompter, TState>( - wizardClass: new (...args: any[]) => TWizard, - ...args: ConstructorParameters TWizard> - ): Prompter { - const memoizeKey = createHash('sha256') - .update(wizardClass.name + JSON.stringify(args)) - .digest('hex') - - if (!this.wizardInstances.get(memoizeKey)) { - this.wizardInstances.set(memoizeKey, new wizardClass(...args)) - } - - return new WizardPrompter(this.wizardInstances.get(memoizeKey)) as Prompter - } -} diff --git a/packages/core/src/shared/ui/wizardPrompter.ts b/packages/core/src/shared/ui/wizardPrompter.ts index f1103426c8f..6f6aefdafe1 100644 --- a/packages/core/src/shared/ui/wizardPrompter.ts +++ b/packages/core/src/shared/ui/wizardPrompter.ts @@ -8,19 +8,12 @@ import { StepEstimator, Wizard, WIZARD_BACK, WIZARD_SKIP } from '../wizards/wiza import { Prompter, PromptResult } from './prompter' /** - * Wraps {@link Wizard} object into its own {@link Prompter}, allowing wizards to use other - * wizards in their flows. - * + * Wraps {@link Wizard} object into its own {@link Prompter}, allowing wizards to use other wizards in their flows. + * This is meant to be used exclusively in createWizardPrompter() method of {@link Wizard} class. * @remarks - * - This class should only be used inside wizard classes that extend {@link NestedWizard}. - * - See examples: - * - {@link SingleNestedWizard} - * - {@link DoubleNestedWizard} + * - Use createWizardPrompter() method of {@link Wizard} when creating a nested wizard prompter for proper state management. + * - The WizardPrompter class should never be instantiated with directly. */ - -// eslint-disable-next-line @typescript-eslint/naming-convention -export const WIZARD_PROMPTER = 'WIZARD_PROMPTER' - export class WizardPrompter extends Prompter { public get recentItem(): any { return undefined @@ -64,7 +57,7 @@ export class WizardPrompter extends Prompter { if (this.response === undefined) { return WIZARD_BACK as PromptResult - } else if (_.isEmpty(this.response)) { + } else if (JSON.stringify(this.response) === '{}') { return WIZARD_SKIP as PromptResult } diff --git a/packages/core/src/shared/wizards/stateController.ts b/packages/core/src/shared/wizards/stateController.ts index 8c9ecbecca9..bac52f7478c 100644 --- a/packages/core/src/shared/wizards/stateController.ts +++ b/packages/core/src/shared/wizards/stateController.ts @@ -16,7 +16,7 @@ export enum ControlSignal { * Value for indicating current direction of the wizard * Created mainly to support skipping prompters */ -export enum DIRECTION { +export enum Direction { Forward, Backward, } @@ -48,11 +48,11 @@ export class StateMachineController { private extraSteps = new Map>() private steps: Branch = [] private internalStep: number = 0 - private direction: DIRECTION + private direction: Direction public constructor(private state: TState = {} as TState) { this.previousStates = [_.cloneDeep(state)] - this.direction = DIRECTION.Forward + this.direction = Direction.Forward } public addStep(step: StepFunction): void { @@ -82,14 +82,14 @@ export class StateMachineController { this.state = this.previousStates.pop()! this.internalStep -= 1 - this.direction = DIRECTION.Backward + this.direction = Direction.Backward } protected advanceState(nextState: TState): void { this.previousStates.push(this.state) this.state = nextState this.internalStep += 1 - this.direction = DIRECTION.Forward + this.direction = Direction.Forward } protected detectCycle(step: StepFunction): TState | undefined { @@ -122,7 +122,7 @@ export class StateMachineController { /** * Depending on current wizard direction, skip signal get converted to forward or backward control signal */ - result.controlSignal = this.direction === DIRECTION.Forward ? undefined : ControlSignal.Back + result.controlSignal = this.direction === Direction.Forward ? undefined : ControlSignal.Back } return result } else { diff --git a/packages/core/src/shared/wizards/wizard.ts b/packages/core/src/shared/wizards/wizard.ts index 05d45584802..4fc5c01b6be 100644 --- a/packages/core/src/shared/wizards/wizard.ts +++ b/packages/core/src/shared/wizards/wizard.ts @@ -7,6 +7,8 @@ import { Branch, ControlSignal, StateMachineController, StepFunction } from './s import * as _ from 'lodash' import { Prompter, PromptResult } from '../../shared/ui/prompter' import { PrompterProvider, WizardForm } from './wizardForm' +import { createHash } from 'crypto' +import { WizardPrompter } from '../ui/wizardPrompter' /** Checks if the user response is valid (i.e. not undefined and not a control signal) */ export function isValidResponse(response: PromptResult): response is T { @@ -89,6 +91,10 @@ export class Wizard>> { private _exitStep?: StepFunction /** Guards against accidental use of the Wizard before `init()`. */ private _ready: boolean + /** + * Map to store memoized wizard instances using SHA-256 hashed keys + */ + private childWizards: Map = new Map() /** Checks that `init()` was performed (if it was defined). */ private assertReady() { @@ -99,6 +105,45 @@ export class Wizard>> { } } + /** + * Creates a prompter for a wizard instance with memoization. + * + * @template TWizard - The type of wizard, must extend Wizard + * @template TState - The type of state managed by the wizard + * + * @param wizardClass - The wizard class constructor + * @param args - Constructor arguments for the wizard instance + * + * @returns A wizard prompter to be used as prompter + * + * @example + * // Create a prompter for SyncWizard + * const prompter = this.createWizardPrompter( + * SyncWizard, + * template.uri, + * syncUrl + * ) + * + * @remarks + * - Instances are memoized using a SHA-256 hash of the wizard class name and arguments + * - The same wizard instance is reused for identical constructor parameters for restoring wizard prompter + * states during back button click event + */ + protected createWizardPrompter, TState>( + wizardClass: new (...args: any[]) => TWizard, + ...args: ConstructorParameters TWizard> + ): Prompter { + const memoizeKey = createHash('sha256') + .update(wizardClass.name + JSON.stringify(args)) + .digest('hex') + + if (!this.childWizards.get(memoizeKey)) { + this.childWizards.set(memoizeKey, new wizardClass(...args)) + } + + return new WizardPrompter(this.childWizards.get(memoizeKey)) as Prompter + } + /** * The offset is applied to both the current step and total number of steps. Useful if the wizard is * apart of some overarching flow. diff --git a/packages/core/src/test/shared/wizards/nestedWizard.test.ts b/packages/core/src/test/shared/wizards/nestedWizard.test.ts index 6cdf0f3bd79..8d9a6e782a2 100644 --- a/packages/core/src/test/shared/wizards/nestedWizard.test.ts +++ b/packages/core/src/test/shared/wizards/nestedWizard.test.ts @@ -4,7 +4,6 @@ */ import * as vscode from 'vscode' import { createCommonButtons } from '../../../shared/ui/buttons' -import { NestedWizard } from '../../../shared/ui/nestedWizardPrompter' import { createQuickPick, DataQuickPickItem } from '../../../shared/ui/pickerPrompter' import { Wizard } from '../../../shared/wizards/wizard' import * as assert from 'assert' @@ -56,7 +55,7 @@ class ChildWizard extends Wizard { } } -class SingleNestedWizard extends NestedWizard { +class SingleNestedWizard extends Wizard { constructor() { super() @@ -75,7 +74,7 @@ class SingleNestedWizard extends NestedWizard { } } -class DoubleNestedWizard extends NestedWizard { +class DoubleNestedWizard extends Wizard { constructor() { super() From 83cc0ff4656ce0130b41056c81dd5c9c8834a36a Mon Sep 17 00:00:00 2001 From: vicheey Date: Wed, 11 Dec 2024 16:47:28 -0800 Subject: [PATCH 6/7] Update typo Co-authored-by: Justin M. Keyes --- packages/core/src/shared/ui/common/skipPrompter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/shared/ui/common/skipPrompter.ts b/packages/core/src/shared/ui/common/skipPrompter.ts index 8162c25c4a2..b99b952de0f 100644 --- a/packages/core/src/shared/ui/common/skipPrompter.ts +++ b/packages/core/src/shared/ui/common/skipPrompter.ts @@ -6,7 +6,7 @@ import { StepEstimator, WIZARD_SKIP } from '../../wizards/wizard' import { Prompter, PromptResult } from '../prompter' -/** Prompter that return SKIP control signal to parent wizard */ +/** Prompter that returns SKIP control signal to parent wizard */ export class SkipPrompter extends Prompter { constructor() { super() From 77f402f6bfe19980fef2ebf5335b288d4e3a62a1 Mon Sep 17 00:00:00 2001 From: Vichym Date: Thu, 12 Dec 2024 10:48:53 -0800 Subject: [PATCH 7/7] [revert] move NestedWizard logic to Wizard class --- .../src/shared/ui/nestedWizardPrompter.ts | 63 +++++++++++++++++++ packages/core/src/shared/ui/wizardPrompter.ts | 10 ++- packages/core/src/shared/wizards/wizard.ts | 45 ------------- .../test/shared/wizards/nestedWizard.test.ts | 20 +++--- 4 files changed, 80 insertions(+), 58 deletions(-) create mode 100644 packages/core/src/shared/ui/nestedWizardPrompter.ts diff --git a/packages/core/src/shared/ui/nestedWizardPrompter.ts b/packages/core/src/shared/ui/nestedWizardPrompter.ts new file mode 100644 index 00000000000..cb3e91c8747 --- /dev/null +++ b/packages/core/src/shared/ui/nestedWizardPrompter.ts @@ -0,0 +1,63 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Wizard, WizardOptions } from '../wizards/wizard' +import { Prompter } from './prompter' +import { WizardPrompter } from './wizardPrompter' +import { createHash } from 'crypto' + +/** + * An abstract class that extends the base Wizard class plus the ability to + * use other wizard classes as prompters + */ +export abstract class NestedWizard extends Wizard { + /** + * Map to store memoized wizard instances using SHA-256 hashed keys + */ + private wizardInstances: Map = new Map() + + public constructor(options?: WizardOptions) { + super(options) + } + + /** + * Creates a prompter for a wizard instance with memoization. + * + * @template TWizard - The type of wizard, must extend Wizard + * @template TState - The type of state managed by the wizard + * + * @param wizardClass - The wizard class constructor + * @param args - Constructor arguments for the wizard instance + * + * @returns A wizard prompter to be used as prompter + * + * @example + * // Create a prompter for SyncWizard + * const prompter = this.createWizardPrompter( + * SyncWizard, + * template.uri, + * syncUrl + * ) + * + * @remarks + * - Instances are memoized using a SHA-256 hash of the wizard class name and arguments + * - The same wizard instance is reused for identical constructor parameters for restoring wizard prompter + * states during back button click event + */ + protected createWizardPrompter, TState>( + wizardClass: new (...args: any[]) => TWizard, + ...args: ConstructorParameters TWizard> + ): Prompter { + const memoizeKey = createHash('sha256') + .update(wizardClass.name + JSON.stringify(args)) + .digest('hex') + + if (!this.wizardInstances.get(memoizeKey)) { + this.wizardInstances.set(memoizeKey, new wizardClass(...args)) + } + + return new WizardPrompter(this.wizardInstances.get(memoizeKey)) as Prompter + } +} diff --git a/packages/core/src/shared/ui/wizardPrompter.ts b/packages/core/src/shared/ui/wizardPrompter.ts index 6f6aefdafe1..d4a15c881bf 100644 --- a/packages/core/src/shared/ui/wizardPrompter.ts +++ b/packages/core/src/shared/ui/wizardPrompter.ts @@ -9,10 +9,14 @@ import { Prompter, PromptResult } from './prompter' /** * Wraps {@link Wizard} object into its own {@link Prompter}, allowing wizards to use other wizards in their flows. - * This is meant to be used exclusively in createWizardPrompter() method of {@link Wizard} class. + * This is meant to be used exclusively in createWizardPrompter() method of {@link NestedWizard} class. + * * @remarks - * - Use createWizardPrompter() method of {@link Wizard} when creating a nested wizard prompter for proper state management. * - The WizardPrompter class should never be instantiated with directly. + * - Use createWizardPrompter() method of {@link NestedWizard} when creating a nested wizard prompter for proper state management. + * - See examples: + * - {@link SingleNestedWizard} + * - {@link DoubleNestedWizard} */ export class WizardPrompter extends Prompter { public get recentItem(): any { @@ -57,7 +61,7 @@ export class WizardPrompter extends Prompter { if (this.response === undefined) { return WIZARD_BACK as PromptResult - } else if (JSON.stringify(this.response) === '{}') { + } else if (_.isEmpty(this.response)) { return WIZARD_SKIP as PromptResult } diff --git a/packages/core/src/shared/wizards/wizard.ts b/packages/core/src/shared/wizards/wizard.ts index 4fc5c01b6be..05d45584802 100644 --- a/packages/core/src/shared/wizards/wizard.ts +++ b/packages/core/src/shared/wizards/wizard.ts @@ -7,8 +7,6 @@ import { Branch, ControlSignal, StateMachineController, StepFunction } from './s import * as _ from 'lodash' import { Prompter, PromptResult } from '../../shared/ui/prompter' import { PrompterProvider, WizardForm } from './wizardForm' -import { createHash } from 'crypto' -import { WizardPrompter } from '../ui/wizardPrompter' /** Checks if the user response is valid (i.e. not undefined and not a control signal) */ export function isValidResponse(response: PromptResult): response is T { @@ -91,10 +89,6 @@ export class Wizard>> { private _exitStep?: StepFunction /** Guards against accidental use of the Wizard before `init()`. */ private _ready: boolean - /** - * Map to store memoized wizard instances using SHA-256 hashed keys - */ - private childWizards: Map = new Map() /** Checks that `init()` was performed (if it was defined). */ private assertReady() { @@ -105,45 +99,6 @@ export class Wizard>> { } } - /** - * Creates a prompter for a wizard instance with memoization. - * - * @template TWizard - The type of wizard, must extend Wizard - * @template TState - The type of state managed by the wizard - * - * @param wizardClass - The wizard class constructor - * @param args - Constructor arguments for the wizard instance - * - * @returns A wizard prompter to be used as prompter - * - * @example - * // Create a prompter for SyncWizard - * const prompter = this.createWizardPrompter( - * SyncWizard, - * template.uri, - * syncUrl - * ) - * - * @remarks - * - Instances are memoized using a SHA-256 hash of the wizard class name and arguments - * - The same wizard instance is reused for identical constructor parameters for restoring wizard prompter - * states during back button click event - */ - protected createWizardPrompter, TState>( - wizardClass: new (...args: any[]) => TWizard, - ...args: ConstructorParameters TWizard> - ): Prompter { - const memoizeKey = createHash('sha256') - .update(wizardClass.name + JSON.stringify(args)) - .digest('hex') - - if (!this.childWizards.get(memoizeKey)) { - this.childWizards.set(memoizeKey, new wizardClass(...args)) - } - - return new WizardPrompter(this.childWizards.get(memoizeKey)) as Prompter - } - /** * The offset is applied to both the current step and total number of steps. Useful if the wizard is * apart of some overarching flow. diff --git a/packages/core/src/test/shared/wizards/nestedWizard.test.ts b/packages/core/src/test/shared/wizards/nestedWizard.test.ts index 8d9a6e782a2..00f79fc06f2 100644 --- a/packages/core/src/test/shared/wizards/nestedWizard.test.ts +++ b/packages/core/src/test/shared/wizards/nestedWizard.test.ts @@ -4,8 +4,8 @@ */ import * as vscode from 'vscode' import { createCommonButtons } from '../../../shared/ui/buttons' +import { NestedWizard } from '../../../shared/ui/nestedWizardPrompter' import { createQuickPick, DataQuickPickItem } from '../../../shared/ui/pickerPrompter' -import { Wizard } from '../../../shared/wizards/wizard' import * as assert from 'assert' import { PrompterTester } from './prompterTester' import { TestQuickPick } from '../vscode/quickInput' @@ -40,7 +40,7 @@ export function createTestPrompter(title: string, itemsString: string[]) { return createQuickPick(items, { title: title, buttons: createCommonButtons() }) } -class ChildWizard extends Wizard { +class ChildWizard extends NestedWizard { constructor() { super() this.form.childWizardProp1.bindPrompter(() => @@ -55,7 +55,7 @@ class ChildWizard extends Wizard { } } -class SingleNestedWizard extends Wizard { +class SingleNestedWizard extends NestedWizard { constructor() { super() @@ -74,7 +74,7 @@ class SingleNestedWizard extends Wizard { } } -class DoubleNestedWizard extends Wizard { +class DoubleNestedWizard extends NestedWizard { constructor() { super() @@ -105,12 +105,12 @@ describe('NestedWizard', () => { +-- ChildWizard | | | +-- Prompter 1 - | | - | +-- Prompter 2 - | | - | +-- Prompter 3 - | - +-- Prompter 3 + | | + | +-- Prompter 2 + | | + | +-- Prompter 3 + | + +-- Prompter 3 */ const expectedCallOrders = [ 'SingleNestedWizard Prompter 1',