src/app/components/set-editor/set-editor-dialog.component.ts
This is an internal component used in the set editor.
selector | app-set-editor-dialog |
styleUrls | ./set-editor-dialog.component.scss |
templateUrl | ./set-editor-dialog.component.html |
Properties |
|
Methods |
constructor(dataService: DataService, notifyService: UserNotifyService, dialogRef: MatDialogRef<SetEditorDialogComponent<T, F>>, data: SetEditorDialogData
|
|||||||||||||||
Parameters :
|
apply |
apply()
|
Returns :
void
|
createItem |
createItem()
|
Returns :
void
|
getEncodedId | ||||
getEncodedId(item)
|
||||
Parameters :
Returns :
NodeIdEnc
|
getNodeId | ||||
getNodeId(item)
|
||||
Parameters :
Returns :
NodeId
|
isInSet | ||||
isInSet(item)
|
||||
Parameters :
Returns :
boolean
|
searchQueryDidChange |
searchQueryDidChange()
|
Returns :
void
|
toggleInSet | ||||
toggleInSet(item)
|
||||
Parameters :
Returns :
void
|
Private additions |
Type : Set<NodeIdEnc>
|
Default value : new Set()
|
Public data |
Type : SetEditorDialogData<T | F>
|
Decorators :
@Inject(MAT_DIALOG_DATA)
|
Private deletions |
Type : Set<NodeIdEnc>
|
Default value : new Set()
|
Public isLocalSet |
Default value : false
|
Public listAll |
Type : MultiSourceList<T | F>
|
Public listSet$ |
Type : DataList<T | F>
|
Private listSetSub |
Type : Subscription
|
Public localSet |
Type : NodeIdEnc[]
|
Default value : []
|
Public searchQuery |
Type : string
|
Default value : ''
|
import {Component, Inject, OnDestroy, OnInit, TemplateRef} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {
decodeListId,
decodeNodeId,
encodeListId,
encodeNodeId,
ListId,
ListIdEnc,
NodeId,
NodeIdEnc,
nodeTypeFromTypename
} from '@app/data-dgql/id';
import {DataList} from '@app/data-dgql/query';
import {Subscription} from 'rxjs';
import DataService from '@app/data-dgql';
import {UserNotifyService} from '@app/user-notify/user-notify.service';
import {quickScore} from 'quick-score';
/**
* This interface is used to source items from multiple sources in the set editor.
*
* staticSources specifies lists whose items will always be loaded.
* sourceNodes specifies a list of nodes whose the sub-items will be loaded (e.g. a list of components whose labels will be loaded).
*/
export interface SetMultiSource {
/** A static list of source lists. */
staticSources: ListId[];
/** A list of nodes that will be passed to listFromNode. */
sourceNodes?: ListId | NodeId[];
/** Maps nodes from sourceNodes to lists from which items will be sourced. */
listFromNode?: (n: NodeId) => ListId;
}
/**
* This is an internal component used to load data from multiple sources and through a layer of indirection (also see SetMultiSource).
*/
class MultiSourceList<T, F> {
/** A DataList that loads the value of sourceNodes, if it's a ListId. */
public sourceNodeList?: DataList<{__typename: string}, unknown>;
/**
* @ignore
* Internal: subscription to the sourceNodeList.
*/
private sourceNodeListSub?: Subscription;
/** The list of nodes specified in sourceNodes, if it's a NodeId[]. */
public staticSourceNodeList?: NodeId[];
/** List of all sources that will be included in the results. */
public sources: Map<ListIdEnc, DataList<T, F>> = new Map();
/**
* @ignore
* Internal: subscriptions to all of the sources.
*/
private sourceSubs: Map<ListIdEnc, Subscription> = new Map();
/** Max number of items in results. */
public limit = 10;
/** Current results. */
public results?: T[];
/** If true, there are more than `limit` items in the source data. */
public hasMore = false;
/**
* Current search query. Used to rank results by relevance.
* The filters are computed separately! Use {@link #setFilter} to set both simultaneously.
*/
public query = '';
constructor(public spec: SetMultiSource, public scoreKeys: string[], private dataService: DataService) {
if (Array.isArray(spec.sourceNodes)) {
this.staticSourceNodeList = spec.sourceNodes;
} else if (typeof spec.sourceNodes === 'object') {
this.sourceNodeList = dataService.getList(spec.sourceNodes);
this.sourceNodeListSub = this.sourceNodeList.subscribe(() => this.update());
}
this.update();
}
/** Creates a new MultiSourceList that actually just loads a single list. */
static fromSingleList<T, F>(list: ListId, scoreKeys: string[], dataService: DataService) {
return new this<T, F>({staticSources: [list]}, scoreKeys, dataService);
}
/** Updates lists. */
update() {
const newSourceSet = new Set<ListIdEnc>();
for (const id of this.sourceNodeList?.current?.keys() || []) {
const nodeId = decodeNodeId(id);
newSourceSet.add(encodeListId(this.spec.listFromNode(nodeId)));
}
for (const nodeId of this.staticSourceNodeList || []) {
newSourceSet.add(encodeListId(this.spec.listFromNode(nodeId)));
}
for (const source of this.spec.staticSources) {
newSourceSet.add(encodeListId(source));
}
for (const source of newSourceSet) {
if (!this.sources.has(source)) {
const list = this.dataService.getList<T, F>(decodeListId(source));
list.interactive = true;
this.sources.set(source, list);
this.sourceSubs.set(
source,
list.subscribe(() => this.updateResults())
);
}
}
for (const source of [...this.sources.keys()]) {
if (!newSourceSet.has(source)) {
this.sourceSubs.get(source).unsubscribe();
this.sourceSubs.delete(source);
this.sources.delete(source);
}
}
}
/** Sets a filter on all lists. */
setFilter(query: string, filter: F) {
this.query = query;
for (const source of this.sources.values()) {
source.filter = filter;
}
}
/** Scores an item for ranking in results. */
score(item: T) {
const matchStrings = [];
for (const key of this.scoreKeys) {
let cursor = item;
for (const objKey of key.split('.')) {
cursor = cursor[objKey];
if (!cursor) {
break;
}
}
if (cursor) {
matchStrings.push(cursor);
}
}
return quickScore(matchStrings.join(' '), this.query);
}
/** Updates the results array from loaded data. */
updateResults() {
const seenItems = new Set();
const items = [];
this.hasMore = false;
for (const source of this.sources.values()) {
if (!source.hasData) {
continue;
}
for (const [id, item] of source.current.entries()) {
if (!seenItems.has(id)) {
seenItems.add(id);
items.push(item);
}
}
this.hasMore = this.hasMore || source.current.size < source.totalCount;
}
items.sort((a, b) => this.score(a) - this.score(b));
items.splice(this.limit);
this.results = items;
}
/** If true, something is loading somewhere. */
isLoading() {
if (this.sourceNodeList?.loading) {
return true;
}
for (const source of this.sources.values()) {
if (source.loading) {
return true;
}
}
return false;
}
/** Unsubscribes from all subscriptions. This object should no longer be used afterwards. */
unsubscribe() {
this.sourceNodeListSub?.unsubscribe();
this.sourceSubs.forEach((sub) => sub.unsubscribe());
}
}
/** Types of item operations that may be made available. */
export type ItemOps = 'none' | 'edit' | 'create-edit' | 'create-edit-delete';
export interface SetEditorDialogData<T, F> {
title: string;
listSet: ListId | NodeId[];
listAll: ListId | SetMultiSource;
applyChangeset: (add: NodeId[], del: NodeId[]) => Promise<void>;
itemTemplate: TemplateRef<unknown>;
makeFilter: (query: string) => F;
scoreKeys: string[];
emptySuggestionsLabel: string;
emptyResultsLabel: string;
createItem?: () => Promise<NodeId | null | undefined>;
editItem?: ({id: NodeId, preview: T}) => void;
deleteItem?: ({id: NodeId, preview: T}) => void;
}
/** This is an internal component used in the set editor. */
@Component({
selector: 'app-set-editor-dialog',
templateUrl: './set-editor-dialog.component.html',
styleUrls: ['./set-editor-dialog.component.scss']
})
export class SetEditorDialogComponent<T extends {id: string; __typename: string}, F> implements OnInit, OnDestroy {
public isLocalSet = false;
public localSet: NodeIdEnc[] = [];
public listSet$: DataList<T, F>;
public listAll: MultiSourceList<T, F>;
private listSetSub: Subscription;
private additions: Set<NodeIdEnc> = new Set();
private deletions: Set<NodeIdEnc> = new Set();
public searchQuery = '';
constructor(
private dataService: DataService,
private notifyService: UserNotifyService,
private dialogRef: MatDialogRef<SetEditorDialogComponent<T, F>>,
@Inject(MAT_DIALOG_DATA) public data: SetEditorDialogData<T, F>
) {}
ngOnInit() {
if (Array.isArray(this.data.listSet)) {
this.isLocalSet = true;
this.localSet = [...this.data.listSet].map((id) => encodeNodeId(id));
} else {
this.listSet$ = this.dataService.getList(this.data.listSet);
}
this.listAll =
'staticSources' in this.data.listAll
? new MultiSourceList<T, F>(this.data.listAll, this.data.scoreKeys, this.dataService)
: MultiSourceList.fromSingleList<T, F>(this.data.listAll, this.data.scoreKeys, this.dataService);
if (this.listSet$) {
this.listSetSub = this.listSet$?.subscribe();
// TODO: is this a reasonable heuristic for the listSet count? we need to cover >= results from listAll
this.listSet$.count = 10;
this.listSet$.interactive = true;
}
}
searchQueryDidChange(): void {
if (this.listSet$) {
this.listSet$.filter = this.data.makeFilter(this.searchQuery);
}
this.listAll.setFilter(this.searchQuery, this.data.makeFilter(this.searchQuery));
}
getNodeId(item): NodeId {
const type = nodeTypeFromTypename(item.__typename);
return {type, id: item.id};
}
getEncodedId(item): NodeIdEnc {
return encodeNodeId(this.getNodeId(item));
}
isInSet(item): boolean {
const id = this.getEncodedId(item);
if (this.additions.has(id)) {
return true;
}
if (this.deletions.has(id)) {
return false;
}
if (this.isLocalSet) {
return this.localSet.includes(id);
}
return this.listSet$.current?.has(id) || false;
}
toggleInSet(item): void {
const id = this.getEncodedId(item);
if (this.isInSet(item)) {
this.additions.delete(id);
this.deletions.add(id);
} else {
this.deletions.delete(id);
this.additions.add(id);
}
}
apply(): void {
if (this.additions.size + this.deletions.size === 0) {
this.dialogRef.close(null);
return;
}
this.data
.applyChangeset([...this.additions].map(decodeNodeId), [...this.deletions].map(decodeNodeId))
.then(() => {
this.dialogRef.close(null);
})
.catch((error) => {
this.notifyService.notifyError('Failed to apply changes', error);
});
}
createItem(): void {
this.data.createItem().then((node) => {
if (node) {
this.additions.add(encodeNodeId(node));
}
});
}
ngOnDestroy() {
this.listSetSub?.unsubscribe();
this.listAll.unsubscribe();
}
}
<div class="set-editor-dialog">
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>
<div class="dialog-search">
<mat-form-field class="search-field">
<mat-label>Search</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input matInput type="search" [(ngModel)]="searchQuery" (ngModelChange)="searchQueryDidChange()" />
</mat-form-field>
</div>
<div class="dialog-items">
<ng-container *ngFor="let item of listAll.results || []">
<div class="selectable-item">
<mat-checkbox class="inner-checkbox" [checked]="isInSet(item)" (change)="toggleInSet(item)">
<ng-container
*ngTemplateOutlet="
data.itemTemplate;
context: { $implicit: item, interactive: false }
"
></ng-container>
</mat-checkbox>
<ng-container *ngIf="data.editItem || data.deleteItem">
<mat-menu #itemMenu>
<ng-template matMenuContent>
<button
mat-menu-item
*ngIf="data.editItem"
(click)="
data.editItem({ id: getNodeId(item), preview: item })
"
>
Edit
</button>
<button
mat-menu-item
*ngIf="data.deleteItem"
(click)="
data.deleteItem({ id: getNodeId(item), preview: item })
"
>
Delete
</button>
</ng-template>
</mat-menu>
<button mat-icon-button [matMenuTriggerFor]="itemMenu">
<mat-icon>more_vert</mat-icon>
</button>
</ng-container>
</div>
</ng-container>
<div class="items-more" *ngIf="!listAll.isLoading() && listAll.hasMore">
<div class="more-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
Use search to find more items
</div>
<div class="items-empty" *ngIf="!listAll.results?.length && !listAll.isLoading()">
<ng-container *ngIf="!!listAll.query; else noQueryEmptyResults"> {{ data.emptyResultsLabel }} </ng-container>
<ng-template #noQueryEmptyResults> {{ data.emptySuggestionsLabel }} </ng-template>
</div>
<div class="items-create" *ngIf="data.createItem">
<button mat-button color="primary" class="new-item-button" (click)="createItem()">
<mat-icon>add</mat-icon>
Create new
</button>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]="null">Cancel</button>
<button mat-button [color]="'primary'" (click)="apply()">Apply</button>
</mat-dialog-actions>
</div>
./set-editor-dialog.component.scss
.set-editor-dialog {
.dialog-search {
.search-field {
width: 100%;
}
}
.dialog-items {
margin-bottom: 16px;
.selectable-item {
border-top: 1px solid rgba(0, 0, 0, 0.2);
display: flex;
&:first-child {
border-top: none;
}
.inner-checkbox {
display: block;
flex: 1;
padding: 8px 16px;
}
}
.items-more {
text-align: center;
opacity: 0.7;
font-size: smaller;
.more-dots {
display: block;
margin-bottom: 4px;
.dot {
display: block;
width: 4px;
height: 4px;
border-radius: 2px;
background: currentColor;
margin: 0 auto 3px auto;
&:nth-child(2) {
opacity: 0.6;
}
&:nth-child(3) {
opacity: 0.27;
}
&:nth-child(4) {
opacity: 0.08;
}
}
}
}
.items-empty {
text-align: center;
opacity: 0.5;
}
.items-create {
text-align: center;
margin-top: 16px;
margin-bottom: 8px;
}
}
}