Search code examples
angulardomviewchildangular-changedetection

Using @viewChild nativeElement.value and interpolated values to bind a template element to the value of a hidden input with ONPUSH CD


The component below is a hidden input component that also has a visible span whose purpose is to display the value of the hidden input at all times.

I am attempting to bind the current value of the hidden input (which itself is dynamically bound to another component outside of this component) to the span. For that I am using a @ViewChild('hiddenInput') reference in my component.

Although i can console.log the value of the hidden input at all points in the code where I'm setting that value to update the interpolated values on the test span elements, I cannot get the spans to update with those interpolated values.

What am I missing? Does the onPush CD strategy make this impossible as is or is it something else I'm missing?

The hidden input's value is updating as expected dynamically based on the element its value is bound to, however, the spans do not update to reflect the value of the hidden input when it changes.

import cloneDeep from 'lodash/cloneDeep';
import has from 'lodash/has';
import { AbstractControl, FormControl } from '@angular/forms';
import { ChangeDetectionStrategy } from '@angular/core';
import { Component } from '@angular/core';
import { ElementRef } from '@angular/core';
import { Input } from '@angular/core';
import { OnDestroy } from '@angular/core';
import { OnInit } from '@angular/core';
import { ViewChild } from '@angular/core';
import { ViewEncapsulation } from '@angular/core';
import { JsonSchemaFormService, SyncComponents } from '../json-schema-form.service';
import { decodeHtmlValue, isFormControlParentInFormArray, retainUndefinedNullValue, safeUnsubscribe, setValueByType } from '../shared/utility.functions';
import { hasValue, isInterpolated } from '../shared/validator.functions';
import { Subscription } from 'rxjs';

@Component({
  // tslint:disable-next-line:component-selector
  selector: 'hidden-widget',
  template: `
    <!-- THIS IS THE ELEMENT I WANT TO BIND TO -->
    <input #hiddenInput [formControl]="formControl" [id]="'control' + layoutNode?._id + '_' + componentId" [name]="controlName" type="hidden" />

    <!-- THESE ARE THE SPANS I WANT TO ALWAYS REFLECT THE CURRENT VALUE FROM THE #hiddenInput element but NO JOY! -->
    <span class="test01">{{ controlValueText }}</span>
    <span class="test02">{{ controlValueTextTest }}</span>
    <span class="test1">{{ controlValueTextTest1 }}</span>
    <span class="test2">{{ controlValueTextTest2 }}</span>
    <span class="test3">{{ controlValueTextTest3 }}</span>
    <span class="test4">{{ controlValueTextTest4 }}</span>
    <span class="test5" [innerHTML]="getControlValue(hiddenInput)"></span>
  `,
  styles: [
    `
      .dnd-hidden-input {
        padding: 12px 0;
      }
      .dnd-hidden-input strong .mat-icon {
        position: relative;
        top: 7px;
      }
    `
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Emulated // Emulated | Native | None | ShadowDom,
})
export class HiddenComponent implements OnInit, OnDestroy {
  componentId: string = JsonSchemaFormService.GetUniqueId();
  formControl: AbstractControl;
  controlName: string;
  controlValueInit: any;
  controlValue: any;
  controlValueText: any;
  controlValueTextTest: string;
  controlValueTextTest1: string;
  controlValueTextTest2: string;
  controlValueTextTest3: string;
  controlValueTextTest4: string;
  options: any;
  syncComponentsSubscription: Subscription;
  @Input() layoutNode: any;
  @Input() layoutIndex: number[];
  @Input() dataIndex: number[];
  @Input() rowIndex: number;
  // @ViewChild('hiddenInput', { static: true }) hiddenInput: ElementRef<HTMLInputElement>;
  @ViewChild('hiddenInput', { static: false }) hiddenInput: ElementRef;

  constructor(public jsf: JsonSchemaFormService) { }

  ngOnInit() {
    this.options = cloneDeep(this.layoutNode.options) || {};
    this.jsf.initializeControl(this);

    if (!hasValue(this.controlValue) && hasValue(this.options.defaultValue)) {
      this.controlValue = this.options.defaultValue;
      this.jsf.triggerSyncComponents();
    }

    this.controlValueInit = setValueByType(this.options.dataType, this.controlValue);
    if (this.controlValue) {
      this.controlValueText = `: ${this.controlValue}`;
    }
    this.syncComponentsSubscription = this.jsf.syncComponents.subscribe((value: SyncComponents) => {
      if (!value.targets.length || value.targets.includes(this.controlName)) {
        console.log('syncComponentsSubscription 1', this.controlValue); // BINGO!!! returns the value
        this.controlValueTextTest1 = this.controlValue; // should set the span but alas, it doesnt
        if (has(value, `data.${this.controlName}`)) {
          this.controlValue = value.data[this.controlName];
        }
        this.syncChanges();
      }
    });

    this.jsf.registerComponentInit({ componentId: this.componentId, name: this.controlName });
  }

  ngOnDestroy() {

    safeUnsubscribe(this.syncComponentsSubscription);
  }

  updateValue(value: any) {
    const typedValue = retainUndefinedNullValue(setValueByType(this.options.dataType, value));
    this.jsf.updateValue(this, typedValue);
    // try update the span here
    console.log('updateValue called value:', value); // BINGO RETURNS THE VALUE!!!
    this.controlValueTextTest3 = value; // BUT THE SPAN IS NOT UPDATED!!!
  }

  get isDynamicValue(): boolean {
    return hasValue(this.options.dynamicValue);
  }

  syncChanges() {
    let value: any;
    /**
    * NOTE - Try to maintain interpolated value. Old way relied on value from form.data, but that can be lost after changed.
    *        Interpolated values for Hidden inputs need to persist.
    */
    if (this.isDynamicValue) {
      // NEW - Interpolated value set by Admin, should always be used to set latest value from.
      value = this.options.dynamicValue;
    } else if (isInterpolated(this.controlValueInit)) {
      // OLD - Uses `controlValueInit`, but init value can be lost when Hidden value has been changed and form is re-rendered.
      value = this.controlValueInit;
    } else {
      // Either way, use current value if not interpolated.
      value = this.controlValue;
    }
    const values = this.jsf.formGroup.value;

    /** Check for reference to FormControl data */
    if (this.jsf.hasFormControlDataVariables(value)) {
      let autocompleteData = {};
      let formControlInFormArray: FormControl;
      /** Check if this FormControl is part of a FormArray */
      if (isFormControlParentInFormArray(<FormControl>this.formControl)) {
        formControlInFormArray = <FormControl>this.formControl;
      }
      const result = this.jsf.getAutoCompleteFormControlData(value, formControlInFormArray);
      value = result.newValue;
      autocompleteData = result.autocompleteData;
      const keys = Object.keys(autocompleteData);
      for (let j = 0; j < keys.length; j++) {
        values[keys[j]] = decodeHtmlValue(autocompleteData[keys[j]]);
      }
    }
    const parsedValue = this.jsf.parseVariables(value, values);
    const typedValue = retainUndefinedNullValue(setValueByType(this.options.dataType, parsedValue));
    this.controlValue = typedValue;
    if (this.controlValue) {
      this.controlValueText = `: ${this.controlValue}`;
      console.log('syncchanges this.controlValueText2: ', this.controlValue); // BINGO!!!
      this.controlValueTextTest2 = this.controlValue; // BUT THIS DOESNT UPDATE THE SPAN!!!
    }
    this.updateValue(this.controlValue);
  }

  getControlValue(el) {
    if (this.hiddenInput && this.hiddenInput?.nativeElement?.value !== '') {
      console.log('getControlValue this.hiddenInput.nativeElement.value', this.hiddenInput.nativeElement.value);
      this.controlValueTextTest4 = this.hiddenInput.nativeElement.value;
      return this.hiddenInput.nativeElement.value;
    }
  }

}

Thanks in advance for the help understanding what I'm doing wrong!


Solution

  • The issue here was related to the onPush ChangeDetectionStrategy. Because of that, the interpolated values were not being updated even though the values themselves were being updated.

    To resolve the issue, I added a changeDetector.markForCheck() call inside the subscription after I updated the interpolated value:

    this.syncComponentsSubscription = this.jsf.syncComponents.subscribe((value: SyncComponents) => {
      if (!value.targets.length || value.targets.includes(this.controlName)) {
    
        // THIS IS THE UPDATED CODE
        if (this.options.tableItem) {
          this.hiddenInputText = this.controlValue;
          this.changeDetector.markForCheck(); // required to update the spans because of onpush cd
        }
        // END UPDATE
    
        if (has(value, `data.${this.controlName}`)) {
          console.log('hidden sync fired');
          this.controlValue = value.data[this.controlName];
        }
        this.syncChanges();
      }
    });
    

    Now the spans are updated to reflect the value of the hidden input. The only remaining issue that I have is that when I add a new row to the mat-table, the previous row gets redrawn with the same value it had. I need to insert some checks to only do the changedetection and replacement when the innerText of the span is different than the value of the hidden input #hiddenInput local referenced element.