src/app/features/reporting/edit-report-definition/edit-report-definition.component.ts
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.
CustomFormControlDirective<ReportDefinitionDto[]>
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
EditReportDefinitionComponent
|
| selector | app-edit-report-definition |
| standalone | true |
| imports |
MatButtonModule
MatTooltipModule
FontAwesomeModule
|
| styleUrls | ./edit-report-definition.component.scss |
| templateUrl | ./edit-report-definition.component.html |
| styleUrl | ./edit-report-definition.component.scss |
No results matching.
ReactiveFormsModule
DragDropModule
MatButtonModule
MatTooltipModule
FontAwesomeModule
JsonEditorComponent
SqlCodeEditorComponent
CustomFormControlDirective<ReportDefinitionDto[]>
EditComponent
OnInit
Properties |
|
Methods |
Inputs |
Outputs |
constructor()
|
| formFieldConfig | |
Type : FormFieldConfig
|
|
| aria-describedby | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
| disabled | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| ngControl | |
Type : any
|
|
Default value : inject(NgControl, { optional: true, self: true })
|
|
|
Inherited from
CustomFormControlDirective
|
|
| placeholder | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
| required | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| value | |
Type : T
|
|
|
Inherited from
CustomFormControlDirective
|
|
| valueChange | |
Type : EventEmitter
|
|
|
Inherited from
CustomFormControlDirective
|
|
| addChildGroup | ||||||
addChildGroup(groupIndex: number)
|
||||||
|
add a sub-group as the first child of the group at
Parameters :
Returns :
void
|
| addChildQuery | ||||||
addChildQuery(groupIndex: number)
|
||||||
|
add a query as the first child of the group at
Parameters :
Returns :
void
|
| addGroup |
addGroup()
|
|
Returns :
void
|
| addQuery |
addQuery()
|
|
Returns :
void
|
| onDrop | ||||||
onDrop(event: CdkDragDrop
|
||||||
|
Parameters :
Returns :
void
|
| remove | ||||||
remove(index: number)
|
||||||
|
remove the row and, for a group, its whole subtree
Parameters :
Returns :
void
|
| setGroupTitle |
setGroupTitle(index: number, event: Event)
|
|
Returns :
void
|
| setQuery |
setQuery(index: number, query: string)
|
|
Returns :
void
|
| blur |
blur()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| focus |
focus()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| onContainerClick | ||||||
onContainerClick(event: MouseEvent)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| registerOnChange | ||||||
registerOnChange(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| registerOnTouched | ||||||
registerOnTouched(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| setDescribedByIds | ||||||
setDescribedByIds(ids: string[])
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| setDisabledState | ||||||
setDisabledState(isDisabled: boolean)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| writeValue | |||||||||||||||
writeValue(value: T, notifyFormControl: unknown)
|
|||||||||||||||
|
Inherited from
CustomFormControlDirective
|
|||||||||||||||
|
Implementation for Angular ControlValueAccessor interface that links the form control value to the component value
Parameters :
Returns :
void
|
| Readonly indentPerLevel |
Type : number
|
Default value : 24
|
|
pixels of indentation per nesting level |
| Readonly isSql |
Type : unknown
|
Default value : computed<boolean>(() => this.mode() === "sql")
|
| controlType |
Type : string
|
Default value : "custom-control"
|
|
Inherited from
CustomFormControlDirective
|
| elementRef |
Type : unknown
|
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
|
|
Inherited from
CustomFormControlDirective
|
| Readonly enabled |
Type : Signal<boolean>
|
Default value : computed(() => !this._disabled())
|
|
Inherited from
CustomFormControlDirective
|
|
Whether the control is currently enabled, as a signal (tracks disabled). |
| errorStateMatcher |
Type : unknown
|
Default value : inject(ErrorStateMatcher)
|
|
Inherited from
CustomFormControlDirective
|
| id |
Type : unknown
|
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
|
|
Inherited from
CustomFormControlDirective
|
| Static nextId |
Type : number
|
Default value : 0
|
|
Inherited from
CustomFormControlDirective
|
| onChange |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
| onTouched |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
| parentForm |
Type : unknown
|
Default value : inject(NgForm, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
| parentFormGroup |
Type : unknown
|
Default value : inject(FormGroupDirective, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
| stateChanges |
Type : unknown
|
Default value : new Subject<void>()
|
|
Inherited from
CustomFormControlDirective
|
| Readonly valueSignal |
Type : Signal<T>
|
Default value : computed(() => this._value())
|
|
Inherited from
CustomFormControlDirective
|
|
The current value of the control as a signal.
Authoritative in both modes: it reflects the bound |
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);
}