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;