Custom Array Renderer - Angular

Hello, i created a custom table renderer for angular material.

I used this as a base ArrayLayoutRenderer

What i wanted is to mimic is the react array renderer that has an accordion built into it. But what i ended up with is not very efficient. So i wanted to ask for feedback/improvements.

In the search of improving the code i found this issue that has been solved with pipes, so i implemented it, without significant improvement.

I have 2 main issues.

  1. My Array Renderer is being re-initialized once per item in the array, so if i have 6 items in the array, the component is being initialized 6 times. The more items the array has the slower is the form to fully render.
  2. The getStringKeyValue$ is called multiple times, even on the action button hover. (Also im looking for a better way to implement this, pipes, etc.)

Here is the code:

Angular Component Code
import { ce } from '@shared/scripts/compute-engine';
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit, ViewChildren } from '@angular/core';
import { MatBadgeModule } from '@angular/material/badge';
import { MatBadgeHarness } from '@angular/material/badge/testing';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
  JsonFormsAngularService,
  JsonFormsAbstractControl,
  JsonFormsModule,
} from '@jsonforms/angular';
import {
  arrayDefaultTranslations,
  ArrayLayoutProps,
  ArrayTranslations,
  createDefaultValue,
  defaultJsonFormsI18nState,
  findUISchema,
  getArrayTranslations,
  isObjectArrayWithNesting,
  JsonFormsState,
  mapDispatchToArrayControlProps,
  mapStateToArrayLayoutProps,
  OwnPropsOfRenderer,
  Paths,
  RankedTester,
  rankWith,
  setReadonly,
  StatePropsOfArrayLayout,
  UISchemaElement,
  UISchemaTester,
  unsetReadonly,
} from '@jsonforms/core';
import { distinctUntilChanged, map, Observable } from 'rxjs';
import { GetProps } from './get-props.pipe';
declare const MathLive: any;

@Component({
  selector: 'app-array-layout-renderer',
  templateUrl: 'custom-array-renderer.component.html',
  styleUrls: ['./custom-array-renderer.component.scss'],
  imports: [GetProps, MatIconModule, MatCardModule, MatButtonModule, CommonModule, JsonFormsModule, MatTooltipModule, MatBadgeModule, MatExpansionModule],
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomArrayRendererComponent
  extends JsonFormsAbstractControl<StatePropsOfArrayLayout>
  implements OnInit {
  noData!: boolean;
  translations: ArrayTranslations = {};
  addItem: ((path: string, value: any) => () => void) | undefined;
  moveItemUp: ((path: string, index: number) => () => void) | undefined;
  moveItemDown: ((path: string, index: number) => () => void) | undefined;
  removeItems: ((path: string, toDelete: number[]) => () => void) | undefined;
  uischemas: {
    tester: UISchemaTester;
    uischema: UISchemaElement;
  }[] | undefined;
  constructor(jsonFormsService: JsonFormsAngularService) {
    super(jsonFormsService);
  }
  mapToProps(
    state: JsonFormsState
  ): StatePropsOfArrayLayout & { translations: ArrayTranslations } {
    const props = mapStateToArrayLayoutProps(state, this.getOwnProps()) as StatePropsOfArrayLayout;
    // console.log('Data:', props); 
    const t =
      state.jsonforms.i18n?.translate ?? defaultJsonFormsI18nState.translate;
    const translations = getArrayTranslations(
      t,
      arrayDefaultTranslations,
      props.i18nKeyPrefix || '',
      props.label
    );
    return { ...props, translations };
  }
  remove(index: number): void {
    if (this.removeItems) {
      this.removeItems(this.propsPath, [index])();
    }
  }
  add(): void {
    if (this.addItem) {
      this.addItem(
        this.propsPath,
        createDefaultValue(this.scopedSchema, this.rootSchema)
      )();
    }
  }
  up(index: number): void {
    if (this.moveItemUp) {
      this.moveItemUp(this.propsPath, index)();
    }
  }
  down(index: number): void {
    if (this.moveItemDown) {
      this.moveItemDown(this.propsPath, index)();
    }
  }

  override ngOnInit() {
    console.log('init')
    super.ngOnInit();
    const { addItem, removeItems, moveUp, moveDown } =
      mapDispatchToArrayControlProps(
        this.jsonFormsService.updateCore.bind(this.jsonFormsService)
      );
    this.addItem = addItem;
    this.moveItemUp = moveUp;
    this.moveItemDown = moveDown;
    this.removeItems = removeItems;
  }
  override mapAdditionalProps(
    props: ArrayLayoutProps & { translations: ArrayTranslations }
  ) {
    this.noData = !props.data || props.data === 0;
    this.uischemas = props.uischemas;
    this.translations = props.translations;
  }
  getProps(index: number): OwnPropsOfRenderer {
    const uischema = findUISchema(
      this.uischemas ?? [],
      this.scopedSchema,
      this.uischema.scope,
      this.propsPath,
      undefined,
      this.uischema,
      this.rootSchema
    );
    if (this.isEnabled()) {
      unsetReadonly(uischema);
    } else {
      setReadonly(uischema);
    }
    return {
      schema: this.scopedSchema,
      path: Paths.compose(this.propsPath, `${index}`),
      uischema,
    };
  }
  trackByFn(index: number) {
    return index;
  }

  hasSingleKey(item: any): boolean {
    return item && Object.keys(item).length === 1;
  }

  getStringKeyValue$(idx: number): Observable<string> {
    return this.jsonFormsService.$state.pipe(
      distinctUntilChanged(),
      map(state => {
        if (this.uischema?.options?.['elementLabelProp']) { 
          const label = this.uischema?.options?.['elementLabelProp']
          if ((this.scopedSchema as any)?.['properties']?.[label].options?.['formula']) {
            const instance = state.jsonforms.core?.data[this.propsPath]?.[idx]?.label;
            const v = instance
              ? ce.serialize(JSON.parse(instance), { canonical: false })
              : '';
            return MathLive.convertLatexToMarkup(v);
          }
        } else{
          const key = Object.keys((this.scopedSchema as any)?.['properties']).filter((key: any)=>{
            return ((this.scopedSchema as any)?.['properties']?.[key].type == 'string') 
          })[0]
          return  key? state.jsonforms.core?.data[this.propsPath]?.[idx]?.[key] : `Item ${idx + 1}`
        }
      })
    );
  }
}

export const CustomArrayLayoutRendererTester: RankedTester = rankWith(
  5,
  isObjectArrayWithNesting
);


HTML template Code
<div class="array-layout">
  <div class="array-layout-toolbar">
    <h4 class="mat-h2 array-layout-title">{{ label }}</h4>
    <span></span>
    <mat-icon
      *ngIf="error?.length"
      color="warn"
      matBadge="{{ error && error.split('').length }}"
      matBadgeColor="warn"
      matTooltip="{{ error }}"
      matTooltipClass="error-message-tooltip"
    >
      error_outline
    </mat-icon>
    <span></span>
    <button
      mat-button
      matTooltip="{{ translations.addTooltip }}"
      [disabled]="!isEnabled()"
      (click)="add()"
    >
      <mat-icon>add</mat-icon>
    </button>
  </div>
  <p *ngIf="noData">{{ translations.noDataMessage }}</p>

  <mat-accordion>
    @for(item of [].constructor(data); track $index; let idx = $index; let last
    = $last; let first = $first){

    <mat-expansion-panel>
      <mat-expansion-panel-header class="panel-header">
        <mat-panel-title
          [innerHTML]="getStringKeyValue$(idx) | async"
        ></mat-panel-title>
        <mat-panel-description style="justify-content: flex-end">
          <div class="action-buttons">
            <button
              *ngIf="uischema?.options?.['showSortButtons']"
              class="item-up"
              mat-button
              [disabled]="first"
              (click)="$event.stopPropagation(); up(idx)"
              matTooltip="{{ translations.up }}"
              matTooltipPosition="right"
            >
              <mat-icon>arrow_upward</mat-icon>
            </button>
            <button
              *ngIf="uischema?.options?.['showSortButtons']"
              class="item-down"
              mat-button
              [disabled]="last"
              (click)="$event.stopPropagation(); down(idx)"
              matTooltip="{{ translations.down }}"
              matTooltipPosition="right"
            >
              <mat-icon>arrow_downward</mat-icon>
            </button>
            <button
              mat-button
              color="warn"
              (click)="$event.stopPropagation(); remove(idx)"
              matTooltip="{{ translations.removeTooltip }}"
              matTooltipPosition="right"
            >
              <mat-icon>delete</mat-icon>
            </button>
          </div>
        </mat-panel-description>
      </mat-expansion-panel-header>
      <mat-card class="array-item" appearance="outlined">
        <mat-card-content>
          <jsonforms-outlet
            [renderProps]="
              idx
                | getProps
                  : uischemas
                  : scopedSchema
                  : uischema
                  : propsPath
                  : rootSchema
            "
          ></jsonforms-outlet>
        </mat-card-content>
      </mat-card>
    </mat-expansion-panel>

    }
  </mat-accordion>
</div>

Pipe
import { Pipe, PipeTransform } from '@angular/core';
import { findUISchema, Paths } from '@jsonforms/core';

@Pipe({ name: 'getProps', standalone: true })
export class GetProps implements PipeTransform {
  transform(
    index: number,
    uischemas: any,
    scopedSchema: any,
    uischema: any,
    propsPath: any,
    rootSchema: any
  ) {
    const uischemaInstance = findUISchema(
      uischemas ?? [],
      scopedSchema,
      uischema.scope,
      propsPath,
      undefined,
      uischema,
      rootSchema
    );
  
    return {
      schema: scopedSchema,
      path: Paths.compose(propsPath, `${index}`),
      uischema: uischemaInstance,
    };
  }
}

Result

Hi @edlacer
I have two questions:

  1. Did you check whether the ArrayLayoutRenderer also has the problem of being re-initialized once per item?
  2. I saw that your component has a method getProps in addition to the separately given pipe. Is this on purpose? I can imagine the method instead of the pipe being used could be part of the problem?

Cheers,
Lucas

Hello @lucas-koehler.
Thanks for the reply.
As for your questions:

  1. I’ve tested the original ArrayLayoutRenderer and found out that is being initialized at least twice per item in the list, so if my array has 6 items the component would be started 12 times. Using the pipe over the original component i got the base from TableRenderer i got it down to once per item of the array.
  2. As for the getProps method, i forgot to remove it after implementing the pipe. But it doesnt show any difference in the component behaviour after removing it.

Hi @edlacer ,
that’s interesting. Thanks for checking :slight_smile:
Mhm…the angular renderer set is not as optimized as the React one, so there very well might be inefficient code in the ArrayLayoutRenderer and TableRenderer.
Still, it is surprising that ngOnInit is called more than once considering it shouldn’t even be called again when the renderer’s inputs change. Or is there another init method you are referring to?

Regarding getStringKeyValue$, I think it makes sense to implement this as a pipe to avoid the calculation being done multiple times for the same index.

Thanks for the quick response,

No, i was referring to the ngOnInit of the ArrayLayoutRenderer.

I’ll keep looking for a solution, will report on any developments

Thanks for the insight

Hello @lucas-koehler its me again.

After looking into it for a while i determined that my original assessment was wrong.

The re-initialization was a miss-judgement of my part. The thing that happened is that in my array schema i had an object that had an array inside it
Example:

schema
schema:{
    "type": "object",
    "properties": {
      "attributes": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "attr1": {
              "type": "string",
              "label": "Attr1"
            },
            "attr2": {
              "type": "object",
              "properties": {
                "NestedObject": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "nested_attr1": {
                        "type": "string"
                      },
                      "nested_attr2": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
uichema
uischema:{
    "type": "VerticalLayout",
    "elements": [
      {
        "type": "Control",
        "scope": "#/properties/attributes",
        "options": {
          "showSortButtons": true,
          "detail": {
            "type": "VerticalLayout",
            "elements": [
              {
                "type": "HorizontalLayout",
                "elements": [
                  {
                    "type": "Control",
                    "scope": "#/properties/attr1"
                  }
                ]
              },
              {
                "type": "VerticalLayout",
                "elements": [
                  {
                    "type": "Control",
                    "scope": "#/properties/attr2/properties/NestedObject",
                    "options": {
                      "showSortButtons": true,
                      "detail": {
                        "type": "VerticalLayout",
                        "elements": [
                          {
                            "type": "Control",
                            "scope": "#/properties/nested_attr1"
                          },
                          {
                            "type": "Control",
                            "scope": "#/properties/nested_attr2",
                            "options": {
                              "showSortButtons": true
                            }
                          }
                        ]
                      }
                    }
                  }
                ],
                "rule": {
                  "effect": "SHOW",
                  "condition": {
                    "scope": "#/properties/attr1",
                    "schema": {
                      "const": "show"
                    }
                  }
                }
              }
            ]
          }
        }
      }
    ]
  }
Example Data
{
  "attributes": [
    {
      "id": "987103af-2f27-4768-a142-2a4389f1c6e5",
      "attr1": "hello"
    },
    {
      "id": "db7bc581-2aa3-46f6-9a0a-2fbe2ef35766",
      "attr1": "hello 2"
    },
    {
      "id": "685dc34f-94fc-48a5-bb6e-0459a18ad74e",
      "attr1": "show",
      "attr2": {
        "NestedObject": [
          {
            "nested_attr1": "a",
            "nested_attr2": [
              "b"
            ]
          }
        ]
      }
    }
  ]
}
Array Custom Renderer Init
override ngOnInit() {
    super.ngOnInit();
    const { addItem, removeItems, moveUp, moveDown } =
      mapDispatchToArrayControlProps(
        this.jsonFormsService.updateCore.bind(this.jsonFormsService)
      );
    this.addItem = addItem;
    this.moveItemUp = moveUp;
    this.moveItemDown = moveDown;
    this.removeItems = removeItems;
    console.log('init ---->  ' , this.propsPath)

  }

The reason i thought the component was initialized once per item was because of the inner array, which was initialized internally.
Which brings me to my question, is there a way that i can check for the rule so it only initializes when the rule is valid.