0

Context

A business component A rely on a business service which say either:

  • true: all collapsable components should be open by default.

  • false: all collapsable component should be closed by default.

A pass this knowledge (a calculation, not a stored value) through a one-way property binding to multiple technical children components B, which define the template for a collapsable component (their content is the projection of a specific business component).

When A and B are first rendered, the bindings are resolved and B receive the knowledge: the collapsable components know if they should display their content or hide it. Nonetheless, they are still providing a button to display or hide the content if the user wants to.

Note: a collapsable component does not inject the service itself to access the knowledge directly, because it is a business service and it is a technical component.

Problem

After an action in A, I want to "re-resolve" the property binding with B, so I want A to pass the service knowledge to B again and thus override any action of the user (ex: if he opened a collapsable component which is closed by default, I want it to be restored to its default state, so closed).

The simplest solution would be to re-render the component (destroy/create), but I don't want that because :

1) I don't want the user to see a blink caused by the destruction/rendering of the component.

2) There is no good reason beyond this issue to re-render the component.

Code

@Component({
    selector: 'business-parent',
    template: '
    <generic-collapsable-component [opened]="businessHelper.conditionA">
        // A business-child component
    </generic-collapsable-component> 
    // More generic-collapsable-component
    '
})
export class BusinessParentComponent {

    constructor(private businessHelper: BusinessHelper) {
    }

    onBusinessAction() {
        // Here I do some business stuff...
        // And I want to force the binding [opened] to re-execute its default value, so I want GenericCollapsableComponent.opened = businessHelper.conditionA, and not what I currently have, which is the value of the last GenericCollapsableComponent.switch() I called.
    }
}

@Component({
    selector: 'generic-collapsable-component',
    template: '
    <button (click)="switch()">Switch to show or hide content</button>
    <div [ngClass]="{'is-hidden': !isOpen()}"
        <ng-content></ng-content>
    </div>
    '
})
export class GenericCollapsableComponent {

    @Input() opened: boolean; // Is intialized by the parent component but can be modified by the inner switch() method called in the template.

    constructor() {
    }

    switch(): void {
        this.opened = !this.opened;
    }

    isOpen(): boolean {
        return this.opened;
    };
}

Solutions

  • Re-render the component: NO.
  • Bind a function () => boolean to set the initial value and use an other private boolean value to respond to the user actions: this is what I did and it works, but it is not ideal.
Flyout91
  • 782
  • 10
  • 31

2 Answers2

1

I think what you're asking for is a two-way binding (aka "BANANA IN A BOX"). This will allow your parent component to pass in the initial state. The child component can then communicate back the value when it changes in the child. You can then also change the "opened" state in the parent component based on whatever business logic you have, keeping your generic collapsible component ignorant of whatever business rules dictate when something is opened/closed.

The collapsible component:

export class GenericCollapsableComponent {

    @Input() opened: boolean;
    @Output() openedChange = new EventEmittter<boolean>(); //must have the variable name followed by "Change"

    constructor() {
    }

    switch(): void {
        this.opened = !this.opened;
        this.openedChange.emit(this.opened); //alert the parent that the "opened" state has changed
    }

    isOpen(): boolean {
        return this.opened;
    };
}

Usages of the collapse component:

<generic-collapsible [(opened)]="isOpened">
   <div>Some content!</div>
</generic-collapsible>

And the code-behind:

export class ParentComponent {
    isOpened: boolean = true;
}

Here's a stackblitz showing the two-way binding and a number of different ways to to change the collapsible state.

spots
  • 2,483
  • 5
  • 23
  • 38
  • Thanks for the sugggestion, but unfortunately the boolean value comes from a calculation, not a stored value ("isOpened" in your example), so there is nowhere to store the emitted value: it can only be one way. Also if it could be two way, I would not want that because the value comes from a business service, and the composant, being technical, should not be able to modify this service, which would happen when emitting the value. – Flyout91 Apr 17 '20 at 13:51
  • 1
    I think it's unavoidable to store the state of the collapsible somewhere. I've added a stackblitz that shows how you can use a two-way binding to update the collapsed state from three ways (a service, the parent component and the child component). Does that help? – spots Apr 17 '20 at 14:10
  • I think your solution with two way-binding suits my case the best, thank you (but I made the collapsable projected component store the default value of "'isOpened", instead of the parent component, because each collapsable may have a different default value) – Flyout91 Apr 20 '20 at 08:43
1

I don't see a way to force the data binding to reapply the same value when that value hasn't changed. Since you don't want to inject the business service in the child component, I suggest to let the two components communicate via a technical service, instead of using data binding.

The technical service could be defined as :

import { Injectable } from "@angular/core";
import { BehaviorSubject, Observable } from "rxjs";

@Injectable({
  providedIn: "root",
})
export class TechnicalService {

  private conditionChanged = new BehaviorSubject<boolean>(false);
  public conditionChanged$ = this.conditionChanged.asObservable();

  public notifyConditionChanged(value: boolean) {
    this.conditionChanged.next(value);
  }
}

Assuming that the parent component is notified when a calculated value is emitted by the business service, it could relay the information to the child component through the technical service:

export class AppComponent {
  constructor(
    private businessService: BusinessService,
    private technicalService: TechnicalService) {

    this.businessService.conditionChanged$.subscribe((value) => {
      this.technicalService.notifyConditionChanged(value);
    })
  }
  ...
}

The child would update its property when notified by the technical service, replacing the value changed manually by the user:

export class ChildComponent {
  public opened: boolean;
  private subscription: Subscription;

  constructor(private technicalService: TechnicalService) {
    this.subscription = this.technicalService.conditionChanged$.subscribe(value => {
      this.opened = value;
    });
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }

  toggleOpened() {
    this.opened = !this.opened;
  }
}

See this stackblitz for a demo.

ConnorsFan
  • 70,558
  • 13
  • 122
  • 146