projects/cobbler-frontend/src/app/signatures/signatures.component.ts
| selector | cobbler-signatures |
| standalone | true |
| imports |
MatTree
MatTreeNode
MatIcon
MatIconButton
MatTreeNodeToggle
MatTreeNodePadding
MatTreeNodeDef
MatTable
MatHeaderCell
MatCell
MatHeaderRow
MatRow
MatColumnDef
MatHeaderCellDef
MatCellDef
MatHeaderRowDef
MatRowDef
MatProgressSpinner
MatButtonModule
|
| styleUrls | ./signatures.component.scss |
| templateUrl | ./signatures.component.html |
| styleUrl | ./signatures.component.scss |
No results matching.
MatTree
MatTreeNode
MatIcon
MatIconButton
MatTreeNodeToggle
MatTreeNodePadding
MatTreeNodeDef
MatTable
MatHeaderCell
MatCell
MatHeaderRow
MatRow
MatColumnDef
MatHeaderCellDef
MatCellDef
MatHeaderRowDef
MatRowDef
MatProgressSpinner
ReactiveFormsModule
TextAutocompleteComponent
MatButtonModule
OnInit
OnDestroy
Properties |
|
Methods |
|
constructor()
|
| Private applyTreeFilter |
applyTreeFilter(breedFilter: string, osVersionFilter: string)
|
|
Returns :
void
|
| Private breedMatches |
breedMatches(breedNode: OsNode, breedTerm: string)
|
|
Returns :
boolean
|
| clearFilter |
clearFilter()
|
|
Returns :
void
|
| Private filterBreedChildren |
filterBreedChildren(breedNode: OsNode, osVersionTerm: string)
|
|
Returns :
OsNode
|
| generateSignatureUiData |
generateSignatureUiData()
|
|
Returns :
void
|
| ngOnDestroy |
ngOnDestroy()
|
|
Returns :
void
|
| ngOnInit |
ngOnInit()
|
|
Returns :
void
|
| updateSignatures |
updateSignatures()
|
|
Returns :
void
|
| Private Readonly _formBuilder |
Type : unknown
|
Default value : inject(FormBuilder)
|
| Private _snackBar |
Type : unknown
|
Default value : inject(MatSnackBar)
|
| Private _transformer |
Type : unknown
|
Default value : () => {...}
|
| Private allData |
Type : Array<OsNode>
|
Default value : []
|
| Private cobblerApiService |
Type : unknown
|
Default value : inject(CobblerApiService)
|
| Protected Readonly CobblerInputChoices |
Type : unknown
|
Default value : CobblerInputChoices
|
| dataSource |
Type : unknown
|
Default value : new MatTreeFlatDataSource(this.treeControl, this.treeFlattener)
|
| displayedColumns |
Type : unknown
|
Default value : this.columns.map((c) => c.columnDef)
|
| hasChild |
Type : unknown
|
Default value : () => {...}
|
| hasOsVersion |
Type : unknown
|
Default value : () => {...}
|
| Protected isFilterEmpty |
Type : boolean
|
Default value : false
|
| Public isLoading |
Type : unknown
|
Default value : true
|
| Private ngUnsubscribe |
Type : unknown
|
Default value : new Subject<void>()
|
| Private route |
Type : unknown
|
Default value : inject(ActivatedRoute)
|
| signaturesEditableInputdata |
Type : Array<CobblerInputData>
|
Default value : [
...cobblerItemEditableData,
{
formControlName: 'breed',
inputType: CobblerInputChoices.TEXT_AUTOCOMPLETE,
label: $localize`:@@distro.edit.label.breed:Search for OS`,
disabled: false,
readonly: false,
defaultValue: '',
inherited: false,
options: [],
},
{
formControlName: 'os_version',
inputType: CobblerInputChoices.TEXT_AUTOCOMPLETE,
label: $localize`:@@distro.edit.label.os_version:Search for OS version`,
disabled: false,
readonly: false,
defaultValue: '',
inherited: false,
options: [],
},
]
|
| signaturesFormGroup |
Type : unknown
|
Default value : this._formBuilder.group({})
|
| Private targetBreed |
Type : string | null
|
Default value : null
|
| Private targetOsVersion |
Type : string | null
|
Default value : null
|
| targetTable |
Type : ElementRef<HTMLDivElement>
|
Decorators :
@ViewChild('targetElement')
|
| treeControl |
Type : unknown
|
Default value : new FlatTreeControl<OsBreedFlatNode>(
(node) => node.level,
(node) => node.expandable,
)
|
| treeFlattener |
Type : unknown
|
Default value : new MatTreeFlattener(
this._transformer,
(node) => node.level,
(node) => node.expandable,
(node) => node.children,
)
|
| userService |
Type : unknown
|
Default value : inject(UserService)
|
import {
Component,
ElementRef,
OnDestroy,
OnInit,
QueryList,
ViewChild,
inject,
} from '@angular/core';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable,
} from '@angular/material/table';
import {
debounceTime,
filter,
repeat,
startWith,
switchMap,
take,
takeUntil,
} from 'rxjs/operators';
import { UserService } from '../services/user.service';
import { CobblerApiService } from 'cobbler-api';
import {
MatTree,
MatTreeFlatDataSource,
MatTreeFlattener,
MatTreeNode,
MatTreeNodeDef,
MatTreeNodePadding,
MatTreeNodeToggle,
} from '@angular/material/tree';
import { FlatTreeControl } from '@angular/cdk/tree';
import { MatIcon } from '@angular/material/icon';
import {
MatIconButton,
MatAnchor,
MatButtonModule,
} from '@angular/material/button';
import { Subject, of, map, combineLatest } from 'rxjs';
import Utils, { CobblerInputChoices, CobblerInputData } from '../utils';
import { ActivatedRoute } from '@angular/router';
import { cobblerItemEditableData } from '../items/metadata';
import { FormBuilder, FormControl, ReactiveFormsModule } from '@angular/forms';
import { TextAutocompleteComponent } from '../common/text-autocomplete/text-autocomplete.component';
interface TableRow {
key: string;
value: any;
}
/**
* Food data with nested structure.
* Each node has a name and an optional list of children.
*/
interface OsNode {
data: string | Array<TableRow>;
children?: OsNode[];
}
/** Flat node with expandable and level information */
interface OsBreedFlatNode {
expandable: boolean;
data: string | Array<TableRow>;
level: number;
}
@Component({
selector: 'cobbler-signatures',
imports: [
MatTree,
MatTreeNode,
MatIcon,
MatIconButton,
MatTreeNodeToggle,
MatTreeNodePadding,
MatTreeNodeDef,
MatTable,
MatHeaderCell,
MatCell,
MatHeaderRow,
MatRow,
MatColumnDef,
MatHeaderCellDef,
MatCellDef,
MatHeaderRowDef,
MatRowDef,
MatProgressSpinner,
ReactiveFormsModule,
TextAutocompleteComponent,
MatButtonModule,
],
templateUrl: './signatures.component.html',
styleUrl: './signatures.component.scss',
})
export class SignaturesComponent implements OnInit, OnDestroy {
userService = inject(UserService);
private cobblerApiService = inject(CobblerApiService);
private _snackBar = inject(MatSnackBar);
private route = inject(ActivatedRoute);
private targetOsVersion: string | null = null;
private targetBreed: string | null = null;
protected isFilterEmpty: boolean = false;
@ViewChild('targetElement') targetTable!: ElementRef<HTMLDivElement>;
// Bring Enum to HTML scope
protected readonly CobblerInputChoices = CobblerInputChoices;
// Unsubscribe
private ngUnsubscribe = new Subject<void>();
// Form Data
signaturesEditableInputdata: Array<CobblerInputData> = [
...cobblerItemEditableData,
{
formControlName: 'breed',
inputType: CobblerInputChoices.TEXT_AUTOCOMPLETE,
label: $localize`:@@distro.edit.label.breed:Search for OS`,
disabled: false,
readonly: false,
defaultValue: '',
inherited: false,
options: [],
},
{
formControlName: 'os_version',
inputType: CobblerInputChoices.TEXT_AUTOCOMPLETE,
label: $localize`:@@distro.edit.label.os_version:Search for OS version`,
disabled: false,
readonly: false,
defaultValue: '',
inherited: false,
options: [],
},
];
// Form
private readonly _formBuilder = inject(FormBuilder);
signaturesFormGroup = this._formBuilder.group({});
constructor() {
Utils.fillupSingleFormGroup(
this.signaturesFormGroup,
this.signaturesEditableInputdata,
);
}
// Table
columns = [
{
columnDef: 'key',
header: 'Attribute',
cell: (element: TableRow) => `${element.key}`,
},
{
columnDef: 'value',
header: 'Value',
cell: (element: TableRow) => `${element.value}`,
},
];
displayedColumns = this.columns.map((c) => c.columnDef);
// Tree
private _transformer = (node: OsNode, level: number) => {
return {
expandable: !!node.children && node.children.length > 0,
data: node.data,
level: level,
};
};
treeControl = new FlatTreeControl<OsBreedFlatNode>(
(node) => node.level,
(node) => node.expandable,
);
treeFlattener = new MatTreeFlattener(
this._transformer,
(node) => node.level,
(node) => node.expandable,
(node) => node.children,
);
private allData: Array<OsNode> = [];
dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener);
// Spinner
public isLoading = true;
ngOnInit(): void {
this.targetOsVersion = this.route.snapshot.queryParamMap.get('osVersion');
this.targetBreed = this.route.snapshot.queryParamMap.get('breed');
this.generateSignatureUiData();
this.signaturesFormGroup
.get('breed')
?.valueChanges.pipe(
debounceTime(300),
switchMap((breed: string) => {
const validBreeds =
(this.signaturesEditableInputdata.find(
(s) => s.formControlName === 'breed',
)?.options as string[]) ?? [];
// If invalid breed, don't contact the API
if (!validBreeds.includes(breed)) {
return of([] as string[]);
}
return this.cobblerApiService.get_valid_os_versions_for_breed(
breed,
this.userService.token,
);
}),
takeUntil(this.ngUnsubscribe),
)
.subscribe((osVersions) => {
const osVersionInput = this.signaturesEditableInputdata.find(
(s) => s.formControlName === 'os_version',
);
if (osVersionInput) {
osVersionInput.options = osVersions;
}
});
// A user was redirected for breed only (Distro, Image etc. pages)
if (this.targetBreed) {
(
this.signaturesFormGroup.get('breed') as unknown as FormControl
)?.setValue(this.targetBreed);
}
// A user was redirected for OS version only (Distro, Image etc. pages)
if (this.targetOsVersion) {
(
this.signaturesFormGroup.get('breed') as unknown as FormControl
)?.setValue(this.targetBreed);
(
this.signaturesFormGroup.get('os_version') as unknown as FormControl
)?.setValue(this.targetOsVersion);
}
// Subscribe to breed and os version autocomplete inputs at the same time
combineLatest([
this.signaturesFormGroup
.get('breed')!
.valueChanges.pipe(
startWith(this.signaturesFormGroup.get('breed')!.value),
),
this.signaturesFormGroup
.get('os_version')!
.valueChanges.pipe(
startWith(this.signaturesFormGroup.get('os_version')!.value),
),
])
.pipe(debounceTime(150), takeUntil(this.ngUnsubscribe))
.subscribe(([breedFilter, osVersionFilter]) => {
this.applyTreeFilter(breedFilter, osVersionFilter);
});
}
ngOnDestroy(): void {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
hasChild = (_: number, node: OsBreedFlatNode) => node.expandable;
hasOsVersion = (_: number, node: OsBreedFlatNode) =>
typeof node.data !== 'string';
generateSignatureUiData(): void {
this.cobblerApiService
.get_signatures(this.userService.token)
.pipe(
switchMap((value) => {
return this.cobblerApiService
.get_valid_breeds(this.userService.token)
.pipe(
map((breedList) => ({
value,
validBreeds: breedList,
})),
);
}),
takeUntil(this.ngUnsubscribe),
)
.subscribe({
next: ({ value, validBreeds }) => {
const breedsInput = this.signaturesEditableInputdata.find(
(b) => b.formControlName === 'breed',
);
if (breedsInput) {
breedsInput.options = validBreeds;
}
const newData: Array<OsNode> = [];
for (const k in value.breeds) {
const children: Array<OsNode> = [];
for (const j in value.breeds[k]) {
const osVersionData: Array<TableRow> = [];
for (const i in value.breeds[k][j]) {
osVersionData.push({ key: i, value: value.breeds[k][j][i] });
}
children.push({
data: j,
children: [{ data: osVersionData, children: [] }],
});
}
newData.push({ data: k, children: children });
}
this.allData = newData;
this.dataSource.data = newData;
this.isLoading = false;
},
error: (error) => {
// HTML encode the error message since it originates from XML
this._snackBar.open(
Utils.toHTML(error.message),
$localize`:@@snackbar.action.close:Close`,
);
},
});
}
updateSignatures(): void {
this.isLoading = true;
this.cobblerApiService
.background_signature_update(this.userService.token)
.pipe(takeUntil(this.ngUnsubscribe))
.subscribe(
(value) => {
this.cobblerApiService
.get_task_status(value)
.pipe(
repeat(),
filter(
(data) => data.state === 'failed' || data.state === 'complete',
),
take(1),
)
.subscribe((value1) => {
this.isLoading = false;
this.generateSignatureUiData();
});
},
(error) => {
// HTML encode the error message since it originates from XML
this._snackBar.open(
Utils.toHTML(error.message),
$localize`:@@snackbar.action.close:Close`,
);
},
);
}
// Main algorithm to filter the tree by input values
private applyTreeFilter(breedFilter: string, osVersionFilter: string): void {
const breedTerm = (breedFilter ?? '').trim().toLowerCase(); // breed value (input)
const osVersionTerm = (osVersionFilter ?? '').trim().toLowerCase(); // os version value (input)
this.isFilterEmpty = !breedTerm && !osVersionTerm;
if (!breedTerm && !osVersionTerm) {
this.dataSource.data = this.allData;
return;
}
const filtered: Array<OsNode> = this.allData
.filter((breedNode) => this.breedMatches(breedNode, breedTerm))
.map((breedNode) => this.filterBreedChildren(breedNode, osVersionTerm))
.filter((breedNode) => breedNode.children!.length > 0);
this.dataSource.data = filtered;
this.treeControl.collapseAll();
// Expand all breed nodes
if (breedTerm) {
this.treeControl.dataNodes
.filter((node) => node.level === 0)
.forEach((node) => this.treeControl.expand(node));
}
// Expand only the exact os version if it matches completely
if (osVersionTerm) {
const exactMatch = this.treeControl.dataNodes.find(
(node) =>
node.level === 1 &&
typeof node.data === 'string' &&
(node.data as string).toLowerCase() === osVersionTerm,
);
if (exactMatch) {
this.treeControl.expand(exactMatch);
}
}
}
// Filter by breed name
private breedMatches(breedNode: OsNode, breedTerm: string): boolean {
if (!breedTerm) {
return true;
}
return (breedNode.data as string).toLowerCase().includes(breedTerm);
}
// Filter by os version in breed
private filterBreedChildren(
breedNode: OsNode,
osVersionTerm: string,
): OsNode {
if (!osVersionTerm) {
return breedNode;
}
const filteredChildren = (breedNode.children ?? []).filter(
(osVersionNode) =>
(osVersionNode.data as string).toLowerCase().includes(osVersionTerm),
);
return { ...breedNode, children: filteredChildren };
}
clearFilter(): void {
(this.signaturesFormGroup.get('breed') as unknown as FormControl)?.setValue(
'',
);
(
this.signaturesFormGroup.get('os_version') as unknown as FormControl
)?.setValue('');
}
}
<div class="title-table">
<div class="title-row">
<h1 class="title title-cell-text" i18n="@@signatures.title">Signatures</h1>
<span class="title-cell-button">
<button mat-icon-button (click)="this.updateSignatures()">
<mat-icon>refresh</mat-icon>
</button>
</span>
</div>
</div>
<div class="filter-container">
<form class="filter-form" [formGroup]="signaturesFormGroup">
@for (input of signaturesEditableInputdata; track input) {
@switch (input.inputType) {
@case (CobblerInputChoices.TEXT_AUTOCOMPLETE) {
<ng-container>
<cobbler-text-autocomplete
label="{{ input.label }}"
formControlName="{{ input.formControlName }}"
[options]="input.options"
[readonly]="input.readonly"
[hint]="input.hint"
></cobbler-text-autocomplete>
</ng-container>
}
}
}
</form>
@if (!isFilterEmpty) {
<button class="delete-button" matButton (click)="clearFilter()">
Clear filter
</button>
}
</div>
@if (isLoading) {
<mat-spinner style="margin: 0 auto"></mat-spinner>
}
@if (!isLoading) {
<mat-tree [dataSource]="dataSource" [treeControl]="treeControl">
<!-- This is the tree node template for leaf nodes -->
<mat-tree-node *matTreeNodeDef="let node" matTreeNodePadding>
<!-- use a disabled button to provide padding for tree leaf -->
<button mat-icon-button disabled></button>
{{ node.data }}
</mat-tree-node>
<!-- This is the tree node template for expandable nodes -->
<mat-tree-node
*matTreeNodeDef="let node; when: hasChild"
matTreeNodePadding
[id]="'node-' + node.data"
>
<button
mat-icon-button
matTreeNodeToggle
[attr.aria-label]="'Toggle ' + node.data"
>
<mat-icon class="mat-icon-rtl-mirror">
{{ treeControl.isExpanded(node) ? "expand_more" : "chevron_right" }}
</mat-icon>
</button>
{{ node.data }}
</mat-tree-node>
<!-- This is the tree node template for expandable nodes with an OS version as data -->
<mat-tree-node
*matTreeNodeDef="let node; when: hasOsVersion"
matTreeNodePadding
>
<button mat-icon-button disabled></button>
<div class="table">
<table mat-table [dataSource]="node.data" class="mat-elevation-z8">
@for (column of columns; track column) {
<ng-container [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef>
{{ column.header }}
</th>
<td mat-cell *matCellDef="let row">
{{ column.cell(row) }}
</td>
</ng-container>
}
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>
</mat-tree-node>
</mat-tree>
}
./signatures.component.scss
.filter-container {
display: flex;
flex-direction: row;
width: 100%;
}
.filter-form {
display: flex;
flex-direction: row;
gap: 10px;
max-width: 400px;
}
.delete-button {
margin-left: 37px;
color: rgba(0, 0, 0, 0.774);
font-size: 12px;
}
table.mat-mdc-table {
border-radius: 14px;
overflow: hidden;
background-color: white;
}
.title-table {
display: table;
width: 100%;
}
// Table cell
.mat-mdc-header-cell {
border-bottom: 1px solid #cbcbcb;
}
.mat-mdc-cell {
border-bottom: 1px solid #cbcbcb;
}
.title-row {
display: table-cell;
width: 100%;
}
.title-cell-text {
display: table-cell;
width: 100%;
vertical-align: middle;
}
.title-cell-button {
display: table-cell;
}
.table {
width: 100%;
}
:host-context(.dark-theme) .mat-mdc-table {
background-color: #1a1b1f;
}
:host-context(.dark-theme) a {
color: white;
}
:host-context(.dark-theme) .mat-mdc-header-cell {
border-bottom: 1px solid #555555;
}
:host-context(.dark-theme) .mat-mdc-cell {
border-bottom: 1px solid #555555;
}