1

I am using an @ViewChild reference in my component:

@ViewChild('tableCellWidget', { static: false }) tableCellWidget: ElementRef<HTMLInputElement>;

And I am attempting to access the "value" of the element in which I have attached the tableCellWidget viewChild reference to in order to dynamically set the [innerHTML] of the trailing span element to the current value of the form element loaded via <select-widget-widget> template below (this is inside a loop of a dynamically generated mat-table:

   <td mat-cell *matCellDef="let data; let rowIndex = index"> 
    <ng-container *ngIf="column !== ACTIONS_COLUMN_NAME">
          <div *ngIf="layout[i] && layout[i].widget">
            <select-widget-widget
              #tableCellWidget
              [class]="dragDropEnhancedMode && childTemplateModel ? getActiveItemClass(childTemplateModel[i]) : ''"
              (change)="onCellChange($event, rowIndex, column)"
              [logic]="layout[i]?.options?.logic || {}"
              [rowIndex]="rowIndex + paginationRowOffset"
              [dataIndex]="[].concat(rowIndex + paginationRowOffset)"
              [layoutIndex]="(layoutIndex || []).concat(i)"
              [layoutNode]="layout[i]">
            </select-widget-widget>
            <span *ngIf="tableConfig?.columnTypes[column] === 'default'" [innerHTML]="getPlainText()"></span>
          </div>
    </ng-container>
  </td>

Here is the code for the innerHTML call:

getPlainText() {
  const myValue = this.tableCellWidget?.nativeElement?.value || 'no value';
  const myValue2 = this.tableCellWidget?.nativeElement;
  console.log('tableCellWidget value', myValue); // UNDEFINED
  console.log('tableCellWidget nativeElement', myValue2); // UNDEFINED
  console.log('this.getPlainTextHasValue', this.getPlainTextHasValue); // FALSE
  return myValue;
}

get getPlainTextHasValue(): boolean {
  // returns false;
  return hasValue(this.tableCellWidget?.nativeElement?.value);
}

The logs are all returning undefined and false as seen in the code. Here is an example of the actual DOM html I'm working with and the element I am trying to use to set the innerHTML on the span from:

 <div _ngcontent-sen-c24="" class="ng-star-inserted">
  <!--bindings={
      "ng-reflect-ng-if": "false"
      }-->
  <select-widget-widget _ngcontent-sen-c24="" ng-reflect-layout-node="[object Object]" ng-reflect-layout-index="0,1" ng-reflect-data-index="0" ng-reflect-row-index="0" ng-reflect-logic="[object Object]" class="">
      <!---->
      <hidden-widget _nghost-sen-c37="" class="ng-star-inserted">
        <input _ngcontent-sen-c37="" type="hidden" value="Rob Bischoff-4" ng-reflect-form="[object Object]" id="control1660760198229_1660760203705" name="accountName" class="ng-untouched ng-dirty ng-valid"><!--bindings={
            "ng-reflect-ng-if": "false"
            }-->
      </hidden-widget>
  </select-widget-widget>
</div>

Any help much appreciated. There's obviously something missing from my approach.

Update 1: I have another viewChild element in my component. It appears that when I compare the console.logs for that element and my element, I can see that they are much different. It appears that my viewchild is referencing the angular template vs the underlying native element it represents? From the pic below you can see the log output for this.filterRef vs this.tableCellWidget. With this its obvious to me why its undefined but not obvious how I can obtain the reference to the underlying element instead.

enter image description here

Update2: Based on Chris answer below, here is the contents of select-widget-widget:

    import { ChangeDetectionStrategy } from '@angular/core';
import { Component } from '@angular/core';
import { ComponentFactoryResolver } from '@angular/core';
import { ComponentRef } from '@angular/core';
import { Input } from '@angular/core';
import { OnChanges } from '@angular/core';
import { OnInit } from '@angular/core';
import { ViewChild } from '@angular/core';
import { ViewContainerRef } from '@angular/core';

@Component({
  // tslint:disable-next-line:component-selector
  selector: 'select-widget-widget',
  template: `
    <ng-container #widgetContainer></ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SelectWidgetComponent implements OnChanges, OnInit {
  newComponent: ComponentRef<any> = null;
  @Input() layoutNode: any;
  @Input() layoutIndex: number[];
  @Input() dataIndex: number[];
  @Input() rowIndex: number;
  @Input() childTemplateModel: any;
  @Input() flexContainerClass: string;
  @ViewChild('widgetContainer', { static: true, read: ViewContainerRef }) widgetContainer: ViewContainerRef;

  constructor(private componentFactory: ComponentFactoryResolver) {}

  ngOnInit() {
    this.updateComponent();
  }

  ngOnChanges() {
    this.updateComponent();
  }

  updateComponent() {
    if (!this.newComponent && (this.layoutNode || {}).widget) {
      this.newComponent = this.widgetContainer.createComponent(this.componentFactory.resolveComponentFactory(this.layoutNode.widget));
    }
    if (this.newComponent) {
      for (const input of ['layoutNode', 'layoutIndex', 'dataIndex', 'rowIndex', 'childTemplateModel', 'flexContainerClass']) {
        this.newComponent.instance[input] = this[input];
      }
    }
  }
}

And here is the contents of hidden.component.ts (which is one of many components that can be dynamically pulled via <select-widget-widget> and is the component that creates the hidden input in my example code that I am trying to capture the value of for the row span text:

    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 { Input } from '@angular/core';
import { OnDestroy } from '@angular/core';
import { OnInit } 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: `
    <input [formControl]="formControl" [id]="'control' + layoutNode?._id + '_' + componentId" [name]="controlName" type="hidden" />
    <!-- DnD - View for hidden element -->
    <div *ngIf="jsf?.formOptions?.dragDropEnhancedMode || jsf?.formOptions?.debug" class="dnd-hidden-input">
      <strong>&nbsp;<mat-icon>visibility_off</mat-icon> {{ controlName ? controlName : 'hidden' }} </strong> {{ controlValueText }}
    </div>
  `,
  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;
  options: any;
  syncComponentsSubscription: Subscription;
  @Input() layoutNode: any;
  @Input() layoutIndex: number[];
  @Input() dataIndex: number[];
  @Input() rowIndex: number;

  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}`;
    }
    /** TESTING - Tries to add backward compatibility for missing Admin value: `dynamicValue` */
    // if (isInterpolated(this.controlValue) && !this.isDynamicValue) {
    //   this.options.dynamicValue = this.controlValue;
    // }
    this.syncComponentsSubscription = this.jsf.syncComponents.subscribe((value: SyncComponents) => {
      if (!value.targets.length || value.targets.includes(this.controlName)) {
        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);
  }

  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.
     */

    /** TESTING - Tries to add backward compatibility for missing Admin value: `dynamicValue` */
    // if (isInterpolated(this.controlValue) && !this.isDynamicValue) {
    //   this.controlValueInit = this.controlValue;
    //   this.options.dynamicValue = this.controlValue;
    // }

    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}`;
    }
    this.updateValue(this.controlValue);
  }

}
js-newb
  • 421
  • 6
  • 16
  • Have you tried `static: true` – debugger Aug 17 '22 at 18:31
  • see the solutions of [this](https://stackoverflow.com/questions/43383608/cannot-read-property-native-element-of-undefined) – debugger Aug 17 '22 at 18:46
  • @debugger Yes, I'm trying static: true with no change. – js-newb Aug 17 '22 at 19:44
  • Should be `@ViewChild('tableCellWidget', { read:ElementRef})...`. The "{read:....}" indicate how Angular should read the element -else ViewChild will be a "select-widget-widget"-. But really I imagine you can to have a ViewChild in your "select-widget-widget" to get the Element. Rememeber that in Angular by defect all the variables are public so, if you get from parent the "select-widget-widget", you can ask about `selectWidget.variableViewChild` – Eliseo Aug 18 '22 at 06:45

1 Answers1

1

It's because you're referencing a component, not an HTML element. Angular is designed such that components are "black boxes" to each other. The Angular way is to have the child emit the value to the parent, not directly query the value through html.

I don't know the internal workings of select-widget-widget so I can't show you exactly what to do, but it should have an event emitter that emits that value whenever it changes. Then you just have a property in the parent you update on change. Perhaps (change) is already doing this, and you just need to update the property in onCellChange().

Example:

<select-widget-widget
  (valueChange)='widgetValue = $event'
  ...
></select-widget-widget>
<span [innerHTML]="widgetValue"></span>
// Component TS
widgetValue = ''

or if change is emitting the value already:

<select-widget-widget
  (change)="onCellChange($event, rowIndex, column)"
  ...
></select-widget-widget>
<span [innerHTML]="widgetValue"></span>
widgetValue = '';

onCellChange(ev, row, col) {
  this.widgetValue = ev;
  ...
}

But of course you don't have to do things the Angular way. You can always just use vanilla JS.

<select-widget-widget
   id='tableCellWidget'
   ...
></select-widget-widget>
<span [innerHTML]="tableCellWidget?.value"></span>
tableCellWidget?: HTMLInputElement;

ngAfterViewInit() {
  this.tableCellWidget = document.querySelector(
    '#tableCellWidget > hidden-widget > input'
  );
}

Warning: View encapsulation goes out the window with this method. If there are mutliple widgets, you need to ensure they all have a unique id or you're gonna have a bad time. You can just append something unique to the id, like the column / row numbers.

Chris Hamilton
  • 9,252
  • 1
  • 9
  • 26
  • Thanks Chris for the explanation. I'm working through your examples now. I would like to make the first or second approach work. I'm going to update my answer with the hidden.component.ts file because its not currently emitting anything that I can see through the (valueChange) as the span is empty. – js-newb Aug 19 '22 at 21:10
  • The select-widget-widget first loads an autocomplete input component, then next time through the loop it loads the hidden component. The hidden component's value is set through interpolation to bind to the the autocomplete input component's value. So every time the autocomplete input is changed by the user, the hidden component's value is updated, That works. But the change of the autocomplete is not triggering the onCellChange to fire. It only fires when the autocomplete is cleared and blurred or cleared and a new value manually entered by user. – js-newb Aug 19 '22 at 21:24
  • @jcoder `valueChange` was just an example. You need to add an `EventEmitter` with the `@Output()` decorator. You can name it whatever you want. Then call `emit` whenever the value changes. – Chris Hamilton Aug 20 '22 at 13:43
  • Chris, the `select-widget-widget` component loads the `hidden-widget` component which is in effect a grandchild of the `MaterialTableComponent` component. I'm not sure the value emitted from the @Output of the `hidden-widget`component is accessible to the top level parent component (the mat-table aka `MaterialTableComponent`). – js-newb Aug 23 '22 at 15:01
  • @jcoder you can just create a callback in select-widget-widget that takes the value from hidden-widget and emits it up to the next parent – Chris Hamilton Aug 23 '22 at 16:03
  • I accepted your answer and thanks for all the help. Ultimately, the issue here stems from our onpush changedetection mandate. Since the column[data] is set as empty on ngOnInit, then once the Autocomplete input is changed, the CD engine does not (yet) know to update that content. – js-newb Aug 24 '22 at 12:12
  • 1
    @jcoder you can use the `ChangeDetectorRef` service to manually trigger change detection. – Chris Hamilton Aug 24 '22 at 15:09