src/app/features/reporting/edit-report-definition/edit-report-definition.component.ts

Description

Structured editor for a SQL report's reportDefinition tree ({ query?, groupTitle?, items? }[], see ReportDefinitionDto).

The tree is edited as a single flat, indented list (see flat-tree): each query is a syntax-highlighted editor and each group a heading. Queries/groups are added directly into a group via its "+" buttons, and any row can be dragged to a new position — dragging it sideways nests it into the group above or lifts it back out, and dragging a group carries its whole subtree. For non-"sql" modes the definition is edited as raw JSON.

Extends

CustomFormControlDirective<ReportDefinitionDto[]>

Implements

EditComponent OnInit

Example

Metadata

Relationships

Used by

No results matching.

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

formFieldConfig
Type : FormFieldConfig
aria-describedby
Type : string
disabled
Type : boolean
ngControl
Type : any
Default value : inject(NgControl, { optional: true, self: true })
placeholder
Type : string
required
Type : boolean
value
Type : T

Outputs

valueChange
Type : EventEmitter

Methods

addChildGroup
addChildGroup(groupIndex: number)

add a sub-group as the first child of the group at groupIndex

Parameters :
Name Type Optional
groupIndex number No
Returns : void
addChildQuery
addChildQuery(groupIndex: number)

add a query as the first child of the group at groupIndex

Parameters :
Name Type Optional
groupIndex number No
Returns : void
addGroup
addGroup()
Returns : void
addQuery
addQuery()
Returns : void
onDrop
onDrop(event: CdkDragDrop)
Parameters :
Name Type Optional
event CdkDragDrop<unknown> No
Returns : void
remove
remove(index: number)

remove the row and, for a group, its whole subtree

Parameters :
Name Type Optional
index number No
Returns : void
setGroupTitle
setGroupTitle(index: number, event: Event)
Parameters :
Name Type Optional
index number No
event Event No
Returns : void
setQuery
setQuery(index: number, query: string)
Parameters :
Name Type Optional
index number No
query string No
Returns : void
blur
blur()
Returns : void
focus
focus()
Returns : void
onContainerClick
onContainerClick(event: MouseEvent)
Parameters :
Name Type Optional
event MouseEvent No
Returns : void
registerOnChange
registerOnChange(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
registerOnTouched
registerOnTouched(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
setDescribedByIds
setDescribedByIds(ids: string[])
Parameters :
Name Type Optional
ids string[] No
Returns : void
setDisabledState
setDisabledState(isDisabled: boolean)
Parameters :
Name Type Optional
isDisabled boolean No
Returns : void
writeValue
writeValue(value: T, notifyFormControl: unknown)

Implementation for Angular ControlValueAccessor interface that links the form control value to the component value

Parameters :
Name Type Optional Default value Description
value T No

The new value to set

notifyFormControl unknown No false

Whether to notify the FormControl of this change (for internal updates)

Returns : void

Properties

Readonly indentPerLevel
Type : number
Default value : 24

pixels of indentation per nesting level

Readonly isSql
Type : unknown
Default value : computed<boolean>(() => this.mode() === "sql")
Readonly rows
Type : unknown
Default value : computed(() => this.flatRows().map((row) => ({ ...row, isGroup: isGroupNode(row.data) })), )

the rows as rendered, with the display flags the template needs derived once per change

controlType
Type : string
Default value : "custom-control"
elementRef
Type : unknown
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
Readonly enabled
Type : Signal<boolean>
Default value : computed(() => !this._disabled())

Whether the control is currently enabled, as a signal (tracks disabled).

errorStateMatcher
Type : unknown
Default value : inject(ErrorStateMatcher)
id
Type : unknown
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
Static nextId
Type : number
Default value : 0
onChange
Type : unknown
Default value : () => {...}
onTouched
Type : unknown
Default value : () => {...}
parentForm
Type : unknown
Default value : inject(NgForm, { optional: true })
parentFormGroup
Type : unknown
Default value : inject(FormGroupDirective, { optional: true })
stateChanges
Type : unknown
Default value : new Subject<void>()
Readonly valueSignal
Type : Signal<T>
Default value : computed(() => this._value())

The current value of the control as a signal. Authoritative in both modes: it reflects the bound FormControl (synced in ngDoCheck) as well as [(value)] / writeValue updates.

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  effect,
  inject,
  input,
  OnInit,
  signal,
} from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { CdkDragDrop, DragDropModule } from "@angular/cdk/drag-drop";
import { MatFormFieldControl } from "@angular/material/form-field";
import { MatButtonModule } from "@angular/material/button";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { CustomFormControlDirective } from "#src/app/core/common-components/basic-autocomplete/custom-form-control.directive";
import { FormFieldConfig } from "#src/app/core/common-components/entity-form/FormConfig";
import { DynamicComponent } from "#src/app/core/config/dynamic-components/dynamic-component.decorator";
import { EditComponent } from "#src/app/core/entity/entity-field-edit/dynamic-edit/edit-component.interface";
import {
  FlatTreeRow,
  flattenTree,
  insertChild,
  moveSubtree,
  rebuildTree,
  removeSubtree,
  updateRow,
} from "#src/app/utils/flat-tree/flat-tree";
import { ReportDefinitionDto } from "../report-config";
import { JsonEditorComponent } from "#src/app/core/admin/json-editor/json-editor.component";
import { SqlCodeEditorComponent } from "../edit-sql-query/sql-code-editor.component";
import {
  isGroupNode,
  newGroupNode,
  newQueryNode,
  ReportDefinitionUiNode,
  reportDefinitionTree,
  toReportDefinition,
  toUiNodes,
} from "./report-definition-ui-node";

/**
 * Structured editor for a SQL report's `reportDefinition` tree
 * (`{ query?, groupTitle?, items? }[]`, see {@link ReportDefinitionDto}).
 *
 * The tree is edited as a single flat, indented list (see `flat-tree`): each query is a
 * syntax-highlighted editor and each group a heading. Queries/groups are added directly into a
 * group via its "+" buttons, and any row can be dragged to a new position — dragging it sideways
 * nests it into the group above or lifts it back out, and dragging a group carries its whole
 * subtree. For non-"sql" modes the definition is edited as raw JSON.
 */
@DynamicComponent("EditReportDefinition")
@Component({
  selector: "app-edit-report-definition",
  templateUrl: "./edit-report-definition.component.html",
  styleUrl: "./edit-report-definition.component.scss",
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule,
    DragDropModule,
    MatButtonModule,
    MatTooltipModule,
    FontAwesomeModule,
    JsonEditorComponent,
    SqlCodeEditorComponent,
  ],
  providers: [
    {
      provide: MatFormFieldControl,
      useExisting: EditReportDefinitionComponent,
    },
  ],
})
export class EditReportDefinitionComponent
  extends CustomFormControlDirective<ReportDefinitionDto[]>
  implements EditComponent, OnInit
{
  private readonly destroyRef = inject(DestroyRef);

  formFieldConfig = input<FormFieldConfig>();

  /** the report's mode; the structured SQL editor is only used for "sql" reports */
  private readonly mode = signal<string | undefined>(undefined);
  readonly isSql = computed<boolean>(() => this.mode() === "sql");

  /** pixels of indentation per nesting level */
  readonly indentPerLevel = 24;

  /** the definition as a flat, indented list of rows (working copy) */
  private readonly flatRows = signal<FlatTreeRow<ReportDefinitionUiNode>[]>([]);

  /** the rows as rendered, with the display flags the template needs derived once per change */
  readonly rows = computed(() =>
    this.flatRows().map((row) => ({ ...row, isGroup: isGroupNode(row.data) })),
  );

  /** JSON of the last definition synced in either direction, to break the value<->rows loop */
  private lastSync = "";

  constructor() {
    super();

    // mirror external value changes (form load/reset) into the working rows
    effect(() => {
      const value = this.valueSignal();
      const arr = Array.isArray(value) ? value : [];
      const json = JSON.stringify(arr);
      if (json !== this.lastSync) {
        this.lastSync = json;
        this.flatRows.set(flattenTree(toUiNodes(arr), reportDefinitionTree));
      }
    });
  }

  ngOnInit() {
    // Track the report's mode (from the sibling form control) so the structured SQL editor
    // is only shown for "sql" reports; reporting/exporting definitions use the JSON editor.
    const modeControl = this.formControl?.parent?.get("mode");
    if (modeControl) {
      this.mode.set(modeControl.value);
      modeControl.valueChanges
        .pipe(takeUntilDestroyed(this.destroyRef))
        .subscribe((value) => this.mode.set(value));
    }
  }

  setQuery(index: number, query: string): void {
    this.updateNode(index, { query });
  }

  setGroupTitle(index: number, event: Event): void {
    const groupTitle = (event.target as HTMLInputElement).value;
    this.updateNode(index, { groupTitle });
  }

  addQuery(): void {
    this.append(newQueryNode());
  }

  addGroup(): void {
    this.append(newGroupNode());
  }

  /** add a query as the first child of the group at `groupIndex` */
  addChildQuery(groupIndex: number): void {
    this.insertInto(groupIndex, newQueryNode());
  }

  /** add a sub-group as the first child of the group at `groupIndex` */
  addChildGroup(groupIndex: number): void {
    this.insertInto(groupIndex, newGroupNode());
  }

  /** remove the row and, for a group, its whole subtree */
  remove(index: number): void {
    this.flatRows.update((rows) =>
      removeSubtree(rows, index, reportDefinitionTree),
    );
    this.persist();
  }

  onDrop(event: CdkDragDrop<unknown>): void {
    // how far the row was dragged sideways determines how deep it is nested;
    // truncated, so that only a full indentation step re-nests (and not slight drift)
    const levelDelta = Math.trunc(event.distance.x / this.indentPerLevel);
    this.flatRows.update((rows) =>
      moveSubtree(
        rows,
        event.previousIndex,
        event.currentIndex,
        reportDefinitionTree,
        { levelDelta },
      ),
    );
    this.persist();
  }

  private append(node: ReportDefinitionUiNode): void {
    this.flatRows.update((rows) => [
      ...rows,
      { id: node.uniqueId, level: 0, data: node },
    ]);
    this.persist();
  }

  private insertInto(groupIndex: number, node: ReportDefinitionUiNode): void {
    this.flatRows.update((rows) =>
      insertChild(rows, groupIndex, node, reportDefinitionTree),
    );
    this.persist();
  }

  private updateNode(
    index: number,
    patch: Partial<ReportDefinitionUiNode>,
  ): void {
    this.flatRows.update((rows) =>
      updateRow(rows, index, { ...rows[index].data, ...patch }),
    );
    this.persist();
  }

  private persist(): void {
    const definition = toReportDefinition(
      rebuildTree(this.flatRows(), reportDefinitionTree),
    );
    const json = JSON.stringify(definition);
    // Ignore no-op writes: a re-emitted (unchanged) value would otherwise re-mark a pristine
    // form dirty. `lastSync` is the value last synced in either direction.
    if (json === this.lastSync) {
      return;
    }
    this.lastSync = json;
    // Mark dirty before writing the value: the form's `valueChanges` subscriber reads the
    // dirty state synchronously when `setValue` emits, so it must already be up to date.
    // Write through the bound FormControl directly: as a dynamically-created edit component
    // its `onChange` is never registered, so `this.value = …` would not reach the form.
    this.formControl?.markAsDirty();
    this.formControl?.setValue(definition);
  }
}
@if (!isSql()) {
  <!-- reporting/exporting definitions are edited as raw JSON -->
  <app-json-editor [formControl]="formControl"></app-json-editor>
} @else {
  <div class="flex-column gap-small report-definition">
    <div
      class="flex-column gap-small"
      cdkDropList
      (cdkDropListDropped)="onDrop($event)"
    >
      @for (row of rows(); track row.id; let i = $index) {
        <div
          class="report-row"
          [class.group-row]="row.isGroup"
          [style.margin-left.px]="row.level * indentPerLevel"
          cdkDrag
          [cdkDragData]="row"
        >
          <div class="flex-row align-center gap-small">
            @if (enabled()) {
              <fa-icon
                icon="grip-vertical"
                class="drag-handle"
                cdkDragHandle
              ></fa-icon>
            }

            @if (row.isGroup) {
              <fa-icon icon="layer-group" class="row-icon"></fa-icon>
              <input
                class="group-title-input"
                type="text"
                [value]="row.data.groupTitle ?? ''"
                [disabled]="!enabled()"
                placeholder="Group title"
                i18n-placeholder
                aria-label="Group title"
                i18n-aria-label
                (change)="setGroupTitle(i, $event)"
              />
            } @else {
              <app-edit-sql-query
                class="query-editor"
                [value]="row.data.query ?? ''"
                [disabled]="!enabled()"
                (valueChange)="setQuery(i, $event)"
              ></app-edit-sql-query>
            }

            @if (enabled()) {
              @if (row.isGroup) {
                <button
                  mat-icon-button
                  type="button"
                  (click)="addChildQuery(i)"
                  matTooltip="Add query to this group"
                  i18n-matTooltip
                  aria-label="Add query to this group"
                  i18n-aria-label
                >
                  <fa-icon icon="plus"></fa-icon>
                </button>
                <button
                  mat-icon-button
                  type="button"
                  (click)="addChildGroup(i)"
                  matTooltip="Add sub-group to this group"
                  i18n-matTooltip
                  aria-label="Add sub-group to this group"
                  i18n-aria-label
                >
                  <fa-icon icon="layer-group"></fa-icon>
                </button>
              }
              <button
                mat-icon-button
                type="button"
                (click)="remove(i)"
                [matTooltip]="
                  row.isGroup ? 'Remove group and its contents' : 'Remove query'
                "
                i18n-matTooltip
                [attr.aria-label]="
                  row.isGroup ? 'Remove group and its contents' : 'Remove query'
                "
              >
                <fa-icon icon="trash"></fa-icon>
              </button>
            }
          </div>
        </div>
      }
    </div>

    @if (enabled()) {
      <div class="flex-row gap-small margin-top-x-small">
        <button mat-stroked-button type="button" (click)="addQuery()">
          <fa-icon icon="plus" class="standard-icon-with-text"></fa-icon>
          <span i18n>Add query</span>
        </button>
        <button mat-stroked-button type="button" (click)="addGroup()">
          <fa-icon icon="layer-group" class="standard-icon-with-text"></fa-icon>
          <span i18n>Add group</span>
        </button>
      </div>
    }
  </div>
}

./edit-report-definition.component.scss

.report-definition {
  width: 100%;
}

.report-row {
  border: 1px solid rgba(0, 0, 0, 0.12);
  border-radius: 4px;
  padding: 4px 8px;
  background: #fff;
}

// groups stand out from the queries they contain
.report-row.group-row {
  background: rgba(0, 0, 0, 0.03);
  border-color: rgba(0, 0, 0, 0.24);
}

.query-editor {
  flex: 1 1 auto;
  min-width: 0;
}

.group-title-input {
  flex: 1 1 auto;
  min-width: 0;
  border: none;
  background: transparent;
  font: inherit;
  padding: 4px 0;
}

.drag-handle {
  cursor: move;
  opacity: 0.5;
}

.drag-handle:hover {
  opacity: 1;
}

.row-icon {
  opacity: 0.6;
}

// CDK drag & drop feedback
.cdk-drag-preview {
  border-radius: 4px;
  box-shadow:
    0 5px 5px -3px rgba(0, 0, 0, 0.2),
    0 8px 10px 1px rgba(0, 0, 0, 0.14),
    0 3px 14px 2px rgba(0, 0, 0, 0.12);
}

.cdk-drag-placeholder {
  opacity: 0.4;
}

.cdk-drag-animating {
  transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}

.cdk-drop-list-dragging .report-row:not(.cdk-drag-placeholder) {
  transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""