From 69dd674ed384e319b876a7c2ef0532af13d022c8 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Fri, 10 Jul 2026 12:46:08 +0530 Subject: [PATCH] Add step exclusion support to wizard navigation Allow callers of resolveWizardNavigation to exclude provider wizard steps: a new pure filterWizardConfig removes excluded steps and re-points nextStep and conditional skipToStep references at each excluded step's surviving successor. The parameter is optional and defaults to no exclusions, so existing callers (Benchmark) are unaffected. WizardRequest gains an optional templateId field for parity with the enterprise repo's request schema. Co-Authored-By: Claude Fable 5 --- .../src/app/wizard/wizard-config-filter.ts | 62 +++++++++++++++ .../api/src/app/wizard/wizard.service.ts | 4 +- .../unit/benchmark/wizard.service.test.ts | 43 +++++++++++ .../unit/wizard/wizard-config-filter.test.ts | 76 +++++++++++++++++++ .../shared/src/lib/wizard/wizard-request.ts | 1 + 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 packages/server/api/src/app/wizard/wizard-config-filter.ts create mode 100644 packages/server/api/test/unit/wizard/wizard-config-filter.test.ts diff --git a/packages/server/api/src/app/wizard/wizard-config-filter.ts b/packages/server/api/src/app/wizard/wizard-config-filter.ts new file mode 100644 index 0000000000..81b15d6ff1 --- /dev/null +++ b/packages/server/api/src/app/wizard/wizard-config-filter.ts @@ -0,0 +1,62 @@ +import { + throwValidationError, + WizardConfig, + WizardConfigStep, +} from '@openops/shared'; + +// Removes excluded steps from a provider wizard config and re-points +// nextStep / conditional.onFailure.skipToStep references at the excluded +// step's surviving successor (transitively). Pure: never mutates the input; +// returns the input reference untouched when there is nothing to exclude. +export function filterWizardConfig( + config: WizardConfig, + excludedStepIds: string[] | undefined, +): WizardConfig { + if (!excludedStepIds || excludedStepIds.length === 0) { + return config; + } + + const stepById = new Map(config.steps.map((s) => [s.id, s])); + for (const id of excludedStepIds) { + if (!stepById.has(id)) { + throwValidationError(`Excluded wizard step not found: ${id}`); + } + } + + const excluded = new Set(excludedStepIds); + if (config.steps.every((s) => excluded.has(s.id))) { + throwValidationError('Cannot exclude every wizard step'); + } + + // Follow the nextStep chain through excluded steps to the first survivor. + const resolveSurvivor = (id: string | undefined): string | undefined => { + let current = id; + while (current !== undefined && excluded.has(current)) { + current = stepById.get(current)?.nextStep; + } + return current; + }; + + const steps: WizardConfigStep[] = config.steps + .filter((s) => !excluded.has(s.id)) + .map((s) => { + const nextStep = resolveSurvivor(s.nextStep); + const skipToStep = resolveSurvivor(s.conditional?.onFailure?.skipToStep); + return { + ...s, + ...(s.nextStep !== undefined ? { nextStep } : {}), + ...(s.conditional + ? { + conditional: { + ...s.conditional, + ...(s.conditional.onFailure + ? { onFailure: { ...s.conditional.onFailure, skipToStep } } + : {}), + }, + } + : {}), + }; + }); + + return { ...config, steps }; +} diff --git a/packages/server/api/src/app/wizard/wizard.service.ts b/packages/server/api/src/app/wizard/wizard.service.ts index 46ead16f68..a62393d7cd 100644 --- a/packages/server/api/src/app/wizard/wizard.service.ts +++ b/packages/server/api/src/app/wizard/wizard.service.ts @@ -9,6 +9,7 @@ import { WizardRequest, WizardStepResponse, } from '@openops/shared'; +import { filterWizardConfig } from './wizard-config-filter'; function getStepProgress( config: WizardConfig, @@ -135,8 +136,9 @@ export async function resolveWizardNavigation( providerAdapter: ProviderAdapter, request: WizardRequest, projectId: string, + excludedStepIds?: string[], ): Promise { - const config = providerAdapter.config; + const config = filterWizardConfig(providerAdapter.config, excludedStepIds); const context: WizardContext = { wizardState: request.wizardState, diff --git a/packages/server/api/test/unit/benchmark/wizard.service.test.ts b/packages/server/api/test/unit/benchmark/wizard.service.test.ts index e568b6530c..66749415da 100644 --- a/packages/server/api/test/unit/benchmark/wizard.service.test.ts +++ b/packages/server/api/test/unit/benchmark/wizard.service.test.ts @@ -165,6 +165,49 @@ describe('resolveWizardNavigation', () => { expect(result.totalSteps).toBe(4); }); + describe('excludedStepIds', () => { + it('skips an excluded tail step and completes the wizard from its predecessor', async () => { + const result = await resolveWizardNavigation( + 'test', + getProvider('test'), + { currentStep: 'step3' }, + TEST_PROJECT_ID, + ['last_step'], + ); + + expect(result.currentStep).toBe('step3'); + expect(result.nextStep).toBeNull(); + expect(result.totalSteps).toBe(3); + }); + + it('skips an excluded middle step and re-points to the next survivor', async () => { + const result = await resolveWizardNavigation( + 'test', + getProvider('test'), + { currentStep: 'step1' }, + TEST_PROJECT_ID, + ['step2'], + ); + + expect(result.currentStep).toBe('step3'); + expect(result.nextStep).toBe('last_step'); + expect(result.totalSteps).toBe(3); + }); + + it('behaves identically to omitting the argument when excludedStepIds is empty', async () => { + const result = await resolveWizardNavigation( + 'test', + getProvider('test'), + { currentStep: 'step1' }, + TEST_PROJECT_ID, + [], + ); + + expect(result.currentStep).toBe('step2'); + expect(result.totalSteps).toBe(4); + }); + }); + it('throws for unknown currentStep', async () => { await expect( resolveWizardNavigation( diff --git a/packages/server/api/test/unit/wizard/wizard-config-filter.test.ts b/packages/server/api/test/unit/wizard/wizard-config-filter.test.ts new file mode 100644 index 0000000000..37416c697a --- /dev/null +++ b/packages/server/api/test/unit/wizard/wizard-config-filter.test.ts @@ -0,0 +1,76 @@ +import { WizardConfig } from '@openops/shared'; +import { filterWizardConfig } from '../../../src/app/wizard/wizard-config-filter'; + +const awsLikeConfig = { + provider: 'aws', + steps: [ + { + id: 'connection', + title: 'c', + selectionType: 'single', + nextStep: 'accounts', + }, + { + id: 'accounts', + title: 'a', + selectionType: 'multi-select', + selectAll: true, + conditional: { + when: 'hasAnyAccounts', + onSuccess: { + optionsSource: { type: 'dynamic', method: 'getConnectionAccounts' }, + }, + onFailure: { skipToStep: 'regions' }, + }, + nextStep: 'regions', + }, + { + id: 'regions', + title: 'r', + selectionType: 'multi-select', + optionsSource: { type: 'dynamic', method: 'getRegionsList' }, + }, + ], +} as unknown as WizardConfig; + +describe('filterWizardConfig', () => { + it('returns the same config reference when nothing is excluded', () => { + expect(filterWizardConfig(awsLikeConfig, [])).toBe(awsLikeConfig); + expect(filterWizardConfig(awsLikeConfig, undefined)).toBe(awsLikeConfig); + }); + + it('removes a tail step and clears pointers that targeted it', () => { + const filtered = filterWizardConfig(awsLikeConfig, ['regions']); + expect(filtered.steps.map((s) => s.id)).toEqual(['connection', 'accounts']); + const accounts = filtered.steps[1]; + expect(accounts.nextStep).toBeUndefined(); + expect(accounts.conditional?.onFailure?.skipToStep).toBeUndefined(); + }); + + it('removes a middle step and re-points transitively', () => { + const filtered = filterWizardConfig(awsLikeConfig, ['accounts']); + expect(filtered.steps.map((s) => s.id)).toEqual(['connection', 'regions']); + expect(filtered.steps[0].nextStep).toBe('regions'); + }); + + it('removes the first step so navigation starts at the survivor', () => { + const filtered = filterWizardConfig(awsLikeConfig, ['connection']); + expect(filtered.steps[0].id).toBe('accounts'); + }); + + it('does not mutate the input config', () => { + const before = JSON.stringify(awsLikeConfig); + filterWizardConfig(awsLikeConfig, ['regions']); + expect(JSON.stringify(awsLikeConfig)).toBe(before); + }); + + it('throws on an unknown excluded id', () => { + expect(() => filterWizardConfig(awsLikeConfig, ['nope'])).toThrow(); + }); + + it('throws when every step would be excluded', () => { + expect(() => + filterWizardConfig(awsLikeConfig, ['connection', 'accounts', 'regions']), + ).toThrow(); + }); +}); diff --git a/packages/shared/src/lib/wizard/wizard-request.ts b/packages/shared/src/lib/wizard/wizard-request.ts index 71aac53193..12c1683367 100644 --- a/packages/shared/src/lib/wizard/wizard-request.ts +++ b/packages/shared/src/lib/wizard/wizard-request.ts @@ -10,6 +10,7 @@ export type WizardState = Static; export const WizardRequest = Type.Object({ currentStep: Type.Optional(Type.String()), wizardState: Type.Optional(WizardState), + templateId: Type.Optional(Type.String()), }); export type WizardRequest = Static;