partial selection model implementation

This commit is contained in:
jonaswinkler 2020-12-27 23:55:19 +01:00
parent 80420a99f5
commit b8e7506de4
7 changed files with 199 additions and 209 deletions

View File

@ -30,9 +30,15 @@ export class DateDropdownComponent implements OnInit, OnDestroy {
@Input()
dateBefore: string
@Output()
dateBeforeChange = new EventEmitter<string>()
@Input()
dateAfter: string
@Output()
dateAfterChange = new EventEmitter<string>()
@Input()
title: string
@ -83,6 +89,8 @@ export class DateDropdownComponent implements OnInit, OnDestroy {
}
onChange() {
this.dateAfterChange.emit(this.dateAfter)
this.dateBeforeChange.emit(this.dateBefore)
this.datesSet.emit({after: this.dateAfter, before: this.dateBefore})
}
@ -91,12 +99,12 @@ export class DateDropdownComponent implements OnInit, OnDestroy {
}
clearBefore() {
this.dateBefore = null;
this.dateBefore = null
this.onChange()
}
clearAfter() {
this.dateAfter = null;
this.dateAfter = null
this.onChange()
}

View File

@ -1,14 +1,14 @@
<div class="btn-group" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="type !== types.Editing && itemsSelected?.length > 0 ? 'btn-primary' : 'btn-outline-primary'">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="type !== types.Editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'">
<div class="d-none d-md-inline">{{title}}</div>
<div class="d-inline-block d-md-none">
<svg class="toolbaricon" fill="currentColor">
<use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" />
</svg>
</div>
<ng-container *ngIf="type !== types.Editing && itemsSelected?.length > 0">
<ng-container *ngIf="type !== types.Editing && selectionModel.selectionSize() > 0">
<div class="badge bg-secondary text-light rounded-pill badge-corner">
{{itemsSelected?.length}}
{{selectionModel.selectionSize()}}
</div>
</ng-container>
</button>
@ -19,9 +19,9 @@
<input class="form-control" type="text" [(ngModel)]="filterText" placeholder="Filter {{title}}" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
</div>
</div>
<div *ngIf="toggleableItems" class="items">
<ng-container *ngFor="let toggleableItem of toggleableItems | filter: filterText">
<app-toggleable-dropdown-button [toggleableItem]="toggleableItem" (toggle)="toggleItem($event)"></app-toggleable-dropdown-button>
<div *ngIf="selectionModel.items" class="items">
<ng-container *ngFor="let toggleableItem of selectionModel.items | filter: filterText">
<app-toggleable-dropdown-button [toggleableItem]="toggleableItem" (toggle)="selectionModel.toggle(toggleableItem.item)"></app-toggleable-dropdown-button>
</ng-container>
</div>
<button *ngIf="type == types.Editing" class="list-group-item list-group-item-action bg-light" (click)="dropdown.close()" [disabled]="!hasBeenToggled || (toggleableItems | filter: filterText).length == 0">

View File

@ -3,12 +3,55 @@ import { FilterPipe } from 'src/app/pipes/filter.pipe';
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { ToggleableItem, ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component';
import { MatchingModel } from 'src/app/data/matching-model';
import { Subject } from 'rxjs';
export enum FilterableDropdownType {
Filtering = 'filtering',
Editing = 'editing'
}
export class FilterableDropdownSelectionModel {
changed = new Subject<FilterableDropdownSelectionModel>()
multiple = false
items: ToggleableItem[] = []
getSelected() {
return this.items.filter(i => i.state == ToggleableItemState.Selected).map(i => i.item)
}
toggle(item: MatchingModel, fireEvent = true) {
console.log("TOGGLE TAG")
let toggleableItem = this.items.find(i => i.item == item)
console.log(toggleableItem)
if (toggleableItem) {
if (toggleableItem.state == ToggleableItemState.Selected) {
toggleableItem.state = ToggleableItemState.NotSelected
} else {
this.items.forEach(i => {
if (i.item == item) {
i.state = ToggleableItemState.Selected
} else if (!this.multiple) {
i.state = ToggleableItemState.NotSelected
}
})
}
if (fireEvent) {
this.changed.next(this)
}
}
}
selectionSize() {
return this.getSelected().length
}
}
@Component({
selector: 'app-filterable-dropdown',
templateUrl: './filterable-dropdown.component.html',
@ -24,33 +67,45 @@ export class FilterableDropdownComponent {
@Input()
set items(items: MatchingModel[]) {
if (items) {
this._toggleableItems = items.map(i => {
this._selectionModel.items = items.map(i => {
return {item: i, state: ToggleableItemState.NotSelected, count: i.document_count}
})
}
}
_toggleableItems: ToggleableItem[] = []
@Input()
set toggleableItems (toggleableItems: ToggleableItem[]) {
if (this.type == FilterableDropdownType.Editing && this.dropdown?.isOpen()) return
else this._toggleableItems = toggleableItems
get items(): MatchingModel[] {
return this._selectionModel.items.map(i => i.item)
}
get toggleableItems(): ToggleableItem[] {
return this._toggleableItems
}
_selectionModel = new FilterableDropdownSelectionModel()
@Input()
set itemsSelected(itemsSelected: MatchingModel[]) {
this.toggleableItems.forEach(i => {
i.state = (itemsSelected.find(is => is.id == i.item.id)) ? ToggleableItemState.Selected : ToggleableItemState.NotSelected
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 itemsSelected(): MatchingModel[] {
return this.toggleableItems.filter(ti => ti.state == ToggleableItemState.Selected).map(ti => ti.item)
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()
@ -64,50 +119,40 @@ export class FilterableDropdownComponent {
types = FilterableDropdownType
@Input()
singular: boolean = false
@Output()
toggle = new EventEmitter()
@Output()
open = new EventEmitter()
@Output()
editingComplete = new EventEmitter()
hasBeenToggled:boolean = false
constructor(private filterPipe: FilterPipe) { }
constructor(private filterPipe: FilterPipe) {
this.selectionModel = new FilterableDropdownSelectionModel()
}
toggleItem(toggleableItem: ToggleableItem): void {
if (this.singular && toggleableItem.state == ToggleableItemState.Selected) {
this._toggleableItems.filter(ti => ti.item.id !== toggleableItem.item.id).forEach(ti => ti.state = ToggleableItemState.NotSelected)
}
this.hasBeenToggled = true
this.toggle.emit(toggleableItem.item)
// if (this.singular && toggleableItem.state == ToggleableItemState.Selected) {
// this.selectionModel.items.filter(ti => ti.item.id !== toggleableItem.item.id).forEach(ti => ti.state = ToggleableItemState.NotSelected)
// }
// this.hasBeenToggled = true
// this.toggle.emit(toggleableItem.item)
}
dropdownOpenChange(open: boolean): void {
if (open) {
setTimeout(() => {
this.listFilterTextInput.nativeElement.focus();
}, 0)
this.hasBeenToggled = false
this.open.next()
} else {
this.filterText = ''
if (this.type == FilterableDropdownType.Editing) this.editingComplete.emit(this.toggleableItems)
}
// if (open) {
// setTimeout(() => {
// this.listFilterTextInput.nativeElement.focus();
// }, 0)
// this.hasBeenToggled = false
// this.open.next()
// } else {
// this.filterText = ''
// if (this.type == FilterableDropdownType.Editing) this.editingComplete.emit(this.toggleableItems)
// }
}
listFilterEnter(): void {
let filtered = this.filterPipe.transform(this.toggleableItems, this.filterText)
if (filtered.length == 1) {
let toggleableItem = this.toggleableItems.find(ti => ti.item.id == filtered[0].item.id)
if (toggleableItem) toggleableItem.state = ToggleableItemState.Selected
this.toggleItem(filtered[0])
this.dropdown.close()
}
// let filtered = this.filterPipe.transform(this.toggleableItems, this.filterText)
// if (filtered.length == 1) {
// let toggleableItem = this.toggleableItems.find(ti => ti.item.id == filtered[0].item.id)
// if (toggleableItem) toggleableItem.state = ToggleableItemState.Selected
// this.toggleItem(filtered[0])
// this.dropdown.close()
// }
}
}

View File

@ -31,8 +31,7 @@ export class ToggleableDropdownButtonComponent {
}
toggleItem(): void {
this.toggleableItem.state = (this.toggleableItem.state == ToggleableItemState.NotSelected || this.toggleableItem.state == ToggleableItemState.PartiallySelected) ? ToggleableItemState.Selected : ToggleableItemState.NotSelected
this.toggle.emit(this.toggleableItem)
this.toggle.emit()
}
getSelectedIconName() {

View File

@ -1,4 +1,4 @@
<div class="row">
<!-- <div class="row">
<div class="col-auto mb-2 mb-xl-0" role="group" aria-label="Select">
<button class="btn btn-sm btn-outline-danger" (click)="documentList.selectNone()">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
@ -58,4 +58,4 @@
Delete
</button>
</div>
</div>
</div> -->

View File

@ -8,11 +8,11 @@
<div class="w-100 d-xl-none"></div>
<div class="col col-xl-auto mb-2 mb-xl-0">
<div class="d-flex">
<app-filterable-dropdown class="mr-2 mr-md-3" [items]="tags" [itemsSelected]="selectedTags" title="Tags" icon="tag-fill" (toggle)="toggleTag($event.id)"></app-filterable-dropdown>
<app-filterable-dropdown class="mr-2 mr-md-3" [items]="correspondents" [itemsSelected]="selectedCorrespondents" title="Correspondents" icon="person-fill" (toggle)="toggleCorrespondent($event.id)"></app-filterable-dropdown>
<app-filterable-dropdown class="mr-2 mr-md-3" [items]="documentTypes" [itemsSelected]="selectedDocumentTypes" title="Document types" icon="file-earmark-fill" (toggle)="toggleDocumentType($event.id)"></app-filterable-dropdown>
<app-date-dropdown class="mr-2 mr-md-3" [dateBefore]="dateCreatedBefore" [dateAfter]="dateCreatedAfter" title="Created" (datesSet)="onDatesCreatedSet($event)"></app-date-dropdown>
<app-date-dropdown [dateBefore]="dateAddedBefore" [dateAfter]="dateAddedAfter" title="Added" (datesSet)="onDatesAddedSet($event)"></app-date-dropdown>
<app-filterable-dropdown class="mr-2 mr-md-3" [items]="tags" [(selectionModel)]="tagSelectionModel" (selectionModelChange)="updateRules()" [multiple]="true" title="Tags" icon="tag-fill"></app-filterable-dropdown>
<app-filterable-dropdown class="mr-2 mr-md-3" [items]="correspondents" [(selectionModel)]="correspondentSelectionModel" (selectionModelChange)="updateRules()" title="Correspondents" icon="person-fill"></app-filterable-dropdown>
<app-filterable-dropdown class="mr-2 mr-md-3" [items]="documentTypes" [(selectionModel)]="documentTypeSelectionModel" (selectionModelChange)="updateRules()" title="Document types" icon="file-earmark-fill"></app-filterable-dropdown>
<app-date-dropdown class="mr-2 mr-md-3" [(dateBefore)]="dateCreatedBefore" [(dateAfter)]="dateCreatedAfter" title="Created" (datesSet)="updateRules()"></app-date-dropdown>
<app-date-dropdown [(dateBefore)]="dateAddedBefore" [(dateAfter)]="dateAddedAfter" title="Added" (datesSet)="updateRules()"></app-date-dropdown>
</div>
</div>
<div class="w-100 d-xl-none"></div>

View File

@ -3,14 +3,13 @@ import { PaperlessTag } from 'src/app/data/paperless-tag';
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { NgbDateParserFormatter } from '@ng-bootstrap/ng-bootstrap';
import { debounceTime, distinctUntilChanged, filter, flatMap, mergeMap } from 'rxjs/operators';
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
import { TagService } from 'src/app/services/rest/tag.service';
import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
import { FilterRule } from 'src/app/data/filter-rule';
import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_RULE_TYPES, FILTER_TITLE } from 'src/app/data/filter-rule-type';
import { DateSelection } from 'src/app/components/common/date-dropdown/date-dropdown.component';
import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component';
@Component({
selector: 'app-filter-editor',
@ -46,37 +45,91 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
) { }
tags: PaperlessTag[] = []
correspondents: PaperlessCorrespondent[]
correspondents: PaperlessCorrespondent[] = []
documentTypes: PaperlessDocumentType[] = []
_titleFilter = ""
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string
dateCreatedAfter: string
dateAddedBefore: string
dateAddedAfter: string
@Input()
filterRules: FilterRule[]
set filterRules (value: FilterRule[]) {
console.log("SET FILTER RULES")
value.forEach(rule => {
switch (rule.rule_type) {
case FILTER_TITLE:
this._titleFilter = rule.value
break
case FILTER_CREATED_AFTER:
this.dateCreatedAfter = rule.value
break
case FILTER_CREATED_BEFORE:
this.dateCreatedBefore = rule.value
break
case FILTER_ADDED_AFTER:
this.dateAddedAfter = rule.value
break
case FILTER_ADDED_BEFORE:
this.dateAddedBefore = rule.value
break
}
})
this.tagService.getCachedMany(value.filter(v => v.rule_type == FILTER_HAS_TAG).map(rule => +rule.value)).subscribe(tags => {
console.log(tags)
tags.forEach(tag => this.tagSelectionModel.toggle(tag, false))
})
}
@Output()
filterRulesChange = new EventEmitter<FilterRule[]>()
updateRules() {
console.log("UPDATE RULES!!!")
let filterRules: FilterRule[] = []
if (this._titleFilter) {
filterRules.push({rule_type: FILTER_TITLE, value: this._titleFilter})
}
this.tagSelectionModel.getSelected().forEach(tag => {
filterRules.push({rule_type: FILTER_HAS_TAG, value: tag.id.toString()})
})
this.correspondentSelectionModel.getSelected().forEach(correspondent => {
filterRules.push({rule_type: FILTER_CORRESPONDENT, value: correspondent.id.toString()})
})
this.documentTypeSelectionModel.getSelected().forEach(documentType => {
filterRules.push({rule_type: FILTER_DOCUMENT_TYPE, value: documentType.id.toString()})
})
if (this.dateCreatedBefore) {
filterRules.push({rule_type: FILTER_CREATED_BEFORE, value: this.dateCreatedBefore})
}
if (this.dateCreatedAfter) {
filterRules.push({rule_type: FILTER_CREATED_AFTER, value: this.dateCreatedAfter})
}
if (this.dateAddedBefore) {
filterRules.push({rule_type: FILTER_ADDED_BEFORE, value: this.dateAddedBefore})
}
if (this.dateAddedAfter) {
filterRules.push({rule_type: FILTER_ADDED_AFTER, value: this.dateAddedAfter})
}
console.log(filterRules)
this.filterRulesChange.next(filterRules)
}
hasFilters() {
return this.filterRules.length > 0
}
get selectedTags(): PaperlessTag[] {
let tagRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_HAS_TAG)
return this.tags?.filter(t => tagRules.find(tr => +tr.value == t.id))
}
get selectedCorrespondents(): PaperlessCorrespondent[] {
let correspondentRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_CORRESPONDENT)
return this.correspondents?.filter(c => correspondentRules.find(cr => +cr.value == c.id))
}
get selectedDocumentTypes(): PaperlessDocumentType[] {
let documentTypeRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_DOCUMENT_TYPE)
return this.documentTypes?.filter(dt => documentTypeRules.find(dtr => +dtr.value == dt.id))
return this._titleFilter ||
this.dateCreatedAfter || this.dateAddedBefore || this.dateCreatedAfter || this.dateCreatedBefore ||
this.tagSelectionModel.selectionSize() || this.correspondentSelectionModel.selectionSize() || this.documentTypeSelectionModel.selectionSize()
}
get titleFilter() {
let existingRule = this.filterRules.find(rule => rule.rule_type == FILTER_TITLE)
return existingRule ? existingRule.value : ''
return this._titleFilter
}
set titleFilter(value) {
@ -97,142 +150,27 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
debounceTime(400),
distinctUntilChanged()
).subscribe(title => {
this.setTitleRule(title)
this._titleFilter = title
this.updateRules()
})
}
ngOnDestroy() {
this.titleFilterDebounce.complete()
// TODO: not sure if both is necessary
this.subscription.unsubscribe()
}
applyFilters() {
this.filterRulesChange.next(this.filterRules)
}
clearSelected() {
this.filterRules = []
this.applyFilters()
}
private toggleFilterRule(filterRuleTypeID: number, value: number) {
let filterRuleType = FILTER_RULE_TYPES.find(t => t.id == filterRuleTypeID)
let existingRule = this.filterRules.find(rule => rule.rule_type == filterRuleTypeID && rule.value == value?.toString())
let existingRuleOfSameType = this.filterRules.find(rule => rule.rule_type == filterRuleTypeID)
if (existingRule) {
// if this exact rule already exists, remove it in all cases.
this.filterRules.splice(this.filterRules.indexOf(existingRule), 1)
} else if (filterRuleType.multi || !existingRuleOfSameType) {
// if we allow multiple rules per type, or no rule of this type already exists, push a new rule.
this.filterRules.push({rule_type: filterRuleTypeID, value: value?.toString()})
} else {
// otherwise (i.e., no multi support AND there's already a rule of this type), update the rule.
existingRuleOfSameType.value = value?.toString()
}
this.applyFilters()
}
private setTitleRule(title: string) {
let existingRule = this.filterRules.find(rule => rule.rule_type == FILTER_TITLE)
if (!existingRule && title) {
this.filterRules.push({rule_type: FILTER_TITLE, value: title})
} else if (existingRule && !title) {
this.filterRules.splice(this.filterRules.findIndex(rule => rule.rule_type == FILTER_TITLE), 1)
} else if (existingRule && title) {
existingRule.value = title
}
this.applyFilters()
this._titleFilter = ""
this.updateRules()
}
toggleTag(tagId: number) {
this.toggleFilterRule(FILTER_HAS_TAG, tagId)
}
toggleCorrespondent(correspondentId: number) {
this.toggleFilterRule(FILTER_CORRESPONDENT, correspondentId)
}
toggleDocumentType(documentTypeId: number) {
this.toggleFilterRule(FILTER_DOCUMENT_TYPE, documentTypeId)
}
// Date handling
onDatesCreatedSet(dates: DateSelection) {
this.setDateCreatedBefore(dates.before)
this.setDateCreatedAfter(dates.after)
this.applyFilters()
}
onDatesAddedSet(dates: DateSelection) {
this.setDateAddedBefore(dates.before)
this.setDateAddedAfter(dates.after)
this.applyFilters()
}
get dateCreatedBefore(): string {
let createdBeforeRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_CREATED_BEFORE)
return createdBeforeRule ? createdBeforeRule.value : null
}
get dateCreatedAfter(): string {
let createdAfterRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_CREATED_AFTER)
return createdAfterRule ? createdAfterRule.value : null
}
get dateAddedBefore(): string {
let addedBeforeRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_ADDED_BEFORE)
return addedBeforeRule ? addedBeforeRule.value : null
}
get dateAddedAfter(): string {
let addedAfterRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_ADDED_AFTER)
return addedAfterRule ? addedAfterRule.value : null
}
setDateCreatedBefore(date?: string) {
if (date) this.setDateFilter(date, FILTER_CREATED_BEFORE)
else this.clearDateFilter(FILTER_CREATED_BEFORE)
}
setDateCreatedAfter(date?: string) {
if (date) this.setDateFilter(date, FILTER_CREATED_AFTER)
else this.clearDateFilter(FILTER_CREATED_AFTER)
}
setDateAddedBefore(date?: string) {
if (date) this.setDateFilter(date, FILTER_ADDED_BEFORE)
else this.clearDateFilter(FILTER_ADDED_BEFORE)
}
setDateAddedAfter(date?: string) {
if (date) this.setDateFilter(date, FILTER_ADDED_AFTER)
else this.clearDateFilter(FILTER_ADDED_AFTER)
}
setDateFilter(date: string, dateRuleTypeID: number) {
let existingRule = this.filterRules.find(rule => rule.rule_type == dateRuleTypeID)
if (existingRule) {
existingRule.value = date
} else {
this.filterRules.push({rule_type: dateRuleTypeID, value: date})
}
}
clearDateFilter(dateRuleTypeID: number) {
let ruleIndex = this.filterRules.findIndex(rule => rule.rule_type == dateRuleTypeID)
if (ruleIndex != -1) {
this.filterRules.splice(ruleIndex, 1)
}
}
}