File

src/app/issue-list/issue-filter.component.ts

Description

Edits an IssueFilter object.

Metadata

selector app-issue-filter
styleUrls ./issue-filter.component.scss
templateUrl ./issue-filter.component.html

Index

Properties
Methods
Inputs
Outputs

Inputs

allLabelsList
Type : ListId

The list from which to source labels in the label picker.

projectId
Type : string

Raw project ID.

Outputs

filterChange
Type : EventEmitter

Emitted every time the filter is changed.

Methods

addPredicateAfter
addPredicateAfter(index: number)

Adds a new predicate after the given index in activePredicates.

Parameters :
Name Type Optional Description
index number No

index in activePredicates

Returns : void
applyIdChangeset
applyIdChangeset(id: string)

Returns a function that can be passed to an app-set-editor to apply the changeset to the value of the id predicate.

Parameters :
Name Type Optional Description
id string No

predicate name

Returns : Promise<void>
buildFilter
buildFilter()

Builds an IssueFilter from the search query and selected filters.

Returns : IssueFilter
getRemainingTypes
getRemainingTypes(index: number)

Returns all types of predicates that were not taken in previous items.

Parameters :
Name Type Optional Description
index number No

index in activePredicates

Returns : string[]
removePredicateAt
removePredicateAt(index: number)

Removes the predicate at the given index in activePredicates.

Parameters :
Name Type Optional Description
index number No

index in activePredicates

Returns : void
setInEnumArray
setInEnumArray(array, item, inArray)

Sets the presence of a value in an array of enum variants.

Parameters :
Name Optional Description
array No

the array to mutate

item No

the enum variant

inArray No

whether or not it should be in the array

Returns : void
setPredicateType
setPredicateType(index: number, type: string)

Sets the type of the predicate at index in activePredicates.

Parameters :
Name Type Optional Description
index number No

index in activePredicates

type string No

new type (must be unique!)

Returns : void
update
update()

Emits a change event.

Returns : void

Properties

activePredicates
Type : string[]
Default value : []

The names of currently active predicates.

predicateCount
Default value : Object.keys(PREDICATES).length
predicates
Default value : PREDICATES
predicateValues
Type : literal type
Default value : {}

The values of currently active predicates.

searchQuery
Type : string
Default value : ''

Current search query.

import {Component, EventEmitter, Input, Output} from '@angular/core';
import {IssueCategory, IssueFilter} from '../../generated/graphql-dgql';
import {ListId, ListType, NodeType, ROOT_NODE} from '@app/data-dgql/id';

/** Returns the ListId for listing all project issues. */
const listAllIssues = (self: IssueFilterComponent): {node: {id: string; type: NodeType}; type: ListType} => ({
  node: {type: NodeType.Project, id: self.projectId},
  type: ListType.Issues
});

/**
 * List of all possible issue filter predicates.
 *
 * Keyed by their name, each predicate has a type, label,
 * and possibly additional options depending on their type.
 */
const PREDICATES = {
  isOpen: {type: 'bool', label: 'Is open'},
  isDuplicate: {type: 'bool', label: 'Is duplicate'},
  category: {
    type: 'enum',
    label: 'Category',
    options: [
      [IssueCategory.Unclassified, 'Unclassified'],
      [IssueCategory.Bug, 'Bug'],
      [IssueCategory.FeatureRequest, 'Feature Request']
    ]
  },
  labels: {
    type: 'ids',
    label: 'Labels',
    dataType: 'label',
    scoreKeys: ['name'],
    listAll: (self: IssueFilterComponent): ListId => self.allLabelsList,
    makeFilter: (query: string): {name: string} => ({name: query}),
    ifEmpty: 'No labels selected'
  },
  linksIssues: {type: 'bool', label: 'Has linked issues'},
  linkedIssues: {
    type: 'ids',
    label: 'Linked issues',
    dataType: 'issue',
    scoreKeys: ['title'],
    listAll: listAllIssues,
    makeFilter: (query: string): {title: string} => ({title: query}),
    ifEmpty: 'No issues selected'
  },
  isLinkedByIssues: {type: 'bool', label: 'Is linked by issues'},
  linkedByIssues: {
    type: 'ids',
    label: 'Linked by issues',
    dataType: 'issue',
    scoreKeys: ['title'],
    listAll: listAllIssues,
    makeFilter: (query: string): {title: string} => ({title: query}),
    ifEmpty: 'No issues selected'
  },
  participants: {
    type: 'ids',
    label: 'Participants',
    dataType: 'user',
    scoreKeys: ['username', 'displayName'],
    listAll: (): {node: {id: string; type: NodeType}; type: ListType} => ({node: ROOT_NODE, type: ListType.SearchUsers}),
    makeFilter: (query: string): {username: string} => ({username: query}),
    ifEmpty: 'No users selected'
  },
  locations: {
    type: 'ids',
    label: 'Locations',
    dataType: 'location',
    scoreKeys: ['name'],
    listAll: (
      self: IssueFilterComponent
    ): {
      staticSources: ({node: {id: string; type: NodeType}; type: ListType} | {node: {id: string; type: NodeType}; type: ListType})[];
    } => ({
      staticSources: [
        {
          node: {type: NodeType.Project, id: self.projectId},
          type: ListType.Components
        },
        {
          node: {type: NodeType.Project, id: self.projectId},
          type: ListType.ComponentInterfaces
        }
      ]
    }),
    makeFilter: (query: string): {title: string} => ({title: query}),
    ifEmpty: 'No locations selected'
  }
};

/** Returns the default value for a predicate type. */
function getDefaultForType(type: string) {
  switch (type) {
    case 'bool':
      return true;
    case 'enum':
    case 'ids':
      return [];
    default:
      throw new Error(`unknown type ${type}`);
  }
}

/** Converts a predicate value into the backend representation for use in the filter. */
function convertValueForFilter(type: string, value: any) {
  switch (type) {
    case 'ids':
      return value.map((item) => item.id);
    default:
      return value;
  }
}

/**
 * Edits an IssueFilter object.
 */
@Component({
  selector: 'app-issue-filter',
  templateUrl: './issue-filter.component.html',
  styleUrls: ['./issue-filter.component.scss']
})
export class IssueFilterComponent {
  /** Raw project ID. */
  @Input() projectId: string;
  /** The list from which to source labels in the label picker. */
  @Input() allLabelsList: ListId;
  /** Emitted every time the filter is changed. */
  @Output() filterChange = new EventEmitter<IssueFilter>();

  // constants as class properties because angular
  predicates = PREDICATES;
  predicateCount = Object.keys(PREDICATES).length;

  /** The names of currently active predicates. */
  activePredicates: string[] = [];
  /** The values of currently active predicates. */
  predicateValues: {[key: string]: any} = {};
  /** Current search query. */
  searchQuery = '';

  /**
   * Returns all types of predicates that were not taken in previous items.
   * @param index index in activePredicates
   */
  getRemainingTypes(index: number): string[] {
    const previousItems = this.activePredicates.slice(0, index);
    return Object.keys(PREDICATES).filter((id) => !previousItems.includes(id));
  }

  /**
   * Sets the type of the predicate at index in activePredicates.
   * @param index index in activePredicates
   * @param type new type (must be unique!)
   */
  setPredicateType(index: number, type: string): void {
    this.activePredicates[index] = type;
    this.predicateValues[type] = getDefaultForType(PREDICATES[type].type);
    this.update();
  }

  /**
   * Removes the predicate at the given index in activePredicates.
   * @param index index in activePredicates
   */
  removePredicateAt(index: number): void {
    const type = this.activePredicates.splice(index, 1)[0];
    delete this.predicateValues[type];
    this.update();
  }

  /**
   * Adds a new predicate after the given index in activePredicates.
   * @param index index in activePredicates
   */
  addPredicateAfter(index: number): void {
    const type = this.getRemainingTypes(index + 1)[0];
    this.activePredicates.splice(index + 1, 0, type);
    this.predicateValues[type] = getDefaultForType(PREDICATES[type].type);
    this.update();
  }

  /**
   * Sets the presence of a value in an array of enum variants.
   * @param array the array to mutate
   * @param item the enum variant
   * @param inArray whether or not it should be in the array
   */
  setInEnumArray(array, item, inArray): void {
    if (inArray && !array.includes(item)) {
      array.push(item);
    }
    if (!inArray && array.includes(item)) {
      array.splice(array.indexOf(item), 1);
    }
    this.update();
  }

  /**
   * Returns a function that can be passed to an app-set-editor to apply the changeset to the
   * value of the id predicate.
   * @param id predicate name
   */
  applyIdChangeset(id: string): (added: any, removed: any) => Promise<void> {
    return async (added, removed) => {
      for (const item of added) {
        this.predicateValues[id].push(item);
      }
      for (const item of removed) {
        this.predicateValues[id].splice(this.predicateValues[id].indexOf(item), 1);
      }
      this.update();
    };
  }

  /**
   * Builds an IssueFilter from the search query and selected filters.
   */
  buildFilter(): IssueFilter {
    const filter: IssueFilter = {};
    if (this.searchQuery.trim()) {
      filter.fullSearch = {text: this.searchQuery.trim()};
    }
    for (const id of this.activePredicates) {
      filter[id] = convertValueForFilter(PREDICATES[id].type, this.predicateValues[id]);
    }
    return filter;
  }

  /** Emits a change event. */
  update(): void {
    this.filterChange.emit(this.buildFilter());
  }
}
<div class="filter-box">
  <div class="search-box">
    <mat-form-field appearance="outline" class="search-field">
      <mat-label>Search</mat-label>
      <input matInput [(ngModel)]="searchQuery" (keyup)="update()" />
    </mat-form-field>
    <button mat-button class="pred-add" (click)="addPredicateAfter(-1)" *ngIf="!activePredicates.length">
      <mat-icon>filter_list</mat-icon>
    </button>
  </div>

  <div class="filter-predicates" *ngIf="activePredicates.length">
    <div class="filter-title">Filter</div>
    <div class="filter-predicate" *ngFor="let id of activePredicates; index as idx">
      <div class="predicate-type">
        <mat-form-field appearance="outline" class="predicate-type-selector">
          <mat-select [value]="id" (selectionChange)="setPredicateType(idx, $event.value)">
            <mat-option *ngFor="let type of getRemainingTypes(idx)" [value]="type"> {{ predicates[type].label }} </mat-option>
          </mat-select>
        </mat-form-field>
      </div>
      <div class="predicate-value" [ngSwitch]="predicates[id].type">
        <!-- FILTER VALUES -->

        <ng-container *ngSwitchCase="'bool'">
          <mat-button-toggle-group class="bool-value" [(ngModel)]="predicateValues[id]" (ngModelChange)="update()">
            <mat-button-toggle [value]="true"> Yes </mat-button-toggle>
            <mat-button-toggle [value]="false"> No </mat-button-toggle>
          </mat-button-toggle-group>
        </ng-container>
        <ng-container *ngSwitchCase="'enum'">
          <mat-button-toggle-group class="enum-value" multiple>
            <mat-button-toggle
              *ngFor="let entry of predicates[id].options"
              [checked]="predicateValues[id].includes(entry[0])"
              (change)="
                setInEnumArray(
                  predicateValues[id],
                  entry[0],
                  $event.source.checked
                )
              "
              [value]="entry[0]"
            >
              {{ entry[1] }}
            </mat-button-toggle>
          </mat-button-toggle-group>
        </ng-container>
        <ng-container *ngSwitchCase="'ids'">
          <app-set-editor
            [listSet]="predicateValues[id]"
            [listAll]="predicates[id].listAll(this)"
            [scoreKeys]="predicates[id].scoreKeys"
            [makeFilter]="predicates[id].makeFilter"
            [applyChangeset]="applyIdChangeset(id)"
          >
            <span title>{{ predicates[id].label }}</span>
            <span if-empty>{{ predicates[id].ifEmpty }}</span>
            <ng-container *appItem="let item" [ngSwitch]="predicates[id].dataType">
              <app-issue-label *ngSwitchCase="'label'" [label]="item"></app-issue-label>
              <app-issue-item *ngSwitchCase="'issue'" [projectId]="projectId" [issue]="item"></app-issue-item>
              <app-user-item *ngSwitchCase="'user'" [user]="item"></app-user-item>
              <ng-container *ngSwitchCase="'location'">
                <div *ngIf="item.__typename === 'Component'">{{ item.name }}</div>
                <div *ngIf="item.__typename === 'ComponentInterface'">
                  <span class="location-containing-component-name" *ngIf="!!item.component"> {{ item.component.name }} › </span>
                  {{ item.name }}
                </div>
              </ng-container>
            </ng-container>
          </app-set-editor>
        </ng-container>

        <!-- -------------------- -->
      </div>
      <div class="predicate-mgmt">
        <button mat-button class="pred-remove" (click)="removePredicateAt(idx)">
          <mat-icon>remove</mat-icon>
        </button>
        <button mat-button class="pred-add" (click)="addPredicateAfter(idx)" *ngIf="activePredicates.length < predicateCount">
          <mat-icon>add</mat-icon>
        </button>
      </div>
    </div>
  </div>
</div>

./issue-filter.component.scss

.filter-box {
  padding: 8px;

  // we do not want the extra spacing around form fields
  // (we wont be showing any helper text or errors anyway)
  ::ng-deep .mat-form-field-wrapper {
    padding-bottom: 0;
    margin-top: 0;
    margin-bottom: 0;
  }

  .pred-add,
  .pred-remove {
    min-width: 48px;
    width: 48px;
    padding: 0 8px;
  }

  .search-box {
    display: flex;
    align-items: center;

    .search-field {
      flex: 1;
    }

    .pred-add {
      margin-left: 16px;
    }
  }

  .filter-predicates {
    margin-top: 8px;

    .filter-title {
      font-weight: bold;
      padding-left: 8px;
    }

    .filter-predicate {
      display: flex;
      align-items: flex-start;

      .predicate-type {
        margin-right: 16px;
      }
      .predicate-value {
        flex: 1;
      }

      .predicate-mgmt {
        margin-left: 16px;
        border: 1px solid rgba(0, 0, 0, 0.1);
        border-radius: 4px;

        .pred-add {
          margin-left: 4px;
        }
      }

      @media (max-width: 500px) {
        display: block;
        border-top: 1px solid rgba(0, 0, 0, 0.1);
        padding: 8px 0;

        .predicate-type {
          margin-right: 0;
          margin-bottom: 8px;

          .predicate-type-selector {
            width: 100%;
          }
        }
        .predicate-value {
          .bool-value {
            width: 100%;
            mat-button-toggle {
              width: 100%;
            }
          }
        }
        .predicate-mgmt {
          border: none;
          margin-left: 0;
          text-align: right;
        }
      }
    }
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""