Organization Onboarding Workflow

This commit is contained in:
Gagan7900
2026-07-26 14:11:19 +05:30
parent f88f8739b8
commit 95fc5bf17f
61 changed files with 2645 additions and 1051 deletions
@@ -4,7 +4,8 @@ import {
computed,
DestroyRef,
inject,
signal
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
@@ -15,13 +16,26 @@ import {
OnboardingStepper
} from './components/onboarding-stepper/onboarding-stepper';
import { OrganizationBasicsStepComponent } from './steps/organization-basics/organization-basics';
import { OrganizationLocalizationStepComponent } from './steps/organization-localization/organization-localization';
import { OrganizationPlanLimitsStepComponent } from './steps/organization-plan-limits/organization-plan-limits';
import { OrganizationAdminStepComponent } from './steps/organization-admin/organization-admin';
import { OrganizationOnboardingStateService } from './services/organization-onboarding-state.service';
import { OrganizationOnboardingService } from './services/organization-onboarding.service';
import { ConfirmDialog } from '../../../shared/components/confirm-dialog/confirm-dialog';
import { OnboardingStepForm } from './models/organization-onboarding.model';
import { OrganizationProvisioningRequest } from './models/organization-provisioning.model';
@Component({
selector: 'app-organization-onboarding',
standalone: true,
imports: [OnboardingStepper, OrganizationBasicsStepComponent],
imports: [
OnboardingStepper,
OrganizationBasicsStepComponent,
OrganizationLocalizationStepComponent,
OrganizationPlanLimitsStepComponent,
OrganizationAdminStepComponent,
ConfirmDialog,
],
templateUrl: './organization-onboarding.html',
styleUrl: './organization-onboarding.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -31,7 +45,7 @@ export class OrganizationOnboarding {
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly toastr = inject(ToastrService);
private readonly stateService = inject(OrganizationOnboardingStateService);
readonly stateService = inject(OrganizationOnboardingStateService);
private readonly onboardingService = inject(OrganizationOnboardingService);
readonly steps: readonly OnboardingStep[] = this.stateService.createStepDefinitions();
@@ -39,7 +53,15 @@ export class OrganizationOnboarding {
readonly savingDraft = signal(false);
readonly finishing = signal(false);
private basicsStep?: OrganizationBasicsStepComponent;
private readonly basicsStep = viewChild(OrganizationBasicsStepComponent);
private readonly localizationStep = viewChild(OrganizationLocalizationStepComponent);
private readonly planLimitsStep = viewChild(OrganizationPlanLimitsStepComponent);
private readonly adminStep = viewChild(OrganizationAdminStepComponent);
private readonly cancelConfirmDialog = viewChild<ConfirmDialog>('cancelConfirmDialog');
private readonly finishConfirmDialog = viewChild<ConfirmDialog>('finishConfirmDialog');
private savedDraftSnapshot = '';
readonly provisioningRequest = signal<OrganizationProvisioningRequest | null>(null);
readonly currentStepIndex = this.stateService.currentStepIndex;
readonly completedStepIndexes = this.stateService.completedStepIndexes;
@@ -63,28 +85,24 @@ export class OrganizationOnboarding {
)
.subscribe(draft => {
if (!draft) {
this.savedDraftSnapshot = this.serializeDraftState();
return;
}
this.stateService.restoreDraft(draft);
this.savedDraftSnapshot = this.serializeDraftState();
this.hydrateActiveStep();
});
}
registerBasicsStep(component: OrganizationBasicsStepComponent): void {
this.basicsStep = component;
const basicsDraft = this.stateService.onboardingDraft().basics;
if (basicsDraft) {
component.patchValue(basicsDraft);
}
}
onStepSelected(index: number): void {
if (!this.stateService.canOpenStep(index)) {
return;
}
this.captureActiveStepDraft();
this.stateService.setCurrentStepIndex(index);
this.hydrateActiveStep();
}
onBack(): void {
@@ -94,7 +112,9 @@ export class OrganizationOnboarding {
return;
}
this.captureActiveStepDraft();
this.stateService.setCurrentStepIndex(currentIndex - 1);
this.hydrateActiveStep();
}
onNext(): void {
@@ -104,17 +124,16 @@ export class OrganizationOnboarding {
return;
}
if (currentIndex === 0) {
if (!this.basicsStep?.validate()) {
this.stateService.unmarkStepCompleted(currentIndex);
return;
}
this.stateService.updateBasics(this.basicsStep.getValue());
const activeStep = this.getActiveStep();
if (!activeStep?.validate()) {
this.stateService.unmarkStepCompleted(currentIndex);
return;
}
this.storeValidatedActiveStep(activeStep);
this.stateService.markStepCompleted(currentIndex);
this.stateService.setCurrentStepIndex(currentIndex + 1);
this.hydrateActiveStep();
}
onSaveDraft(): void {
@@ -122,9 +141,7 @@ export class OrganizationOnboarding {
return;
}
if (this.currentStepIndex() === 0 && this.basicsStep) {
this.stateService.updateBasicsDraft(this.basicsStep.getDraftValue());
}
this.captureActiveStepDraft();
this.savingDraft.set(true);
@@ -137,6 +154,7 @@ export class OrganizationOnboarding {
)
.subscribe({
next: () => {
this.savedDraftSnapshot = this.serializeDraftState();
this.toastr.success('Onboarding draft saved successfully.', 'Draft saved');
},
error: () => {
@@ -150,15 +168,182 @@ export class OrganizationOnboarding {
return;
}
this.finishing.set(true);
const activeStep = this.adminStep();
if (!activeStep?.validate()) {
this.stateService.unmarkStepCompleted(3);
return;
}
queueMicrotask(() => {
this.finishing.set(false);
this.stateService.updateAdmin(activeStep.getValue());
this.stateService.markStepCompleted(3);
const data = this.stateService.onboardingData();
if (!data.basics || !data.localization || !data.planLimits || !data.admin) {
this.toastr.error('Complete and validate every onboarding step before provisioning.');
return;
}
this.provisioningRequest.set({
basics: data.basics,
localization: data.localization,
planLimits: data.planLimits,
admin: data.admin,
});
void this.finishConfirmDialog()?.open();
}
onCancel(): void {
this.stateService.clear();
void this.router.navigate(['/configuration/organizations']);
this.captureActiveStepDraft();
if (this.serializeDraftState() !== this.savedDraftSnapshot) {
void this.cancelConfirmDialog()?.open();
return;
}
void this.leaveOnboarding(false);
}
}
onDiscardAndLeave(): void {
void this.leaveOnboarding(true);
}
onProvisioningConfirmed(): void {
this.toastr.info(
'The provisioning request is ready. Backend provisioning is not connected yet.',
'Provisioning pending'
);
}
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
switch (this.currentStepIndex()) {
case 0:
return this.basicsStep();
case 1:
return this.localizationStep();
case 2:
return this.planLimitsStep();
case 3:
return this.adminStep();
default:
return undefined;
}
}
private storeValidatedActiveStep(step: OnboardingStepForm<unknown>): void {
switch (this.currentStepIndex()) {
case 0:
this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue());
break;
case 1:
this.stateService.updateLocalization((step as OrganizationLocalizationStepComponent).getValue());
break;
case 2:
this.stateService.updatePlanLimits((step as OrganizationPlanLimitsStepComponent).getValue());
break;
case 3:
this.stateService.updateAdmin((step as OrganizationAdminStepComponent).getValue());
break;
}
}
private captureActiveStepDraft(): void {
const currentIndex = this.currentStepIndex();
const validatedValue = this.getValidatedStepValue(currentIndex);
switch (currentIndex) {
case 0:
this.stateService.updateBasicsDraft(this.basicsStep()?.getDraftValue() ?? null);
break;
case 1:
this.stateService.updateLocalizationDraft(this.localizationStep()?.getDraftValue() ?? null);
break;
case 2:
this.stateService.updatePlanLimitsDraft(this.planLimitsStep()?.getDraftValue() ?? null);
break;
case 3:
this.stateService.updateAdminDraft(this.adminStep()?.getDraftValue() ?? null);
break;
}
const draftValue = this.getDraftStepValue(currentIndex);
if (
this.stateService.isStepCompleted(currentIndex) &&
JSON.stringify(draftValue) !== JSON.stringify(validatedValue)
) {
this.stateService.invalidateStep(currentIndex);
}
}
private getValidatedStepValue(index: number): unknown {
const data = this.stateService.onboardingData();
return [data.basics, data.localization, data.planLimits, data.admin][index] ?? null;
}
private getDraftStepValue(index: number): unknown {
const draft = this.stateService.onboardingDraft();
return [draft.basics, draft.localization, draft.planLimits, draft.admin][index] ?? null;
}
private hydrateActiveStep(): void {
queueMicrotask(() => {
const draft = this.stateService.onboardingDraft();
switch (this.currentStepIndex()) {
case 0:
if (draft.basics) this.basicsStep()?.patchValue(draft.basics);
break;
case 1:
if (draft.localization) {
this.localizationStep()?.patchValue(draft.localization);
}
this.applyCountryDefaults();
break;
case 2:
if (draft.planLimits) this.planLimitsStep()?.patchValue(draft.planLimits);
break;
case 3:
if (draft.admin) this.adminStep()?.patchValue(draft.admin);
break;
}
});
}
private applyCountryDefaults(): void {
const countryIso2 = this.stateService.basics()?.registrationCountry.iso2 ?? null;
this.onboardingService.getCountryLocalizationDefaults(countryIso2)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(defaults => {
if (defaults) {
this.localizationStep()?.applyCountryDefaults(defaults, false);
}
});
}
private serializeDraftState(): string {
const draft = this.stateService.buildDraft();
return JSON.stringify({
currentStepIndex: draft.currentStepIndex,
completedStepIndexes: draft.completedStepIndexes,
basics: draft.basics,
localization: draft.localization,
planLimits: draft.planLimits,
admin: draft.admin,
});
}
private async leaveOnboarding(clearDraft: boolean): Promise<void> {
if (clearDraft) {
try {
await new Promise<void>((resolve, reject) => {
this.onboardingService.clearDraft()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ next: resolve, error: reject });
});
} catch {
this.toastr.error('Unable to clear the saved draft.');
return;
}
}
this.stateService.clear();
await this.router.navigate(['/organizations']);
}
}