From 5bfe300e87176e72ab5219e9473edb60e9134b36 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 30 Aug 2024 13:26:34 -0600 Subject: [PATCH 1/2] feat: implement .skipTo and refactor .stop --- src/multi-stage-output.tsx | 94 +++++++++++++++++++++++++++++++++----- src/stage-tracker.ts | 22 ++++----- test/stage-tracker.test.ts | 24 +++++++++- 3 files changed, 113 insertions(+), 27 deletions(-) diff --git a/src/multi-stage-output.tsx b/src/multi-stage-output.tsx index 9689256..53b546b 100644 --- a/src/multi-stage-output.tsx +++ b/src/multi-stage-output.tsx @@ -14,7 +14,7 @@ import { StagesProps, } from './components/stages.js' import {Design, RequiredDesign, constructDesignParams} from './design.js' -import {StageTracker} from './stage-tracker.js' +import {StageStatus, StageTracker} from './stage-tracker.js' import {readableTime} from './utils.js' // Taken from https://github.com/sindresorhus/is-in-ci @@ -281,6 +281,24 @@ export class MultiStageOutput> implements Disp } } + /** + * Stop multi-stage output from running with a failed status. + */ + public error(): void { + this.stop('failed') + } + + /** + * Go to a stage, marking any stages in between the current stage and the provided stage as completed. + * + * If the stage does not exist or is before the current stage, nothing will happen. + * + * If the stage is the same as the current stage, the data will be updated. + * + * @param stage Stage to go to + * @param data - Optional data to pass to the next stage. + * @returns void + */ public goto(stage: string, data?: Partial): void { if (this.stopped) return @@ -290,29 +308,73 @@ export class MultiStageOutput> implements Disp // prevent going to a previous stage if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0])) return - this.update(stage, data) + this.update(stage, 'completed', data) } + /** + * Moves to the next stage of the process. + * + * @param data - Optional data to pass to the next stage. + * @returns void + */ public next(data?: Partial): void { if (this.stopped) return const nextStageIndex = this.stages.indexOf(this.stageTracker.current ?? this.stages[0]) + 1 if (nextStageIndex < this.stages.length) { - this.update(this.stages[nextStageIndex], data) + this.update(this.stages[nextStageIndex], 'completed', data) } } - public stop(error?: Error): void { + /** + * Go to a stage, marking any stages in between the current stage and the provided stage as skipped. + * + * If the stage does not exist or is before the current stage, nothing will happen. + * + * If the stage is the same as the current stage, the data will be updated. + * + * @param stage Stage to go to + * @param data - Optional data to pass to the next stage. + * @returns void + */ + public skipTo(stage: string, data?: Partial): void { + if (this.stopped) return + + // ignore non-existent stages + if (!this.stages.includes(stage)) return + + // prevent going to a previous stage + if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0])) return + + this.update(stage, 'skipped', data) + } + + /** + * Stop multi-stage output from running. + * + * The stage currently running will be changed to the provided `finalStatus`. + * + * @param finalStatus - The status to set the current stage to. + * @returns void + */ + public stop(finalStatus: StageStatus = 'completed'): void { if (this.stopped) return this.stopped = true - this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], {hasError: Boolean(error), isStopping: true}) + this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], { + finalStatus, + }) if (isInCi) { this.ciInstance?.stop(this.stageTracker) return } + // The underlying components expect an Error, although they don't currently use anything on the error - they check if it exists. + // Instead of refactoring the components to take a boolean, we pass in a placeholder Error, + // which, gives us the flexibility in the future to pass in an actual Error if we want + const error = finalStatus === 'failed' ? new Error('Error') : undefined + const stagesInput = {...this.generateStagesInput(), ...(error ? {error} : {})} this.inkInstance?.rerender() @@ -323,11 +385,17 @@ export class MultiStageOutput> implements Disp this.inkInstance?.unmount() } + /** + * Updates the data of the component. + * + * @param data - The partial data object to update the component's data with. + * @returns void + */ public updateData(data: Partial): void { if (this.stopped) return this.data = {...this.data, ...data} as T - this.update(this.stageTracker.current ?? this.stages[0], data) + this.rerender() } private formatKeyValuePairs(infoBlock: InfoBlock | StageInfoBlock | undefined): FormattedKeyValue[] { @@ -361,15 +429,19 @@ export class MultiStageOutput> implements Disp } } - private update(stage: string, data?: Partial): void { - this.data = {...this.data, ...data} as Partial - - this.stageTracker.refresh(stage) - + private rerender(): void { if (isInCi) { this.ciInstance?.update(this.stageTracker, this.data) } else { this.inkInstance?.rerender() } } + + private update(stage: string, bypassStatus: StageStatus, data?: Partial): void { + this.data = {...this.data, ...data} as Partial + + this.stageTracker.refresh(stage, {bypassStatus}) + + this.rerender() + } } diff --git a/src/stage-tracker.ts b/src/stage-tracker.ts index 0a6f53e..a6a9c4e 100644 --- a/src/stage-tracker.ts +++ b/src/stage-tracker.ts @@ -19,22 +19,16 @@ export class StageTracker { return this.map.get(stage) } - public refresh(nextStage: string, opts?: {hasError?: boolean; isStopping?: boolean}): void { + public refresh(nextStage: string, opts?: {finalStatus?: StageStatus; bypassStatus?: StageStatus}): void { const stages = [...this.map.keys()] + for (const stage of stages) { if (this.map.get(stage) === 'skipped') continue if (this.map.get(stage) === 'failed') continue - // .stop() was called with an error => set the stage to failed - if (nextStage === stage && opts?.hasError) { - this.set(stage, 'failed') - this.stopMarker(stage) - continue - } - - // .stop() was called without an error => set the stage to completed - if (nextStage === stage && opts?.isStopping) { - this.set(stage, 'completed') + // .stop() was called with a finalStatus + if (nextStage === stage && opts?.finalStatus) { + this.set(stage, opts.finalStatus) this.stopMarker(stage) continue } @@ -50,13 +44,13 @@ export class StageTracker { continue } - // any stage before the current stage should be marked as skipped if it's still pending + // any pending stage before the current stage should be marked using opts.bypassStage if (stages.indexOf(stage) < stages.indexOf(nextStage) && this.map.get(stage) === 'pending') { - this.set(stage, 'skipped') + this.set(stage, opts?.bypassStatus ?? 'completed') continue } - // any stage before the current stage should be as completed (if it hasn't been marked as skipped or failed yet) + // any stage before the current stage should be marked as completed (if it hasn't been marked as skipped or failed yet) if (stages.indexOf(nextStage) > stages.indexOf(stage)) { this.set(stage, 'completed') this.stopMarker(stage) diff --git a/test/stage-tracker.test.ts b/test/stage-tracker.test.ts index ddfd12c..5f49954 100644 --- a/test/stage-tracker.test.ts +++ b/test/stage-tracker.test.ts @@ -18,13 +18,33 @@ describe('StageTracker', () => { it("should set the current stage to error when there's an error", () => { const tracker = new StageTracker(['one', 'two', 'three']) - tracker.refresh('two', {hasError: true}) + tracker.refresh('two', {finalStatus: 'failed'}) expect(tracker.get('two')).to.equal('failed') }) it('should set the current stage to completed when stopping', () => { const tracker = new StageTracker(['one', 'two', 'three']) - tracker.refresh('two', {isStopping: true}) + tracker.refresh('two', {finalStatus: 'completed'}) expect(tracker.get('two')).to.equal('completed') }) + + it('should mark bypassed steps as completed', () => { + const tracker = new StageTracker(['one', 'two', 'three']) + tracker.refresh('three', {bypassStatus: 'completed'}) + expect(tracker.get('two')).to.equal('completed') + }) + + it('should mark bypassed steps as skipped', () => { + const tracker = new StageTracker(['one', 'two', 'three']) + tracker.refresh('three', {bypassStatus: 'skipped'}) + expect(tracker.get('two')).to.equal('skipped') + }) + + it('should mark previous current step as completed', () => { + const tracker = new StageTracker(['one', 'two', 'three']) + tracker.refresh('one') + tracker.refresh('two') + expect(tracker.get('one')).to.equal('completed') + expect(tracker.get('two')).to.equal('current') + }) }) From f4ed5d2e9c386b9ea2389e85dc58f21d1c628330 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 30 Aug 2024 13:31:20 -0600 Subject: [PATCH 2/2] chore: clean up --- src/stage-tracker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stage-tracker.ts b/src/stage-tracker.ts index a6a9c4e..f7095dd 100644 --- a/src/stage-tracker.ts +++ b/src/stage-tracker.ts @@ -44,7 +44,7 @@ export class StageTracker { continue } - // any pending stage before the current stage should be marked using opts.bypassStage + // any pending stage before the current stage should be marked using opts.bypassStatus if (stages.indexOf(stage) < stages.indexOf(nextStage) && this.map.get(stage) === 'pending') { this.set(stage, opts?.bypassStatus ?? 'completed') continue