File

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

Description

The set editor displays and edits a list of items (used in the issue detail sidebar).

  • The list of selected items listSet may be either a local array of IDs or a ListId.
  • When editing, items may be selected from a list of all available items #listAll.
  • The list of items may be searched using a search box (#makeFilter and #scoreKeys).
  • Upon closing the edit dialog, a changeset of additions and deletions is passed to a callback (#applyChangeset).

Implements

OnInit OnChanges OnDestroy

Metadata

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

Index

Methods
Inputs
Outputs
Accessors

Constructor

constructor(dataService: DataService, dialogService: MatDialog)
Parameters :
Name Type Optional
dataService DataService No
dialogService MatDialog No

Inputs

applyChangeset
Type : function

Callback for applying a changeset to the listSet.

createItem
Type : function

Callback to create a new item (enabled using itemOps). If the promise returns a node ID, it will be added to the set.

editable
Type : boolean
Default value : true

Set editable to false to just display items in the set, without being able to edit them.

emptyResultsLabel
Type : string
Default value : 'No results'

Set to override the “no results” text in the dialog. Appears when there is a search query.

emptySuggestionsLabel
Type : string
Default value : 'No suggestions'

Set to override the “no results” text in the dialog. Appears only when there is no search query.

hydrate
Type : Promise<HydrateList<T>>

Pass a HydrateList object to load the listSet with existing data instead of sending a request to the server.

itemOps
Type : ItemOps
Default value : 'none'

Additional operations available from the set editor.

listAll
Type : ListId | SetMultiSource

The list of all possible items. Should be a superset of listSet, as otherwise the user may not be able to deselect items.

listSet
Type : ListId | NodeId[]

The list that contains all nodes that are part of the set. string[] is treated as local state.

makeFilter
Type : function

Callback for making a filter for the given search query.

scoreKeys
Type : string[]

Object keys used for scoring a search result. (e.g. 'title') Should correspond to fields searched in makeFilter.

Outputs

deleteItem
Type : EventEmitter

Callback to delete an item.

editItem
Type : EventEmitter

Callback to edit an item.

Methods

beginEditing
beginEditing()

Opens the editor dialog.

Returns : void

Accessors

totalCount
gettotalCount()

Returns the number of selected items.

May be NaN if it hasn't been loaded yet.

Returns : number
import {
  Component,
  ContentChild,
  ElementRef,
  EventEmitter,
  Input,
  OnChanges,
  OnDestroy,
  OnInit,
  Output,
  SimpleChanges,
  TemplateRef,
  ViewChild
} from '@angular/core';
import {ListId, NodeId} from '@app/data-dgql/id';
import {DataList, HydrateList} from '@app/data-dgql/query';
import DataService from '@app/data-dgql';
import {Subscription} from 'rxjs';
import {ItemDirective} from '@app/components/item.directive';
import {MatDialog} from '@angular/material/dialog';
import {SetEditorDialogComponent, SetEditorDialogData, SetMultiSource} from './set-editor-dialog.component';

type ItemOps = 'none' | 'edit' | 'create-edit' | 'create-edit-delete';

/**
 * The set editor displays and edits a list of items (used in the issue detail sidebar).
 *
 * - The list of selected items {@link listSet} may be either a local array of IDs or a {@link ListId}.
 * - When editing, items may be selected from a list of all available items {@link #listAll}.
 * - The list of items may be searched using a search box ({@link #makeFilter} and {@link #scoreKeys}).
 * - Upon closing the edit dialog, a changeset of additions and deletions is passed to a callback ({@link #applyChangeset}).
 */
@Component({
  selector: 'app-set-editor',
  templateUrl: './set-editor.component.html',
  styleUrls: ['./set-editor.component.scss']
})
export class SetEditorComponent<T extends {id: string; __typename: string}, F> implements OnInit, OnChanges, OnDestroy {
  /** The list that contains all nodes that are part of the set. string[] is treated as local state. */
  @Input() listSet: ListId | NodeId[];
  /** The list of all possible items. Should be a superset of listSet, as otherwise the user may not be able to deselect items. */
  @Input() listAll: ListId | SetMultiSource;
  /** Callback for applying a changeset to the listSet. */
  @Input() applyChangeset: (additions: NodeId[], deletions: NodeId[]) => Promise<void>;
  /** Callback for making a filter for the given search query. */
  @Input() makeFilter: (searchQuery: string) => F;
  /** Object keys used for scoring a search result. (e.g. 'title') Should correspond to fields searched in makeFilter. */
  @Input() scoreKeys: string[];
  /** Set editable to false to just display items in the set, without being able to edit them. */
  @Input() editable = true;
  /** Set to override the “no results” text in the dialog. Appears only when there is no search query. */
  @Input() emptySuggestionsLabel = 'No suggestions';
  /** Set to override the “no results” text in the dialog. Appears when there is a search query. */
  @Input() emptyResultsLabel = 'No results';
  /** Pass a HydrateList object to load the listSet with existing data instead of sending a request to the server. */
  @Input() hydrate: Promise<HydrateList<T>>;
  /** Additional operations available from the set editor. */
  @Input() itemOps: ItemOps = 'none';

  /** Callback to create a new item (enabled using itemOps). If the promise returns a node ID, it will be added to the set. */
  @Input() createItem: () => Promise<NodeId | null | undefined>;
  /** Callback to edit an item. */
  @Output() editItem = new EventEmitter<{id: NodeId; preview: T}>();
  /** Callback to delete an item. */
  @Output() deleteItem = new EventEmitter<{id: NodeId; preview: T}>();

  /**
   * @ignore
   * Pointer to the title text element. Used to read the title, because we can't read angular components directly.
   */
  @ViewChild('titleText') titleText: ElementRef<HTMLElement>;
  /**
   * @ignore
   * Pointer to the *appItem directive. Used to instantiate list items.
   */
  @ContentChild(ItemDirective, {read: TemplateRef}) itemTemplate;

  /**
   * @ignore
   * List that fetches the first few items of the list of items in the set.
   */
  public listSet$: DataList<T, unknown>;
  /**
   * @ignore
   * The subscription to listSet$.
   */
  private listSetSub: Subscription;

  /**
   * @ignore
   * If true, the listSet$ variable is null and listSet contains an array of node IDs.
   */
  public isLocalSet = false;

  constructor(private dataService: DataService, private dialogService: MatDialog) {}

  /**
   * @ignore
   * Internal: (re-)loads the subscription to whatever the user put in listSet.
   */
  private reloadListSet() {
    if (Array.isArray(this.listSet)) {
      this.isLocalSet = true;
      if (this.listSet$) {
        this.listSetSub.unsubscribe();
        this.listSet$ = null;
      }
    } else {
      this.isLocalSet = false;
      this.listSetSub?.unsubscribe();
      this.listSet$ = this.dataService.getList(this.listSet);
      this.listSetSub = this.listSet$.subscribe();
    }
  }

  ngOnInit() {
    this.reloadListSet();
    if (this.hydrate && this.listSet$) {
      this.listSet$.hydrateInitial(this.hydrate);
    }
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes.listSet) {
      const newValue = changes.listSet.currentValue;
      if (this.isLocalSet !== (typeof newValue !== 'string')) {
        this.reloadListSet();
      }
    }
  }

  ngOnDestroy() {
    this.listSetSub?.unsubscribe();
  }

  /**
   * Returns the number of selected items.
   *
   * May be NaN if it hasn't been loaded yet.
   */
  get totalCount(): number {
    if (this.isLocalSet) {
      return (this.listSet as NodeId[]).length;
    } else {
      if (this.listSet$.loading) {
        return NaN;
      }
      return this.listSet$.totalCount;
    }
  }

  // Callbacks for the set editor dialog.
  // They all forward to the user-provided function.
  // We do not pass the user-provided function directly because they may change while the dialog is open.
  /** @ignore */
  private onDialogApplyChangeset = (additions: NodeId[], deletions: NodeId[]): Promise<void> => {
    return this.applyChangeset(additions, deletions);
  };
  /** @ignore */
  private onDialogCreateItem = () => this.createItem();
  /** @ignore */
  private onDialogEditItem = (item) => this.editItem.emit(item);
  /** @ignore */
  private onDialogDeleteItem = (item) => this.deleteItem.emit(item);

  /**
   * @internal
   * Opens the editor dialog.
   */
  beginEditing(): void {
    this.dialogService.open<SetEditorDialogComponent<T, F>>(SetEditorDialogComponent, {
      width: '400px',
      data: {
        title: this.titleText?.nativeElement.textContent || '',
        listSet: this.listSet,
        listAll: this.listAll,
        itemTemplate: this.itemTemplate,
        applyChangeset: this.onDialogApplyChangeset,
        makeFilter: this.makeFilter,
        scoreKeys: this.scoreKeys,
        emptySuggestionsLabel: this.emptySuggestionsLabel,
        emptyResultsLabel: this.emptyResultsLabel,
        createItem: this.itemOps.includes('create') ? this.onDialogCreateItem : null,
        editItem: this.itemOps.includes('edit') ? this.onDialogEditItem : null,
        deleteItem: this.itemOps.includes('delete') ? this.onDialogDeleteItem : null
      } as SetEditorDialogData<T, F>
    });
  }
}
<div class="app-set-editor">
  <div class="set-editor-title">
    <span class="title-text-container" #titleText>
      <ng-content select="[title]"></ng-content>
    </span>
    <button *ngIf="editable" mat-icon-button class="settings-button" aria-label="Edit" (click)="beginEditing()">
      <mat-icon>settings</mat-icon>
    </button>
  </div>
  <div class="set-editor-items">
    <ng-container *ngIf="isLocalSet">
      <ng-container *ngFor="let itemId of listSet">
        <span cache-node [node]="itemId">
          <ng-container *appItem="let item">
            <ng-container
              *ngTemplateOutlet="
                itemTemplate;
                context: { $implicit: item, interactive: true }
              "
            ></ng-container>
          </ng-container>
        </span>
      </ng-container>
    </ng-container>
    <ng-container *ngIf="!isLocalSet">
      <ng-container *ngFor="let item of listSet$.currentItems">
        <ng-container
          *ngTemplateOutlet="
            itemTemplate;
            context: { $implicit: item, interactive: true }
          "
        ></ng-container>
      </ng-container>
      <div class="more-items" *ngIf="listSet$.current?.size < listSet$.totalCount">...</div>
    </ng-container>
    <div class="items-empty" *ngIf="isLocalSet ? !totalCount : !totalCount && !listSet$.loading">
      <ng-content select="[if-empty]"></ng-content>
    </div>
  </div>
</div>

./set-editor.component.scss

:host {
  display: block;
}

.app-set-editor {
  .set-editor-title {
    display: flex;
    align-items: center;
    justify-content: space-between;
    min-height: 36px;

    .title-text-container {
      font-weight: bold;
      line-height: 1.5;
    }
  }

  .items-empty {
    opacity: 0.5;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""