Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/server/api/src/app/wizard/wizard-config-filter.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Comment on lines +31 to +38

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 };
}
4 changes: 3 additions & 1 deletion packages/server/api/src/app/wizard/wizard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
WizardRequest,
WizardStepResponse,
} from '@openops/shared';
import { filterWizardConfig } from './wizard-config-filter';

function getStepProgress(
config: WizardConfig,
Expand Down Expand Up @@ -135,8 +136,9 @@ export async function resolveWizardNavigation(
providerAdapter: ProviderAdapter,
request: WizardRequest,
projectId: string,
excludedStepIds?: string[],
): Promise<WizardStepResponse> {
const config = providerAdapter.config;
const config = filterWizardConfig(providerAdapter.config, excludedStepIds);

const context: WizardContext = {
wizardState: request.wizardState,
Expand Down
43 changes: 43 additions & 0 deletions packages/server/api/test/unit/benchmark/wizard.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
76 changes: 76 additions & 0 deletions packages/server/api/test/unit/wizard/wizard-config-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
1 change: 1 addition & 0 deletions packages/shared/src/lib/wizard/wizard-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type WizardState = Static<typeof WizardState>;
export const WizardRequest = Type.Object({
currentStep: Type.Optional(Type.String()),
wizardState: Type.Optional(WizardState),
templateId: Type.Optional(Type.String()),
});

export type WizardRequest = Static<typeof WizardRequest>;
Loading