3
\$\begingroup\$

I've recently started working on an Angular 7 project (I'm pretty new to Angular and RxJS), and I was requested to implement this new functionality:

The Problem

I have to implement a component to show some data fetched from a server via a service, and allow the user to edit it. The HTTP call can be triggered after selecting an option from a dropdown in the header of the page. When I get the response from the HTTP call, the user should be able to edit this data (using a form). If data is modified (= the page form is dirty), trying to change option in the dropdown should cause a warning modal to be displayed, and the HTTP call to fetch corresponding data shouldn't be performed. The value in the dropdown should be the last one for which the HTTP call was performed successfully (so, even in case of errors while requesting data to server, the dropdown value should be set accordingly). The user can discard the changes he made pressing a Discard Changes button: A confirmation modal will be shown and, if the user confirms to discard changes, the data must be downloaded again from the server

My Attempt

So far, I've tried this approach:

private destroyed$ = new Subject();
private triggerReload$ = new Subject();
private previousPhase: number;

public numericTextboxLowerBound = 0;
public numericTextboxUpperBound = 100;
public pageData: ProportionOfEffortByRoleIdeal = null;
public phases: KeyValue<number, string>[] = [];

public filterForm: FormGroup;
public pageForm: FormGroup;
public phaseSelect: FormControl;
public roleRatios: FormArray;

constructor(
  private activatedRoute: ActivatedRoute,
  private formBuilder: FormBuilder,
  private modalService: ModalService,
  private roleService: RoleService,
  private translateService: TranslateService
) { }

initFilterFormControls() {
  this.phaseSelect = this.formBuilder.control(null);
}

initFilterForm() {
  this.filterForm = this.formBuilder.group({ phaseSelect: this.phaseSelect });
}

initPageFormControls() {
  this.roleRatios = this.formBuilder.array([]);
}

initPageForm() {
  this.pageForm = this.formBuilder.group({
    roleRatios: this.roleRatios
  });
}

createWbsItemFormGroup(sourceItem: WbsItem): FormGroup {
  return this.formBuilder.group({
    WorkStreamID: this.formBuilder.control(sourceItem.id, Validators.required),
    RoleList: this.formBuilder.array(
      sourceItem.roleEfforts.map((effort) => this.formBuilder.group({
        ID: this.formBuilder.control(effort.key, Validators.required),
        Value: this.formBuilder.control(effort.value * this.numericTextboxUpperBound)
      })),
      ArrayValidators.sumEqualTo(ValidationErrorName.EstimateByRoleArraySum, this.numericTextboxUpperBound, 'Value', sourceItem.name))
  });
}

initPageFormData(data: ProportionOfEffortByRoleIdeal) {
  while (this.roleRatios.length > 0) {
    this.roleRatios.removeAt(0);
  }
  data.wbsItems.forEach((item) => {
    item.children.forEach((activity) => {
      this.roleRatios.push(this.createWbsItemFormGroup(activity));
    });
  });
  if (data.isReadOnly) {
    this.pageForm.disable({ emitEvent: false });
  } else {
    this.pageForm.enable({ emitEvent: false });
  }
  this.pageForm.markAsUntouched();
  this.pageForm.markAsPristine();
}

ngOnInit() {
  this.initFilterFormControls();
  this.initFilterForm();
  this.initPageFormControls();
  this.initPageForm();
  this.phases = this.activatedRoute.parent.snapshot.data.phasesList as KeyValue<number, string>[];
  this.previousPhase = this.phaseSelect.value;
  const phaseChange$ = this.phaseSelect.valueChanges.pipe(
    takeUntil(this.destroyed$),
    filter(() => {
      if (this.pageForm.dirty) {
        this.modalService.pendingChanges();
        this.phaseSelect.setValue(this.previousPhase, { emitEvent: false });
        return false;
      }
      return true;
    })
  );
  merge(this.triggerReload$, phaseChange$).pipe(
    takeUntil(this.destroyed$),
    switchMap(() => this.getPageData()),
    filter((data) => data !== null),
    tap((next) => {
      this.previousPhase = this.phaseSelect.value;
      this.initPageFormData(next);
    })
  ).subscribe((next) => this.pageData = next);
}

ngOnDestroy(): void {
  this.destroyed$.next();
  this.destroyed$.complete();
}

openDiscardChangesDialog() {
  this.modalService.discard().pipe(take(1)).subscribe((confirmDiscard) => confirmDiscard && this.triggerReload$.next());
}

getPageData(): Observable<ProportionOfEffortByRoleIdeal> {
  return this.roleService.GetRoleRatioByPhaseAsync(
    +this.activatedRoute.parent.snapshot.paramMap.get(RouteParamKey.ChunkVersionID),
    +this.phaseSelect.value
  ).pipe(
    map((operationResult) => {
      if (operationResult.isResultOK && operationResult.hasResult) {
        const result = operationResult.result as ProportionOfEffortByRoleResponse;
        const idealResult: ProportionOfEffortByRoleIdeal = {
          isReadOnly: result.isReadOnly || result.userRoleAccessLevel[0].isReadOnly,
          roles: result.roleRatioWorkStreams[0].roleList.sort((a, b) => a.order - b.order).map(
            (role) => ({ key: role.id, value: role.roleName })),
          wbsItems: this.createWbsItems(result.roleRatioWorkStreams, false)
        };
        return idealResult;
      }
      throw new Error();
    }),
    catchError((error) => {
      this.modalService.error(this.translateService.instant('msg_error_generic'));
      this.phaseSelect.setValue(this.previousPhase, { emitEvent: false });
      return of(null);
    })
  );
}
  • phaseChange$ is a local variable I use to keep track of when the user selects a different option in the header dropdown: phaseSelect is the FormControl for the dropdown in the header: I listen to its valueChanges, which emits a value every time the user selects a new option. I then apply filter to check for changes in the page form (where data can be edited by the user), and if I find there's some pending changes, I prevent the emission of a value, setting the value of the dropdown to the value when the last successful request was performed
  • triggerReload$, as the name might suggest, is a Subject I use to trigger the HTTP call when the user confirms to discard changes in the form
  • Since the HTTP call to fetch data from the server should be performed either when the user discards the changes he made in the form or when the user chooses a new option in the dropdown in the header, I apply the merge operator to phaseChange$ and triggerReload$, and subscribe to the HTTP call by using the switchMap operator.
  • In the getPageData() function, where I invoke the service method to perform the HTTP call, I use catchError to handle error situations: other than returning a default null value, even here I restore the value for the header dropdown

Question

The code I attached seems to work, but I'm not convinced it is a good solution; so, my question is: is there some better way to achieve the desired behaviour? For example, is there some way this.phaseSelect.setValue(this.previousPhase, { emitEvent: false }); can be executed only once to avoid code repetitions?

Thanks in advance

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy