mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Merge branch 'dev' into feature-autocolor
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p *ngIf="messageBold"><b>{{messageBold}}</b></p>
|
||||
<p *ngIf="message">{{message}}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
<span *ngIf="!confirmButtonEnabled"> ({{seconds}})</span>
|
||||
</button>
|
||||
</div>
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConfirmDialogComponent } from './confirm-dialog.component';
|
||||
|
||||
describe('ConfirmDialogComponent', () => {
|
||||
let component: ConfirmDialogComponent;
|
||||
let fixture: ComponentFixture<ConfirmDialogComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ ConfirmDialogComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConfirmDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,55 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
templateUrl: './confirm-dialog.component.html',
|
||||
styleUrls: ['./confirm-dialog.component.scss']
|
||||
})
|
||||
export class ConfirmDialogComponent implements OnInit {
|
||||
|
||||
constructor(public activeModal: NgbActiveModal) { }
|
||||
|
||||
@Output()
|
||||
public confirmClicked = new EventEmitter()
|
||||
|
||||
@Input()
|
||||
title = $localize`Confirmation`
|
||||
|
||||
@Input()
|
||||
messageBold
|
||||
|
||||
@Input()
|
||||
message
|
||||
|
||||
@Input()
|
||||
btnClass = "btn-primary"
|
||||
|
||||
@Input()
|
||||
btnCaption = $localize`Confirm`
|
||||
|
||||
@Input()
|
||||
buttonsEnabled = true
|
||||
|
||||
confirmButtonEnabled = true
|
||||
seconds = 0
|
||||
|
||||
delayConfirm(seconds: number) {
|
||||
this.confirmButtonEnabled = false
|
||||
this.seconds = seconds
|
||||
setTimeout(() => {
|
||||
if (this.seconds <= 1) {
|
||||
this.confirmButtonEnabled = true
|
||||
} else {
|
||||
this.delayConfirm(seconds - 1)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
cancelClicked() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
}
|
@@ -0,0 +1,62 @@
|
||||
<div class="btn-group w-100" ngbDropdown role="group">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'">
|
||||
{{title}}
|
||||
</button>
|
||||
<div class="dropdown-menu date-dropdown shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div class="list-group list-group-flush">
|
||||
<button *ngFor="let qf of quickFilters" class="list-group-item small list-goup list-group-item-action d-flex p-2 pl-3" role="menuitem" (click)="setDateQuickFilter(qf.id)">
|
||||
{{qf.name}}
|
||||
</button>
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
|
||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||
<div i18n>After</div>
|
||||
<a *ngIf="dateAfter" class="btn btn-link p-0 m-0" (click)="clearAfter()">
|
||||
<svg width="0.8em" height="0.8em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
|
||||
</svg>
|
||||
<small i18n>Clear</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" id="dateAfter" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()"
|
||||
[(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
|
||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
|
||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||
<div i18n>Before</div>
|
||||
<a *ngIf="dateBefore" class="btn btn-link p-0 m-0" (click)="clearBefore()">
|
||||
<svg width="0.8em" height="0.8em" viewBox="0 0 16 16" class="bi bi-x" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
|
||||
</svg>
|
||||
<small i18n>Clear</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" id="dateBefore" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()"
|
||||
[(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
|
||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,7 @@
|
||||
.date-dropdown {
|
||||
min-width: 250px;
|
||||
|
||||
.btn-link {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
@@ -1,20 +1,20 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DeleteDialogComponent } from './delete-dialog.component';
|
||||
import { DateDropdownComponent } from './date-dropdown.component';
|
||||
|
||||
describe('DeleteDialogComponent', () => {
|
||||
let component: DeleteDialogComponent;
|
||||
let fixture: ComponentFixture<DeleteDialogComponent>;
|
||||
describe('DateDropdownComponent', () => {
|
||||
let component: DateDropdownComponent;
|
||||
let fixture: ComponentFixture<DateDropdownComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ DeleteDialogComponent ]
|
||||
declarations: [ DateDropdownComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DeleteDialogComponent);
|
||||
fixture = TestBed.createComponent(DateDropdownComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
@@ -0,0 +1,123 @@
|
||||
import { formatDate } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core';
|
||||
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { SettingsService } from 'src/app/services/settings.service';
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter';
|
||||
|
||||
export interface DateSelection {
|
||||
before?: string
|
||||
after?: string
|
||||
}
|
||||
|
||||
const LAST_7_DAYS = 0
|
||||
const LAST_MONTH = 1
|
||||
const LAST_3_MONTHS = 2
|
||||
const LAST_YEAR = 3
|
||||
|
||||
@Component({
|
||||
selector: 'app-date-dropdown',
|
||||
templateUrl: './date-dropdown.component.html',
|
||||
styleUrls: ['./date-dropdown.component.scss'],
|
||||
providers: [
|
||||
{provide: NgbDateAdapter, useClass: ISODateAdapter},
|
||||
]
|
||||
})
|
||||
export class DateDropdownComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(settings: SettingsService) {
|
||||
this.datePlaceHolder = settings.getLocalizedDateInputFormat()
|
||||
}
|
||||
|
||||
quickFilters = [
|
||||
{id: LAST_7_DAYS, name: $localize`Last 7 days`},
|
||||
{id: LAST_MONTH, name: $localize`Last month`},
|
||||
{id: LAST_3_MONTHS, name: $localize`Last 3 months`},
|
||||
{id: LAST_YEAR, name: $localize`Last year`}
|
||||
]
|
||||
|
||||
datePlaceHolder: string
|
||||
|
||||
@Input()
|
||||
dateBefore: string
|
||||
|
||||
@Output()
|
||||
dateBeforeChange = new EventEmitter<string>()
|
||||
|
||||
@Input()
|
||||
dateAfter: string
|
||||
|
||||
@Output()
|
||||
dateAfterChange = new EventEmitter<string>()
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@Output()
|
||||
datesSet = new EventEmitter<DateSelection>()
|
||||
|
||||
private datesSetDebounce$ = new Subject()
|
||||
|
||||
private sub: Subscription
|
||||
|
||||
ngOnInit() {
|
||||
this.sub = this.datesSetDebounce$.pipe(
|
||||
debounceTime(400)
|
||||
).subscribe(() => {
|
||||
this.onChange()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.sub) {
|
||||
this.sub.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
setDateQuickFilter(qf: number) {
|
||||
this.dateBefore = null
|
||||
let date = new Date()
|
||||
switch (qf) {
|
||||
case LAST_7_DAYS:
|
||||
date.setDate(date.getDate() - 7)
|
||||
break;
|
||||
|
||||
case LAST_MONTH:
|
||||
date.setMonth(date.getMonth() - 1)
|
||||
break;
|
||||
|
||||
case LAST_3_MONTHS:
|
||||
date.setMonth(date.getMonth() - 3)
|
||||
break
|
||||
|
||||
case LAST_YEAR:
|
||||
date.setFullYear(date.getFullYear() - 1)
|
||||
break
|
||||
|
||||
}
|
||||
this.dateAfter = formatDate(date, 'yyyy-MM-dd', "en-us", "UTC")
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
onChange() {
|
||||
this.dateAfterChange.emit(this.dateAfter)
|
||||
this.dateBeforeChange.emit(this.dateBefore)
|
||||
this.datesSet.emit({after: this.dateAfter, before: this.dateBefore})
|
||||
}
|
||||
|
||||
onChangeDebounce() {
|
||||
this.datesSetDebounce$.next({after: this.dateAfter, before: this.dateBefore})
|
||||
}
|
||||
|
||||
clearBefore() {
|
||||
this.dateBefore = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
clearAfter() {
|
||||
this.dateAfter = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
}
|
@@ -1,14 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p><b>{{message}}</b></p>
|
||||
<p *ngIf="message2">{{message2}}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" (click)="deleteClicked.emit()">Delete</button>
|
||||
</div>
|
@@ -1,31 +0,0 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delete-dialog',
|
||||
templateUrl: './delete-dialog.component.html',
|
||||
styleUrls: ['./delete-dialog.component.scss']
|
||||
})
|
||||
export class DeleteDialogComponent implements OnInit {
|
||||
|
||||
constructor(public activeModal: NgbActiveModal) { }
|
||||
|
||||
@Output()
|
||||
public deleteClicked = new EventEmitter()
|
||||
|
||||
@Input()
|
||||
title = "Delete confirmation"
|
||||
|
||||
@Input()
|
||||
message = "Do you really want to delete this?"
|
||||
|
||||
@Input()
|
||||
message2
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
cancelClicked() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
}
|
@@ -2,10 +2,11 @@ import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Observable } from 'rxjs';
|
||||
import { MATCHING_ALGORITHMS } from 'src/app/data/matching-model';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model';
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id';
|
||||
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service';
|
||||
import { Toast, ToastService } from 'src/app/services/toast.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
@Directive()
|
||||
export abstract class EditDialogComponent<T extends ObjectWithId> implements OnInit {
|
||||
@@ -13,8 +14,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
|
||||
constructor(
|
||||
private service: AbstractPaperlessService<T>,
|
||||
private activeModal: NgbActiveModal,
|
||||
private toastService: ToastService,
|
||||
private entityName: string) { }
|
||||
private toastService: ToastService) { }
|
||||
|
||||
@Input()
|
||||
dialogMode: string = 'create'
|
||||
@@ -25,6 +25,12 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
|
||||
@Output()
|
||||
success = new EventEmitter()
|
||||
|
||||
networkActive = false
|
||||
|
||||
closeEnabled = false
|
||||
|
||||
error = null
|
||||
|
||||
abstract getForm(): FormGroup
|
||||
|
||||
objectForm: FormGroup = this.getForm()
|
||||
@@ -33,14 +39,31 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
|
||||
if (this.object != null) {
|
||||
this.objectForm.patchValue(this.object)
|
||||
}
|
||||
|
||||
// wait to enable close button so it doesnt steal focus from input since its the first clickable element in the DOM
|
||||
setTimeout(() => {
|
||||
this.closeEnabled = true
|
||||
});
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
return $localize`Create new item`
|
||||
}
|
||||
|
||||
getEditTitle() {
|
||||
return $localize`Edit item`
|
||||
}
|
||||
|
||||
getSaveErrorMessage(error: string) {
|
||||
return $localize`Could not save element: ${error}`
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
switch (this.dialogMode) {
|
||||
case 'create':
|
||||
return "Create new " + this.entityName
|
||||
return this.getCreateTitle()
|
||||
case 'edit':
|
||||
return "Edit " + this.entityName
|
||||
return this.getEditTitle()
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -50,6 +73,10 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
|
||||
return MATCHING_ALGORITHMS
|
||||
}
|
||||
|
||||
get patternRequired(): boolean {
|
||||
return this.objectForm?.value.matching_algorithm !== MATCH_AUTO
|
||||
}
|
||||
|
||||
save() {
|
||||
var newObject = Object.assign(Object.assign({}, this.object), this.objectForm.value)
|
||||
var serverResponse: Observable<T>
|
||||
@@ -62,11 +89,13 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.networkActive = true
|
||||
serverResponse.subscribe(result => {
|
||||
this.activeModal.close()
|
||||
this.success.emit(result)
|
||||
}, error => {
|
||||
this.toastService.showToast(Toast.makeError(`Could not save ${this.entityName}: ${error.error.name}`))
|
||||
this.error = error.error
|
||||
this.networkActive = false
|
||||
})
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,33 @@
|
||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'">
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" />
|
||||
</svg>
|
||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||
<ng-container *ngIf="!editing && selectionModel.selectionSize() > 0">
|
||||
<div class="badge bg-secondary text-light rounded-pill badge-corner">
|
||||
{{selectionModel.selectionSize()}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</button>
|
||||
<div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div class="list-group list-group-flush">
|
||||
<div class="list-group-item">
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="selectionModel.items" class="items">
|
||||
<ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText">
|
||||
<app-toggleable-dropdown-button *ngIf="allowSelectNone || item.id" [item]="item" [state]="selectionModel.get(item.id)" (toggle)="selectionModel.toggle(item.id)"></app-toggleable-dropdown-button>
|
||||
</ng-container>
|
||||
</div>
|
||||
<button *ngIf="editing" class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!selectionModel.isDirty()">
|
||||
<small class="ml-1" [ngClass]="{'font-weight-bold': selectionModel.isDirty()}" i18n>Apply</small>
|
||||
<svg width="1.5em" height="1em" viewBox="0 0 16 16" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,14 @@
|
||||
.badge-corner {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: 250px;
|
||||
|
||||
.items {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FilterableDropodownComponent } from './filterable-dropdown.component';
|
||||
|
||||
describe('FilterableDropodownComponent', () => {
|
||||
let component: FilterableDropodownComponent;
|
||||
let fixture: ComponentFixture<FilterableDropodownComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FilterableDropodownComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FilterableDropodownComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,272 @@
|
||||
import { Component, EventEmitter, Input, Output, ElementRef, ViewChild } from '@angular/core';
|
||||
import { FilterPipe } from 'src/app/pipes/filter.pipe';
|
||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component';
|
||||
import { MatchingModel } from 'src/app/data/matching-model';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export interface ChangedItems {
|
||||
itemsToAdd: MatchingModel[],
|
||||
itemsToRemove: MatchingModel[]
|
||||
}
|
||||
|
||||
export class FilterableDropdownSelectionModel {
|
||||
|
||||
changed = new Subject<FilterableDropdownSelectionModel>()
|
||||
|
||||
multiple = false
|
||||
|
||||
items: MatchingModel[] = []
|
||||
|
||||
get itemsSorted(): MatchingModel[] {
|
||||
// TODO: this is getting called very often
|
||||
return this.items.sort((a,b) => {
|
||||
if (a.id == null && b.id != null) {
|
||||
return -1
|
||||
} else if (a.id != null && b.id == null) {
|
||||
return 1
|
||||
} else if (this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(b.id) != ToggleableItemState.NotSelected) {
|
||||
return 1
|
||||
} else if (this.getNonTemporary(a.id) != ToggleableItemState.NotSelected && this.getNonTemporary(b.id) == ToggleableItemState.NotSelected) {
|
||||
return -1
|
||||
} else {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private selectionStates = new Map<number, ToggleableItemState>()
|
||||
|
||||
private temporarySelectionStates = new Map<number, ToggleableItemState>()
|
||||
|
||||
getSelectedItems() {
|
||||
return this.items.filter(i => this.temporarySelectionStates.get(i.id) == ToggleableItemState.Selected)
|
||||
}
|
||||
|
||||
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
||||
if (state == ToggleableItemState.NotSelected) {
|
||||
this.temporarySelectionStates.delete(id)
|
||||
} else {
|
||||
this.temporarySelectionStates.set(id, state)
|
||||
}
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
}
|
||||
|
||||
toggle(id: number, fireEvent = true) {
|
||||
let state = this.temporarySelectionStates.get(id)
|
||||
if (state == null || state != ToggleableItemState.Selected) {
|
||||
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
|
||||
} else if (state == ToggleableItemState.Selected) {
|
||||
this.temporarySelectionStates.delete(id)
|
||||
}
|
||||
|
||||
if (!this.multiple) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
if (key != id) {
|
||||
this.temporarySelectionStates.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
if (key) {
|
||||
this.temporarySelectionStates.delete(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.temporarySelectionStates.delete(null)
|
||||
}
|
||||
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private getNonTemporary(id: number) {
|
||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||
}
|
||||
|
||||
get(id: number) {
|
||||
return this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected
|
||||
}
|
||||
|
||||
selectionSize() {
|
||||
return this.getSelectedItems().length
|
||||
}
|
||||
|
||||
clear(fireEvent = true) {
|
||||
this.temporarySelectionStates.clear()
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
}
|
||||
|
||||
isDirty() {
|
||||
if (!Array.from(this.temporarySelectionStates.keys()).every(id => this.temporarySelectionStates.get(id) == this.selectionStates.get(id))) {
|
||||
return true
|
||||
} else if (!Array.from(this.selectionStates.keys()).every(id => this.selectionStates.get(id) == this.temporarySelectionStates.get(id))) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
isNoneSelected() {
|
||||
return this.selectionSize() == 1 && this.get(null) == ToggleableItemState.Selected
|
||||
}
|
||||
|
||||
init(map) {
|
||||
this.temporarySelectionStates = map
|
||||
this.apply()
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.selectionStates.clear()
|
||||
this.temporarySelectionStates.forEach((value, key) => {
|
||||
this.selectionStates.set(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.temporarySelectionStates.clear()
|
||||
this.selectionStates.forEach((value, key) => {
|
||||
this.temporarySelectionStates.set(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
diff(): ChangedItems {
|
||||
return {
|
||||
itemsToAdd: this.items.filter(item => this.temporarySelectionStates.get(item.id) == ToggleableItemState.Selected && this.selectionStates.get(item.id) != ToggleableItemState.Selected),
|
||||
itemsToRemove: this.items.filter(item => !this.temporarySelectionStates.has(item.id) && this.selectionStates.has(item.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-filterable-dropdown',
|
||||
templateUrl: './filterable-dropdown.component.html',
|
||||
styleUrls: ['./filterable-dropdown.component.scss']
|
||||
})
|
||||
export class FilterableDropdownComponent {
|
||||
|
||||
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||
|
||||
filterText: string
|
||||
|
||||
@Input()
|
||||
set items(items: MatchingModel[]) {
|
||||
if (items) {
|
||||
this._selectionModel.items = Array.from(items)
|
||||
this._selectionModel.items.unshift({
|
||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||
id: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
get items(): MatchingModel[] {
|
||||
return this._selectionModel.items
|
||||
}
|
||||
|
||||
_selectionModel = new FilterableDropdownSelectionModel()
|
||||
|
||||
@Input()
|
||||
set selectionModel(model: FilterableDropdownSelectionModel) {
|
||||
if (this.selectionModel) {
|
||||
this.selectionModel.changed.complete()
|
||||
model.items = this.selectionModel.items
|
||||
model.multiple = this.selectionModel.multiple
|
||||
}
|
||||
model.changed.subscribe(updatedModel => {
|
||||
this.selectionModelChange.next(updatedModel)
|
||||
})
|
||||
this._selectionModel = model
|
||||
}
|
||||
|
||||
get selectionModel(): FilterableDropdownSelectionModel {
|
||||
return this._selectionModel
|
||||
}
|
||||
|
||||
@Output()
|
||||
selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
|
||||
|
||||
@Input()
|
||||
set multiple(value: boolean) {
|
||||
this.selectionModel.multiple = value
|
||||
}
|
||||
|
||||
get multiple() {
|
||||
return this.selectionModel.multiple
|
||||
}
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@Input()
|
||||
filterPlaceholder: string = ""
|
||||
|
||||
@Input()
|
||||
icon: string
|
||||
|
||||
@Input()
|
||||
allowSelectNone: boolean = false
|
||||
|
||||
@Input()
|
||||
editing = false
|
||||
|
||||
@Input()
|
||||
applyOnClose = false
|
||||
|
||||
@Output()
|
||||
apply = new EventEmitter<ChangedItems>()
|
||||
|
||||
@Output()
|
||||
open = new EventEmitter()
|
||||
|
||||
constructor(private filterPipe: FilterPipe) {
|
||||
this.selectionModel = new FilterableDropdownSelectionModel()
|
||||
}
|
||||
|
||||
applyClicked() {
|
||||
if (this.selectionModel.isDirty()) {
|
||||
this.dropdown.close()
|
||||
if (!this.applyOnClose) {
|
||||
this.apply.emit(this.selectionModel.diff())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropdownOpenChange(open: boolean): void {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
this.listFilterTextInput.nativeElement.focus();
|
||||
}, 0)
|
||||
if (this.editing) {
|
||||
this.selectionModel.reset()
|
||||
}
|
||||
this.open.next()
|
||||
} else {
|
||||
this.filterText = ''
|
||||
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
||||
this.apply.emit(this.selectionModel.diff())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listFilterEnter(): void {
|
||||
let filtered = this.filterPipe.transform(this.items, this.filterText)
|
||||
if (filtered.length == 1) {
|
||||
this.selectionModel.toggle(filtered[0].id)
|
||||
if (this.editing) {
|
||||
this.applyClicked()
|
||||
} else {
|
||||
this.dropdown.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-left-0 border-right-0 border-bottom" role="menuitem" (click)="toggleItem()">
|
||||
<div class="selected-icon mr-1">
|
||||
<ng-container *ngIf="isChecked()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="isPartiallyChecked()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-dash" viewBox="0 0 16 16">
|
||||
<path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8z"/>
|
||||
</svg>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
<div class="mr-1">
|
||||
<app-tag *ngIf="isTag; else displayName" [tag]="item" [clickable]="true" linkTitle="Filter by tag"></app-tag>
|
||||
<ng-template #displayName><small>{{item.name}}</small></ng-template>
|
||||
</div>
|
||||
<div class="badge badge-light rounded-pill ml-auto mr-1">{{item.document_count}}</div>
|
||||
</button>
|
@@ -0,0 +1,4 @@
|
||||
.selected-icon {
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ToggleableDropdownButtonComponent } from './toggleable-dropdown-button.component';
|
||||
|
||||
describe('ToggleableDropdownButtonComponent', () => {
|
||||
let component: ToggleableDropdownButtonComponent;
|
||||
let fixture: ComponentFixture<ToggleableDropdownButtonComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ ToggleableDropdownButtonComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ToggleableDropdownButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,51 @@
|
||||
import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core';
|
||||
import { MatchingModel } from 'src/app/data/matching-model';
|
||||
|
||||
export interface ToggleableItem {
|
||||
item: MatchingModel,
|
||||
state: ToggleableItemState,
|
||||
count: number
|
||||
}
|
||||
|
||||
export enum ToggleableItemState {
|
||||
NotSelected = 0,
|
||||
Selected = 1,
|
||||
PartiallySelected = 2
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-toggleable-dropdown-button',
|
||||
templateUrl: './toggleable-dropdown-button.component.html',
|
||||
styleUrls: ['./toggleable-dropdown-button.component.scss']
|
||||
})
|
||||
export class ToggleableDropdownButtonComponent {
|
||||
|
||||
@Input()
|
||||
item: MatchingModel
|
||||
|
||||
@Input()
|
||||
state: ToggleableItemState
|
||||
|
||||
@Input()
|
||||
count: number
|
||||
|
||||
@Output()
|
||||
toggle = new EventEmitter()
|
||||
|
||||
get isTag(): boolean {
|
||||
return 'is_inbox_tag' in this.item
|
||||
}
|
||||
|
||||
toggleItem(): void {
|
||||
this.toggle.emit()
|
||||
}
|
||||
|
||||
isChecked() {
|
||||
return this.state == ToggleableItemState.Selected
|
||||
}
|
||||
|
||||
isPartiallyChecked() {
|
||||
return this.state == ToggleableItemState.PartiallySelected
|
||||
}
|
||||
|
||||
}
|
@@ -1,10 +1,13 @@
|
||||
import { Component, Directive, forwardRef, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
|
||||
import { ControlValueAccessor } from '@angular/forms';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Directive()
|
||||
export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
|
||||
|
||||
@ViewChild("inputField")
|
||||
inputField: ElementRef
|
||||
|
||||
constructor() { }
|
||||
|
||||
onChange = (newValue: T) => {};
|
||||
@@ -24,12 +27,21 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
|
||||
this.disabled = isDisabled;
|
||||
}
|
||||
|
||||
focus() {
|
||||
if (this.inputField && this.inputField.nativeElement) {
|
||||
this.inputField.nativeElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@Input()
|
||||
disabled = false;
|
||||
|
||||
@Input()
|
||||
error: string
|
||||
|
||||
value: T
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@@ -1,14 +0,0 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group col">
|
||||
<label for="created_date">{{titleDate}}</label>
|
||||
<input type="date" class="form-control" id="created_date" [(ngModel)]="dateValue" (change)="dateOrTimeChanged()">
|
||||
</div>
|
||||
<div class="form-group col">
|
||||
<label for="created_time">{{titleTime}}</label>
|
||||
<input type="time" class="form-control" id="created_time" [(ngModel)]="timeValue" (change)="dateOrTimeChanged()">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> -->
|
@@ -1,62 +0,0 @@
|
||||
import { formatDate } from '@angular/common';
|
||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { AbstractInputComponent } from '../abstract-input';
|
||||
|
||||
@Component({
|
||||
providers: [{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => DateTimeComponent),
|
||||
multi: true
|
||||
}],
|
||||
selector: 'app-input-date-time',
|
||||
templateUrl: './date-time.component.html',
|
||||
styleUrls: ['./date-time.component.scss']
|
||||
})
|
||||
export class DateTimeComponent implements OnInit,ControlValueAccessor {
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
onChange = (newValue: any) => {};
|
||||
|
||||
onTouched = () => {};
|
||||
|
||||
writeValue(newValue: any): void {
|
||||
this.dateValue = formatDate(newValue, 'yyyy-MM-dd', "en-US")
|
||||
this.timeValue = formatDate(newValue, 'HH:mm:ss', 'en-US')
|
||||
}
|
||||
registerOnChange(fn: any): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: any): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
setDisabledState?(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
}
|
||||
|
||||
@Input()
|
||||
titleDate: string = "Date"
|
||||
|
||||
@Input()
|
||||
titleTime: string = "Time"
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
@Input()
|
||||
hint: string
|
||||
|
||||
timeValue
|
||||
|
||||
dateValue
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
dateOrTimeChanged() {
|
||||
this.onChange(formatDate(this.dateValue + "T" + this.timeValue,"yyyy-MM-ddTHH:mm:ssZZZZZ", "en-us", "UTC"))
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
<div class="form-group">
|
||||
<label [for]="inputId">{{title}}</label>
|
||||
<div class="input-group">
|
||||
<input [class.is-invalid]="error" class="form-control" [placeholder]="placeholder" [id]="inputId" (dateSelect)="onChange(value)" (change)="onChange(value)"
|
||||
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
|
||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="invalid-feedback" *ngIf="error" i18n>Invalid date.</div>
|
||||
</div>
|
||||
</div>
|
@@ -1,20 +1,20 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DateTimeComponent } from './date-time.component';
|
||||
import { DateComponent } from './date.component';
|
||||
|
||||
describe('DateTimeComponent', () => {
|
||||
let component: DateTimeComponent;
|
||||
let fixture: ComponentFixture<DateTimeComponent>;
|
||||
describe('DateComponent', () => {
|
||||
let component: DateComponent;
|
||||
let fixture: ComponentFixture<DateComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ DateTimeComponent ]
|
||||
declarations: [ DateComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DateTimeComponent);
|
||||
fixture = TestBed.createComponent(DateComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
@@ -0,0 +1,32 @@
|
||||
import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgbDateAdapter, NgbDateParserFormatter, NgbDatepickerContent } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SettingsService } from 'src/app/services/settings.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AbstractInputComponent } from '../abstract-input';
|
||||
|
||||
|
||||
@Component({
|
||||
providers: [{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => DateComponent),
|
||||
multi: true
|
||||
}],
|
||||
selector: 'app-input-date',
|
||||
templateUrl: './date.component.html',
|
||||
styleUrls: ['./date.component.scss']
|
||||
})
|
||||
export class DateComponent extends AbstractInputComponent<string> implements OnInit {
|
||||
|
||||
constructor(private settings: SettingsService) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit()
|
||||
this.placeholder = this.settings.getLocalizedDateInputFormat()
|
||||
}
|
||||
|
||||
placeholder: string
|
||||
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
<div class="form-group">
|
||||
<label [for]="inputId">{{title}}</label>
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="value">+1</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
|
||||
</div>
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NumberComponent } from './number.component';
|
||||
|
||||
describe('NumberComponent', () => {
|
||||
let component: NumberComponent;
|
||||
let fixture: ComponentFixture<NumberComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ NumberComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(NumberComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,39 @@
|
||||
import { Component, forwardRef } from '@angular/core';
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { FILTER_ASN_ISNULL } from 'src/app/data/filter-rule-type';
|
||||
import { DocumentService } from 'src/app/services/rest/document.service';
|
||||
import { AbstractInputComponent } from '../abstract-input';
|
||||
|
||||
@Component({
|
||||
providers: [{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => NumberComponent),
|
||||
multi: true
|
||||
}],
|
||||
selector: 'app-input-number',
|
||||
templateUrl: './number.component.html',
|
||||
styleUrls: ['./number.component.scss']
|
||||
})
|
||||
export class NumberComponent extends AbstractInputComponent<number> {
|
||||
|
||||
constructor(private documentService: DocumentService) {
|
||||
super()
|
||||
}
|
||||
|
||||
nextAsn() {
|
||||
if (this.value) {
|
||||
return
|
||||
}
|
||||
this.documentService.listFiltered(1, 1, "archive_serial_number", true, [{rule_type: FILTER_ASN_ISNULL, value: "false"}]).subscribe(
|
||||
results => {
|
||||
if (results.count > 0) {
|
||||
this.value = results.results[0].archive_serial_number + 1
|
||||
} else {
|
||||
this.value = 1
|
||||
}
|
||||
this.onChange(this.value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@@ -1,11 +1,18 @@
|
||||
<div class="form-group">
|
||||
<div class="form-group paperless-input-select">
|
||||
<label [for]="inputId">{{title}}</label>
|
||||
<div [class.input-group]="showPlusButton()">
|
||||
<select class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()"
|
||||
[disabled]="disabled" [style.color]="textColor" [style.background]="backgroundColor">
|
||||
<option *ngIf="allowNull" [ngValue]="null" class="form-control">---</option>
|
||||
<option *ngFor="let i of items" [ngValue]="i.id" class="form-control">{{i.name}}</option>
|
||||
</select>
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background]="backgroundColor"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
bindLabel="name"
|
||||
bindValue="id"
|
||||
(change)="onChange(value)"
|
||||
(blur)="onTouched()">
|
||||
</ng-select>
|
||||
|
||||
<div *ngIf="showPlusButton()" class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="createNew.emit()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
@@ -15,4 +22,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
</div>
|
||||
<small *ngIf="getSuggestions().length > 0">
|
||||
<span i18n>Suggestions:</span>
|
||||
<ng-container *ngFor="let s of getSuggestions()">
|
||||
<a (click)="value = s.id; onChange(value)" [routerLink]="">{{s.name}}</a>
|
||||
</ng-container>
|
||||
|
||||
|
||||
</small>
|
||||
</div>
|
||||
|
@@ -0,0 +1 @@
|
||||
// styles for ng-select child are in styles.scss
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core';
|
||||
import { Component, EventEmitter, forwardRef, Input, Output } from '@angular/core';
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { AbstractInputComponent } from '../abstract-input';
|
||||
|
||||
@@ -30,11 +30,22 @@ export class SelectComponent extends AbstractInputComponent<number> {
|
||||
@Input()
|
||||
allowNull: boolean = false
|
||||
|
||||
@Input()
|
||||
suggestions: number[]
|
||||
|
||||
@Output()
|
||||
createNew = new EventEmitter()
|
||||
|
||||
|
||||
showPlusButton(): boolean {
|
||||
return this.createNew.observers.length > 0
|
||||
}
|
||||
|
||||
getSuggestions() {
|
||||
if (this.suggestions && this.items) {
|
||||
return this.suggestions.filter(id => id != this.value).map(id => this.items.find(item => item.id == id))
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,30 +1,46 @@
|
||||
<div class="form-group">
|
||||
<label for="exampleFormControlTextarea1">Tags</label>
|
||||
<div class="form-group paperless-input-select paperless-input-tags">
|
||||
<label for="tags" i18n>Tags</label>
|
||||
|
||||
<div class="input-group">
|
||||
<div class="form-control tags-form-control" id="tags">
|
||||
<app-tag class="mr-2" *ngFor="let id of displayValue" [tag]="getTag(id)" (click)="removeTag(id)"></app-tag>
|
||||
</div>
|
||||
<div class="input-group flex-nowrap">
|
||||
<ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
[clearSearchOnAdd]="true"
|
||||
[hideSelected]="true"
|
||||
(change)="onChange(value)"
|
||||
(blur)="onTouched()">
|
||||
|
||||
<div class="input-group-append" ngbDropdown placement="top-right">
|
||||
<button class="btn btn-outline-secondary" type="button" ngbDropdownToggle></button>
|
||||
<div ngbDropdownMenu class="scrollable-menu">
|
||||
<button type="button" *ngFor="let tag of tags" ngbDropdownItem (click)="addTag(tag.id)">
|
||||
<app-tag [tag]="tag"></app-tag>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
<span class="tag-wrap tag-wrap-delete" (click)="removeTag(item.id)">
|
||||
<svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg>
|
||||
<app-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></app-tag>
|
||||
</span>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
||||
<div class="tag-wrap">
|
||||
<app-tag *ngIf="item.id && tags" class="mr-2" [tag]="getTag(item.id)"></app-tag>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
|
||||
<div class="input-group-append">
|
||||
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="createTag()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
|
||||
<small *ngIf="getSuggestions().length > 0">
|
||||
<span i18n>Suggestions:</span>
|
||||
<ng-container *ngFor="let tag of getSuggestions()">
|
||||
<a (click)="addTag(tag.id)" [routerLink]="">{{tag.name}}</a>
|
||||
</ng-container>
|
||||
|
||||
|
||||
</small>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,10 +1,12 @@
|
||||
.tags-form-control {
|
||||
height: auto;
|
||||
.selected-icon {
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.tag-wrap {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.scrollable-menu {
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.tag-wrap-delete {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@@ -1,8 +1,6 @@
|
||||
import { ThrowStmt } from '@angular/compiler';
|
||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Observable } from 'rxjs';
|
||||
import { TagEditDialogComponent } from 'src/app/components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component';
|
||||
import { PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
import { TagService } from 'src/app/services/rest/tag.service';
|
||||
@@ -23,14 +21,11 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
|
||||
|
||||
onChange = (newValue: number[]) => {};
|
||||
|
||||
|
||||
onTouched = () => {};
|
||||
|
||||
writeValue(newValue: number[]): void {
|
||||
this.value = newValue
|
||||
if (this.tags) {
|
||||
this.displayValue = newValue
|
||||
}
|
||||
}
|
||||
registerOnChange(fn: any): void {
|
||||
this.onChange = fn;
|
||||
@@ -45,7 +40,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
ngOnInit(): void {
|
||||
this.tagService.listAll().subscribe(result => {
|
||||
this.tags = result.results
|
||||
this.displayValue = this.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,42 +49,54 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
@Input()
|
||||
hint
|
||||
|
||||
value: number[]
|
||||
@Input()
|
||||
suggestions: number[]
|
||||
|
||||
displayValue: number[] = []
|
||||
value: number[]
|
||||
|
||||
tags: PaperlessTag[]
|
||||
|
||||
getTag(id) {
|
||||
return this.tags.find(tag => tag.id == id)
|
||||
if (this.tags) {
|
||||
return this.tags.find(tag => tag.id == id)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(id) {
|
||||
let index = this.displayValue.indexOf(id)
|
||||
let index = this.value.indexOf(id)
|
||||
if (index > -1) {
|
||||
this.displayValue.splice(index, 1)
|
||||
this.onChange(this.displayValue)
|
||||
let oldValue = this.value
|
||||
oldValue.splice(index, 1)
|
||||
this.value = [...oldValue]
|
||||
this.onChange(this.value)
|
||||
}
|
||||
}
|
||||
|
||||
addTag(id) {
|
||||
let index = this.displayValue.indexOf(id)
|
||||
if (index == -1) {
|
||||
this.displayValue.push(id)
|
||||
this.onChange(this.displayValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createTag() {
|
||||
var modal = this.modalService.open(TagEditDialogComponent, {backdrop: 'static'})
|
||||
modal.componentInstance.dialogMode = 'create'
|
||||
modal.componentInstance.success.subscribe(newTag => {
|
||||
this.tagService.listAll().subscribe(tags => {
|
||||
this.tags = tags.results
|
||||
this.addTag(newTag.id)
|
||||
this.value = [...this.value, newTag.id]
|
||||
this.onChange(this.value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getSuggestions() {
|
||||
if (this.suggestions && this.tags) {
|
||||
return this.suggestions.filter(id => !this.value.includes(id)).map(id => this.tags.find(tag => tag.id == id))
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
addTag(id) {
|
||||
this.value = [...this.value, id]
|
||||
this.onChange(this.value)
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,5 +1,8 @@
|
||||
<div class="form-group">
|
||||
<label [for]="inputId">{{title}}</label>
|
||||
<input type="text" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)">
|
||||
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)">
|
||||
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
</div>
|
@@ -1,6 +1,5 @@
|
||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Component, forwardRef } from '@angular/core';
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { AbstractInputComponent } from '../abstract-input';
|
||||
|
||||
@Component({
|
||||
|
@@ -1,9 +1,9 @@
|
||||
<div class="row pt-3 pb-1 mb-3 border-bottom align-items-center" >
|
||||
<div class="row pt-3 pb-3 pb-md-1 mb-3 border-bottom align-items-center">
|
||||
<div class="col-md text-truncate">
|
||||
<p class="h2 text-truncate" style="line-height: 1.4">{{title}}</p>
|
||||
<p *ngIf="subTitle" class="h5 text-truncate" style="line-height: 1.4">{{subTitle}}</p>
|
||||
</div>
|
||||
<div class="btn-toolbar col-auto">
|
||||
<div class="btn-toolbar col col-md-auto">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,21 +1,29 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { environment } from 'src/environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-header',
|
||||
templateUrl: './page-header.component.html',
|
||||
styleUrls: ['./page-header.component.scss']
|
||||
})
|
||||
export class PageHeaderComponent implements OnInit {
|
||||
export class PageHeaderComponent {
|
||||
|
||||
constructor() { }
|
||||
constructor(private titleService: Title) { }
|
||||
|
||||
_title = ""
|
||||
|
||||
@Input()
|
||||
title: string = ""
|
||||
set title(title: string) {
|
||||
this._title = title
|
||||
this.titleService.setTitle(`${this.title} - ${environment.appTitle}`)
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this._title
|
||||
}
|
||||
|
||||
@Input()
|
||||
subTitle: string = ""
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -0,0 +1,15 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<app-input-select [items]="objects" [title]="message" [(ngModel)]="selected"></app-input-select>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" i18n>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="selectClicked.emit(selected)" i18n>Select</button>
|
||||
</div>
|
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SelectDialogComponent } from './select-dialog.component';
|
||||
|
||||
describe('SelectDialogComponent', () => {
|
||||
let component: SelectDialogComponent;
|
||||
let fixture: ComponentFixture<SelectDialogComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ SelectDialogComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SelectDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,34 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id';
|
||||
|
||||
@Component({
|
||||
selector: 'app-select-dialog',
|
||||
templateUrl: './select-dialog.component.html',
|
||||
styleUrls: ['./select-dialog.component.scss']
|
||||
})
|
||||
|
||||
export class SelectDialogComponent implements OnInit {
|
||||
constructor(public activeModal: NgbActiveModal) { }
|
||||
|
||||
@Output()
|
||||
public selectClicked = new EventEmitter()
|
||||
|
||||
@Input()
|
||||
title = $localize`Select`
|
||||
|
||||
@Input()
|
||||
message = $localize`Please select an object`
|
||||
|
||||
@Input()
|
||||
objects: ObjectWithId[] = []
|
||||
|
||||
selected: number
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
cancelClicked() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { TAG_COLOURS, PaperlessTag } from 'src/app/data/paperless-tag';
|
||||
|
||||
@Component({
|
||||
|
@@ -3,5 +3,6 @@
|
||||
[header]="toast.title" [autohide]="true" [delay]="toast.delay"
|
||||
[class]="toast.classname"
|
||||
(hide)="toastService.closeToast(toast)">
|
||||
{{toast.content}}
|
||||
</ngb-toast>
|
||||
<p>{{toast.content}}</p>
|
||||
<p *ngIf="toast.action"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p>
|
||||
</ngb-toast>
|
||||
|
Reference in New Issue
Block a user