All these

This commit is contained in:
shamoon
2026-07-08 18:02:13 -07:00
parent 2ac3e63b92
commit c4d425a72e
13 changed files with 173 additions and 353 deletions
@@ -9,16 +9,16 @@
autocomplete="off"
spellcheck="false"
[(ngModel)]="query"
(ngModelChange)="this.queryDebounce.next($event)"
(ngModelChange)="queryDebounce.next($event)"
(keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle">
@if (loading) {
@if (loading()) {
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
}
</div>
</div>
@if (query) {
@if (query()) {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
@if (useAdvancedForFullSearch) {
<ng-container i18n>Advanced search</ng-container>
@@ -77,88 +77,88 @@
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
<div (keydown)="dropdownKeyDown($event)">
@if (searchResults?.total === 0) {
@if (searchResults()?.total === 0) {
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
} @else {
@if (searchResults?.documents.length) {
@if (searchResults()?.documents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
@for (document of searchResults.documents; track document.id) {
@for (document of searchResults().documents; track document.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
}
}
@if (searchResults?.saved_views.length) {
@if (searchResults()?.saved_views.length) {
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
@for (saved_view of searchResults.saved_views; track saved_view.id) {
@for (saved_view of searchResults().saved_views; track saved_view.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
}
}
@if (searchResults?.tags.length) {
@if (searchResults()?.tags.length) {
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
@for (tag of searchResults.tags; track tag.id) {
@for (tag of searchResults().tags; track tag.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
}
}
@if (searchResults?.correspondents.length) {
@if (searchResults()?.correspondents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
@for (correspondent of searchResults.correspondents; track correspondent.id) {
@for (correspondent of searchResults().correspondents; track correspondent.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
}
}
@if (searchResults?.document_types.length) {
@if (searchResults()?.document_types.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
@for (documentType of searchResults.document_types; track documentType.id) {
@for (documentType of searchResults().document_types; track documentType.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
}
}
@if (searchResults?.storage_paths.length) {
@if (searchResults()?.storage_paths.length) {
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
@for (storagePath of searchResults().storage_paths; track storagePath.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
}
}
@if (searchResults?.users.length) {
@if (searchResults()?.users.length) {
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
@for (user of searchResults.users; track user.id) {
@for (user of searchResults().users; track user.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
}
}
@if (searchResults?.groups.length) {
@if (searchResults()?.groups.length) {
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
@for (group of searchResults.groups; track group.id) {
@for (group of searchResults().groups; track group.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
}
}
@if (searchResults?.custom_fields.length) {
@if (searchResults()?.custom_fields.length) {
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
@for (customField of searchResults.custom_fields; track customField.id) {
@for (customField of searchResults().custom_fields; track customField.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
}
}
@if (searchResults?.mail_accounts.length) {
@if (searchResults()?.mail_accounts.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
@for (mailAccount of searchResults().mail_accounts; track mailAccount.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
}
}
@if (searchResults?.mail_rules.length) {
@if (searchResults()?.mail_rules.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
@for (mailRule of searchResults().mail_rules; track mailRule.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
}
}
@if (searchResults?.workflows.length) {
@if (searchResults()?.workflows.length) {
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
@for (workflow of searchResults.workflows; track workflow.id) {
@for (workflow of searchResults().workflows; track workflow.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
}
}
@@ -170,7 +170,7 @@ describe('GlobalSearchComponent', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
expect(focusSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
component.resultsDropdown.open()
fixture.detectChanges()
@@ -238,26 +238,26 @@ describe('GlobalSearchComponent', () => {
)
expect(component['currentItemIndex']).toBe(0)
component.searchResults = { total: 1 } as any
component.searchResults.set({ total: 1 } as any)
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(primaryActionSpy).toHaveBeenCalled()
component.query = 'test'
component.query.set('test')
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(resetSpy).toHaveBeenCalled()
component.query = ''
component.query.set('')
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(blurSpy).toHaveBeenCalled()
component.searchResults = { total: 1 } as any
component.searchResults.set({ total: 1 } as any)
component.resultsDropdown.open()
component.searchInputKeyDown(
@@ -268,9 +268,9 @@ describe('GlobalSearchComponent', () => {
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
component.resultsDropdown.open()
component.query = 'test'
component.query.set('test')
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(advancedSearchSpy).toHaveBeenCalled()
@@ -471,7 +471,7 @@ describe('GlobalSearchComponent', () => {
})
it('should support focus current item', () => {
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
fixture.detectChanges()
const focusSpy = jest.spyOn(
component.primaryButtons.get(0).nativeElement,
@@ -489,7 +489,7 @@ describe('GlobalSearchComponent', () => {
})
it('should focus button on dropdown item hover', () => {
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
fixture.detectChanges()
const item: ElementRef = component.resultItems.first
const focusSpy = jest.spyOn(
@@ -515,7 +515,7 @@ describe('GlobalSearchComponent', () => {
component.primaryAction(DataType.Document, { id: 2 }, event as any)
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
component.resultsDropdown.open()
fixture.detectChanges()
@@ -542,7 +542,7 @@ describe('GlobalSearchComponent', () => {
it('should support title content search and advanced search', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.query = 'test'
component.query.set('test')
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
@@ -552,7 +552,7 @@ describe('GlobalSearchComponent', () => {
SETTINGS_KEYS.SEARCH_FULL_TYPE,
GlobalSearchType.ADVANCED
)
component.query = 'test'
component.query.set('test')
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
@@ -83,36 +83,12 @@ export class GlobalSearchComponent implements OnInit {
private locationStrategy = inject(LocationStrategy)
public DataType = DataType
private querySignal = signal<string>(null)
readonly query = signal<string>(null)
public queryDebounce: Subject<string>
private searchResultsSignal = signal<GlobalSearchResult>(null)
readonly searchResults = signal<GlobalSearchResult>(null)
private currentItemIndex: number = -1
private domIndex: number = -1
private loadingSignal = signal(false)
public get query(): string {
return this.querySignal()
}
public set query(value: string) {
this.querySignal.set(value)
}
public get searchResults(): GlobalSearchResult {
return this.searchResultsSignal()
}
public set searchResults(value: GlobalSearchResult) {
this.searchResultsSignal.set(value)
}
public get loading(): boolean {
return this.loadingSignal()
}
public set loading(value: boolean) {
this.loadingSignal.set(value)
}
readonly loading = signal(false)
@ViewChild('searchInput') searchInput: ElementRef
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
@@ -138,7 +114,7 @@ export class GlobalSearchComponent implements OnInit {
distinctUntilChanged()
)
.subscribe((text) => {
this.query = text
this.query.set(text)
if (text) this.search(text)
})
}
@@ -152,10 +128,10 @@ export class GlobalSearchComponent implements OnInit {
}
private search(query: string) {
this.loading = true
this.loading.set(true)
this.searchService.globalSearch(query.trim()).subscribe((results) => {
this.searchResults = results
this.loading = false
this.searchResults.set(results)
this.loading.set(false)
this.resultsDropdown.open()
})
}
@@ -282,8 +258,8 @@ export class GlobalSearchComponent implements OnInit {
private reset(close: boolean = false) {
this.queryDebounce.next(null)
this.query = null
this.searchResults = null
this.query.set(null)
this.searchResults.set(null)
this.currentItemIndex = -1
if (close) {
this.resultsDropdown.close()
@@ -318,7 +294,7 @@ export class GlobalSearchComponent implements OnInit {
public searchInputKeyDown(event: KeyboardEvent) {
if (
event.key === 'ArrowDown' &&
this.searchResults?.total &&
this.searchResults()?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
@@ -326,22 +302,22 @@ export class GlobalSearchComponent implements OnInit {
this.setCurrentItem()
} else if (
event.key === 'ArrowUp' &&
this.searchResults?.total &&
this.searchResults()?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
this.currentItemIndex = this.searchResults.total - 1
this.currentItemIndex = this.searchResults()?.total - 1
this.setCurrentItem()
} else if (event.key === 'Enter') {
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
if (this.searchResults()?.total === 1 && this.resultsDropdown.isOpen()) {
this.primaryButtons.first.nativeElement.click()
this.searchInput.nativeElement.blur()
} else if (this.query?.length) {
} else if (this.query()?.length) {
this.runFullSearch()
this.reset(true)
}
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
if (this.query?.length) {
if (this.query()?.length) {
this.reset(true)
} else {
this.searchInput.nativeElement.blur()
@@ -351,14 +327,14 @@ export class GlobalSearchComponent implements OnInit {
public dropdownKeyDown(event: KeyboardEvent) {
if (
this.searchResults?.total &&
this.searchResults()?.total &&
this.resultsDropdown.isOpen() &&
document.activeElement !== this.searchInput.nativeElement
) {
if (event.key === 'ArrowDown') {
event.preventDefault()
event.stopImmediatePropagation()
if (this.currentItemIndex < this.searchResults.total - 1) {
if (this.currentItemIndex < this.searchResults()?.total - 1) {
this.currentItemIndex++
this.setCurrentItem()
} else {
@@ -438,10 +414,10 @@ export class GlobalSearchComponent implements OnInit {
? FILTER_FULLTEXT_QUERY
: FILTER_SIMPLE_TEXT
this.documentService.searchQuery = this.useAdvancedForFullSearch
? this.query
? this.query()
: ''
this.documentListViewService.quickFilter([
{ rule_type: ruleType, value: this.query },
{ rule_type: ruleType, value: this.query() },
])
this.reset(true)
}
@@ -2,31 +2,31 @@
<div class="col-md text-truncate">
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
<span class="text-truncate">{{title}}</span>
@if (id) {
@if (id()) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@if (copied) {
@if (copied()) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{id}}
ID: {{id()}}
}
</span>
}
@if (subTitle) {
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
@if (subTitle()) {
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
}
@if (info) {
@if (info()) {
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<i-bs name="question-circle"></i-bs>
</button>
<ng-template #infoPopover>
<p [class.mb-0]="!infoLink" [innerHTML]="info"></p>
@if (infoLink) {
<a href="https://docs.paperless-ngx.com/{{infoLink}}" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
<p [class.mb-0]="!infoLink()" [innerHTML]="info()"></p>
@if (infoLink()) {
<a href="https://docs.paperless-ngx.com/{{infoLink()}}" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
}
</ng-template>
}
@if (loading) {
@if (loading()) {
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
}
</h3>
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, inject, signal } from '@angular/core'
import { Component, Input, inject, input, signal } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -16,83 +16,31 @@ export class PageHeaderComponent {
private titleService = inject(Title)
private clipboard = inject(Clipboard)
private titleSignal = signal('')
private idSignal = signal<number>(undefined)
private subTitleSignal = signal('')
private infoSignal = signal<string>(undefined)
private infoLinkSignal = signal<string>(undefined)
private loadingSignal = signal(false)
private copiedSignal = signal(false)
readonly id = input<number>(undefined)
readonly subTitle = input('')
readonly info = input<string>(undefined)
readonly infoLink = input<string>(undefined)
readonly loading = input(false)
readonly copied = signal(false)
private copyTimeout: any
public get copied(): boolean {
return this.copiedSignal()
}
public set copied(copied: boolean) {
this.copiedSignal.set(copied)
}
private readonly titleSignal = signal('')
@Input()
set title(title: string) {
this.titleSignal.set(title)
this.titleService.setTitle(`${this.title} - ${environment.appTitle}`)
this.titleService.setTitle(`${title} - ${environment.appTitle}`)
}
get title() {
return this.titleSignal()
}
@Input()
get id(): number {
return this.idSignal()
}
set id(id: number) {
this.idSignal.set(id)
}
@Input()
get subTitle(): string {
return this.subTitleSignal()
}
set subTitle(subTitle: string) {
this.subTitleSignal.set(subTitle)
}
@Input()
get info(): string {
return this.infoSignal()
}
set info(info: string) {
this.infoSignal.set(info)
}
@Input()
get infoLink(): string {
return this.infoLinkSignal()
}
set infoLink(infoLink: string) {
this.infoLinkSignal.set(infoLink)
}
@Input()
get loading(): boolean {
return this.loadingSignal()
}
set loading(loading: boolean) {
this.loadingSignal.set(loading)
}
public copyID() {
this.copied = this.clipboard.copy(this.id.toString())
this.copied.set(this.clipboard.copy(this.id().toString()))
clearTimeout(this.copyTimeout)
this.copyTimeout = setTimeout(() => {
this.copied = false
this.copied.set(false)
}, 3000)
}
}
@@ -1,15 +1,15 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<h4 class="modal-title" id="modal-basic-title">{{title()}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body p-0">
<ul class="list-group list-group-flush">
@if (!shareLinks || shareLinks.length === 0) {
@if (!shareLinks() || shareLinks().length === 0) {
<li class="list-group-item fst-italic small text-center text-secondary" i18n>
No existing links
</li>
}
@for (link of shareLinks; track link) {
@for (link of shareLinks(); track link) {
<li class="list-group-item">
<div class="input-group w-100">
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
@@ -19,10 +19,10 @@
</span>
}
<button type="button" class="btn btn-outline-primary" (click)="copy(link)">
@if (copied !== link.id) {
@if (copied() !== link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
}
@if (copied === link.id) {
@if (copied() === link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
@@ -36,7 +36,7 @@
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
</button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied() === link.id" i18n>Copied!</span>
</li>
}
</ul>
@@ -44,7 +44,7 @@
<div class="modal-footer">
<div class="input-group w-100">
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion()" [(ngModel)]="useArchiveVersion">
<label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
</div>
</div>
@@ -55,11 +55,11 @@
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
<button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
@if (loading) {
<button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading()">
@if (loading()) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
@if (!loading) {
@if (!loading()) {
<i-bs name="plus"></i-bs>
}
<ng-container i18n>Create</ng-container>
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, OnInit, inject, signal } from '@angular/core'
import { Component, OnInit, effect, inject, input, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -27,79 +27,40 @@ export class ShareLinksDialogComponent implements OnInit {
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
private titleSignal = signal($localize`Share Links`)
private documentIdSignal = signal<number>(undefined)
private hasArchiveVersionSignal = signal(true)
private shareLinksSignal = signal<ShareLink[]>(undefined)
private copiedSignal = signal<number>(undefined)
@Input()
get title(): string {
return this.titleSignal()
}
set title(title: string) {
this.titleSignal.set(title)
}
@Input()
set documentId(id: number) {
if (id !== undefined) {
this.documentIdSignal.set(id)
this.refresh()
}
}
get documentId(): number {
return this.documentIdSignal()
}
@Input()
set hasArchiveVersion(value: boolean) {
this.hasArchiveVersionSignal.set(value)
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this.hasArchiveVersionSignal()
}
get shareLinks(): ShareLink[] {
return this.shareLinksSignal()
}
set shareLinks(shareLinks: ShareLink[]) {
this.shareLinksSignal.set(shareLinks)
}
loading: boolean = false
get copied(): number {
return this.copiedSignal()
}
set copied(copied: number) {
this.copiedSignal.set(copied)
}
readonly title = input($localize`Share Links`)
readonly documentId = input<number>(undefined)
readonly hasArchiveVersion = input(true)
readonly shareLinks = signal<ShareLink[]>(undefined)
readonly loading = signal(false)
readonly copied = signal<number>(undefined)
expirationDays: number = 7
useArchiveVersion: boolean = true
constructor() {
effect(() => {
const id = this.documentId()
if (id !== undefined) this.refresh()
})
effect(() => {
this.useArchiveVersion = this.hasArchiveVersion()
})
}
ngOnInit(): void {
if (this.documentId !== undefined) this.refresh()
if (this.documentId() !== undefined) this.refresh()
}
refresh() {
if (this.documentId === undefined) return
this.loading = true
if (this.documentId() === undefined) return
this.loading.set(true)
this.shareLinkService
.getLinksForDocument(this.documentId)
.getLinksForDocument(this.documentId())
.pipe(first())
.subscribe({
next: (results) => {
this.loading = false
this.shareLinks = results
this.loading.set(false)
this.shareLinks.set(results)
},
error: (e) => {
this.toastService.showError(
@@ -128,9 +89,9 @@ export class ShareLinksDialogComponent implements OnInit {
copy(link: ShareLink) {
const success = this.clipboard.copy(this.getShareUrl(link))
if (success) {
this.copied = link.id
this.copied.set(link.id)
setTimeout(() => {
this.copied = null
this.copied.set(null)
}, 3000)
}
}
@@ -162,23 +123,23 @@ export class ShareLinksDialogComponent implements OnInit {
expiration = new Date()
expiration.setDate(expiration.getDate() + this.expirationDays)
}
this.loading = true
this.loading.set(true)
this.shareLinkService
.createLinkForDocument(
this.documentId,
this.documentId(),
this.useArchiveVersion ? FileVersion.Archive : FileVersion.Original,
expiration
)
.subscribe({
next: (result) => {
this.loading = false
this.loading.set(false)
setTimeout(() => {
this.copy(result)
}, 10)
this.refresh()
},
error: (e) => {
this.loading = false
this.loading.set(false)
this.toastService.showError($localize`Error creating link`, 10000, e)
},
})
@@ -1,6 +1,6 @@
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled || loading || (suggestions && !aiEnabled)">
@if (loading) {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
@@ -13,32 +13,32 @@
@if (aiEnabled) {
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled || loading || !suggestions" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<span class="visually-hidden" i18n>Show suggestions</span>
</button>
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (!suggestions?.suggested_tags && !suggestions?.suggested_document_types && !suggestions?.suggested_correspondents) {
@if (!suggestions()?.suggested_tags && !suggestions()?.suggested_document_types && !suggestions()?.suggested_correspondents) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
}
@if (suggestions?.suggested_tags.length > 0) {
@if (suggestions()?.suggested_tags.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions.suggested_tags; track tag) {
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
}
}
@if (suggestions?.suggested_document_types.length > 0) {
@if (suggestions()?.suggested_document_types.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions.suggested_document_types; track type) {
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
}
}
@if (suggestions?.suggested_correspondents.length > 0) {
@if (suggestions()?.suggested_correspondents.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions.suggested_correspondents; track correspondent) {
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
}
}
@@ -1,10 +1,9 @@
import {
Component,
EventEmitter,
Input,
Output,
ViewChild,
signal,
input,
} from '@angular/core'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -21,46 +20,10 @@ export class SuggestionsDropdownComponent {
public popperOptions = pngxPopperOptions
@ViewChild('dropdown') dropdown: NgbDropdown
private suggestionsSignal = signal<DocumentSuggestions>(null)
private aiEnabledSignal = signal(false)
private loadingSignal = signal(false)
private disabledSignal = signal(false)
@Input()
get suggestions(): DocumentSuggestions {
return this.suggestionsSignal()
}
set suggestions(suggestions: DocumentSuggestions) {
this.suggestionsSignal.set(suggestions)
}
@Input()
get aiEnabled(): boolean {
return this.aiEnabledSignal()
}
set aiEnabled(aiEnabled: boolean) {
this.aiEnabledSignal.set(aiEnabled)
}
@Input()
get loading(): boolean {
return this.loadingSignal()
}
set loading(loading: boolean) {
this.loadingSignal.set(loading)
}
@Input()
get disabled(): boolean {
return this.disabledSignal()
}
set disabled(disabled: boolean) {
this.disabledSignal.set(disabled)
}
readonly suggestions = input<DocumentSuggestions>(null)
readonly aiEnabled = input(false)
readonly loading = input(false)
readonly disabled = input(false)
@Output()
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
@@ -77,14 +40,14 @@ export class SuggestionsDropdownComponent {
public clickSuggest(): void {
if (
this.disabled ||
this.loading ||
(this.suggestions && !this.aiEnabled)
this.disabled() ||
this.loading() ||
(this.suggestions() && !this.aiEnabled())
) {
return
}
if (!this.suggestions) {
if (!this.suggestions()) {
this.getSuggestions.emit(this)
} else {
this.dropdown?.toggle()
@@ -93,9 +56,9 @@ export class SuggestionsDropdownComponent {
get totalSuggestions(): number {
return (
this.suggestions?.suggested_correspondents?.length +
this.suggestions?.suggested_tags?.length +
this.suggestions?.suggested_document_types?.length || 0
this.suggestions()?.suggested_correspondents?.length +
this.suggestions()?.suggested_tags?.length +
this.suggestions()?.suggested_document_types?.length || 0
)
}
}
@@ -1,17 +1,17 @@
@if (loading) {
@if (loading()) {
<div class="d-flex">
<output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
</div>
} @else {
<ul class="list-group">
@if (entries.length === 0) {
@if (entries().length === 0) {
<li class="list-group-item">
<div class="d-flex justify-content-center">
<span class="fst-italic" i18n>No entries found.</span>
</div>
</li>
} @else {
@for (entry of entries; track entry.id) {
@for (entry of entries(); track entry.id) {
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<ng-template #timestamp>
@@ -1,5 +1,5 @@
import { AsyncPipe, KeyValuePipe, TitleCasePipe } from '@angular/common'
import { Component, Input, OnInit, inject } from '@angular/core'
import { Component, Input, OnInit, inject, signal } from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Observable, first, map, of, shareReplay } from 'rxjs'
@@ -44,8 +44,8 @@ export class DocumentHistoryComponent implements OnInit {
}
}
public loading: boolean = true
public entries: AuditLogEntry[] = []
readonly loading = signal(true)
readonly entries = signal<AuditLogEntry[]>([])
private readonly prettyNameCache = new Map<string, Observable<string>>()
@@ -55,10 +55,10 @@ export class DocumentHistoryComponent implements OnInit {
private loadHistory(): void {
if (this._documentId) {
this.loading = true
this.loading.set(true)
this.documentService.getHistory(this._documentId).subscribe((entries) => {
this.entries = entries
this.loading = false
this.entries.set(entries)
this.loading.set(false)
})
}
}
@@ -10,13 +10,13 @@
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (loading) {
@if (loading()) {
<div class="text-center my-5">
<div class="spinner-border" role="status">
<span class="visually-hidden" i18n>Loading...</span>
</div>
</div>
} @else if (processedMails.length === 0) {
} @else if (processedMails().length === 0) {
<span i18n>No processed email messages found.</span>
} @else {
<div class="table-responsive">
@@ -25,7 +25,7 @@
<tr>
<th scope="col" style="width: 40px;">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
@@ -37,7 +37,7 @@
</tr>
</thead>
<tbody>
@for (mail of processedMails; track mail.id) {
@for (mail of processedMails(); track mail.id) {
<ng-template #statusTooltip>
<div class="small text-light font-monospace">
{{mail.status}}
@@ -46,7 +46,7 @@
<tr>
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds().has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();">
<label class="form-check-label" [for]="mail.id"></label>
</div>
</td>
@@ -82,7 +82,7 @@
</table>
</div>
<div class="btn-toolbar">
<button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button>
<button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds().size === 0" i18n>Clear</button>
<pngx-confirm-button
label="Delete selected"
i18n-label
@@ -90,12 +90,12 @@
i18n-title
buttonClasses="btn-outline-danger"
iconName="trash"
[disabled]="selectedMailIds.size === 0"
[disabled]="selectedMailIds().size === 0"
(confirm)="deleteSelected()">
</pngx-confirm-button>
<div class="ms-auto">
<ngb-pagination
[collectionSize]="processedMails.length"
[collectionSize]="processedMails().length"
[(page)]="page"
[pageSize]="50"
[maxSize]="5"
@@ -36,39 +36,11 @@ export class ProcessedMailDialogComponent implements OnInit {
private readonly processedMailService = inject(ProcessedMailService)
private readonly toastService = inject(ToastService)
private ruleSignal = signal<MailRule>(undefined)
private processedMailsSignal = signal<ProcessedMail[]>([])
private loadingSignal = signal(true)
private toggleAllEnabledSignal = signal(false)
private selectedMailIdsSignal = signal<Set<number>>(new Set())
public get processedMails(): ProcessedMail[] {
return this.processedMailsSignal()
}
public set processedMails(processedMails: ProcessedMail[]) {
this.processedMailsSignal.set(processedMails)
}
public get loading(): boolean {
return this.loadingSignal()
}
public set loading(loading: boolean) {
this.loadingSignal.set(loading)
}
public get toggleAllEnabled(): boolean {
return this.toggleAllEnabledSignal()
}
public set toggleAllEnabled(toggleAllEnabled: boolean) {
this.toggleAllEnabledSignal.set(toggleAllEnabled)
}
public get selectedMailIds(): Set<number> {
return this.selectedMailIdsSignal()
}
private readonly ruleSignal = signal<MailRule>(undefined)
readonly processedMails = signal<ProcessedMail[]>([])
readonly loading = signal(true)
readonly toggleAllEnabled = signal(false)
readonly selectedMailIds = signal<Set<number>>(new Set())
public page: number = 1
@@ -90,19 +62,19 @@ export class ProcessedMailDialogComponent implements OnInit {
}
private loadProcessedMails(): void {
this.loading = true
this.loading.set(true)
this.clearSelection()
this.processedMailService
.list(this.page, 50, 'processed_at', true, { rule: this.rule.id })
.subscribe((result) => {
this.processedMails = result.results
this.loading = false
this.processedMails.set(result.results)
this.loading.set(false)
})
}
public deleteSelected(): void {
this.processedMailService
.bulk_delete(Array.from(this.selectedMailIds))
.bulk_delete(Array.from(this.selectedMailIds()))
.subscribe(() => {
this.toastService.showInfo($localize`Processed mail(s) deleted`)
this.loadProcessedMails()
@@ -112,23 +84,23 @@ export class ProcessedMailDialogComponent implements OnInit {
public toggleAll(event: PointerEvent) {
const selectedMailIds = new Set<number>()
if ((event.target as HTMLInputElement).checked) {
this.processedMails.forEach((mail) => selectedMailIds.add(mail.id))
this.selectedMailIdsSignal.set(selectedMailIds)
this.processedMails().forEach((mail) => selectedMailIds.add(mail.id))
this.selectedMailIds.set(selectedMailIds)
} else {
this.clearSelection()
}
}
public clearSelection() {
this.toggleAllEnabled = false
this.selectedMailIdsSignal.set(new Set())
this.toggleAllEnabled.set(false)
this.selectedMailIds.set(new Set())
}
public toggleSelected(mail: ProcessedMail) {
const selectedMailIds = new Set(this.selectedMailIds)
const selectedMailIds = new Set(this.selectedMailIds())
selectedMailIds.has(mail.id)
? selectedMailIds.delete(mail.id)
: selectedMailIds.add(mail.id)
this.selectedMailIdsSignal.set(selectedMailIds)
this.selectedMailIds.set(selectedMailIds)
}
}