UI: Lazy loading and TG fixes (#77)

Reviewed-on: #77
Co-authored-by: Daniel Ponte <amigan@gmail.com>
Co-committed-by: Daniel Ponte <amigan@gmail.com>
This commit is contained in:
Daniel Ponte 2024-12-19 12:28:37 -05:00 committed by amigan
parent d95cda6b44
commit b4cf5550d7
11 changed files with 291 additions and 55 deletions

View file

@ -8,7 +8,7 @@
<div class="centerNav"></div>
<div class="rightNav">
@if (auth.loggedIn) {
<button class="ybtn">
<button class="ybtn sbButton">
<a (click)="logout()" class="logout">Logout</a>
</button>
}

View file

@ -1,30 +1,68 @@
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { LoginComponent } from './login/login.component';
import { TalkgroupsComponent } from './talkgroups/talkgroups.component';
import { TalkgroupRecordComponent } from './talkgroups/talkgroup-record/talkgroup-record.component';
import { CallsComponent } from './calls/calls.component';
import { IncidentsComponent } from './incidents/incidents.component';
import { AlertsComponent } from './alerts/alerts.component';
import { ImportComponent } from './talkgroups/import/import.component';
import { ExportComponent } from './talkgroups/export/export.component';
import { AuthGuard } from './auth.guard';
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{
path: 'login',
loadComponent: () =>
import('./login/login.component').then((m) => m.LoginComponent),
},
{
path: '',
canActivateChild: [AuthGuard],
children: [
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'talkgroups', component: TalkgroupsComponent },
{ path: 'talkgroups/import', component: ImportComponent },
{ path: 'talkgroups/export', component: ExportComponent },
{ path: 'talkgroups/:sys/:tg', component: TalkgroupRecordComponent },
{ path: 'calls', component: CallsComponent },
{ path: 'incidents', component: IncidentsComponent },
{ path: 'alerts', component: AlertsComponent },
{
path: '',
loadComponent: () =>
import('./home/home.component').then((m) => m.HomeComponent),
pathMatch: 'full',
},
{
path: 'talkgroups',
loadComponent: () =>
import('./talkgroups/talkgroups.component').then(
(m) => m.TalkgroupsComponent,
),
},
{
path: 'talkgroups/import',
loadComponent: () =>
import('./talkgroups/import/import.component').then(
(m) => m.ImportComponent,
),
},
{
path: 'talkgroups/export',
loadComponent: () =>
import('./talkgroups/export/export.component').then(
(m) => m.ExportComponent,
),
},
{
path: 'talkgroups/:sys/:tg',
loadComponent: () =>
import(
'./talkgroups/talkgroup-record/talkgroup-record.component'
).then((m) => m.TalkgroupRecordComponent),
},
{
path: 'calls',
loadComponent: () =>
import('./calls/calls.component').then((m) => m.CallsComponent),
},
{
path: 'incidents',
loadComponent: () =>
import('./incidents/incidents.component').then(
(m) => m.IncidentsComponent,
),
},
{
path: 'alerts',
loadComponent: () =>
import('./alerts/alerts.component').then((m) => m.AlertsComponent),
},
],
},
];

View file

@ -63,7 +63,7 @@
<div>
<mat-form-field class="tagsField">
<mat-label>Tags</mat-label>
<mat-chip-grid #tagsChipGrid [formControl]="tagsControl">
<mat-chip-grid #tagsChipGrid formControlName="tagsControl">
@for (tag of tg.tags; track tag) {
<mat-chip-row (removed)="removeTag(tag)">
{{ tag }}
@ -74,10 +74,25 @@
}
</mat-chip-grid>
<input
name="tag"
placeholder="New tag..."
[matChipInputFor]="tagsChipGrid"
(matChipInputTokenEnd)="addTag($event)"
(matChipInputTokenEnd)="addTagEv($event)"
[matChipInputAddOnBlur]="false"
[formControlName]="'tagInput'"
#tagInput
[matAutocomplete]="auto"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
<mat-autocomplete
#auto="matAutocomplete"
(optionSelected)="selected($event)"
(optionActivated)="activated($event)"
>
@for (tag of filteredTags(); track tag) {
<mat-option [value]="tag">{{ tag }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div>
<div>
@ -88,6 +103,6 @@
Rules:
<alert-rule-builder [rules]="tg.alert_config" />
</div>
<button type="submit">Save</button>
<button class="sbButton" type="submit">Save</button>
</form>
</div>

View file

@ -1,14 +1,33 @@
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import {
Component,
computed,
inject,
model,
ChangeDetectionStrategy,
Signal,
ViewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs/operators';
import {
Talkgroup,
TalkgroupUpdate,
IconMap,
iconMapping,
} from '../../talkgroup';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { TalkgroupService } from '../talkgroups.service';
import { AlertRuleBuilderComponent } from './alert-rule-builder/alert-rule-builder.component';
import {
MatAutocomplete,
MatAutocompleteModule,
MatAutocompleteSelectedEvent,
MatAutocompleteActivatedEvent,
} from '@angular/material/autocomplete';
import { CommonModule } from '@angular/common';
import { catchError, of } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
import { Observable } from 'rxjs';
import {
ReactiveFormsModule,
FormGroup,
@ -35,22 +54,49 @@ import { MatIconModule } from '@angular/material/icon';
MatCheckboxModule,
MatChipsModule,
MatIconModule,
MatAutocompleteModule,
],
templateUrl: './talkgroup-record.component.html',
styleUrl: './talkgroup-record.component.scss',
// changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TalkgroupRecordComponent {
tg!: Talkgroup;
iconMapping: IconMap = iconMapping;
tgService: TalkgroupService = inject(TalkgroupService);
tagsControl = new FormControl<string[]>([]);
form!: FormGroup;
allTags = <string[]>[];
readonly separatorKeysCodes: number[] = [ENTER, COMMA];
readonly filteredTags = computed(() => {
const currentTag = this.tagInputSig()?.toLowerCase() ?? '';
return currentTag
? this.allTags.filter((tag) => tag.toLowerCase().includes(currentTag))
: this.allTags.slice();
});
readonly _allTags: Observable<string[]>;
form = new FormGroup({
name: new FormControl(''),
alpha_tag: new FormControl(''),
tg_group: new FormControl(''),
frequency: new FormControl(0),
alert: new FormControl(false),
weight: new FormControl(0.0),
icon: new FormControl(''),
tagInput: new FormControl(''),
tagsControl: new FormControl<string[]>([]),
});
tagInputSig = toSignal(
this.form.get('tagInput')!.valueChanges.pipe(debounceTime(300)) ?? of(null),
{},
);
@ViewChild('auto') autocomp!: MatAutocomplete;
active: string | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
) {}
) {
this._allTags = this.tgService.allTags().pipe(shareReplay());
}
removeTag(tag: string) {
const idx = this.tg.tags.indexOf(tag);
@ -63,17 +109,52 @@ export class TalkgroupRecordComponent {
return [...this.tg.tags];
}
addTag(event: MatChipInputEvent) {
const value = (event.value || '').trim();
if (value) {
this.tg.tags = [...this.tg.tags, value];
addTagEv(event: MatChipInputEvent) {
if (this.active != null) {
// this is a hack
this.addTag(this.active);
this.active = null;
event.chipInput!.clear();
return;
}
const value = (event.value || '').trim();
this.addTag(value);
event.chipInput!.clear();
}
activated(event: MatAutocompleteActivatedEvent) {
console.log('activated');
this.active = event.option?.value;
}
addTag(tag: string) {
const idx = this.tg.tags.indexOf(tag);
if (idx > -1) {
return;
}
if (tag) {
this.tg.tags = [...this.tg.tags, tag];
}
}
selected(event: any) {
let ev = event as MatAutocompleteSelectedEvent;
this.addTag(ev.option.viewValue);
ev.option.deselect();
this.form.controls['tagInput'].reset();
}
loadTags() {
this._allTags.subscribe((event) => {
this.allTags = event;
});
}
ngOnInit() {
this.loadTags();
const sysId = this.route.snapshot.paramMap.get('sys');
const tgId = this.route.snapshot.paramMap.get('tg');
@ -81,16 +162,15 @@ export class TalkgroupRecordComponent {
.getTalkgroup(Number(sysId), Number(tgId))
.subscribe((data: Talkgroup) => {
this.tg = data;
this.form = new FormGroup({
name: new FormControl(this.tg.name),
alpha_tag: new FormControl(this.tg.alpha_tag),
tg_group: new FormControl(this.tg.tg_group),
frequency: new FormControl(this.tg.frequency),
alert: new FormControl(this.tg.alert),
weight: new FormControl(this.tg.weight),
icon: new FormControl(this.tg?.metadata?.icon ?? ''),
});
this.tagsControl.setValue(this.tg?.tags ?? []);
this.form.controls['name'].setValue(this.tg.name);
this.form.controls['alpha_tag'].setValue(this.tg.alpha_tag);
this.form.controls['tg_group'].setValue(this.tg.tg_group);
this.form.controls['frequency'].setValue(this.tg.frequency);
this.form.controls['alert'].setValue(this.tg.alert);
this.form.controls['weight'].setValue(this.tg.weight);
this.form.controls['icon'].setValue(this.tg?.metadata?.icon ?? '');
this.form.controls['tagInput'].setValue('');
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
});
}
@ -118,11 +198,11 @@ export class TalkgroupRecordComponent {
if (this.form.controls['weight'].dirty) {
tgu.weight = Number(this.form.controls['weight'].value);
}
if (this.tagsControl.dirty) {
tgu.tags = this.tagsControl.value;
if (this.form.controls['tagsControl'].dirty) {
tgu.tags = this.form.controls['tagsControl'].value;
}
if (this.form.controls['icon'].dirty) {
let iv: string = this.form.controls['icon'].value;
let iv: string = this.form.controls['icon'].value ?? '';
if (tgu.metadata == null) {
tgu.metadata = {};
}

View file

@ -1,5 +1,23 @@
<div class="tableContainer">
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
>
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="icon">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let tg">

View file

@ -2,8 +2,10 @@ import {
Component,
inject,
Pipe,
Input,
PipeTransform,
output,
ViewChild,
input,
} from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
@ -14,13 +16,17 @@ import { RouterModule, RouterLink } from '@angular/router';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import {
MatPaginator,
MatPaginatorModule,
PageEvent,
} from '@angular/material/paginator';
import { PrefsService } from '../../prefs/prefs.service';
import { MatIconModule } from '@angular/material/icon';
import {
MatChipSelectionChange,
MatChipsModule,
} from '@angular/material/chips';
import { MatChipsModule } from '@angular/material/chips';
import { SelectionModel } from '@angular/cdk/collections';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { Observable, Subscription } from 'rxjs';
@Pipe({
standalone: true,
@ -60,6 +66,7 @@ export class SanitizeHtmlPipe implements PipeTransform {
IconifyPipe,
MatTableModule,
MatPaginatorModule,
MatCheckboxModule,
MatChipsModule,
],
templateUrl: './talkgroup-table.component.html',
@ -81,6 +88,7 @@ export class TalkgroupTableComponent {
perPage: number = 25;
count = 0;
columns = [
'select',
'icon',
'sysID',
'sysName',
@ -92,14 +100,30 @@ export class TalkgroupTableComponent {
'learned',
'edit',
];
selection = new SelectionModel<Talkgroup>(true, []);
private resetPageSub!: Subscription;
@Input() resetPage!: Observable<void>;
@ViewChild('paginator') paginator!: MatPaginator;
suppress = false;
constructor(private route: ActivatedRoute) {}
setPage(p: PageEvent) {
this.switchPage.emit(p);
// don't needlessly request page 0 if we were asked merely to reset the state of the control
this.selection.clear();
if (!this.suppress) {
this.switchPage.emit(p);
}
}
ngOnInit() {
this.resetPageSub = this.resetPage.subscribe(() => {
this.suppress = true;
this.paginator.firstPage();
this.selection.clear();
this.suppress = false;
});
this.perPage = this.prefsService.last.tgsPerPage;
this.talkgroups$.subscribe((event) => {
if (event != null) {
@ -109,10 +133,26 @@ export class TalkgroupTableComponent {
});
}
ngOnDestroy() {
this.resetPageSub.unsubscribe();
}
searchChip(event: MouseEvent) {
// not a fan of how this looks, but it works...
this.changeFilter.emit(
(event.target as Element).childNodes[0].textContent ?? '',
);
}
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.data.length;
return numSelected === numRows;
}
masterToggle() {
this.isAllSelected()
? this.selection.clear()
: this.dataSource.data.forEach((row) => this.selection.select(row));
}
}

View file

@ -1,16 +1,33 @@
<div class="tgTools">
<mat-form-field class="filterBox" subscriptSizing="dynamic">
<mat-label>Filter</mat-label>
<input matInput name="filter" type="search" autocomplete="off" [formControl]="filter" />
<input
matInput
name="filter"
type="text"
autocomplete="off"
[formControl]="filter"
/>
<button
class="clearBtn"
*ngIf="filter.value"
matSuffix
mat-icon-button
(click)="filter.setValue('')"
>
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<div class="addTag"></div>
<div class="impExp">
<button [routerLink]="'/talkgroups/import'">Import</button>
<button [routerLink]="'/talkgroups/export'">Export</button>
<button class="sbButton" [routerLink]="'/talkgroups/import'">Import</button>
<button class="sbButton" [routerLink]="'/talkgroups/export'">Export</button>
</div>
</div>
<talkgroup-table
*ngIf="tgs; else spinner"
[talkgroups]="tgs"
[resetPage]="pageReset.asObservable()"
(switchPage)="switchPage($event)"
(changeFilter)="changeFilter($event)"
></talkgroup-table>

View file

@ -3,6 +3,10 @@ talkgroup-table {
height: 100%;
}
.filterBox {
width: 300px;
}
talkgroups {
display: flex;
flex-direction: column;
@ -10,6 +14,11 @@ talkgroups {
height: 90%;
}
.clearBtn {
border: 0;
background-color: transparent;
}
.tgTools {
display: flex;
}

View file

@ -8,10 +8,12 @@ import { TalkgroupTableComponent } from './talkgroup-table/talkgroup-table.compo
import { PageEvent } from '@angular/material/paginator';
import { MatToolbarModule } from '@angular/material/toolbar';
import { PrefsService } from '../prefs/prefs.service';
import { Subject } from 'rxjs';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatInputModule, MatInput } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'talkgroups',
@ -27,6 +29,7 @@ import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
ReactiveFormsModule,
FormsModule,
MatProgressSpinnerModule,
MatIconModule,
],
templateUrl: './talkgroups.component.html',
styleUrl: './talkgroups.component.scss',
@ -43,6 +46,12 @@ export class TalkgroupsComponent {
curPage = <PageEvent>{ pageIndex: 0, pageSize: this.perPage };
constructor(private route: ActivatedRoute) {}
pageReset: Subject<void> = new Subject<void>();
resetPage() {
this.pageReset.next();
}
switchPage(p: PageEvent) {
this.curPage = p;
this.route.paramMap
@ -84,6 +93,8 @@ export class TalkgroupsComponent {
this.filter.setValue(f);
}
filterChange() {
this.curPage.pageIndex = 0;
this.resetPage();
this.switchPage(this.curPage);
}
}

View file

@ -40,6 +40,10 @@ export class TalkgroupService {
});
}
allTags(): Observable<string[]> {
return this.http.get<string[]>('/api/talkgroup/tags');
}
exportTGs(
type: string,
sysID: number,

View file

@ -167,7 +167,7 @@ button.ybtn {
text-shadow: 2px 2px 2px rgb(156, 156, 156);
}
button {
button.sbButton {
border-radius: 3px;
padding: 10px;
border: none;
@ -199,3 +199,7 @@ body {
.navItems {
width: 100px;
}
input {
caret-color: var(--color-dark-fg) !important;
}