Prepare explorer page

This commit is contained in:
Martin Tan 2023-05-18 02:23:09 +08:00
parent 5f33ff941e
commit 62d6c30ee1
9 changed files with 1557 additions and 7 deletions

View File

@ -1,22 +1,23 @@
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
import { RouterModule, Routes } from '@angular/router'
import { AppFrameComponent } from './components/app-frame/app-frame.component'
import { DashboardComponent } from './components/dashboard/dashboard.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { DocumentListComponent } from './components/document-list/document-list.component'
import { ExplorerComponent } from './components/explorer/explorer.component'
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
import { LogsComponent } from './components/manage/logs/logs.component'
import { SettingsComponent } from './components/manage/settings/settings.component'
import { TagListComponent } from './components/manage/tag-list/tag-list.component'
import { NotFoundComponent } from './components/not-found/not-found.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DirtyFormGuard } from './guards/dirty-form.guard'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
import { TagListComponent } from './components/manage/tag-list/tag-list.component'
import { TasksComponent } from './components/manage/tasks/tasks.component'
import { PermissionsGuard } from './guards/permissions.guard'
import { NotFoundComponent } from './components/not-found/not-found.component'
import { DirtyDocGuard } from './guards/dirty-doc.guard'
import { DirtyFormGuard } from './guards/dirty-form.guard'
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
import { PermissionsGuard } from './guards/permissions.guard'
import {
PermissionAction,
PermissionType,
@ -30,6 +31,18 @@ const routes: Routes = [
canDeactivate: [DirtyDocGuard],
children: [
{ path: 'dashboard', component: DashboardComponent },
{
path: 'explorer',
component: ExplorerComponent,
canDeactivate: [DirtySavedViewGuard],
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Document,
},
},
},
{
path: 'documents',
component: DocumentListComponent,

View File

@ -9,6 +9,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'
import { DocumentListComponent } from './components/document-list/document-list.component'
import { ExplorerComponent } from './components/explorer/explorer.component'
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { DashboardComponent } from './components/dashboard/dashboard.component'
import { TagListComponent } from './components/manage/tag-list/tag-list.component'
@ -29,6 +30,7 @@ import { PageHeaderComponent } from './components/common/page-header/page-header
import { AppFrameComponent } from './components/app-frame/app-frame.component'
import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
import { FilterEditorComponent as ExplorerFilterEditorComponent } from './components/explorer/filter-editor/filter-editor.component'
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'
@ -144,6 +146,7 @@ function initializeApp(settings: SettingsService) {
declarations: [
AppComponent,
DocumentListComponent,
ExplorerComponent,
DocumentDetailComponent,
DashboardComponent,
TagListComponent,
@ -164,6 +167,7 @@ function initializeApp(settings: SettingsService) {
AppFrameComponent,
ToastsComponent,
FilterEditorComponent,
ExplorerFilterEditorComponent,
FilterableDropdownComponent,
ToggleableDropdownButtonComponent,
DateDropdownComponent,

View File

@ -72,9 +72,16 @@
</li>
<li class="nav-item" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#search"/>
</svg><span>&nbsp;<ng-container i18n>Search</ng-container></span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="explorer" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Explorer" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#files"/>
</svg><span>&nbsp;<ng-container i18n>Documents</ng-container></span>
</svg><span>&nbsp;<ng-container i18n>File Explorer</ng-container></span>
</a>
</li>
</ul>

View File

@ -0,0 +1,233 @@
<app-page-header [title]="getTitle()">
<div ngbDropdown class="me-2 d-flex">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#text-indent-left" />
</svg>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
</div>
</div>
<div class="btn-group flex-fill" role="group">
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails" name="displayModeDetails">
<label for="displayModeDetails" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#list-ul" />
</svg>
</label>
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall" name="displayModeSmall">
<label for="displayModeSmall" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#grid" />
</svg>
</label>
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge" name="displayModeLarge">
<label for="displayModeLarge" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#hdd-stack" />
</svg>
</label>
</div>
<div ngbDropdown class="btn-group ms-2 flex-fill">
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
<div class="w-100 d-flex pb-2 mb-1 border-bottom">
<input type="radio" class="btn-check" [value]="false" [(ngModel)]="listSortReverse" id="listSortReverseFalse">
<label class="btn btn-outline-primary btn-sm mx-2 flex-fill" for="listSortReverseFalse">
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" />
</svg>
</label>
<input type="radio" class="btn-check" [value]="true" [(ngModel)]="listSortReverse" id="listSortReverseTrue">
<label class="btn btn-outline-primary btn-sm me-2 flex-fill" for="listSortReverseTrue">
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" />
</svg>
</label>
</div>
<div>
<button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="setSortField(f.field)"
[class.active]="list.sortField === f.field">{{f.name}}
</button>
</div>
</div>
</div>
<div class="btn-group ms-2 flex-fill" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle>
<ng-container i18n>Views</ng-container>
<div *ngIf="savedViewIsModified" class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle">
<span class="visually-hidden">selected</span>
</div>
</button>
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
<ng-container *ngIf="!list.activeSavedViewId">
<button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view.id)">{{view.name}}</button>
<div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div>
</ng-container>
</div>
</div>
</app-page-header>
<div class="row sticky-top pt-3 pt-sm-4 pb-2 pb-lg-4 bg-body">
<app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></app-filter-editor>
<app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor>
</div>
<ng-template #pagination>
<div class="d-flex justify-content-between align-items-center">
<p>
<ng-container *ngIf="list.isReloading">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</ng-container>
<span i18n *ngIf="list.selected.size > 0">{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span>
<ng-container *ngIf="!list.isReloading">
<span i18n *ngIf="list.selected.size === 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>&nbsp;<span i18n *ngIf="isFiltered">(filtered)</span>
</ng-container>
</p>
<ngb-pagination *ngIf="list.collectionSize" [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
[rotate]="true" aria-label="Default pagination"></ngb-pagination>
</div>
</ng-template>
<div tourAnchor="tour.documents">
<ng-container *ngTemplateOutlet="pagination"></ng-container>
</div>
<ng-container *ngIf="list.error ; else documentListNoError">
<div class="alert alert-danger" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{list.error}}</div>
</ng-container>
<ng-template #documentListNoError>
<div *ngIf="displayMode === 'largeCards'">
<app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" (dblClickDocument)="openDocumentDetail(d)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)">
</app-document-card-large>
</div>
<table class="table table-sm align-middle border shadow-sm" *ngIf="displayMode === 'details'">
<thead>
<th></th>
<th class="d-none d-lg-table-cell"
appSortable="archive_serial_number"
title="Sort by ASN" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>ASN</th>
<th class="d-none d-md-table-cell"
appSortable="correspondent__name"
title="Sort by correspondent" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Correspondent</th>
<th
appSortable="title"
title="Sort by title" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Title</th>
<th *ngIf="notesEnabled" class="d-none d-xl-table-cell"
appSortable="num_notes"
title="Sort by notes" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Notes</th>
<th class="d-none d-xl-table-cell"
appSortable="document_type__name"
title="Sort by document type" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Document type</th>
<th class="d-none d-xl-table-cell"
appSortable="storage_path__name"
title="Sort by storage path" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Storage path</th>
<th
appSortable="created"
title="Sort by created date" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Created</th>
<th class="d-none d-xl-table-cell"
appSortable="added"
title="Sort by added date" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Added</th>
</thead>
<tbody>
<tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" (click)="toggleSelected(d, $event); $event.stopPropagation();" (dblclick)="openDocumentDetail(d)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
<td>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (click)="toggleSelected(d, $event); $event.stopPropagation();">
<label class="form-check-label" for="docCheck{{d.id}}"></label>
</div>
</td>
<td class="d-none d-lg-table-cell">
{{d.archive_serial_number}}
</td>
<td class="d-none d-md-table-cell">
<ng-container *ngIf="d.correspondent">
<a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a>
</ng-container>
</td>
<td>
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
</td>
<td *ngIf="notesEnabled" class="d-none d-xl-table-cell">
<a *ngIf="d.notes.length" routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0">
<span class="badge rounded-pill bg-light border text-primary">
<svg class="metadata-icon ms-1 me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg>
{{d.notes.length}}</span>
</a>
</td>
<td class="d-none d-xl-table-cell">
<ng-container *ngIf="d.document_type">
<a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a>
</ng-container>
</td>
<td class="d-none d-xl-table-cell">
<ng-container *ngIf="d.storage_path">
<a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path" i18n-title>{{(d.storage_path$ | async)?.name}}</a>
</ng-container>
</td>
<td>
{{d.created_date | customDate}}
</td>
<td class="d-none d-xl-table-cell">
{{d.added | customDate}}
</td>
</tr>
</tbody>
</table>
<div class="row row-cols-paperless-cards" *ngIf="displayMode === 'smallCards'">
<app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" (dblClickDocument)="openDocumentDetail(d)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small>
</div>
<div *ngIf="list.documents?.length > 15" class="mt-3">
<ng-container *ngTemplateOutlet="pagination"></ng-container>
</div>
</ng-template>

View File

@ -0,0 +1,82 @@
::ng-deep app-document-list app-page-header > div.mb-3 {
margin-bottom: 0 !important;
}
tr {
user-select: none;
}
th {
cursor: pointer;
}
.table-row-selected {
background-color: var(--pngx-primary-faded);
}
$paperless-card-breakpoints: (
// 0: 2, // xs is manual for slim-sidebar
768px: 3, //md
992px: 4, //lg
1200px: 5, //xl
1400px: 6, // xxl
1600px: 7,
1800px: 8,
2000px: 9
);
.row-cols-paperless-cards {
// xs, we dont want in .col-slim block
> * {
flex: 0 0 auto;
width: calc(100% / 2);
}
@each $width, $n_cols in $paperless-card-breakpoints {
@media(min-width: $width) {
> * {
flex: 0 0 auto;
width: calc(100% / $n-cols);
}
}
}
}
::ng-deep .col-slim .row-cols-paperless-cards {
@each $width, $n_cols in $paperless-card-breakpoints {
@media(min-width: $width) {
> * {
flex: 0 0 auto;
width: calc(100% / ($n-cols + 1)) !important;
}
}
}
}
.dropdown-menu-right {
right: 0 !important;
left: auto !important;
}
.sticky-top {
z-index: 990; // below main navbar
top: calc(7rem - 2px); // height of navbar (mobile)
@media (min-width: 580px) {
top: 3.5rem; // height of navbar
}
}
.table .form-check {
padding: 0.2rem;
min-height: 0;
margin-bottom: 0;
.form-check-input {
margin-left: 0;
}
}
a {
cursor: pointer;
}

View File

@ -0,0 +1,243 @@
import {
Component,
OnDestroy,
OnInit,
QueryList,
ViewChild,
ViewChildren,
} from '@angular/core'
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
import {
FilterRule,
filterRulesDiffer,
isFullTextFilterRule,
} from 'src/app/data/filter-rule'
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import {
SortableDirective,
SortEvent,
} from 'src/app/directives/sortable.directive'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
DOCUMENT_SORT_FIELDS,
DOCUMENT_SORT_FIELDS_FULLTEXT,
} from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { FilterEditorComponent } from './filter-editor/filter-editor.component'
@Component({
selector: 'app-explorer',
templateUrl: './explorer.component.html',
styleUrls: ['./explorer.component.scss'],
})
export class ExplorerComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
constructor(
public list: DocumentListViewService,
public savedViewService: SavedViewService,
public route: ActivatedRoute,
private router: Router,
private toastService: ToastService,
private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService,
private settingsService: SettingsService
) {
super()
}
@ViewChild('filterEditor')
private filterEditor: FilterEditorComponent
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
displayMode = 'smallCards' // largeCards, smallCards, details
unmodifiedFilterRules: FilterRule[] = []
private unmodifiedSavedView: PaperlessSavedView
private unsubscribeNotifier: Subject<any> = new Subject()
get savedViewIsModified(): boolean {
if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false
else {
return (
this.unmodifiedSavedView.sort_field !== this.list.sortField ||
this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse ||
filterRulesDiffer(
this.unmodifiedSavedView.filter_rules,
this.list.filterRules
)
)
}
}
get isFiltered() {
return this.list.filterRules?.length > 0
}
getTitle() {
let title = this.list.activeSavedViewTitle
if (title && this.savedViewIsModified) {
title += '*'
} else if (!title) {
title = $localize`File Explorer`
}
return title
}
getSortFields() {
return isFullTextFilterRule(this.list.filterRules)
? DOCUMENT_SORT_FIELDS_FULLTEXT
: DOCUMENT_SORT_FIELDS
}
set listSortReverse(reverse: boolean) {
this.list.sortReverse = reverse
}
get listSortReverse(): boolean {
return this.list.sortReverse
}
setSortField(field: string) {
this.list.sortField = field
}
onSort(event: SortEvent) {
this.list.setSort(event.column, event.reverse)
}
get isBulkEditing(): boolean {
return this.list.selected.size > 0
}
saveDisplayMode() {
localStorage.setItem('document-list:displayMode', this.displayMode)
}
ngOnInit(): void {
if (localStorage.getItem('document-list:displayMode') != null) {
this.displayMode = localStorage.getItem('document-list:displayMode')
}
this.consumerStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.list.reload()
})
this.route.paramMap
.pipe(
filter((params) => params.has('id')), // only on saved view e.g. /view/id
switchMap((params) => {
return this.savedViewService
.getCached(+params.get('id'))
.pipe(map((view) => ({ view })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ view }) => {
if (!view) {
this.router.navigate(['404'])
return
}
this.unmodifiedSavedView = view
this.list.activateSavedViewWithQueryParams(
view,
convertToParamMap(this.route.snapshot.queryParams)
)
// this.list.reload()
this.unmodifiedFilterRules = view.filter_rules
})
this.route.queryParamMap
.pipe(
filter(() => !this.route.snapshot.paramMap.has('id')), // only when not on /view/id
takeUntil(this.unsubscribeNotifier)
)
.subscribe((queryParams) => {
if (queryParams.has('view')) {
// loading a saved view on /documents
this.loadViewConfig(parseInt(queryParams.get('view')))
} else {
// this.list.activateSavedView(null)
// this.list.loadFromQueryParams(queryParams)
// this.unmodifiedFilterRules = []
}
})
}
ngOnDestroy() {
// unsubscribes all
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
loadViewConfig(viewID: number) {
this.savedViewService
.getCached(viewID)
.pipe(first())
.subscribe((view) => {
this.unmodifiedSavedView = view
this.list.activateSavedView(view)
this.list.reload()
})
}
openDocumentDetail(document: PaperlessDocument) {
this.router.navigate(['documents', document.id])
}
toggleSelected(document: PaperlessDocument, event: MouseEvent): void {
if (!event.shiftKey) this.list.toggleSelected(document)
else this.list.selectRangeTo(document)
}
clickTag(tagID: number) {
this.list.selectNone()
this.filterEditor.toggleTag(tagID)
}
clickCorrespondent(correspondentID: number) {
this.list.selectNone()
this.filterEditor.toggleCorrespondent(correspondentID)
}
clickDocumentType(documentTypeID: number) {
this.list.selectNone()
this.filterEditor.toggleDocumentType(documentTypeID)
}
clickStoragePath(storagePathID: number) {
this.list.selectNone()
this.filterEditor.toggleStoragePath(storagePathID)
}
clickMoreLike(documentID: number) {
this.list.quickFilter([
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() },
])
}
trackByDocumentId(index, item: PaperlessDocument) {
return item.id
}
get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}
}

View File

@ -0,0 +1,85 @@
<div class="row flex-wrap" tourAnchor="tour.documents-filter-editor">
<div class="col mb-2 mb-xxl-0">
<div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget === t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button>
</div>
</div>
<select *ngIf="textFilterTarget === 'asn'" class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
<option *ngFor="let m of textFilterModifiers" ngbDropdownItem [value]="m.id">{{m.label}}</option>
</select>
<button *ngIf="_textFilter" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x me-1" 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>
</button>
<input #textFilterInput class="form-control form-control-sm" type="text" [disabled]="textFilterModifierIsNull" [(ngModel)]="textFilter" (keyup)="textFilterKeyup($event)" [readonly]="textFilterTarget === 'fulltext-morelike'">
</div>
</div>
</div>
<div class="w-100 d-xxl-none"></div>
<div class="col col-xl-auto">
<div class="d-flex flex-wrap">
<div class="d-flex flex-wrap mb-2 mb-xxl-0">
<app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[manyToOne]="true"
[(selectionModel)]="tagSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onTagsDropdownOpen()"
[documentCounts]="tagDocumentCounts"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[(selectionModel)]="correspondentSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onCorrespondentDropdownOpen()"
[documentCounts]="correspondentDocumentCounts"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onDocumentTypeDropdownOpen()"
[documentCounts]="documentTypeDocumentCounts"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-filterable-dropdown class="me-2 flex-fill" title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[(selectionModel)]="storagePathSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onStoragePathDropdownOpen()"
[documentCounts]="storagePathDocumentCounts"
[allowSelectNone]="true"></app-filterable-dropdown>
</div>
<div class="d-flex flex-wrap">
<app-date-dropdown class="mb-2 mb-xl-0"
title="Created" i18n-title
(datesSet)="updateRules()"
[(dateBefore)]="dateCreatedBefore"
[(dateAfter)]="dateCreatedAfter"
[(relativeDate)]="dateCreatedRelativeDate"></app-date-dropdown>
<app-date-dropdown class="mb-2 mb-xl-0"
title="Added" i18n-title
(datesSet)="updateRules()"
[(dateBefore)]="dateAddedBefore"
[(dateAfter)]="dateAddedAfter"
[(relativeDate)]="dateAddedRelativeDate"></app-date-dropdown>
</div>
</div>
</div>
<div class="w-100 d-xxl-none"></div>
<div class="col col-xl-auto ps-xxl-0">
<button class="btn btn-link btn-sm px-0" [disabled]="!rulesModified" (click)="resetSelected()">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x me-1 ms-n1" 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><ng-container i18n>Reset filters</ng-container>
</button>
</div>
</div>

View File

@ -0,0 +1,27 @@
.quick-filter {
min-width: 250px;
max-height: 400px;
overflow-y: scroll;
.selected-icon {
min-width: 1em;
min-height: 1em;
}
}
.input-group .dropdown .btn {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.d-flex.flex-wrap {
column-gap: 0.7rem;
}
input[type="text"] {
min-width: 120px;
}
.z-10 {
z-index: 10;
}

View File

@ -0,0 +1,856 @@
import {
Component,
EventEmitter,
Input,
Output,
OnInit,
OnDestroy,
ViewChild,
ElementRef,
} from '@angular/core'
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, filter } 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 { filterRulesDiffer, FilterRule } from 'src/app/data/filter-rule'
import {
FILTER_ADDED_AFTER,
FILTER_ADDED_BEFORE,
FILTER_ASN,
FILTER_HAS_CORRESPONDENT_ANY,
FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE,
FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_FULLTEXT_MORELIKE,
FILTER_FULLTEXT_QUERY,
FILTER_HAS_ANY_TAG,
FILTER_HAS_TAGS_ALL,
FILTER_HAS_TAGS_ANY,
FILTER_DOES_NOT_HAVE_TAG,
FILTER_TITLE,
FILTER_TITLE_CONTENT,
FILTER_HAS_STORAGE_PATH_ANY,
FILTER_ASN_ISNULL,
FILTER_ASN_GT,
FILTER_ASN_LT,
FILTER_DOES_NOT_HAVE_CORRESPONDENT,
FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
FILTER_DOES_NOT_HAVE_STORAGE_PATH,
FILTER_DOCUMENT_TYPE,
FILTER_CORRESPONDENT,
FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
Intersection,
LogicalOperator,
} from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import {
DocumentService,
SelectionData,
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { RelativeDate } from '../../common/date-dropdown/date-dropdown.component'
const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
const TEXT_FILTER_TARGET_ASN = 'asn'
const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
const TEXT_FILTER_MODIFIER_NULL = 'is null'
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
const TEXT_FILTER_MODIFIER_GT = 'greater'
const TEXT_FILTER_MODIFIER_LT = 'less'
const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:\[([^\]]+)\]/g
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:\[([^\]]+)\]/g
const RELATIVE_DATE_QUERYSTRINGS = [
{
relativeDate: RelativeDate.LAST_7_DAYS,
dateQuery: '-1 week to now',
},
{
relativeDate: RelativeDate.LAST_MONTH,
dateQuery: '-1 month to now',
},
{
relativeDate: RelativeDate.LAST_3_MONTHS,
dateQuery: '-3 month to now',
},
{
relativeDate: RelativeDate.LAST_YEAR,
dateQuery: '-1 year to now',
},
]
@Component({
selector: 'app-explorer-filter-editor',
templateUrl: './filter-editor.component.html',
styleUrls: ['./filter-editor.component.scss'],
})
export class FilterEditorComponent implements OnInit, OnDestroy {
generateFilterName() {
if (this.filterRules.length == 1) {
let rule = this.filterRules[0]
switch (this.filterRules[0].rule_type) {
case FILTER_HAS_CORRESPONDENT_ANY:
if (rule.value) {
return $localize`Correspondent: ${
this.correspondents.find((c) => c.id == +rule.value)?.name
}`
} else {
return $localize`Without correspondent`
}
case FILTER_HAS_DOCUMENT_TYPE_ANY:
if (rule.value) {
return $localize`Type: ${
this.documentTypes.find((dt) => dt.id == +rule.value)?.name
}`
} else {
return $localize`Without document type`
}
case FILTER_HAS_TAGS_ALL:
return $localize`Tag: ${
this.tags.find((t) => t.id == +rule.value)?.name
}`
case FILTER_HAS_ANY_TAG:
if (rule.value == 'false') {
return $localize`Without any tag`
}
case FILTER_TITLE:
return $localize`Title: ${rule.value}`
case FILTER_ASN:
return $localize`ASN: ${rule.value}`
}
}
return ''
}
constructor(
private documentTypeService: DocumentTypeService,
private tagService: TagService,
private correspondentService: CorrespondentService,
private documentService: DocumentService,
private storagePathService: StoragePathService
) {}
@ViewChild('textFilterInput')
textFilterInput: ElementRef
tags: PaperlessTag[] = []
correspondents: PaperlessCorrespondent[] = []
documentTypes: PaperlessDocumentType[] = []
storagePaths: PaperlessStoragePath[] = []
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
_textFilter = ''
_moreLikeId: number
_moreLikeDoc: PaperlessDocument
get textFilterTargets() {
let targets = [
{ id: TEXT_FILTER_TARGET_TITLE, name: $localize`Title` },
{
id: TEXT_FILTER_TARGET_TITLE_CONTENT,
name: $localize`Title & content`,
},
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
{
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
name: $localize`Advanced search`,
},
]
if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) {
targets.push({
id: TEXT_FILTER_TARGET_FULLTEXT_MORELIKE,
name: $localize`More like`,
})
}
return targets
}
textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
get textFilterTargetName() {
return this.textFilterTargets.find((t) => t.id == this.textFilterTarget)
?.name
}
public textFilterModifier: string
get textFilterModifiers() {
return [
{
id: TEXT_FILTER_MODIFIER_EQUALS,
label: $localize`equals`,
},
{
id: TEXT_FILTER_MODIFIER_NULL,
label: $localize`is empty`,
},
{
id: TEXT_FILTER_MODIFIER_NOTNULL,
label: $localize`is not empty`,
},
{
id: TEXT_FILTER_MODIFIER_GT,
label: $localize`greater than`,
},
{
id: TEXT_FILTER_MODIFIER_LT,
label: $localize`less than`,
},
]
}
get textFilterModifierIsNull(): boolean {
return [TEXT_FILTER_MODIFIER_NULL, TEXT_FILTER_MODIFIER_NOTNULL].includes(
this.textFilterModifier
)
}
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string
dateCreatedAfter: string
dateAddedBefore: string
dateAddedAfter: string
dateCreatedRelativeDate: RelativeDate
dateAddedRelativeDate: RelativeDate
_unmodifiedFilterRules: FilterRule[] = []
_filterRules: FilterRule[] = []
@Input()
set unmodifiedFilterRules(value: FilterRule[]) {
this._unmodifiedFilterRules = value
this.rulesModified = filterRulesDiffer(
this._unmodifiedFilterRules,
this._filterRules
)
}
get unmodifiedFilterRules(): FilterRule[] {
return this._unmodifiedFilterRules
}
@Input()
set filterRules(value: FilterRule[]) {
this._filterRules = value
this.documentTypeSelectionModel.clear(false)
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
this.dateAddedAfter = null
this.dateCreatedBefore = null
this.dateCreatedAfter = null
this.dateCreatedRelativeDate = null
this.dateAddedRelativeDate = null
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
value.forEach((rule) => {
switch (rule.rule_type) {
case FILTER_TITLE:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE
break
case FILTER_TITLE_CONTENT:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
break
case FILTER_ASN:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break
case FILTER_FULLTEXT_QUERY:
let allQueryArgs = rule.value.split(',')
let textQueryArgs = []
allQueryArgs.forEach((arg) => {
if (arg.match(RELATIVE_DATE_QUERY_REGEXP_CREATED)) {
;[...arg.matchAll(RELATIVE_DATE_QUERY_REGEXP_CREATED)].forEach(
(match) => {
if (match[1]?.length) {
this.dateCreatedRelativeDate =
RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.dateQuery == match[1]
)?.relativeDate
}
}
)
} else if (arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)) {
;[...arg.matchAll(RELATIVE_DATE_QUERY_REGEXP_ADDED)].forEach(
(match) => {
if (match[1]?.length) {
this.dateAddedRelativeDate =
RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.dateQuery == match[1]
)?.relativeDate
}
}
)
} else {
textQueryArgs.push(arg)
}
})
if (textQueryArgs.length) {
this._textFilter = textQueryArgs.join(',')
this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_QUERY
}
break
case FILTER_FULLTEXT_MORELIKE:
this._moreLikeId = +rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
this.documentService.get(this._moreLikeId).subscribe((result) => {
this._moreLikeDoc = result
this._textFilter = result.title
})
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
case FILTER_HAS_TAGS_ALL:
this.tagSelectionModel.logicalOperator = LogicalOperator.And
this.tagSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_HAS_TAGS_ANY:
this.tagSelectionModel.logicalOperator = LogicalOperator.Or
this.tagSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_HAS_ANY_TAG:
this.tagSelectionModel.set(null, ToggleableItemState.Selected, false)
break
case FILTER_DOES_NOT_HAVE_TAG:
this.tagSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_CORRESPONDENT:
case FILTER_HAS_CORRESPONDENT_ANY:
this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or
this.correspondentSelectionModel.intersection = Intersection.Include
this.correspondentSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_DOES_NOT_HAVE_CORRESPONDENT:
this.correspondentSelectionModel.intersection = Intersection.Exclude
this.correspondentSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_DOCUMENT_TYPE:
case FILTER_HAS_DOCUMENT_TYPE_ANY:
this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or
this.documentTypeSelectionModel.intersection = Intersection.Include
this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE:
this.documentTypeSelectionModel.intersection = Intersection.Exclude
this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_STORAGE_PATH:
case FILTER_HAS_STORAGE_PATH_ANY:
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
this.storagePathSelectionModel.intersection = Intersection.Include
this.storagePathSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_DOES_NOT_HAVE_STORAGE_PATH:
this.storagePathSelectionModel.intersection = Intersection.Exclude
this.storagePathSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_ASN_ISNULL:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier =
rule.value == 'true' || rule.value == '1'
? TEXT_FILTER_MODIFIER_NULL
: TEXT_FILTER_MODIFIER_NOTNULL
break
case FILTER_ASN_GT:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier = TEXT_FILTER_MODIFIER_GT
this._textFilter = rule.value
break
case FILTER_ASN_LT:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier = TEXT_FILTER_MODIFIER_LT
this._textFilter = rule.value
break
}
})
this.rulesModified = filterRulesDiffer(
this._unmodifiedFilterRules,
this._filterRules
)
}
get filterRules(): FilterRule[] {
let filterRules: FilterRule[] = []
if (
this._textFilter &&
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT
) {
filterRules.push({
rule_type: FILTER_TITLE_CONTENT,
value: this._textFilter,
})
}
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
filterRules.push({ rule_type: FILTER_TITLE, value: this._textFilter })
}
if (this.textFilterTarget == TEXT_FILTER_TARGET_ASN) {
if (
this.textFilterModifier == TEXT_FILTER_MODIFIER_EQUALS &&
this._textFilter
) {
filterRules.push({ rule_type: FILTER_ASN, value: this._textFilter })
} else if (this.textFilterModifierIsNull) {
filterRules.push({
rule_type: FILTER_ASN_ISNULL,
value: (
this.textFilterModifier == TEXT_FILTER_MODIFIER_NULL
).toString(),
})
} else if (
[TEXT_FILTER_MODIFIER_GT, TEXT_FILTER_MODIFIER_LT].includes(
this.textFilterModifier
) &&
this._textFilter
) {
filterRules.push({
rule_type:
this.textFilterModifier == TEXT_FILTER_MODIFIER_GT
? FILTER_ASN_GT
: FILTER_ASN_LT,
value: this._textFilter,
})
}
}
if (
this._textFilter &&
this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY
) {
filterRules.push({
rule_type: FILTER_FULLTEXT_QUERY,
value: this._textFilter,
})
}
if (
this._moreLikeId &&
this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
) {
filterRules.push({
rule_type: FILTER_FULLTEXT_MORELIKE,
value: this._moreLikeId?.toString(),
})
}
if (this.tagSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_HAS_ANY_TAG, value: 'false' })
} else {
const tagFilterType =
this.tagSelectionModel.logicalOperator == LogicalOperator.And
? FILTER_HAS_TAGS_ALL
: FILTER_HAS_TAGS_ANY
this.tagSelectionModel
.getSelectedItems()
.filter((tag) => tag.id)
.forEach((tag) => {
filterRules.push({
rule_type: tagFilterType,
value: tag.id?.toString(),
})
})
this.tagSelectionModel
.getExcludedItems()
.filter((tag) => tag.id)
.forEach((tag) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_TAG,
value: tag.id?.toString(),
})
})
}
if (this.correspondentSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null })
} else {
this.correspondentSelectionModel
.getSelectedItems()
.forEach((correspondent) => {
filterRules.push({
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
value: correspondent.id?.toString(),
})
})
this.correspondentSelectionModel
.getExcludedItems()
.forEach((correspondent) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
value: correspondent.id?.toString(),
})
})
}
if (this.documentTypeSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null })
} else {
this.documentTypeSelectionModel
.getSelectedItems()
.forEach((documentType) => {
filterRules.push({
rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
value: documentType.id?.toString(),
})
})
this.documentTypeSelectionModel
.getExcludedItems()
.forEach((documentType) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
value: documentType.id?.toString(),
})
})
}
if (this.storagePathSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
} else {
this.storagePathSelectionModel
.getSelectedItems()
.forEach((storagePath) => {
filterRules.push({
rule_type: FILTER_HAS_STORAGE_PATH_ANY,
value: storagePath.id?.toString(),
})
})
this.storagePathSelectionModel
.getExcludedItems()
.forEach((storagePath) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
value: storagePath.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,
})
}
if (
this.dateAddedRelativeDate !== null ||
this.dateCreatedRelativeDate !== null
) {
let queryArgs: Array<string> = []
let existingRule = filterRules.find(
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
)
// if had a title / content search and added a relative date we need to carry it over...
if (
!existingRule &&
this._textFilter?.length > 0 &&
(this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT ||
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE)
) {
existingRule = filterRules.find(
(fr) =>
fr.rule_type == FILTER_TITLE_CONTENT || fr.rule_type == FILTER_TITLE
)
existingRule.rule_type = FILTER_FULLTEXT_QUERY
}
let existingRuleArgs = existingRule?.value.split(',')
if (this.dateCreatedRelativeDate !== null) {
queryArgs.push(
`created:[${
RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.relativeDate == this.dateCreatedRelativeDate
).dateQuery
}]`
)
if (existingRule) {
queryArgs = existingRuleArgs
.filter((arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_CREATED))
.concat(queryArgs)
}
}
if (this.dateAddedRelativeDate !== null) {
queryArgs.push(
`added:[${
RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.relativeDate == this.dateAddedRelativeDate
).dateQuery
}]`
)
if (existingRule) {
queryArgs = existingRuleArgs
.filter((arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED))
.concat(queryArgs)
}
}
if (existingRule) {
existingRule.value = queryArgs.join(',')
} else {
filterRules.push({
rule_type: FILTER_FULLTEXT_QUERY,
value: queryArgs.join(','),
})
}
}
if (
this.dateCreatedRelativeDate == null &&
this.dateAddedRelativeDate == null
) {
const existingRule = filterRules.find(
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
)
if (
existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_CREATED) ||
existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)
) {
// remove any existing date query
existingRule.value = existingRule.value
.replace(RELATIVE_DATE_QUERY_REGEXP_CREATED, '')
.replace(RELATIVE_DATE_QUERY_REGEXP_ADDED, '')
if (existingRule.value.replace(',', '').trim() === '') {
// if its empty now, remove it entirely
filterRules.splice(filterRules.indexOf(existingRule), 1)
}
}
}
return filterRules
}
@Output()
filterRulesChange = new EventEmitter<FilterRule[]>()
@Input()
set selectionData(selectionData: SelectionData) {
this.tagDocumentCounts = selectionData?.selected_tags ?? null
this.documentTypeDocumentCounts =
selectionData?.selected_document_types ?? null
this.correspondentDocumentCounts =
selectionData?.selected_correspondents ?? null
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? null
}
rulesModified: boolean = false
updateRules() {
this.filterRulesChange.next(this.filterRules)
}
get textFilter() {
return this.textFilterModifierIsNull ? '' : this._textFilter
}
set textFilter(value) {
this.textFilterDebounce.next(value)
}
textFilterDebounce: Subject<string>
subscription: Subscription
ngOnInit() {
this.tagService
.listAll()
.subscribe((result) => (this.tags = result.results))
this.correspondentService
.listAll()
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
this.textFilterDebounce = new Subject<string>()
this.subscription = this.textFilterDebounce
.pipe(
debounceTime(400),
distinctUntilChanged(),
filter((query) => !query.length || query.length > 2)
)
.subscribe((text) => this.updateTextFilter(text))
if (this._textFilter) this.documentService.searchQuery = this._textFilter
}
ngOnDestroy() {
this.textFilterDebounce.complete()
}
resetSelected() {
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
this.filterRules = this._unmodifiedFilterRules
this.updateRules()
}
toggleTag(tagId: number) {
this.tagSelectionModel.toggle(tagId)
}
toggleCorrespondent(correspondentId: number) {
this.correspondentSelectionModel.toggle(correspondentId)
}
toggleDocumentType(documentTypeId: number) {
this.documentTypeSelectionModel.toggle(documentTypeId)
}
toggleStoragePath(storagePathID: number) {
this.storagePathSelectionModel.toggle(storagePathID)
}
onTagsDropdownOpen() {
this.tagSelectionModel.apply()
}
onCorrespondentDropdownOpen() {
this.correspondentSelectionModel.apply()
}
onDocumentTypeDropdownOpen() {
this.documentTypeSelectionModel.apply()
}
onStoragePathDropdownOpen() {
this.storagePathSelectionModel.apply()
}
updateTextFilter(text) {
this._textFilter = text
this.documentService.searchQuery = text
this.updateRules()
}
textFilterKeyup(event: KeyboardEvent) {
if (event.key == 'Enter') {
const filterString = (
this.textFilterInput.nativeElement as HTMLInputElement
).value
if (filterString.length) {
this.updateTextFilter(filterString)
}
} else if (event.key == 'Escape') {
this.resetTextField()
}
}
resetTextField() {
this.updateTextFilter('')
}
changeTextFilterTarget(target) {
if (
this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE &&
target != TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
) {
this._textFilter = ''
}
this.textFilterTarget = target
this.textFilterInput.nativeElement.focus()
this.updateRules()
}
textFilterModifierChange() {
if (
this.textFilterModifierIsNull ||
([
TEXT_FILTER_MODIFIER_EQUALS,
TEXT_FILTER_MODIFIER_GT,
TEXT_FILTER_MODIFIER_LT,
].includes(this.textFilterModifier) &&
this._textFilter)
) {
this.updateRules()
}
}
}