Angular - How to define properly a singleton service for user-provided configuration?

92 views Asked by At

Let's say I have a root standalone component that wishes to share some of its config ( viewerOptions in the example below) to all his children's

import { Component, OnInit, Input, signal, computed, ChangeDetectionStrategy } from '@angular/core';

// imports
import { CommonModule } from '@angular/common';
import { MatExpansionModule } from '@angular/material/expansion';

import {
  CreateNodesComponent
} from "./common/index"

// services
import { SchemaResolutionService } from './services/schema-resolver';
import { JSVOptionsService } from "./services/jsv-options";

// Labels
import {
  ErrorOccurredLabelComponent,
  LoadingLabelComponent
} from "./labels/index";

// Types
import type { JSONSchema } from './types';
import type { IResolveOpts } from "@stoplight/json-ref-resolver/types"
import type { JSVOptions } from "./services/jsv-options";
type StatusType = "LOADING" | "ERROR" | "DONE";

@Component({
  selector: 'ngx-json-schema-viewer',
  standalone: true,
  imports: [
    CommonModule,
    MatExpansionModule,
    CreateNodesComponent,
    ErrorOccurredLabelComponent,
    LoadingLabelComponent,
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    JSVOptionsService
  ],
  template: `
    <!-- Error ... -->
    <ng-container *ngIf="status() === 'ERROR'">
      <div>
        <labels-error-occurred [error]="error()!"/>
      </div>
    </ng-container>

    <!-- Loading ... -->
    <ng-container *ngIf="status() === 'LOADING'">
        <labels-loading />
    </ng-container>

    <!-- Schema -->
    <ng-container *ngIf="status() === 'DONE'">
      <mat-accordion>
        <mat-expansion-panel [(expanded)]="expanded">
          <mat-expansion-panel-header>
            <mat-panel-title>
              <strong>
                {{ getSchemaTitle }}
              </strong>
            </mat-panel-title>
          </mat-expansion-panel-header>
          <ng-template matExpansionPanelContent>
            <jse-common-create-nodes [schema]="resolvedSchema()!" />
          </ng-template>
        </mat-expansion-panel>
      </mat-accordion>
    </ng-container>
  `
})
export class NgxJsonSchemaViewerComponent implements OnInit {
  @Input({ required: true }) schema: unknown;
  @Input() resolverOptions?: IResolveOpts;
  @Input() viewerOptions?: Partial<JSVOptions>;

  expanded : boolean = true;
  resolvedSchema = signal<JSONSchema | undefined>(undefined);
  error = signal<Error | undefined>(undefined);

  status = computed<StatusType>(() => {
    if (this.error() !== undefined) {
      return "ERROR";
    } else if (this.resolvedSchema() === undefined) {
      return "LOADING";
    } else {
      return "DONE";
    }
  });
  
  constructor(
    private schemaResolutionService: SchemaResolutionService,
    private jsvOptionsService: JSVOptionsService,
  ) {}

  ngOnInit(): void {
    // If asked, apply user options
    if (this.viewerOptions) {
      this.jsvOptionsService.setOptions(this.viewerOptions);
    }
    // Perform the asynchronous schema resolution
    this.schemaResolution();
  }

  private schemaResolution() {
    this.schemaResolutionService
      .resolveSchema(this.schema, this.resolverOptions)
      .subscribe({
        error: (err) => {
          this.error.set(err);
          this.resolvedSchema.set(undefined);
        },
        next: (result) => {
          this.resolvedSchema.set(result);
          this.error.set(undefined);
        }
      });
  }

  get getSchemaTitle() : string {
    let schema = this.resolvedSchema()!;
    if (typeof schema !== "boolean" && schema.title !== undefined) {
      return schema.title;
    }
    return "Schema";
  }
}

Config service is defined like that :

import { Injectable } from '@angular/core';

export type CheckKey =
  | "nullable"
  | "deprecated"
  | "readOnly"
  | "writeOnly"
  | "enum"
  | "stringLength"
  | "objectProperties"
  | "no-extra-properties"
  | "arrayItems"
  | "arrayContains"
  | "no-extra-items"
  | "number-range"
  | "pattern"
  | "multipleOf"
  | "uniqueItems"
  | "default"
  | "const"
  | "examples"
  | "contentMediaType"
  | "contentEncoding"
  | "contentSchema"

export type JSVOptions = {
    /**
     * Should we display "examples" ?
     * @default false
     */
    showExamples: boolean
    /**
     * To overwrite the order to display qualifier messages
     * @default ["nullable","deprecated","readOnly","writeOnly","enum","stringLength","objectProperties","no-extra-properties","arrayItems","arrayContains","no-extra-items","number-range","pattern","multipleOf","uniqueItems","contentEncoding","contentMediaType","contentSchema","default","const","examples"]
     */
    qualifierMessagesOrder: CheckKey[]
}

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

    private options: JSVOptions;

    constructor() {
        this.options = {
            showExamples: false,
            qualifierMessagesOrder: [
                "nullable",
                "deprecated",
                "readOnly",
                "writeOnly",
                "enum",
                "stringLength",
                "objectProperties",
                "no-extra-properties",
                "arrayItems",
                "arrayContains",
                "no-extra-items",
                "number-range",
                "pattern",
                "multipleOf",
                "uniqueItems",
                "contentEncoding",
                "contentMediaType",
                "contentSchema",
                "default",
                "const",
                "examples"
            ]
        }
    }

    setOptions(userOptions?: Partial<JSVOptions>) {
        this.options = {
            ...this.options,
            ...userOptions
        }
    }

    getOptions(): JSVOptions {
        return this.options;
    }

}

Example of child component that uses it deep down the component tree :

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { JSVOptionsService } from '../services/jsv-options';

// qualifier messages
import {
    ConstantComponent,
    ArrayContainsNumberComponent,
    ArrayNumberOfItemsComponent,
    ArrayUniqueItemsComponent,
    ContentEncodingComponent,
    ContentMediaTypeComponent,
    ContentSchemaComponent,
    DefaultValueComponent,
    DeprecatedComponent,
    EnumComponent,
    ExamplesComponent,
    MultipleOfComponent,
    NoExtraItemsComponent,
    NoExtraPropertiesComponent,
    NullableComponent,
    NumberBoundsComponent,
    ObjectPropertiesComponent,
    PatternComponent,
    ReadOnlyComponent,
    StringLengthComponent,
    WriteOnlyComponent
} from "./QualifierMessages/index";

// Types
import type { JSONSchema, JSONSchemaNS } from '../types';
import type { JSVOptions, CheckKey } from '../services/jsv-options';

@Component({
    selector: 'qm-messages',
    standalone: true,
    imports: [
        CommonModule,
        ConstantComponent,
        DefaultValueComponent,
        EnumComponent,
        ExamplesComponent,
        ReadOnlyComponent,
        WriteOnlyComponent,
        ArrayUniqueItemsComponent,
        DeprecatedComponent,
        NullableComponent,
        StringLengthComponent,
        ObjectPropertiesComponent,
        NoExtraPropertiesComponent,
        ArrayNumberOfItemsComponent,
        ArrayContainsNumberComponent,
        NoExtraItemsComponent,
        NumberBoundsComponent,
        PatternComponent,
        MultipleOfComponent,
        ContentEncodingComponent,
        ContentMediaTypeComponent,
        ContentSchemaComponent
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
        <div>
            <ng-container *ngFor="let key of filteredQualifiers">
                <ng-container [ngSwitch]="key">
                    <qm-constant *ngSwitchCase="'const'" [schema]="schema"/>
                    <qm-default-value *ngSwitchCase="'default'" [schema]="schema"/>
                    <qm-enum *ngSwitchCase="'enum'" [schema]="schema"/>
                    <qm-examples *ngSwitchCase="'examples'" [schema]="schema"/>
                    <qm-read-only *ngSwitchCase="'readOnly'" />
                    <qm-write-only *ngSwitchCase="'writeOnly'" />
                    <qm-array-unique-items *ngSwitchCase="'uniqueItems'" />
                    <qm-deprecated *ngSwitchCase="'deprecated'" />
                    <qm-nullable *ngSwitchCase="'nullable'" />
                    <qm-string-length *ngSwitchCase="'stringLength'" [schema]="schema" />
                    <qm-object-properties *ngSwitchCase="'objectProperties'" [schema]="schema" />
                    <qm-no-extra-properties *ngSwitchCase="'no-extra-properties'" />
                    <qm-array-number-of-items *ngSwitchCase="'arrayItems'" [schema]="schema" />
                    <qm-array-contains *ngSwitchCase="'arrayContains'" [schema]="schema" />
                    <qm-no-extra-items *ngSwitchCase="'no-extra-items'" />
                    <qm-number-bounds *ngSwitchCase="'number-range'" [schema]="schema" />
                    <qm-pattern *ngSwitchCase="'pattern'" [schema]="schema" />
                    <qm-multiple-of *ngSwitchCase="'multipleOf'" [schema]="schema" />
                    <qm-content-encoding *ngSwitchCase="'contentEncoding'" [schema]="schema" />
                    <qm-content-media-type *ngSwitchCase="'contentMediaType'" [schema]="schema" />
                    <qm-content-schema *ngSwitchCase="'contentSchema'" [schema]="typedAsJSONSchemaString" />
                </ng-container>
            </ng-container>
        </div>
    `
})
export class QualifierMessages {
    @Input({ required: true }) schema!: Exclude<JSONSchema, true | false>;

    constructor(private jsvOptionsService: JSVOptionsService) {}

    get options(): JSVOptions {
        return this.jsvOptionsService.getOptions();
    }

    get typedAsJSONSchemaString(): JSONSchemaNS.String {
        return this.schema as JSONSchemaNS.String;
    }

    get filteredQualifiers(): CheckKey[] {
        const qualifierMessagesOrder = this.options.qualifierMessagesOrder;

        const filteredMessagesOrder = qualifierMessagesOrder.filter(qualifierKey => {
            switch(qualifierKey) {
                case 'const':
                    return this.schema.const !== undefined;
                case 'default':
                    return this.schema.default !== undefined;
                case 'enum':
                    return this.schema.enum !== undefined;
                case 'examples':
                    return (this.options.showExamples || false) && this.schema.examples !== undefined;
                case 'readOnly':
                    return this.schema.readOnly === true;
                case 'writeOnly':
                    return this.schema.writeOnly === true;
                case 'uniqueItems':
                    return this.schema.uniqueItems === true;
                case 'deprecated':
                    return (this.schema as JSONSchemaNS.Object).deprecated === true;
                case 'nullable':
                    return (this.schema as any).nullable === true;
                case 'stringLength':
                    return this.schema.minLength !== undefined || this.schema.maxLength !== undefined;
                case 'objectProperties':
                    return this.schema.minProperties !== undefined || this.schema.maxProperties !== undefined;
                case 'no-extra-properties':
                    return this.schema.additionalProperties === false || (this.schema as JSONSchemaNS.Object).unevaluatedProperties === false;
                case 'arrayItems':
                    return this.schema.minItems !== undefined || this.schema.maxItems !== undefined;
                case 'arrayContains':
                    return (this.schema as JSONSchemaNS.Array).minContains !== undefined || (this.schema as JSONSchemaNS.Array).maxContains !== undefined;
                case 'no-extra-items':
                    return (this.schema as JSONSchemaNS.Array).unevaluatedItems === false || this.schema.items === false || this.schema.additionalItems === false;
                case 'number-range':
                    return this.schema.minimum !== undefined || this.schema.exclusiveMinimum !== undefined || this.schema.maximum !== undefined || this.schema.exclusiveMaximum !== undefined;
                case 'pattern':
                    return this.schema.pattern !== undefined;
                case 'multipleOf':
                    return this.schema.multipleOf !== undefined;
                case 'contentMediaType':
                    return this.schema.contentMediaType !== undefined;
                case 'contentEncoding':
                    return this.schema.contentEncoding !== undefined;
                case 'contentSchema':
                    return (this.schema as JSONSchemaNS.String).contentSchema !== undefined;
            }
        });
        // To debug easily in the future ;)
        return filteredMessagesOrder;
    }
}

Instead of getting something like :

enter image description here

I got a crash on Storybook : https://master--65174c82cd070b9998efd7f6.chromatic.com/?path=/story/viewer-generic-keywords--annotations . Could you enlight me where I was wrong ?

Thanks in advance

0

There are 0 answers