Skip to content

Commit

Permalink
Merge pull request #110 from CentreForDigitalHumanities/feature/multi…
Browse files Browse the repository at this point in the history
…-label-select

Feature/multi label select
  • Loading branch information
lukavdplas authored Aug 20, 2024
2 parents e2b5bce + 0340fd8 commit b09166c
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 11 deletions.
24 changes: 13 additions & 11 deletions frontend/src/app/data-entry/shared/data-entry-shared.module.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { NgModule } from '@angular/core';
import { SharedModule } from '@shared/shared.module';
import { DesignatorsControlComponent } from './designators-control/designators-control.component';


import { NgModule } from "@angular/core";
import { SharedModule } from "@shared/shared.module";
import { DesignatorsControlComponent } from "./designators-control/designators-control.component";
import { MultiselectComponent } from "./multiselect/multiselect.component";
import { LabelSelectComponent } from "./label-select/label-select.component";

@NgModule({
declarations: [
DesignatorsControlComponent
],
imports: [
SharedModule
DesignatorsControlComponent,
MultiselectComponent,
LabelSelectComponent,
],
imports: [SharedModule],
exports: [
DesignatorsControlComponent,
]
MultiselectComponent,
LabelSelectComponent,
],
})
export class DataEntrySharedModule { }
export class DataEntrySharedModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<p>Selected labels:</p>
<ul class="ps-0">
<ng-container *ngIf="selectedLabels().length > 0; else noLabels">
<li
*ngFor="let label of selectedLabels()"
class="badge rounded-pill text-bg-primary me-2 fs-6 fw-normal d-flex-inline align-items-center"
>
<span class="ms-1">{{ label.label }}</span>
<button
class="btn btn-sm btn-primary py-0 px-1 ms-1"
type="button"
[attr.aria-label]="'remove ' + label.label"
(click)="removeLabel(label.value)"
>
<lc-icon [icon]="actionIcons.cancel" />
</button>
</li>
</ng-container>
<ng-template #noLabels>
<p><i>No labels selected</i></p>
</ng-template>
</ul>

<form>
<label for="label-select" class="form-label">Pick a label</label>
<lc-multiselect
id="label-select"
[options]="options"
[formControl]="control"
[showSelected]="true"
placeholderEmpty="Select a label"
/>
</form>
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";

import { LabelSelectComponent } from "./label-select.component";
import { SharedTestingModule } from "@shared/shared-testing.module";
import { DataEntrySharedModule } from "../data-entry-shared.module";

describe("LabelSelectComponent", () => {
let component: LabelSelectComponent;
let fixture: ComponentFixture<LabelSelectComponent>;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [LabelSelectComponent],
imports: [SharedTestingModule, DataEntrySharedModule],
});
fixture = TestBed.createComponent(LabelSelectComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it("should create", () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Component, computed, forwardRef, Input } from "@angular/core";
import {
FormControl,
ControlValueAccessor,
NG_VALUE_ACCESSOR,
} from "@angular/forms";
import { actionIcons } from "@shared/icons";
import { toSignal } from "@angular/core/rxjs-interop";
import { MultiselectOption } from "../multiselect/multiselect.component";

@Component({
selector: "lc-label-select",
templateUrl: "./label-select.component.html",
styleUrls: ["./label-select.component.scss"],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => LabelSelectComponent),
multi: true,
},
],
})
export class LabelSelectComponent implements ControlValueAccessor {
@Input() options: MultiselectOption[] = [];

public control = new FormControl<string[]>([], { nonNullable: true });
public actionIcons = actionIcons;

public disabled = false;

private onChange: ((value: string[]) => void) | null = null;
private onTouched: (() => void) | null = null;

public formValue = toSignal<string[]>(this.control.valueChanges);
public selectedLabels = computed(() => {
const selectedIds = this.formValue();
return this.options.filter((item) => selectedIds?.includes(item.value));
});

public removeLabel(labelId: string): void {
const selectedIds = this.control.value.filter((id) => id !== labelId);
this.control.setValue(selectedIds);
}

public writeValue(value: string[]): void {
this.control.setValue(value);
}

public registerOnChange(fn: (value: string[]) => void): void {
this.onChange = fn;
}

public registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}

public setDisabledState?(isDisabled: boolean): void {
if (isDisabled) {
this.control.disable();
} else {
this.control.enable();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<div *ngIf="visibleOptions() as options" ngbDropdown>
<button
class="btn btn-primary"
id="labels-menu"
type="button"
ngbDropdownToggle
>
{{ placeholderEmpty }}
</button>
<div aria-labelledby="labels-menu" ngbDropdownMenu>
<ng-container *ngIf="options.length > 0; else noOptions">
<button
*ngFor="
let option of options;
let i = index;
trackBy: trackById
"
(click)="selectOption(option)"
[disabled]="selectedOptions().includes(option.value)"
ngbDropdownItem
>
{{ option.label }}
</button>
</ng-container>
<ng-template #noOptions>
<button ngbDropdownItem disabled>
{{ noAvailableOptions }}
</button>
</ng-template>
</div>
</div>
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";

import { MultiselectComponent } from "./multiselect.component";
import { SharedTestingModule } from "@shared/shared-testing.module";

describe("MultiselectComponent", () => {
let component: MultiselectComponent;
let fixture: ComponentFixture<MultiselectComponent>;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [MultiselectComponent],
imports: [SharedTestingModule],
});
fixture = TestBed.createComponent(MultiselectComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it("should create", () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Component, forwardRef, Input, signal, computed, ViewChild, ElementRef } from "@angular/core";
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from "@angular/forms";
import { NgbDropdownToggle } from "@ng-bootstrap/ng-bootstrap";

export interface MultiselectOption {
value: string;
label: string;
}

@Component({
selector: "lc-multiselect",
templateUrl: "./multiselect.component.html",
styleUrls: ["./multiselect.component.scss"],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MultiselectComponent),
multi: true,
},
],
})
export class MultiselectComponent implements ControlValueAccessor {
// All available options to choose from.
@Input() options: MultiselectOption[] = [];
// Determines whether to show selected options in the list of selectable options.
@Input() showSelected = false;
// A placeholder to be shown when nothing has been selected.
@Input() placeholderEmpty = "Select an option from the list below...";
@Input() noAvailableOptions = "No options available";
@ViewChild(NgbDropdownToggle) toggler: NgbDropdownToggle | null = null;

public selectedOptions = signal<string[]>([]);
public disabled = false;
public visibleOptions = computed(() => {
return this.options.filter((option) => {
if (this.showSelected) {
return true;
}
return !this.selectedOptions().includes(option.value);
});
});

private onChange: ((value: string[]) => void) | null = null;
private onTouched: (() => void) | null = null;

public selectOption(option: MultiselectOption): void {
const selectedIds: string[] = this.selectedOptions();
const index = selectedIds.indexOf(option.value);
if (index !== -1) {
selectedIds.splice(index, 1);
} else {
selectedIds.push(option.value);
}
// Propagate change to form control in view.
this.selectedOptions.set(selectedIds);
// Propagate change to parent component.
this.onChange?.(selectedIds);
this.onTouched?.();
// Keeps the focus on the toggler after selecting an option.
this.toggler?.nativeElement.focus();
}

public trackById(_index: number, option: MultiselectOption): string {
return option.value;
}

public writeValue(value: string[]): void {
this.selectedOptions.set(value);
}

public registerOnChange(fn: (value: string[]) => void): void {
this.onChange = fn;
}

public registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}

public setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}

0 comments on commit b09166c

Please sign in to comment.