mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-19 02:14:54 +00:00
Compare commits
1
Commits
dev
...
fix-signal-misses
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0df0506a |
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs">
|
||||||
<li [ngbNavItem]="SettingsNavIDs.General">
|
<li [ngbNavItem]="SettingsNavIDs.General">
|
||||||
<a ngbNavLink i18n>General</a>
|
<a ngbNavLink i18n>General</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
|
|||||||
activatedRoute.snapshot.fragment = '#notifications'
|
activatedRoute.snapshot.fragment = '#notifications'
|
||||||
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.activeNavID).toEqual(4) // Notifications
|
expect(component.activeNavID()).toEqual(4) // Notifications
|
||||||
component.ngAfterViewInit()
|
component.ngAfterViewInit()
|
||||||
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class SettingsComponent
|
|||||||
private systemStatusService = inject(SystemStatusService)
|
private systemStatusService = inject(SystemStatusService)
|
||||||
private savedViewsService = inject(SavedViewService)
|
private savedViewsService = inject(SavedViewService)
|
||||||
|
|
||||||
activeNavID: number
|
readonly activeNavID = signal<number>(undefined)
|
||||||
|
|
||||||
settingsForm = new FormGroup({
|
settingsForm = new FormGroup({
|
||||||
bulkEditConfirmationDialogs: new FormControl(null),
|
bulkEditConfirmationDialogs: new FormControl(null),
|
||||||
@@ -283,7 +283,7 @@ export class SettingsComponent
|
|||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
this.activeNavID = SettingsNavIDs[navIDKey]
|
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -386,7 +386,7 @@ export class SettingsComponent
|
|||||||
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
||||||
.then((navigated) => {
|
.then((navigated) => {
|
||||||
if (!navigated && this.isDirty) {
|
if (!navigated && this.isDirty) {
|
||||||
this.activeNavID = navChangeEvent.activeId
|
this.activeNavID.set(navChangeEvent.activeId)
|
||||||
} else if (navigated && this.isDirty) {
|
} else if (navigated && this.isDirty) {
|
||||||
this.initialize()
|
this.initialize()
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,12 @@
|
|||||||
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
||||||
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
LOCALE_ID,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core'
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -22,6 +29,7 @@ export class CustomFieldDisplayComponent
|
|||||||
{
|
{
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
|
private changeDetector = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
CustomFieldDataType = CustomFieldDataType
|
CustomFieldDataType = CustomFieldDataType
|
||||||
|
|
||||||
@@ -74,6 +82,7 @@ export class CustomFieldDisplayComponent
|
|||||||
this.customFieldService.listAll().subscribe((r) => {
|
this.customFieldService.listAll().subscribe((r) => {
|
||||||
this.customFields = r.results
|
this.customFields = r.results
|
||||||
this.init()
|
this.init()
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +120,7 @@ export class CustomFieldDisplayComponent
|
|||||||
this.docLinkDocuments = this.value
|
this.docLinkDocuments = this.value
|
||||||
.map((id) => result.results.find((d) => d.id === id))
|
.map((id) => result.results.find((d) => d.id === id))
|
||||||
.filter((d) => d)
|
.filter((d) => d)
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
it('should support update unused fields', () => {
|
it('should support update unused fields', () => {
|
||||||
component.existingFields = [{ field: fields[0].id } as any]
|
component.existingFields = [{ field: fields[0].id } as any]
|
||||||
component['updateUnusedFields']()
|
component['updateUnusedFields']()
|
||||||
expect(component['unusedFields'].length).toEqual(1)
|
expect(component['unusedFields']().length).toEqual(1)
|
||||||
expect(component['unusedFields'][0].name).toEqual('Field 2')
|
expect(component['unusedFields']()[0].name).toEqual('Field 2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support getting data type label', () => {
|
it('should support getting data type label', () => {
|
||||||
|
|||||||
+7
-4
@@ -8,6 +8,7 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -65,11 +66,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
@ViewChildren('button') buttons: QueryList<ElementRef>
|
@ViewChildren('button') buttons: QueryList<ElementRef>
|
||||||
|
|
||||||
private customFields: CustomField[] = []
|
private customFields: CustomField[] = []
|
||||||
private unusedFields: CustomField[] = []
|
private readonly unusedFields = signal<CustomField[]>([])
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
public get filteredFields(): CustomField[] {
|
public get filteredFields(): CustomField[] {
|
||||||
return this.unusedFields.filter(
|
return this.unusedFields().filter(
|
||||||
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,8 +100,10 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateUnusedFields() {
|
private updateUnusedFields() {
|
||||||
this.unusedFields = this.customFields.filter(
|
this.unusedFields.set(
|
||||||
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
this.customFields.filter(
|
||||||
|
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@
|
|||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<ng-select #fieldSelects
|
<ng-select #fieldSelects
|
||||||
class="paperless-input-select"
|
class="paperless-input-select"
|
||||||
[items]="customFields"
|
[items]="customFields()"
|
||||||
[(ngModel)]="atom.field"
|
[(ngModel)]="atom.field"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
|
|||||||
+3
-3
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize custom fields on creation', () => {
|
it('should initialize custom fields on creation', () => {
|
||||||
expect(component.customFields).toEqual(customFields)
|
expect(component.customFields()).toEqual(customFields)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add an expression when opened if queries are empty', () => {
|
it('should add an expression when opened if queries are empty', () => {
|
||||||
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
data_type: CustomFieldDataType.String,
|
data_type: CustomFieldDataType.String,
|
||||||
extra_data: {},
|
extra_data: {},
|
||||||
}
|
}
|
||||||
component.customFields = [field]
|
component.customFields.set([field])
|
||||||
const operators = component.getOperatorsForField(1)
|
const operators = component.getOperatorsForField(1)
|
||||||
expect(operators.length).toEqual(
|
expect(operators.length).toEqual(
|
||||||
[
|
[
|
||||||
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
component.customFields = [field]
|
component.customFields.set([field])
|
||||||
const options = component.getSelectOptionsForField(1)
|
const options = component.getSelectOptionsForField(1)
|
||||||
expect(options).toEqual([
|
expect(options).toEqual([
|
||||||
{ label: 'Option 1', id: 'abc-123' },
|
{ label: 'Option 1', id: 'abc-123' },
|
||||||
|
|||||||
+6
-5
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
QueryList,
|
QueryList,
|
||||||
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
@@ -278,7 +279,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
@Output()
|
@Output()
|
||||||
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
customFields: CustomField[] = []
|
readonly customFields = signal<CustomField[]>([])
|
||||||
|
|
||||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||||
|
|
||||||
@@ -325,12 +326,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
this.customFields = result.results
|
this.customFields.set(result.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCustomFieldByID(id: number): CustomField {
|
public getCustomFieldByID(id: number): CustomField {
|
||||||
return this.customFields.find((field) => field.id === id)
|
return this.customFields().find((field) => field.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public addAtom(expression: CustomFieldQueryExpression) {
|
public addAtom(expression: CustomFieldQueryExpression) {
|
||||||
@@ -353,7 +354,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
getOperatorsForField(
|
getOperatorsForField(
|
||||||
fieldID: number
|
fieldID: number
|
||||||
): Array<{ value: string; label: string }> {
|
): Array<{ value: string; label: string }> {
|
||||||
const field = this.customFields.find((field) => field.id === fieldID)
|
const field = this.customFields().find((field) => field.id === fieldID)
|
||||||
const groups: CustomFieldQueryOperatorGroups[] = field
|
const groups: CustomFieldQueryOperatorGroups[] = field
|
||||||
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
||||||
: [CustomFieldQueryOperatorGroups.Basic]
|
: [CustomFieldQueryOperatorGroups.Basic]
|
||||||
@@ -369,7 +370,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
getSelectOptionsForField(
|
getSelectOptionsForField(
|
||||||
fieldID: number
|
fieldID: number
|
||||||
): Array<{ label: string; id: string }> {
|
): Array<{ label: string; id: string }> {
|
||||||
const field = this.customFields.find((field) => field.id === fieldID)
|
const field = this.customFields().find((field) => field.id === fieldID)
|
||||||
if (field) {
|
if (field) {
|
||||||
return field.extra_data['select_options']
|
return field.extra_data['select_options']
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -22,11 +22,11 @@
|
|||||||
<ng-template>
|
<ng-template>
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
@if (testLoading) {
|
@if (testLoading()) {
|
||||||
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
||||||
} @else if (testResult) {
|
} @else if (testResult()) {
|
||||||
<code>{{testResult}}</code>
|
<code>{{testResult()}}</code>
|
||||||
} @else if (testFailed) {
|
} @else if (testFailed()) {
|
||||||
<div class="text-danger" i18n>Path test failed</div>
|
<div class="text-danger" i18n>Path test failed</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="text-muted small" i18n>No document selected</div>
|
<div class="text-muted small" i18n>No document selected</div>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
[compareWith]="compareDocuments"
|
[compareWith]="compareDocuments"
|
||||||
[trackByFn]="trackByFn"
|
[trackByFn]="trackByFn"
|
||||||
[minTermLength]="2"
|
[minTermLength]="2"
|
||||||
[loading]="loading"
|
[loading]="loading()"
|
||||||
[typeahead]="documentsInput$"
|
[typeahead]="documentsInput$"
|
||||||
(change)="testPath($event)">
|
(change)="testPath($event)">
|
||||||
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
||||||
|
|||||||
+5
-5
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.testPath({ id: 1 })
|
component.testPath({ id: 1 })
|
||||||
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
||||||
expect(component.testResult).toBe('test/abc123')
|
expect(component.testResult()).toBe('test/abc123')
|
||||||
expect(component.testFailed).toBeFalsy()
|
expect(component.testFailed()).toBeFalsy()
|
||||||
|
|
||||||
// test failed
|
// test failed
|
||||||
testSpy.mockReturnValueOnce(of(''))
|
testSpy.mockReturnValueOnce(of(''))
|
||||||
component.testPath({ id: 1 })
|
component.testPath({ id: 1 })
|
||||||
expect(component.testResult).toBeNull()
|
expect(component.testResult()).toBeNull()
|
||||||
expect(component.testFailed).toBeTruthy()
|
expect(component.testFailed()).toBeTruthy()
|
||||||
|
|
||||||
component.testPath(null)
|
component.testPath(null)
|
||||||
expect(component.testResult).toBeNull()
|
expect(component.testResult()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should compare two documents by id', () => {
|
it('should compare two documents by id', () => {
|
||||||
|
|||||||
+11
-11
@@ -1,5 +1,5 @@
|
|||||||
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnDestroy, inject } from '@angular/core'
|
import { Component, OnDestroy, inject, signal } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
|
|||||||
public documentsInput$ = new Subject<string>()
|
public documentsInput$ = new Subject<string>()
|
||||||
public foundDocuments$: Observable<Document[]>
|
public foundDocuments$: Observable<Document[]>
|
||||||
private testDocument: Document
|
private testDocument: Document
|
||||||
public testResult: string
|
readonly testResult = signal<string>(undefined)
|
||||||
public testFailed: boolean = false
|
readonly testFailed = signal(false)
|
||||||
public testLoading = false
|
readonly testLoading = signal(false)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
|
|||||||
|
|
||||||
public testPath(document: Document) {
|
public testPath(document: Document) {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
this.testResult = null
|
this.testResult.set(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.testDocument = document
|
this.testDocument = document
|
||||||
this.testLoading = true
|
this.testLoading.set(true)
|
||||||
;(this.service as StoragePathService)
|
;(this.service as StoragePathService)
|
||||||
.testPath(this.objectForm.get('path').value, document.id)
|
.testPath(this.objectForm.get('path').value, document.id)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
if (result?.length) {
|
if (result?.length) {
|
||||||
this.testResult = result
|
this.testResult.set(result)
|
||||||
this.testFailed = false
|
this.testFailed.set(false)
|
||||||
} else {
|
} else {
|
||||||
this.testResult = null
|
this.testResult.set(null)
|
||||||
this.testFailed = true
|
this.testFailed.set(true)
|
||||||
}
|
}
|
||||||
this.testLoading = false
|
this.testLoading.set(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
||||||
|
|
||||||
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
||||||
|
|
||||||
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
||||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject, signal } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||||
tags: Tag[]
|
readonly tags = signal<Tag[]>([])
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
|||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
this.service.listAll().subscribe((result) => {
|
this.service.listAll().subscribe((result) => {
|
||||||
this.tags = result.results
|
this.tags.set(result.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -58,8 +58,8 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
|
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
|
||||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
|
||||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
selectionModel.toggle(items[0].id)
|
selectionModel.toggle(items[0].id)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.modelIsDirty).toBeTruthy()
|
expect(component.modelIsDirty()).toBeTruthy()
|
||||||
let applyResult: ChangedItems
|
let applyResult: ChangedItems
|
||||||
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
||||||
component.apply.subscribe((result) => (applyResult = result))
|
component.apply.subscribe((result) => (applyResult = result))
|
||||||
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
selectionModel.toggle(items[0].id)
|
selectionModel.toggle(items[0].id)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.modelIsDirty).toBeTruthy()
|
expect(component.modelIsDirty()).toBeTruthy()
|
||||||
let applyResult: ChangedItems
|
let applyResult: ChangedItems
|
||||||
component.apply.subscribe((result) => (applyResult = result))
|
component.apply.subscribe((result) => (applyResult = result))
|
||||||
component.dropdown.close()
|
component.dropdown.close()
|
||||||
|
|||||||
+4
-3
@@ -12,6 +12,7 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -796,7 +797,7 @@ export class FilterableDropdownComponent
|
|||||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
modelIsDirty: boolean = false
|
readonly modelIsDirty = signal(false)
|
||||||
|
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
@@ -811,7 +812,7 @@ export class FilterableDropdownComponent
|
|||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.selectionModelChange.subscribe((updatedModel) => {
|
this.selectionModelChange.subscribe((updatedModel) => {
|
||||||
this.modelIsDirty = updatedModel.isDirty()
|
this.modelIsDirty.set(updatedModel.isDirty())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,7 +859,7 @@ export class FilterableDropdownComponent
|
|||||||
}, 0)
|
}, 0)
|
||||||
if (this.editing) {
|
if (this.editing) {
|
||||||
this.selectionModel.reset()
|
this.selectionModel.reset()
|
||||||
this.modelIsDirty = false
|
this.modelIsDirty.set(false)
|
||||||
}
|
}
|
||||||
this.selectionModel.singleSelect =
|
this.selectionModel.singleSelect =
|
||||||
this.editing && !this.selectionModel.manyToOne
|
this.editing && !this.selectionModel.manyToOne
|
||||||
|
|||||||
+4
-3
@@ -5,6 +5,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -63,11 +64,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
|
|
||||||
super()
|
super()
|
||||||
customFieldsService.listAll().subscribe((items) => {
|
customFieldsService.listAll().subscribe((items) => {
|
||||||
this.fields = items.results
|
this.fields.set(items.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fields: CustomField[]
|
private readonly fields = signal<CustomField[]>([])
|
||||||
|
|
||||||
private _selectedFields: number[]
|
private _selectedFields: number[]
|
||||||
|
|
||||||
@@ -90,6 +91,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
|
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
|
||||||
|
|
||||||
public getCustomField(id: number): CustomField {
|
public getCustomField(id: number): CustomField {
|
||||||
return this.fields.find((field) => field.id === id)
|
return this.fields().find((field) => field.id === id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
[compareWith]="compareDocuments"
|
[compareWith]="compareDocuments"
|
||||||
[trackByFn]="trackByFn"
|
[trackByFn]="trackByFn"
|
||||||
[minTermLength]="2"
|
[minTermLength]="2"
|
||||||
[loading]="loading"
|
[loading]="loading()"
|
||||||
[typeahead]="documentsInput$"
|
[typeahead]="documentsInput$"
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
(change)="onChange(selectedDocumentIDs)">
|
(change)="onChange(selectedDocumentIDs)">
|
||||||
|
|||||||
+26
-2
@@ -2,7 +2,11 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
import { of, throwError } from 'rxjs'
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { provideRouter } from '@angular/router'
|
||||||
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { of, Subject, throwError } from 'rxjs'
|
||||||
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
|
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { DocumentLinkComponent } from './document-link.component'
|
import { DocumentLinkComponent } from './document-link.component'
|
||||||
@@ -33,10 +37,11 @@ describe('DocumentLinkComponent', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [DocumentLinkComponent],
|
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
@@ -60,6 +65,25 @@ describe('DocumentLinkComponent', () => {
|
|||||||
expect(getSpy).toHaveBeenCalled()
|
expect(getSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render loading and selected documents after async updates', async () => {
|
||||||
|
const result$ = new Subject<any>()
|
||||||
|
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
|
||||||
|
|
||||||
|
component.writeValue([1])
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
|
||||||
|
.componentInstance as NgSelectComponent
|
||||||
|
expect(select.loading()).toBe(true)
|
||||||
|
|
||||||
|
result$.next({ count: 1, all: [1], results: [documents[0]] })
|
||||||
|
result$.complete()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(select.loading()).toBe(false)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
it('shoud maintain ordering of selected documents', () => {
|
it('shoud maintain ordering of selected documents', () => {
|
||||||
const getSpy = jest.spyOn(documentService, 'getFew')
|
const getSpy = jest.spyOn(documentService, 'getFew')
|
||||||
getSpy.mockImplementation((ids) => {
|
getSpy.mockImplementation((ids) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -63,7 +64,7 @@ export class DocumentLinkComponent
|
|||||||
|
|
||||||
documentsInput$ = new Subject<string>()
|
documentsInput$ = new Subject<string>()
|
||||||
foundDocuments$: Observable<Document[]>
|
foundDocuments$: Observable<Document[]>
|
||||||
loading = false
|
readonly loading = signal(false)
|
||||||
selectedDocuments: Document[] = []
|
selectedDocuments: Document[] = []
|
||||||
|
|
||||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
@@ -93,12 +94,12 @@ export class DocumentLinkComponent
|
|||||||
this.selectedDocuments = []
|
this.selectedDocuments = []
|
||||||
super.writeValue([])
|
super.writeValue([])
|
||||||
} else {
|
} else {
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.getFew(documentIDs, { fields: 'id,title' })
|
.getFew(documentIDs, { fields: 'id,title' })
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((documentResults) => {
|
.subscribe((documentResults) => {
|
||||||
this.loading = false
|
this.loading.set(false)
|
||||||
this.selectedDocuments = documentIDs.map(
|
this.selectedDocuments = documentIDs.map(
|
||||||
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
||||||
)
|
)
|
||||||
@@ -113,7 +114,7 @@ export class DocumentLinkComponent
|
|||||||
this.documentsInput$.pipe(
|
this.documentsInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap(() => (this.loading = true)),
|
tap(() => this.loading.set(true)),
|
||||||
switchMap((title) =>
|
switchMap((title) =>
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -133,7 +134,7 @@ export class DocumentLinkComponent
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
catchError(() => of([])), // empty on error
|
catchError(() => of([])), // empty on error
|
||||||
tap(() => (this.loading = false))
|
tap(() => this.loading.set(false))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
|||||||
this.documentService.getNextAsn().subscribe((nextAsn) => {
|
this.documentService.getNextAsn().subscribe((nextAsn) => {
|
||||||
this.value = nextAsn
|
this.value = nextAsn
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@
|
|||||||
[(ngModel)]="selectionModel.includeUsers"
|
[(ngModel)]="selectionModel.includeUsers"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[clearable]="false"
|
[clearable]="false"
|
||||||
[items]="users"
|
[items]="users()"
|
||||||
bindLabel="username"
|
bindLabel="username"
|
||||||
multiple="true"
|
multiple="true"
|
||||||
bindValue="id"
|
bindValue="id"
|
||||||
|
|||||||
+10
-3
@@ -1,5 +1,12 @@
|
|||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
@@ -75,7 +82,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
@Output()
|
@Output()
|
||||||
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
|
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
|
||||||
|
|
||||||
users: User[]
|
readonly users = signal<User[]>([])
|
||||||
|
|
||||||
hideUnowned: boolean
|
hideUnowned: boolean
|
||||||
|
|
||||||
@@ -102,7 +109,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => (this.users = result.results),
|
next: (result) => this.users.set(result.results),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -57,15 +57,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
@if (socialAccounts?.length > 0) {
|
@if (socialAccounts().length > 0) {
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p i18n>Connected social accounts</p>
|
<p i18n>Connected social accounts</p>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
@for (account of socialAccounts; track account.id) {
|
@for (account of socialAccounts(); track account.id) {
|
||||||
<li class="list-group-item"
|
<li class="list-group-item"
|
||||||
ngbPopover="Set a password before disconnecting social account."
|
ngbPopover="Set a password before disconnecting social account."
|
||||||
i18n-ngbPopover
|
i18n-ngbPopover
|
||||||
[disablePopover]="hasUsablePassword"
|
[disablePopover]="hasUsablePassword()"
|
||||||
triggers="mouseenter:mouseleave">
|
triggers="mouseenter:mouseleave">
|
||||||
{{account.name}} ({{account.provider}})
|
{{account.name}} ({{account.provider}})
|
||||||
<pngx-confirm-button
|
<pngx-confirm-button
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||||
iconName="trash"
|
iconName="trash"
|
||||||
[disabled]="!hasUsablePassword"
|
[disabled]="!hasUsablePassword()"
|
||||||
(confirm)="disconnectSocialAccount(account.id)">
|
(confirm)="disconnectSocialAccount(account.id)">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
</li>
|
</li>
|
||||||
@@ -84,11 +84,11 @@
|
|||||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (socialAccountProviders?.length > 0) {
|
@if (socialAccountProviders().length > 0) {
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p i18n>Connect new social account</p>
|
<p i18n>Connect new social account</p>
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
@for (provider of socialAccountProviders; track provider.name) {
|
@for (provider of socialAccountProviders(); track provider.name) {
|
||||||
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
||||||
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
|
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!isTotpEnabled) {
|
@if (!isTotpEnabled()) {
|
||||||
<div ngbAccordion>
|
<div ngbAccordion>
|
||||||
<div ngbAccordionItem>
|
<div ngbAccordionItem>
|
||||||
<h2 ngbAccordionHeader>
|
<h2 ngbAccordionHeader>
|
||||||
@@ -105,10 +105,10 @@
|
|||||||
<div ngbAccordionCollapse>
|
<div ngbAccordionCollapse>
|
||||||
<div ngbAccordionBody>
|
<div ngbAccordionBody>
|
||||||
<ng-template>
|
<ng-template>
|
||||||
@if (totpSettingsLoading) {
|
@if (totpSettingsLoading()) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
} @else if (totpSettings) {
|
} @else if (totpSettings()) {
|
||||||
<figure class="figure">
|
<figure class="figure">
|
||||||
@if (qrSvgDataUrl) {
|
@if (qrSvgDataUrl) {
|
||||||
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
|
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
|
||||||
@@ -116,14 +116,14 @@
|
|||||||
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<p>
|
<p>
|
||||||
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
|
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>.
|
||||||
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
||||||
</p>
|
</p>
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
||||||
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
|
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()">
|
||||||
<ng-container i18n>Enable</ng-container>
|
<ng-container i18n>Enable</ng-container>
|
||||||
@if (totpLoading) {
|
@if (totpLoading()) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
}
|
}
|
||||||
@@ -137,18 +137,18 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
||||||
@if (recoveryCodes) {
|
@if (recoveryCodes()) {
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-row align-items-start mb-3">
|
<div class="d-flex flex-row align-items-start mb-3">
|
||||||
<ul class="list-group w-50">
|
<ul class="list-group w-50">
|
||||||
@for (code of recoveryCodes; track code; let i = $index) {
|
@for (code of recoveryCodes(); track code; let i = $index) {
|
||||||
@if (i % 2 === 0) {
|
@if (i % 2 === 0) {
|
||||||
<li class="list-group-item d-flex justify-content-around align-items-center">
|
<li class="list-group-item d-flex justify-content-around align-items-center">
|
||||||
<code>{{code}}</code>
|
<code>{{code}}</code>
|
||||||
@if (recoveryCodes[i + 1]) {
|
@if (recoveryCodes()[i + 1]) {
|
||||||
<code>{{recoveryCodes[i + 1]}}</code>
|
<code>{{recoveryCodes()[i + 1]}}</code>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
buttonClasses="btn-outline-danger btn-sm"
|
buttonClasses="btn-outline-danger btn-sm"
|
||||||
iconName="trash"
|
iconName="trash"
|
||||||
[disabled]="totpLoading"
|
[disabled]="totpLoading()"
|
||||||
(confirm)="deactivateTotp()">
|
(confirm)="deactivateTotp()">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-12
@@ -10,7 +10,7 @@ import {
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { NEVER, of, Subject, throwError } from 'rxjs'
|
||||||
import { ProfileService } from 'src/app/services/profile.service'
|
import { ProfileService } from 'src/app/services/profile.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import * as navUtils from 'src/app/utils/navigation'
|
import * as navUtils from 'src/app/utils/navigation'
|
||||||
@@ -60,6 +60,10 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
|
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
|
||||||
})
|
})
|
||||||
profileService = TestBed.inject(ProfileService)
|
profileService = TestBed.inject(ProfileService)
|
||||||
|
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
|
||||||
|
jest
|
||||||
|
.spyOn(profileService, 'getSocialAccountProviders')
|
||||||
|
.mockReturnValue(of([]))
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
clipboard = TestBed.inject(Clipboard)
|
clipboard = TestBed.inject(Clipboard)
|
||||||
fixture = TestBed.createComponent(ProfileEditDialogComponent)
|
fixture = TestBed.createComponent(ProfileEditDialogComponent)
|
||||||
@@ -155,7 +159,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
'getSocialAccountProviders'
|
'getSocialAccountProviders'
|
||||||
)
|
)
|
||||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
component.hasUsablePassword = true
|
component.hasUsablePassword.set(true)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.form.get('password').patchValue('new*pass')
|
component.form.get('password').patchValue('new*pass')
|
||||||
component.onPasswordKeyUp({
|
component.onPasswordKeyUp({
|
||||||
@@ -268,6 +272,27 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(getProvidersSpy).toHaveBeenCalled()
|
expect(getProvidersSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render social account providers after an async update', async () => {
|
||||||
|
const providers$ = new Subject<typeof socialAccountProviders>()
|
||||||
|
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
|
||||||
|
jest
|
||||||
|
.spyOn(profileService, 'getSocialAccountProviders')
|
||||||
|
.mockReturnValue(providers$)
|
||||||
|
|
||||||
|
component.ngOnInit()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="https://example.com"]')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
providers$.next(socialAccountProviders)
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="https://example.com"]')
|
||||||
|
).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should remove disconnected social account from component, show error if needed', () => {
|
it('should remove disconnected social account from component, show error if needed', () => {
|
||||||
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
@@ -276,7 +301,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
|
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
expect(component.socialAccounts).toContainEqual(socialAccount)
|
expect(component.socialAccounts()).toContainEqual(socialAccount)
|
||||||
|
|
||||||
// fail first
|
// fail first
|
||||||
disconnectSpy.mockReturnValueOnce(
|
disconnectSpy.mockReturnValueOnce(
|
||||||
@@ -289,7 +314,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
||||||
component.disconnectSocialAccount(socialAccount.id)
|
component.disconnectSocialAccount(socialAccount.id)
|
||||||
expect(disconnectSpy).toHaveBeenCalled()
|
expect(disconnectSpy).toHaveBeenCalled()
|
||||||
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
expect(component.socialAccounts()).not.toContainEqual(socialAccount)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get totp settings', () => {
|
it('should get totp settings', () => {
|
||||||
@@ -310,7 +335,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
getSpy.mockReturnValue(of(settings))
|
getSpy.mockReturnValue(of(settings))
|
||||||
component.gettotpSettings()
|
component.gettotpSettings()
|
||||||
expect(getSpy).toHaveBeenCalled()
|
expect(getSpy).toHaveBeenCalled()
|
||||||
expect(component.totpSettings).toEqual(settings)
|
expect(component.totpSettings()).toEqual(settings)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should activate totp', () => {
|
it('should activate totp', () => {
|
||||||
@@ -319,15 +344,15 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const error = new Error('failed to activate totp')
|
const error = new Error('failed to activate totp')
|
||||||
activateSpy.mockReturnValueOnce(throwError(() => error))
|
activateSpy.mockReturnValueOnce(throwError(() => error))
|
||||||
component.totpSettings = {
|
component.totpSettings.set({
|
||||||
url: 'http://localhost/',
|
url: 'http://localhost/',
|
||||||
qr_svg: 'svg',
|
qr_svg: 'svg',
|
||||||
secret: 'secret',
|
secret: 'secret',
|
||||||
}
|
})
|
||||||
component.form.get('totp_code').patchValue('123456')
|
component.form.get('totp_code').patchValue('123456')
|
||||||
component.activateTotp()
|
component.activateTotp()
|
||||||
expect(activateSpy).toHaveBeenCalledWith(
|
expect(activateSpy).toHaveBeenCalledWith(
|
||||||
component.totpSettings.secret,
|
component.totpSettings().secret,
|
||||||
component.form.get('totp_code').value
|
component.form.get('totp_code').value
|
||||||
)
|
)
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
@@ -341,8 +366,8 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
component.activateTotp()
|
component.activateTotp()
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
expect(component.isTotpEnabled).toBeTruthy()
|
expect(component.isTotpEnabled()).toBeTruthy()
|
||||||
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
|
expect(component.recoveryCodes()).toEqual(['1', '2', '3'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should deactivate totp', () => {
|
it('should deactivate totp', () => {
|
||||||
@@ -362,13 +387,13 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
deactivateSpy.mockReturnValueOnce(of(true))
|
deactivateSpy.mockReturnValueOnce(of(true))
|
||||||
component.deactivateTotp()
|
component.deactivateTotp()
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
expect(component.isTotpEnabled).toBeFalsy()
|
expect(component.isTotpEnabled()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should copy recovery codes', () => {
|
it('should copy recovery codes', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||||
component.recoveryCodes = ['1', '2', '3']
|
component.recoveryCodes.set(['1', '2', '3'])
|
||||||
component.copyRecoveryCodes()
|
component.copyRecoveryCodes()
|
||||||
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
||||||
jest.advanceTimersByTime(3000)
|
jest.advanceTimersByTime(3000)
|
||||||
|
|||||||
+36
-33
@@ -56,6 +56,14 @@ export class ProfileEditDialogComponent
|
|||||||
readonly showEmailConfirm = signal(false)
|
readonly showEmailConfirm = signal(false)
|
||||||
readonly copied = signal(false)
|
readonly copied = signal(false)
|
||||||
readonly codesCopied = signal(false)
|
readonly codesCopied = signal(false)
|
||||||
|
readonly hasUsablePassword = signal(false)
|
||||||
|
readonly isTotpEnabled = signal(false)
|
||||||
|
readonly totpSettings = signal<TotpSettings>(undefined)
|
||||||
|
readonly totpSettingsLoading = signal(false)
|
||||||
|
readonly totpLoading = signal(false)
|
||||||
|
readonly recoveryCodes = signal<string[]>(undefined)
|
||||||
|
readonly socialAccounts = signal<SocialAccount[]>([])
|
||||||
|
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
|
||||||
|
|
||||||
public form = new FormGroup({
|
public form = new FormGroup({
|
||||||
email: new FormControl(''),
|
email: new FormControl(''),
|
||||||
@@ -72,25 +80,15 @@ export class ProfileEditDialogComponent
|
|||||||
private newPassword: string
|
private newPassword: string
|
||||||
private passwordConfirm: string
|
private passwordConfirm: string
|
||||||
|
|
||||||
public hasUsablePassword: boolean = false
|
|
||||||
|
|
||||||
private currentEmail: string
|
private currentEmail: string
|
||||||
private newEmail: string
|
private newEmail: string
|
||||||
private emailConfirm: string
|
private emailConfirm: string
|
||||||
|
|
||||||
public isTotpEnabled: boolean = false
|
|
||||||
public totpSettings: TotpSettings
|
|
||||||
public totpSettingsLoading: boolean = false
|
|
||||||
public totpLoading: boolean = false
|
|
||||||
public recoveryCodes: string[]
|
|
||||||
public socialAccounts: SocialAccount[] = []
|
|
||||||
public socialAccountProviders: SocialAccountProvider[] = []
|
|
||||||
|
|
||||||
get qrSvgDataUrl(): string | null {
|
get qrSvgDataUrl(): string | null {
|
||||||
if (!this.totpSettings?.qr_svg) {
|
if (!this.totpSettings()?.qr_svg) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}`
|
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -107,20 +105,20 @@ export class ProfileEditDialogComponent
|
|||||||
this.onEmailChange()
|
this.onEmailChange()
|
||||||
})
|
})
|
||||||
this.currentPassword = profile.password
|
this.currentPassword = profile.password
|
||||||
this.hasUsablePassword = profile.has_usable_password
|
this.hasUsablePassword.set(profile.has_usable_password)
|
||||||
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
||||||
this.newPassword = newPassword
|
this.newPassword = newPassword
|
||||||
this.onPasswordChange()
|
this.onPasswordChange()
|
||||||
})
|
})
|
||||||
this.socialAccounts = profile.social_accounts
|
this.socialAccounts.set(profile.social_accounts ?? [])
|
||||||
this.isTotpEnabled = profile.is_mfa_enabled
|
this.isTotpEnabled.set(profile.is_mfa_enabled)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.profileService
|
this.profileService
|
||||||
.getSocialAccountProviders()
|
.getSocialAccountProviders()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((providers) => {
|
.subscribe((providers) => {
|
||||||
this.socialAccountProviders = providers
|
this.socialAccountProviders.set(providers ?? [])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +257,9 @@ export class ProfileEditDialogComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (id: number) => {
|
next: (id: number) => {
|
||||||
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
|
this.socialAccounts.update((accounts) =>
|
||||||
|
accounts.filter((account) => account.id != id)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -271,36 +271,39 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public gettotpSettings(): void {
|
public gettotpSettings(): void {
|
||||||
this.totpSettingsLoading = true
|
this.totpSettingsLoading.set(true)
|
||||||
this.profileService
|
this.profileService
|
||||||
.getTotpSettings()
|
.getTotpSettings()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (totpSettings) => {
|
next: (totpSettings) => {
|
||||||
this.totpSettingsLoading = false
|
this.totpSettingsLoading.set(false)
|
||||||
this.totpSettings = totpSettings
|
this.totpSettings.set(totpSettings)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error fetching TOTP settings`,
|
$localize`Error fetching TOTP settings`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
this.totpSettingsLoading = false
|
this.totpSettingsLoading.set(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public activateTotp(): void {
|
public activateTotp(): void {
|
||||||
this.totpLoading = true
|
this.totpLoading.set(true)
|
||||||
this.form.get('totp_code').disable()
|
this.form.get('totp_code').disable()
|
||||||
this.profileService
|
this.profileService
|
||||||
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
|
.activateTotp(
|
||||||
|
this.totpSettings().secret,
|
||||||
|
this.form.get('totp_code').value
|
||||||
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (activationResponse) => {
|
next: (activationResponse) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.isTotpEnabled = activationResponse.success
|
this.isTotpEnabled.set(activationResponse.success)
|
||||||
this.recoveryCodes = activationResponse.recovery_codes
|
this.recoveryCodes.set(activationResponse.recovery_codes)
|
||||||
this.form.get('totp_code').enable()
|
this.form.get('totp_code').enable()
|
||||||
if (activationResponse.success) {
|
if (activationResponse.success) {
|
||||||
this.toastService.showInfo($localize`TOTP activated successfully`)
|
this.toastService.showInfo($localize`TOTP activated successfully`)
|
||||||
@@ -309,7 +312,7 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.form.get('totp_code').enable()
|
this.form.get('totp_code').enable()
|
||||||
this.toastService.showError($localize`Error activating TOTP`, error)
|
this.toastService.showError($localize`Error activating TOTP`, error)
|
||||||
},
|
},
|
||||||
@@ -317,15 +320,15 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public deactivateTotp(): void {
|
public deactivateTotp(): void {
|
||||||
this.totpLoading = true
|
this.totpLoading.set(true)
|
||||||
this.profileService
|
this.profileService
|
||||||
.deactivateTotp()
|
.deactivateTotp()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (success) => {
|
next: (success) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.isTotpEnabled = !success
|
this.isTotpEnabled.set(!success)
|
||||||
this.recoveryCodes = null
|
this.recoveryCodes.set(null)
|
||||||
if (success) {
|
if (success) {
|
||||||
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
||||||
} else {
|
} else {
|
||||||
@@ -333,14 +336,14 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public copyRecoveryCodes(): void {
|
public copyRecoveryCodes(): void {
|
||||||
this.clipboard.copy(this.recoveryCodes.join('\n'))
|
this.clipboard.copy(this.recoveryCodes().join('\n'))
|
||||||
this.codesCopied.set(true)
|
this.codesCopied.set(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.codesCopied.set(false)
|
this.codesCopied.set(false)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<p i18n>Searching document with asn {{asn}}</p>
|
<p i18n>Searching document with asn {{asn()}}</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core'
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { FILTER_ASN } from '../../data/filter-rule-type'
|
import { FILTER_ASN } from '../../data/filter-rule-type'
|
||||||
import { DocumentService } from '../../services/rest/document.service'
|
import { DocumentService } from '../../services/rest/document.service'
|
||||||
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute)
|
private route = inject(ActivatedRoute)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
|
|
||||||
asn: string
|
readonly asn = signal<string>(undefined)
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.route.paramMap.subscribe((paramMap) => {
|
this.route.paramMap.subscribe((paramMap) => {
|
||||||
this.asn = paramMap.get('id')
|
this.asn.set(paramMap.get('id'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
|
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }])
|
||||||
.subscribe((documentId) => {
|
.subscribe((documentId) => {
|
||||||
if (documentId.length == 1) {
|
if (documentId.length == 1) {
|
||||||
this.router.navigate(['documents', documentId[0]])
|
this.router.navigate(['documents', documentId[0]])
|
||||||
|
|||||||
+6
-6
@@ -5,7 +5,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||||
<div class="px-3 py-2 mb-2">
|
<div class="px-3 py-2 mb-2">
|
||||||
@if (versionUploadState === UploadState.Idle) {
|
@if (versionUploadState() === UploadState.Idle) {
|
||||||
<div class="input-group input-group-sm mb-2">
|
<div class="input-group input-group-sm mb-2">
|
||||||
<label class="input-group-text" for="newVersionLabel" i18n>Label</label>
|
<label class="input-group-text" for="newVersionLabel" i18n>Label</label>
|
||||||
<input
|
<input
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
@switch (versionUploadState) {
|
@switch (versionUploadState()) {
|
||||||
@case (UploadState.Uploading) {
|
@case (UploadState.Uploading) {
|
||||||
<div class="small text-muted mt-1 d-flex align-items-center">
|
<div class="small text-muted mt-1 d-flex align-items-center">
|
||||||
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
|
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
<span i18n>Version upload failed.</span>
|
<span i18n>Version upload failed.</span>
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
|
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
@if (versionUploadError) {
|
@if (versionUploadError()) {
|
||||||
<div class="small text-muted mt-1">{{ versionUploadError }}</div>
|
<div class="small text-muted mt-1">{{ versionUploadError() }}</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
[(ngModel)]="versionLabelDraft"
|
[(ngModel)]="versionLabelDraft"
|
||||||
i18n-placeholder
|
i18n-placeholder
|
||||||
placeholder="Version label"
|
placeholder="Version label"
|
||||||
[disabled]="savingVersionLabelId !== null"
|
[disabled]="savingVersionLabelId() !== null"
|
||||||
(keydown.enter)="submitEditedVersionLabel(version, $event)"
|
(keydown.enter)="submitEditedVersionLabel(version, $event)"
|
||||||
(keydown.escape)="cancelEditingVersion($event)"
|
(keydown.escape)="cancelEditingVersion($event)"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
[disabled]="savingVersionLabelId !== null"
|
[disabled]="savingVersionLabelId() !== null"
|
||||||
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
|
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
|
||||||
>
|
>
|
||||||
@if (isEditingVersion(version.id)) {
|
@if (isEditingVersion(version.id)) {
|
||||||
|
|||||||
+17
-15
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
|
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
|
||||||
])
|
])
|
||||||
expect(component.savingVersionLabelId).toBeNull()
|
expect(component.savingVersionLabelId()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saveVersionLabel should show error toast on failure', () => {
|
it('saveVersionLabel should show error toast on failure', () => {
|
||||||
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
'Error updating version label',
|
'Error updating version label',
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
expect(component.savingVersionLabelId).toBeNull()
|
expect(component.savingVersionLabelId()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
||||||
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||||
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
|
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
|
||||||
expect(component.newVersionLabel).toEqual('')
|
expect(component.newVersionLabel).toEqual('')
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
expect(component.versionUploadState()).toEqual(UploadState.Idle)
|
||||||
expect(component.versionUploadError).toBeNull()
|
expect(component.versionUploadError()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onVersionFileSelected should set failed state after websocket failure', () => {
|
it('onVersionFileSelected should render failed state after websocket failure', async () => {
|
||||||
const file = new File(['test'], 'new-version.pdf', {
|
const file = new File(['test'], 'new-version.pdf', {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
})
|
})
|
||||||
@@ -266,9 +266,11 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
failed$.next({ taskId: 'task-1', message: 'processing failed' })
|
failed$.next({ taskId: 'task-1', message: 'processing failed' })
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('processing failed')
|
expect(component.versionUploadError()).toEqual('processing failed')
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('processing failed')
|
||||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,8 +284,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('Missing task ID.')
|
expect(component.versionUploadError()).toEqual('Missing task ID.')
|
||||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,8 +300,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('upload failed')
|
expect(component.versionUploadError()).toEqual('upload failed')
|
||||||
expect(toastService.showError).toHaveBeenCalledWith(
|
expect(toastService.showError).toHaveBeenCalledWith(
|
||||||
'Error uploading new version',
|
'Error uploading new version',
|
||||||
error
|
error
|
||||||
@@ -307,8 +309,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ngOnChanges should clear upload status on document switch', () => {
|
it('ngOnChanges should clear upload status on document switch', () => {
|
||||||
component.versionUploadState = UploadState.Failed
|
component.versionUploadState.set(UploadState.Failed)
|
||||||
component.versionUploadError = 'something failed'
|
component.versionUploadError.set('something failed')
|
||||||
component.editingVersionId = 10
|
component.editingVersionId = 10
|
||||||
component.versionLabelDraft = 'draft'
|
component.versionLabelDraft = 'draft'
|
||||||
|
|
||||||
@@ -316,8 +318,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
documentId: new SimpleChange(3, 4, false),
|
documentId: new SimpleChange(3, 4, false),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
expect(component.versionUploadState()).toEqual(UploadState.Idle)
|
||||||
expect(component.versionUploadError).toBeNull()
|
expect(component.versionUploadError()).toBeNull()
|
||||||
expect(component.editingVersionId).toBeNull()
|
expect(component.editingVersionId).toBeNull()
|
||||||
expect(component.versionLabelDraft).toEqual('')
|
expect(component.versionLabelDraft).toEqual('')
|
||||||
})
|
})
|
||||||
|
|||||||
+24
-20
@@ -7,6 +7,7 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
Output,
|
Output,
|
||||||
|
signal,
|
||||||
SimpleChanges,
|
SimpleChanges,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
@@ -59,9 +60,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
|
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
|
||||||
|
|
||||||
newVersionLabel: string = ''
|
newVersionLabel: string = ''
|
||||||
versionUploadState: UploadState = UploadState.Idle
|
readonly versionUploadState = signal(UploadState.Idle)
|
||||||
versionUploadError: string | null = null
|
readonly versionUploadError = signal<string | null>(null)
|
||||||
savingVersionLabelId: number | null = null
|
readonly savingVersionLabelId = signal<number | null>(null)
|
||||||
editingVersionId: number | null = null
|
editingVersionId: number | null = null
|
||||||
versionLabelDraft: string = ''
|
versionLabelDraft: string = ''
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
|
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
if (!this.canEditLabels || this.savingVersionLabelId !== null) return
|
if (!this.canEditLabels || this.savingVersionLabelId() !== null) return
|
||||||
this.editingVersionId = version.id
|
this.editingVersionId = version.id
|
||||||
this.versionLabelDraft = version.version_label ?? ''
|
this.versionLabelDraft = version.version_label ?? ''
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
|
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
if (this.savingVersionLabelId !== null) return
|
if (this.savingVersionLabelId() !== null) return
|
||||||
const nextLabel = this.versionLabelDraft?.trim() || null
|
const nextLabel = this.versionLabelDraft?.trim() || null
|
||||||
const currentLabel = version.version_label?.trim() || null
|
const currentLabel = version.version_label?.trim() || null
|
||||||
if (nextLabel === currentLabel) {
|
if (nextLabel === currentLabel) {
|
||||||
@@ -158,15 +159,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveVersionLabel(versionId: number, versionLabel: string | null): void {
|
saveVersionLabel(versionId: number, versionLabel: string | null): void {
|
||||||
if (this.savingVersionLabelId !== null) return
|
if (this.savingVersionLabelId() !== null) return
|
||||||
this.savingVersionLabelId = versionId
|
this.savingVersionLabelId.set(versionId)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.updateVersionLabel(this.documentId, versionId, versionLabel)
|
.updateVersionLabel(this.documentId, versionId, versionLabel)
|
||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
if (this.savingVersionLabelId === versionId) {
|
if (this.savingVersionLabelId() === versionId) {
|
||||||
this.savingVersionLabelId = null
|
this.savingVersionLabelId.set(null)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
@@ -199,8 +200,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
const file = input.files[0]
|
const file = input.files[0]
|
||||||
input.value = ''
|
input.value = ''
|
||||||
const label = this.newVersionLabel?.trim()
|
const label = this.newVersionLabel?.trim()
|
||||||
this.versionUploadState = UploadState.Uploading
|
this.versionUploadState.set(UploadState.Uploading)
|
||||||
this.versionUploadError = null
|
this.versionUploadError.set(null)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.uploadVersion(uploadDocumentId, file, label)
|
.uploadVersion(uploadDocumentId, file, label)
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -210,7 +211,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
$localize`Uploading new version. Processing will happen in the background.`
|
$localize`Uploading new version. Processing will happen in the background.`
|
||||||
)
|
)
|
||||||
this.newVersionLabel = ''
|
this.newVersionLabel = ''
|
||||||
this.versionUploadState = UploadState.Processing
|
this.versionUploadState.set(UploadState.Processing)
|
||||||
}),
|
}),
|
||||||
map((taskId) =>
|
map((taskId) =>
|
||||||
typeof taskId === 'string'
|
typeof taskId === 'string'
|
||||||
@@ -219,8 +220,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
),
|
),
|
||||||
switchMap((taskId) => {
|
switchMap((taskId) => {
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError = $localize`Missing task ID.`
|
this.versionUploadError.set($localize`Missing task ID.`)
|
||||||
return of(null)
|
return of(null)
|
||||||
}
|
}
|
||||||
return merge(
|
return merge(
|
||||||
@@ -240,9 +241,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
switchMap((result) => {
|
switchMap((result) => {
|
||||||
if (result?.state !== 'success') {
|
if (result?.state !== 'success') {
|
||||||
if (result?.state === 'failed') {
|
if (result?.state === 'failed') {
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError =
|
this.versionUploadError.set(
|
||||||
result.message || $localize`Upload failed.`
|
result.message || $localize`Upload failed.`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return of(null)
|
return of(null)
|
||||||
}
|
}
|
||||||
@@ -264,8 +266,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (uploadDocumentId !== this.documentId) return
|
if (uploadDocumentId !== this.documentId) return
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError = error?.message || $localize`Upload failed.`
|
this.versionUploadError.set(
|
||||||
|
error?.message || $localize`Upload failed.`
|
||||||
|
)
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error uploading new version`,
|
$localize`Error uploading new version`,
|
||||||
error
|
error
|
||||||
@@ -275,7 +279,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearVersionUploadStatus(): void {
|
clearVersionUploadStatus(): void {
|
||||||
this.versionUploadState = UploadState.Idle
|
this.versionUploadState.set(UploadState.Idle)
|
||||||
this.versionUploadError = null
|
this.versionUploadError.set(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
[createRef]="createTag.bind(this)"
|
[createRef]="createTag.bind(this)"
|
||||||
(opened)="openTagsDropdown()"
|
(opened)="openTagsDropdown()"
|
||||||
[(selectionModel)]="tagSelectionModel"
|
[(selectionModel)]="tagSelectionModel"
|
||||||
[documentCounts]="tagDocumentCounts"
|
[documentCounts]="tagDocumentCounts()"
|
||||||
(apply)="setTags($event)"
|
(apply)="setTags($event)"
|
||||||
shortcutKey="t">
|
shortcutKey="t">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
[createRef]="createCorrespondent.bind(this)"
|
[createRef]="createCorrespondent.bind(this)"
|
||||||
(opened)="openCorrespondentDropdown()"
|
(opened)="openCorrespondentDropdown()"
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
[documentCounts]="correspondentDocumentCounts()"
|
||||||
(apply)="setCorrespondents($event)"
|
(apply)="setCorrespondents($event)"
|
||||||
shortcutKey="y">
|
shortcutKey="y">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
[createRef]="createDocumentType.bind(this)"
|
[createRef]="createDocumentType.bind(this)"
|
||||||
(opened)="openDocumentTypeDropdown()"
|
(opened)="openDocumentTypeDropdown()"
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
[documentCounts]="documentTypeDocumentCounts()"
|
||||||
(apply)="setDocumentTypes($event)"
|
(apply)="setDocumentTypes($event)"
|
||||||
shortcutKey="u">
|
shortcutKey="u">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
[createRef]="createStoragePath.bind(this)"
|
[createRef]="createStoragePath.bind(this)"
|
||||||
(opened)="openStoragePathDropdown()"
|
(opened)="openStoragePathDropdown()"
|
||||||
[(selectionModel)]="storagePathsSelectionModel"
|
[(selectionModel)]="storagePathsSelectionModel"
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
[documentCounts]="storagePathDocumentCounts()"
|
||||||
(apply)="setStoragePaths($event)"
|
(apply)="setStoragePaths($event)"
|
||||||
shortcutKey="i">
|
shortcutKey="i">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
[createRef]="createCustomField.bind(this)"
|
[createRef]="createCustomField.bind(this)"
|
||||||
(opened)="openCustomFieldsDropdown()"
|
(opened)="openCustomFieldsDropdown()"
|
||||||
[(selectionModel)]="customFieldsSelectionModel"
|
[(selectionModel)]="customFieldsSelectionModel"
|
||||||
[documentCounts]="customFieldDocumentCounts"
|
[documentCounts]="customFieldDocumentCounts()"
|
||||||
extraButtonTitle="Set values"
|
extraButtonTitle="Set values"
|
||||||
i18n-extraButtonTitle
|
i18n-extraButtonTitle
|
||||||
(extraButton)="setCustomFieldValues($event)"
|
(extraButton)="setCustomFieldValues($event)"
|
||||||
@@ -124,11 +124,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()">
|
||||||
@if (!awaitingDownload) {
|
@if (!awaitingDownload()) {
|
||||||
<i-bs name="arrow-down"></i-bs>
|
<i-bs name="arrow-down"></i-bs>
|
||||||
}
|
}
|
||||||
@if (awaitingDownload) {
|
@if (awaitingDownload()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status">
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
<span class="visually-hidden">Preparing download...</span>
|
<span class="visually-hidden">Preparing download...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openDocumentTypeDropdown()
|
component.openDocumentTypeDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.documentTypeDocumentCounts).toEqual(
|
expect(component.documentTypeDocumentCounts()).toEqual(
|
||||||
selectionData.selected_document_types
|
selectionData.selected_document_types
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -320,7 +320,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openCorrespondentDropdown()
|
component.openCorrespondentDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.correspondentDocumentCounts).toEqual(
|
expect(component.correspondentDocumentCounts()).toEqual(
|
||||||
selectionData.selected_correspondents
|
selectionData.selected_correspondents
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -337,7 +337,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openStoragePathDropdown()
|
component.openStoragePathDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.storagePathDocumentCounts).toEqual(
|
expect(component.storagePathDocumentCounts()).toEqual(
|
||||||
selectionData.selected_storage_paths
|
selectionData.selected_storage_paths
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -354,7 +354,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openCustomFieldsDropdown()
|
component.openCustomFieldsDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.customFieldDocumentCounts).toEqual(
|
expect(component.customFieldDocumentCounts()).toEqual(
|
||||||
selectionData.selected_custom_fields
|
selectionData.selected_custom_fields
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -101,12 +108,12 @@ export class BulkEditorComponent
|
|||||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
|
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
tagDocumentCounts: SelectionDataItem[]
|
readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
correspondentDocumentCounts: SelectionDataItem[]
|
readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
documentTypeDocumentCounts: SelectionDataItem[]
|
readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
storagePathDocumentCounts: SelectionDataItem[]
|
readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
customFieldDocumentCounts: SelectionDataItem[]
|
readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
awaitingDownload: boolean
|
readonly awaitingDownload = signal(false)
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
@@ -365,8 +372,8 @@ export class BulkEditorComponent
|
|||||||
openTagsDropdown() {
|
openTagsDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.tagDocumentCounts = selectionData?.selected_tags ?? []
|
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
|
||||||
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
|
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +381,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.tagDocumentCounts = s.selected_tags
|
this.tagDocumentCounts.set(s.selected_tags)
|
||||||
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
|
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -382,10 +389,11 @@ export class BulkEditorComponent
|
|||||||
openDocumentTypeDropdown() {
|
openDocumentTypeDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.documentTypeDocumentCounts =
|
this.documentTypeDocumentCounts.set(
|
||||||
selectionData?.selected_document_types ?? []
|
selectionData?.selected_document_types ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.documentTypeDocumentCounts,
|
this.documentTypeDocumentCounts(),
|
||||||
this.documentTypeSelectionModel
|
this.documentTypeSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -395,7 +403,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.documentTypeDocumentCounts = s.selected_document_types
|
this.documentTypeDocumentCounts.set(s.selected_document_types)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_document_types,
|
s.selected_document_types,
|
||||||
this.documentTypeSelectionModel
|
this.documentTypeSelectionModel
|
||||||
@@ -406,10 +414,11 @@ export class BulkEditorComponent
|
|||||||
openCorrespondentDropdown() {
|
openCorrespondentDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.correspondentDocumentCounts =
|
this.correspondentDocumentCounts.set(
|
||||||
selectionData?.selected_correspondents ?? []
|
selectionData?.selected_correspondents ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.correspondentDocumentCounts,
|
this.correspondentDocumentCounts(),
|
||||||
this.correspondentSelectionModel
|
this.correspondentSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -419,7 +428,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.correspondentDocumentCounts = s.selected_correspondents
|
this.correspondentDocumentCounts.set(s.selected_correspondents)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_correspondents,
|
s.selected_correspondents,
|
||||||
this.correspondentSelectionModel
|
this.correspondentSelectionModel
|
||||||
@@ -430,10 +439,11 @@ export class BulkEditorComponent
|
|||||||
openStoragePathDropdown() {
|
openStoragePathDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.storagePathDocumentCounts =
|
this.storagePathDocumentCounts.set(
|
||||||
selectionData?.selected_storage_paths ?? []
|
selectionData?.selected_storage_paths ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.storagePathDocumentCounts,
|
this.storagePathDocumentCounts(),
|
||||||
this.storagePathsSelectionModel
|
this.storagePathsSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -443,7 +453,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.storagePathDocumentCounts = s.selected_storage_paths
|
this.storagePathDocumentCounts.set(s.selected_storage_paths)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_storage_paths,
|
s.selected_storage_paths,
|
||||||
this.storagePathsSelectionModel
|
this.storagePathsSelectionModel
|
||||||
@@ -454,10 +464,11 @@ export class BulkEditorComponent
|
|||||||
openCustomFieldsDropdown() {
|
openCustomFieldsDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.customFieldDocumentCounts =
|
this.customFieldDocumentCounts.set(
|
||||||
selectionData?.selected_custom_fields ?? []
|
selectionData?.selected_custom_fields ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.customFieldDocumentCounts,
|
this.customFieldDocumentCounts(),
|
||||||
this.customFieldsSelectionModel
|
this.customFieldsSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -467,7 +478,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.customFieldDocumentCounts = s.selected_custom_fields
|
this.customFieldDocumentCounts.set(s.selected_custom_fields)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_custom_fields,
|
s.selected_custom_fields,
|
||||||
this.customFieldsSelectionModel
|
this.customFieldsSelectionModel
|
||||||
@@ -876,7 +887,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadSelected() {
|
downloadSelected() {
|
||||||
this.awaitingDownload = true
|
this.awaitingDownload.set(true)
|
||||||
let downloadFileType: string =
|
let downloadFileType: string =
|
||||||
this.downloadForm.get('downloadFileTypeArchive').value &&
|
this.downloadForm.get('downloadFileTypeArchive').value &&
|
||||||
this.downloadForm.get('downloadFileTypeOriginals').value
|
this.downloadForm.get('downloadFileTypeOriginals').value
|
||||||
@@ -893,7 +904,7 @@ export class BulkEditorComponent
|
|||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((result: any) => {
|
.subscribe((result: any) => {
|
||||||
saveAs(result, 'documents.zip')
|
saveAs(result, 'documents.zip')
|
||||||
this.awaitingDownload = false
|
this.awaitingDownload.set(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
||||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
QueryList,
|
QueryList,
|
||||||
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
@@ -149,7 +150,7 @@ export class DocumentListComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
unmodifiedFilterRules: FilterRule[] = []
|
readonly unmodifiedFilterRules = signal<FilterRule[]>([])
|
||||||
private unmodifiedSavedView: SavedView
|
private unmodifiedSavedView: SavedView
|
||||||
private activeSavedView: SavedView | null = null
|
private activeSavedView: SavedView | null = null
|
||||||
|
|
||||||
@@ -299,7 +300,7 @@ export class DocumentListComponent
|
|||||||
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
||||||
})
|
})
|
||||||
this.updateDisplayCustomFields()
|
this.updateDisplayCustomFields()
|
||||||
this.unmodifiedFilterRules = view.filter_rules
|
this.unmodifiedFilterRules.set(view.filter_rules)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.route.queryParamMap
|
this.route.queryParamMap
|
||||||
@@ -316,7 +317,7 @@ export class DocumentListComponent
|
|||||||
this.activeSavedView = null
|
this.activeSavedView = null
|
||||||
this.list.activateSavedView(null)
|
this.list.activateSavedView(null)
|
||||||
this.list.loadFromQueryParams(queryParams)
|
this.list.loadFromQueryParams(queryParams)
|
||||||
this.unmodifiedFilterRules = []
|
this.unmodifiedFilterRules.set([])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -415,7 +416,7 @@ export class DocumentListComponent
|
|||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||||
)
|
)
|
||||||
this.unmodifiedFilterRules = this.list.filterRules
|
this.unmodifiedFilterRules.set(this.list.filterRules)
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
shortcutKey="i"></pngx-filterable-dropdown>
|
shortcutKey="i"></pngx-filterable-dropdown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields().length > 0) {
|
||||||
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
|
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
|
||||||
[(selectionModel)]="customFieldQueriesModel"
|
[(selectionModel)]="customFieldQueriesModel"
|
||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -349,7 +351,7 @@ export class FilterEditorComponent
|
|||||||
@ViewChild('textFilterInput')
|
@ViewChild('textFilterInput')
|
||||||
textFilterInput: ElementRef
|
textFilterInput: ElementRef
|
||||||
|
|
||||||
customFields: CustomField[] = []
|
readonly customFields = signal<CustomField[]>([])
|
||||||
|
|
||||||
tagDocumentCounts: SelectionDataItem[]
|
tagDocumentCounts: SelectionDataItem[]
|
||||||
correspondentDocumentCounts: SelectionDataItem[]
|
correspondentDocumentCounts: SelectionDataItem[]
|
||||||
@@ -514,6 +516,7 @@ export class FilterEditorComponent
|
|||||||
this.documentService.get(this._moreLikeId).subscribe((result) => {
|
this.documentService.get(this._moreLikeId).subscribe((result) => {
|
||||||
this._moreLikeDoc = result
|
this._moreLikeDoc = result
|
||||||
this._textFilter = result.title
|
this._textFilter = result.title
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case FILTER_CREATED_AFTER:
|
case FILTER_CREATED_AFTER:
|
||||||
@@ -1162,6 +1165,7 @@ export class FilterEditorComponent
|
|||||||
|
|
||||||
private loadingCountTotal: number = 0
|
private loadingCountTotal: number = 0
|
||||||
private loadingCount: number = 0
|
private loadingCount: number = 0
|
||||||
|
private readonly changeDetector = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
private maybeCompleteLoading() {
|
private maybeCompleteLoading() {
|
||||||
this.loadingCount++
|
this.loadingCount++
|
||||||
@@ -1229,7 +1233,7 @@ export class FilterEditorComponent
|
|||||||
) {
|
) {
|
||||||
this.loadingCountTotal++
|
this.loadingCountTotal++
|
||||||
this.customFieldService.listAll().subscribe((result) => {
|
this.customFieldService.listAll().subscribe((result) => {
|
||||||
this.customFields = result.results
|
this.customFields.set(result.results)
|
||||||
this.maybeCompleteLoading()
|
this.maybeCompleteLoading()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user