File

src/app/components/set-editor/set-editor-dialog.component.ts

Description

This is an internal component used in the set editor.

Implements

OnInit OnDestroy

Metadata

selector app-set-editor-dialog
styleUrls ./set-editor-dialog.component.scss
templateUrl ./set-editor-dialog.component.html

Index

Properties
Methods

Constructor

constructor(dataService: DataService, notifyService: UserNotifyService, dialogRef: MatDialogRef<SetEditorDialogComponent<T, F>>, data: SetEditorDialogData)
Parameters :
Name Type Optional
dataService DataService No
notifyService UserNotifyService No
dialogRef MatDialogRef<SetEditorDialogComponent<T, F>> No
data SetEditorDialogData<T | F> No

Methods

apply
apply()
Returns : void
createItem
createItem()
Returns : void
getEncodedId
getEncodedId(item)
Parameters :
Name Optional
item No
Returns : NodeIdEnc
getNodeId
getNodeId(item)
Parameters :
Name Optional
item No
Returns : NodeId
isInSet
isInSet(item)
Parameters :
Name Optional
item No
Returns : boolean
searchQueryDidChange
searchQueryDidChange()
Returns : void
toggleInSet
toggleInSet(item)
Parameters :
Name Optional
item No
Returns : void

Properties

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;
    }
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""