mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-10 05:55:09 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22e2f5ba35 | |||
| f244442c65 |
+1
-3
@@ -155,9 +155,7 @@
|
|||||||
"builder": "@angular-builders/jest:run",
|
"builder": "@angular-builders/jest:run",
|
||||||
"options": {
|
"options": {
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"zoneless": true,
|
"zoneless": false
|
||||||
"watch": false,
|
|
||||||
"coverage": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
|
|||||||
+5
-24
@@ -3,27 +3,6 @@ const angularTemplatePlugin = require('@angular-eslint/eslint-plugin-template')
|
|||||||
const angularTemplateParser = require('@angular-eslint/template-parser')
|
const angularTemplateParser = require('@angular-eslint/template-parser')
|
||||||
const tsParser = require('@typescript-eslint/parser')
|
const tsParser = require('@typescript-eslint/parser')
|
||||||
|
|
||||||
const angularTsRecommendedRules = {
|
|
||||||
'@angular-eslint/contextual-lifecycle': 'error',
|
|
||||||
'@angular-eslint/no-empty-lifecycle-method': 'error',
|
|
||||||
'@angular-eslint/no-input-rename': 'error',
|
|
||||||
'@angular-eslint/no-inputs-metadata-property': 'error',
|
|
||||||
'@angular-eslint/no-output-native': 'error',
|
|
||||||
'@angular-eslint/no-output-on-prefix': 'error',
|
|
||||||
'@angular-eslint/no-output-rename': 'error',
|
|
||||||
'@angular-eslint/no-outputs-metadata-property': 'error',
|
|
||||||
'@angular-eslint/prefer-inject': 'error',
|
|
||||||
'@angular-eslint/prefer-standalone': 'error',
|
|
||||||
'@angular-eslint/use-pipe-transform-interface': 'error',
|
|
||||||
'@angular-eslint/use-lifecycle-interface': 'warn',
|
|
||||||
}
|
|
||||||
const angularTemplateRecommendedRules = {
|
|
||||||
'@angular-eslint/template/banana-in-box': 'error',
|
|
||||||
'@angular-eslint/template/eqeqeq': 'error',
|
|
||||||
'@angular-eslint/template/no-negated-async': 'error',
|
|
||||||
'@angular-eslint/template/prefer-control-flow': 'error',
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
{
|
{
|
||||||
ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'],
|
ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'],
|
||||||
@@ -33,6 +12,8 @@ module.exports = [
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
project: ['tsconfig.json'],
|
||||||
|
createDefaultProgram: true,
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
@@ -41,9 +22,9 @@ module.exports = [
|
|||||||
'@angular-eslint': angularEslintPlugin,
|
'@angular-eslint': angularEslintPlugin,
|
||||||
'@angular-eslint/template': angularTemplatePlugin,
|
'@angular-eslint/template': angularTemplatePlugin,
|
||||||
},
|
},
|
||||||
processor: angularTemplatePlugin.processors['extract-inline-html'],
|
processor: '@angular-eslint/template/extract-inline-html',
|
||||||
rules: {
|
rules: {
|
||||||
...angularTsRecommendedRules,
|
...angularEslintPlugin.configs.recommended.rules,
|
||||||
'@angular-eslint/directive-selector': [
|
'@angular-eslint/directive-selector': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
@@ -71,7 +52,7 @@ module.exports = [
|
|||||||
'@angular-eslint/template': angularTemplatePlugin,
|
'@angular-eslint/template': angularTemplatePlugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...angularTemplateRecommendedRules,
|
...angularTemplatePlugin.configs.recommended.rules,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+40
-30
@@ -6,22 +6,22 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"test": "ng test",
|
"test": "ng test --no-watch --coverage",
|
||||||
"lint": "ng lint"
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.0.3",
|
"@angular/cdk": "^21.2.12",
|
||||||
"@angular/common": "~22.0.5",
|
"@angular/common": "~21.2.17",
|
||||||
"@angular/compiler": "~22.0.5",
|
"@angular/compiler": "~21.2.17",
|
||||||
"@angular/core": "~22.0.5",
|
"@angular/core": "~21.2.17",
|
||||||
"@angular/forms": "~22.0.5",
|
"@angular/forms": "~21.2.14",
|
||||||
"@angular/localize": "~22.0.5",
|
"@angular/localize": "~21.2.14",
|
||||||
"@angular/platform-browser": "~22.0.5",
|
"@angular/platform-browser": "~21.2.14",
|
||||||
"@angular/platform-browser-dynamic": "~22.0.5",
|
"@angular/platform-browser-dynamic": "~21.2.14",
|
||||||
"@angular/router": "~22.0.5",
|
"@angular/router": "~21.2.14",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@ng-select/ng-select": "^23.2.0",
|
"@ng-select/ng-select": "^21.8.2",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -29,29 +29,30 @@
|
|||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
"ngx-cookie-service": "^22.0.0",
|
"ngx-cookie-service": "^21.3.1",
|
||||||
"ngx-device-detector": "^12.0.0",
|
"ngx-device-detector": "^11.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
||||||
"normalize-diacritics": "^5.0.0",
|
"normalize-diacritics": "^5.0.0",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.0.227",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"uuid": "^14.0.1"
|
"uuid": "^14.0.1",
|
||||||
|
"zone.js": "^0.16.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^22.0.1",
|
"@angular-builders/custom-webpack": "^21.0.3",
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^21.0.3",
|
||||||
"@angular-devkit/core": "^22.0.5",
|
"@angular-devkit/core": "^21.2.12",
|
||||||
"@angular-devkit/schematics": "^22.0.5",
|
"@angular-devkit/schematics": "^21.2.12",
|
||||||
"@angular-eslint/builder": "22.0.0",
|
"@angular-eslint/builder": "21.4.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.0.0",
|
"@angular-eslint/eslint-plugin": "21.4.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
"@angular-eslint/eslint-plugin-template": "21.4.0",
|
||||||
"@angular-eslint/schematics": "22.0.0",
|
"@angular-eslint/schematics": "21.4.0",
|
||||||
"@angular-eslint/template-parser": "22.0.0",
|
"@angular-eslint/template-parser": "21.4.0",
|
||||||
"@angular/build": "^22.0.5",
|
"@angular/build": "^21.2.12",
|
||||||
"@angular/cli": "~22.0.5",
|
"@angular/cli": "~21.2.12",
|
||||||
"@angular/compiler-cli": "~22.0.5",
|
"@angular/compiler-cli": "~21.2.14",
|
||||||
"@codecov/webpack-plugin": "^2.0.1",
|
"@codecov/webpack-plugin": "^2.0.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
@@ -67,8 +68,17 @@
|
|||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^5.9.3",
|
||||||
"webpack": "^5.107.2"
|
"webpack": "^5.107.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1"
|
"packageManager": "pnpm@10.17.1",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"canvas",
|
||||||
|
"esbuild",
|
||||||
|
"lmdb",
|
||||||
|
"msgpackr-extract"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2518
-1915
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
packages:
|
|
||||||
- "."
|
|
||||||
allowBuilds:
|
|
||||||
"@parcel/watcher": true
|
|
||||||
canvas: true
|
|
||||||
esbuild: true
|
|
||||||
lmdb: true
|
|
||||||
msgpackr-extract: true
|
|
||||||
normalize-diacritics: false
|
|
||||||
unrs-resolver: false
|
|
||||||
onlyBuiltDependencies:
|
|
||||||
- "@parcel/watcher"
|
|
||||||
- canvas
|
|
||||||
- esbuild
|
|
||||||
- lmdb
|
|
||||||
- msgpackr-extract
|
|
||||||
ignoredBuiltDependencies:
|
|
||||||
- normalize-diacritics
|
|
||||||
- unrs-resolver
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import '@angular/localize/init'
|
import '@angular/localize/init'
|
||||||
import { jest } from '@jest/globals'
|
import { jest } from '@jest/globals'
|
||||||
import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless'
|
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'
|
||||||
import { TextDecoder, TextEncoder } from 'node:util'
|
import { TextDecoder, TextEncoder } from 'node:util'
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
setupZonelessTestEnv()
|
setupZoneTestEnv()
|
||||||
}
|
}
|
||||||
;(globalThis as any).TextEncoder = TextEncoder as unknown as {
|
;(globalThis as any).TextEncoder = TextEncoder as unknown as {
|
||||||
new (): TextEncoder
|
new (): TextEncoder
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
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,
|
||||||
|
fakeAsync,
|
||||||
|
TestBed,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { Router, RouterModule } from '@angular/router'
|
import { Router, RouterModule } from '@angular/router'
|
||||||
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
@@ -67,8 +72,7 @@ describe('AppComponent', () => {
|
|||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize the tour service & toggle class on body for styling', () => {
|
it('should initialize the tour service & toggle class on body for styling', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const tourSpy = jest.spyOn(tourService, 'initialize')
|
const tourSpy = jest.spyOn(tourService, 'initialize')
|
||||||
@@ -77,10 +81,9 @@ describe('AppComponent', () => {
|
|||||||
tourService.start()
|
tourService.start()
|
||||||
expect(document.body.classList).toContain('tour-active')
|
expect(document.body.classList).toContain('tour-active')
|
||||||
tourService.end()
|
tourService.end()
|
||||||
jest.advanceTimersByTime(500)
|
tick(500)
|
||||||
expect(document.body.classList).not.toContain('tour-active')
|
expect(document.body.classList).not.toContain('tour-active')
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should display toast on document consumed with link if user has access', () => {
|
it('should display toast on document consumed with link if user has access', () => {
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ export class ConfigComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.toastService.showError($localize`Error retrieving config`, e)
|
this.toastService.showError($localize`Error retrieving config`, e)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -162,20 +162,20 @@ export class ConfigComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public saveConfig() {
|
public saveConfig() {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.configService
|
this.configService
|
||||||
.saveConfig(this.configForm.value as PaperlessConfig)
|
.saveConfig(this.configForm.value as PaperlessConfig)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
this.store.next(config)
|
this.store.next(config)
|
||||||
this.settingsService.initializeSettings().subscribe()
|
this.settingsService.initializeSettings().subscribe()
|
||||||
this.toastService.showInfo($localize`Configuration updated`)
|
this.toastService.showInfo($localize`Configuration updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`An error occurred updating configuration`,
|
$localize`An error occurred updating configuration`,
|
||||||
e
|
e
|
||||||
@@ -189,20 +189,20 @@ export class ConfigComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public uploadFile(file: File, key: string) {
|
public uploadFile(file: File, key: string) {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.configService
|
this.configService
|
||||||
.uploadFile(file, this.configForm.value['id'], key)
|
.uploadFile(file, this.configForm.value['id'], key)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
this.store.next(config)
|
this.store.next(config)
|
||||||
this.settingsService.initializeSettings().subscribe()
|
this.settingsService.initializeSettings().subscribe()
|
||||||
this.toastService.showInfo($localize`File successfully updated`)
|
this.toastService.showInfo($localize`File successfully updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`An error occurred uploading file`,
|
$localize`An error occurred uploading file`,
|
||||||
e
|
e
|
||||||
|
|||||||
@@ -11,30 +11,30 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="100"
|
min="100"
|
||||||
step="100"
|
step="100"
|
||||||
[ngModel]="limit()"
|
[(ngModel)]="limit"
|
||||||
(ngModelChange)="limit.set($event); onLimitChange($event)"
|
(ngModelChange)="onLimitChange($event)"
|
||||||
style="width: 100px;">
|
style="width: 100px;">
|
||||||
<span class="input-group-text text-muted" i18n>lines</span>
|
<span class="input-group-text text-muted" i18n>lines</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mt-1">
|
<div class="form-check form-switch mt-1">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
|
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" [activeId]="activeLog()" (activeIdChange)="activeLog.set($event); reloadLogs()" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
|
||||||
@for (logFile of logFiles(); track logFile) {
|
@for (logFile of logFiles; track logFile) {
|
||||||
<li [ngbNavItem]="logFile">
|
<li [ngbNavItem]="logFile">
|
||||||
<a ngbNavLink>
|
<a ngbNavLink>
|
||||||
{{logFile}}.log
|
{{logFile}}.log
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@if (loading() || !logFiles().length) {
|
@if (loading || !logFiles.length) {
|
||||||
<div class="ps-2 d-flex align-items-center">
|
<div class="ps-2 d-flex align-items-center">
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
@if (!logFiles().length) {
|
@if (!logFiles.length) {
|
||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -42,13 +42,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()">
|
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()">
|
||||||
@if (loading && !logFiles().length) {
|
@if (loading && !logFiles.length) {
|
||||||
<div>
|
<div>
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@for (log of logs(); track log) {
|
@for (log of logs; track log) {
|
||||||
<p class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p>
|
<p class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-secondary jump-to-bottom position-fixed bottom-0 end-0 m-5"
|
class="btn btn-sm btn-secondary jump-to-bottom position-fixed bottom-0 end-0 m-5"
|
||||||
[class.visible]="showJumpToBottom()"
|
[class.visible]="showJumpToBottom"
|
||||||
(click)="scrollToBottom()"
|
(click)="scrollToBottom()"
|
||||||
>
|
>
|
||||||
↓ <span i18n>Jump to bottom</span>
|
↓ <span i18n>Jump to bottom</span>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('LogsComponent', () => {
|
|||||||
throwError(() => new Error('error getting logs'))
|
throwError(() => new Error('error getting logs'))
|
||||||
)
|
)
|
||||||
component.reloadLogs()
|
component.reloadLogs()
|
||||||
expect(component.logs()).toHaveLength(0)
|
expect(component.logs).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should auto refresh, allow toggle', () => {
|
it('should auto refresh, allow toggle', () => {
|
||||||
@@ -97,7 +97,7 @@ describe('LogsComponent', () => {
|
|||||||
jest.advanceTimersByTime(6000)
|
jest.advanceTimersByTime(6000)
|
||||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
component.autoRefreshEnabled.set(false)
|
component.autoRefreshEnabled = false
|
||||||
jest.advanceTimersByTime(6000)
|
jest.advanceTimersByTime(6000)
|
||||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
@@ -112,9 +112,9 @@ describe('LogsComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should update jump to bottom visibility on scroll', () => {
|
it('should update jump to bottom visibility on scroll', () => {
|
||||||
component.showJumpToBottom.set(false)
|
component.showJumpToBottom = false
|
||||||
jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false)
|
jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false)
|
||||||
component.onScroll()
|
component.onScroll()
|
||||||
expect(component.showJumpToBottom()).toBe(true)
|
expect(component.showJumpToBottom).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -35,17 +34,17 @@ export class LogsComponent
|
|||||||
private logService = inject(LogService)
|
private logService = inject(LogService)
|
||||||
private changedetectorRef = inject(ChangeDetectorRef)
|
private changedetectorRef = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
readonly logs = signal<Array<{ message: string; level: number }>>([])
|
public logs: Array<{ message: string; level: number }> = []
|
||||||
|
|
||||||
readonly logFiles = signal<string[]>([])
|
public logFiles: string[] = []
|
||||||
|
|
||||||
readonly activeLog = signal<string>(undefined)
|
public activeLog: string
|
||||||
|
|
||||||
readonly autoRefreshEnabled = signal<boolean>(true)
|
public autoRefreshEnabled: boolean = true
|
||||||
|
|
||||||
readonly limit = signal<number>(5000)
|
public limit: number = 5000
|
||||||
|
|
||||||
readonly showJumpToBottom = signal<boolean>(false)
|
public showJumpToBottom = false
|
||||||
|
|
||||||
private readonly limitChange$ = new Subject<number>()
|
private readonly limitChange$ = new Subject<number>()
|
||||||
|
|
||||||
@@ -60,15 +59,15 @@ export class LogsComponent
|
|||||||
.list()
|
.list()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
this.logFiles.set(result)
|
this.logFiles = result
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
if (this.logFiles().length > 0) {
|
if (this.logFiles.length > 0) {
|
||||||
this.activeLog.set(this.logFiles()[0])
|
this.activeLog = this.logFiles[0]
|
||||||
this.reloadLogs()
|
this.reloadLogs()
|
||||||
}
|
}
|
||||||
timer(5000, 5000)
|
timer(5000, 5000)
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(() => this.autoRefreshEnabled()),
|
filter(() => this.autoRefreshEnabled),
|
||||||
takeUntil(this.unsubscribeNotifier)
|
takeUntil(this.unsubscribeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
@@ -86,19 +85,19 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reloadLogs() {
|
reloadLogs() {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
const shouldStickToBottom = this.isNearBottom()
|
const shouldStickToBottom = this.isNearBottom()
|
||||||
this.logService
|
this.logService
|
||||||
.get(this.activeLog(), this.limit())
|
.get(this.activeLog, this.limit)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
const parsed = this.parseLogsWithLevel(result)
|
const parsed = this.parseLogsWithLevel(result)
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
parsed.length !== this.logs().length ||
|
parsed.length !== this.logs.length ||
|
||||||
parsed.some((log, idx) => {
|
parsed.some((log, idx) => {
|
||||||
const current = this.logs()[idx]
|
const current = this.logs[idx]
|
||||||
return (
|
return (
|
||||||
!current ||
|
!current ||
|
||||||
current.message !== log.message ||
|
current.message !== log.message ||
|
||||||
@@ -106,16 +105,16 @@ export class LogsComponent
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
this.logs.set(parsed)
|
this.logs = parsed
|
||||||
if (shouldStickToBottom) {
|
if (shouldStickToBottom) {
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
}
|
}
|
||||||
this.showJumpToBottom.set(!shouldStickToBottom)
|
this.showJumpToBottom = !shouldStickToBottom
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.logs.set([])
|
this.logs = []
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -150,7 +149,7 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
this.changedetectorRef.detectChanges()
|
this.changedetectorRef.detectChanges()
|
||||||
viewport.scrollTop = viewport.scrollHeight
|
viewport.scrollTop = viewport.scrollHeight
|
||||||
this.showJumpToBottom.set(false)
|
this.showJumpToBottom = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private isNearBottom(): boolean {
|
private isNearBottom(): boolean {
|
||||||
@@ -163,6 +162,6 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onScroll(): void {
|
onScroll(): void {
|
||||||
this.showJumpToBottom.set(!this.isNearBottom())
|
this.showJumpToBottom = !this.isNearBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,13 +99,6 @@ const status: SystemStatus = {
|
|||||||
llmindex_status: SystemStatusItemStatus.DISABLED,
|
llmindex_status: SystemStatusItemStatus.DISABLED,
|
||||||
llmindex_last_modified: new Date().toISOString(),
|
llmindex_last_modified: new Date().toISOString(),
|
||||||
llmindex_error: null,
|
llmindex_error: null,
|
||||||
summary: {
|
|
||||||
days: 30,
|
|
||||||
total_count: 12,
|
|
||||||
pending_count: 1,
|
|
||||||
success_count: 10,
|
|
||||||
failure_count: 1,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +161,7 @@ describe('SettingsComponent', () => {
|
|||||||
viewportScroller = TestBed.inject(ViewportScroller)
|
viewportScroller = TestBed.inject(ViewportScroller)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set(users[0])
|
settingsService.currentUser = users[0]
|
||||||
userService = TestBed.inject(UserService)
|
userService = TestBed.inject(UserService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
@@ -209,7 +202,7 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
||||||
@@ -217,19 +210,16 @@ describe('SettingsComponent', () => {
|
|||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
|
||||||
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
||||||
await fixture.whenStable()
|
|
||||||
|
|
||||||
const initSpy = jest.spyOn(component, 'initialize')
|
const initSpy = jest.spyOn(component, 'initialize')
|
||||||
component.isDirty = true // mock dirty
|
component.isDirty = true // mock dirty
|
||||||
navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty
|
navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty
|
||||||
tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
await fixture.whenStable()
|
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general'])
|
||||||
expect(initSpy).not.toHaveBeenCalled()
|
expect(initSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
|
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
|
||||||
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
await fixture.whenStable()
|
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
||||||
expect(initSpy).toHaveBeenCalled()
|
expect(initSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -664,6 +664,6 @@ export class SettingsComponent
|
|||||||
size: 'xl',
|
size: 'xl',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
modal.componentInstance.status.set(this.systemStatus)
|
modal.componentInstance.status = this.systemStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
||||||
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks() === 0">
|
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks === 0">
|
||||||
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<div class="form-check form-switch mb-0 ms-2">
|
<div class="form-check form-switch mb-0 ms-2">
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
|
|
||||||
<ngb-pagination
|
<ngb-pagination
|
||||||
[pageSize]="pageSize"
|
[pageSize]="pageSize"
|
||||||
[collectionSize]="totalTasks()"
|
[collectionSize]="totalTasks"
|
||||||
[page]="page"
|
[page]="page"
|
||||||
[maxSize]="5"
|
[maxSize]="5"
|
||||||
[rotate]="true"
|
[rotate]="true"
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ describe('TasksComponent', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
|
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
|
||||||
expect(component.totalTasks()).toBe(2)
|
expect(component.totalTasks).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply task type and trigger source filters to the server-side task query', () => {
|
it('should apply task type and trigger source filters to the server-side task query', () => {
|
||||||
@@ -435,8 +435,8 @@ describe('TasksComponent', () => {
|
|||||||
.flush(pageTwoTasks)
|
.flush(pageTwoTasks)
|
||||||
|
|
||||||
expect(component.page).toBe(2)
|
expect(component.page).toBe(2)
|
||||||
expect(component.totalTasks()).toBe(30)
|
expect(component.totalTasks).toBe(30)
|
||||||
expect(component.pagedTasks()).toEqual([tasks[0]])
|
expect(component.pagedTasks).toEqual([tasks[0]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not replace section counts with current-page counts', () => {
|
it('should not replace section counts with current-page counts', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
|
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router, RouterLink } from '@angular/router'
|
import { Router, RouterLink } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -166,14 +166,14 @@ export class TasksComponent
|
|||||||
public autoRefreshEnabled: boolean = true
|
public autoRefreshEnabled: boolean = true
|
||||||
public readonly pageSize = 25
|
public readonly pageSize = 25
|
||||||
public page: number = 1
|
public page: number = 1
|
||||||
readonly totalTasks = signal(0)
|
public totalTasks: number = 0
|
||||||
readonly sectionCounts = signal<Record<TaskSection, number>>({
|
public sectionCounts: Record<TaskSection, number> = {
|
||||||
[TaskSection.All]: 0,
|
[TaskSection.All]: 0,
|
||||||
[TaskSection.NeedsAttention]: 0,
|
[TaskSection.NeedsAttention]: 0,
|
||||||
[TaskSection.InProgress]: 0,
|
[TaskSection.InProgress]: 0,
|
||||||
[TaskSection.Completed]: 0,
|
[TaskSection.Completed]: 0,
|
||||||
})
|
}
|
||||||
readonly pagedTasks = signal<PaperlessTask[]>([])
|
public pagedTasks: PaperlessTask[] = []
|
||||||
public selectedSection: TaskSection = TaskSection.All
|
public selectedSection: TaskSection = TaskSection.All
|
||||||
public selectedTaskType: PaperlessTaskType | null = null
|
public selectedTaskType: PaperlessTaskType | null = null
|
||||||
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
|
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
|
||||||
@@ -346,7 +346,7 @@ export class TasksComponent
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Confirm Dismiss All`
|
modal.componentInstance.title = $localize`Confirm Dismiss All`
|
||||||
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks()} tasks?`
|
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks} tasks?`
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
@@ -465,7 +465,7 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasksForSection(section: TaskSection): PaperlessTask[] {
|
tasksForSection(section: TaskSection): PaperlessTask[] {
|
||||||
let tasks = this.pagedTasks().filter((task) =>
|
let tasks = this.pagedTasks.filter((task) =>
|
||||||
this.taskBelongsToSection(task, section)
|
this.taskBelongsToSection(task, section)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -477,14 +477,7 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
sectionCount(section: TaskSection): number {
|
sectionCount(section: TaskSection): number {
|
||||||
return this.sectionCounts()[section]
|
return this.sectionCounts[section]
|
||||||
}
|
|
||||||
|
|
||||||
private setSectionCount(section: TaskSection, count: number) {
|
|
||||||
this.sectionCounts.update((counts) => ({
|
|
||||||
...counts,
|
|
||||||
[section]: count,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sectionShowsResults(section: TaskSection): boolean {
|
sectionShowsResults(section: TaskSection): boolean {
|
||||||
@@ -659,7 +652,7 @@ export class TasksComponent
|
|||||||
? this.sections
|
? this.sections
|
||||||
: [this.selectedSection]
|
: [this.selectedSection]
|
||||||
|
|
||||||
return this.pagedTasks().filter(
|
return this.pagedTasks.filter(
|
||||||
(task) =>
|
(task) =>
|
||||||
sections.some((section) => this.taskBelongsToSection(task, section)) &&
|
sections.some((section) => this.taskBelongsToSection(task, section)) &&
|
||||||
this.taskMatchesFilters(task, { taskType, triggerSource })
|
this.taskMatchesFilters(task, { taskType, triggerSource })
|
||||||
@@ -671,12 +664,10 @@ export class TasksComponent
|
|||||||
.statusCounts(this.getParamsForSection(TaskSection.All))
|
.statusCounts(this.getParamsForSection(TaskSection.All))
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((counts) => {
|
.subscribe((counts) => {
|
||||||
this.sectionCounts.set({
|
this.sectionCounts[TaskSection.All] = counts.all
|
||||||
[TaskSection.All]: counts.all,
|
this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention
|
||||||
[TaskSection.NeedsAttention]: counts.needs_attention,
|
this.sectionCounts[TaskSection.InProgress] = counts.in_progress
|
||||||
[TaskSection.InProgress]: counts.in_progress,
|
this.sectionCounts[TaskSection.Completed] = counts.completed
|
||||||
[TaskSection.Completed]: counts.completed,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +723,7 @@ export class TasksComponent
|
|||||||
|
|
||||||
this.reloadSectionCounts()
|
this.reloadSectionCounts()
|
||||||
|
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.tasksService
|
this.tasksService
|
||||||
.list(
|
.list(
|
||||||
this.page,
|
this.page,
|
||||||
@@ -742,24 +733,24 @@ export class TasksComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.pagedTasks.set(result.results)
|
this.pagedTasks = result.results
|
||||||
this.totalTasks.set(result.count)
|
this.totalTasks = result.count
|
||||||
this.setSectionCount(TaskSection.All, result.count)
|
this.sectionCounts[TaskSection.All] = result.count
|
||||||
if (this.selectedSection !== TaskSection.All) {
|
if (this.selectedSection !== TaskSection.All) {
|
||||||
this.setSectionCount(this.selectedSection, result.count)
|
this.sectionCounts[this.selectedSection] = result.count
|
||||||
}
|
}
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
if (
|
if (
|
||||||
this.page > 1 &&
|
this.page > 1 &&
|
||||||
this.pagedTasks().length === 0 &&
|
this.pagedTasks.length === 0 &&
|
||||||
this.totalTasks() > 0
|
this.totalTasks > 0
|
||||||
) {
|
) {
|
||||||
this.page -= 1
|
this.page -= 1
|
||||||
this.reloadPage()
|
this.reloadPage()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,22 @@
|
|||||||
info="Manage trashed documents that are pending deletion."
|
info="Manage trashed documents that are pending deletion."
|
||||||
i18n-info
|
i18n-info
|
||||||
infoLink="usage/#document-trash">
|
infoLink="usage/#document-trash">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments().size === 0">
|
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments.size === 0">
|
||||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
|
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
|
||||||
<i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container>
|
<i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
|
||||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container>
|
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash().length === 0">
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash.length === 0">
|
||||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container>
|
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border table-responsive mb-3">
|
<div class="card border table-responsive mb-3">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="allToggled()" (ngModelChange)="allToggled.set($event)" [disabled]="documentsInTrash().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="allToggled" [disabled]="documentsInTrash.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="all-objects"></label>
|
<label class="form-check-label" for="all-objects"></label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@if (loading()) {
|
@if (loading) {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (document of documentsInTrash(); track document.id) {
|
@for (document of documentsInTrash; track document.id) {
|
||||||
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show">
|
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show">
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments().has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="{{document.id}}"></label>
|
<label class="form-check-label" for="{{document.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -91,13 +91,13 @@
|
|||||||
@if (!loading) {
|
@if (!loading) {
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
<div>
|
<div>
|
||||||
<ng-container i18n>{totalDocuments(), plural, =1 {One document in trash} other {{{totalDocuments() || 0}} total documents in trash}}</ng-container>
|
<ng-container i18n>{totalDocuments, plural, =1 {One document in trash} other {{{totalDocuments || 0}} total documents in trash}}</ng-container>
|
||||||
@if (selectedDocuments().size > 0) {
|
@if (selectedDocuments.size > 0) {
|
||||||
({{selectedDocuments().size}} selected)
|
({{selectedDocuments.size}} selected)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (documentsInTrash().length > 20) {
|
@if (documentsInTrash.length > 20) {
|
||||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,15 +60,10 @@ describe('TrashComponent', () => {
|
|||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call correct service method on reload', () => {
|
it('should call correct service method on reload', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const trashSpy = jest.spyOn(trashService, 'getTrash')
|
const trashSpy = jest.spyOn(trashService, 'getTrash')
|
||||||
@@ -82,7 +77,7 @@ describe('TrashComponent', () => {
|
|||||||
component.reload()
|
component.reload()
|
||||||
jest.advanceTimersByTime(100)
|
jest.advanceTimersByTime(100)
|
||||||
expect(trashSpy).toHaveBeenCalled()
|
expect(trashSpy).toHaveBeenCalled()
|
||||||
expect(component.documentsInTrash()).toEqual(documentsInTrash)
|
expect(component.documentsInTrash).toEqual(documentsInTrash)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support delete document, show error if needed', () => {
|
it('should support delete document, show error if needed', () => {
|
||||||
@@ -184,8 +179,8 @@ describe('TrashComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support toggle all items in view', () => {
|
it('should support toggle all items in view', () => {
|
||||||
component.documentsInTrash.set(documentsInTrash)
|
component.documentsInTrash = documentsInTrash
|
||||||
expect(component.selectedDocuments().size).toEqual(0)
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
|
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
|
||||||
const checkButton = fixture.debugElement.queryAll(
|
const checkButton = fixture.debugElement.queryAll(
|
||||||
By.css('input.form-check-input')
|
By.css('input.form-check-input')
|
||||||
@@ -194,21 +189,21 @@ describe('TrashComponent', () => {
|
|||||||
checkButton.nativeElement.checked = true
|
checkButton.nativeElement.checked = true
|
||||||
checkButton.nativeElement.dispatchEvent(new Event('click'))
|
checkButton.nativeElement.dispatchEvent(new Event('click'))
|
||||||
expect(toggleAllSpy).toHaveBeenCalled()
|
expect(toggleAllSpy).toHaveBeenCalled()
|
||||||
expect(component.selectedDocuments().size).toEqual(documentsInTrash.length)
|
expect(component.selectedDocuments.size).toEqual(documentsInTrash.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support toggle item', () => {
|
it('should support toggle item', () => {
|
||||||
component.selectedDocuments.set(new Set([1]))
|
component.selectedDocuments = new Set([1])
|
||||||
component.toggleSelected(documentsInTrash[0])
|
component.toggleSelected(documentsInTrash[0])
|
||||||
expect(component.selectedDocuments().size).toEqual(0)
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
component.toggleSelected(documentsInTrash[0])
|
component.toggleSelected(documentsInTrash[0])
|
||||||
expect(component.selectedDocuments().size).toEqual(1)
|
expect(component.selectedDocuments.size).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support clear selection', () => {
|
it('should support clear selection', () => {
|
||||||
component.selectedDocuments.set(new Set([1]))
|
component.selectedDocuments = new Set([1])
|
||||||
component.clearSelection()
|
component.clearSelection()
|
||||||
expect(component.selectedDocuments().size).toEqual(0)
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should correctly display days remaining', () => {
|
it('should correctly display days remaining', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnDestroy, inject, signal } from '@angular/core'
|
import { Component, OnDestroy, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { takeUntil, tap } from 'rxjs'
|
import { delay, takeUntil, tap } from 'rxjs'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -42,11 +42,11 @@ export class TrashComponent
|
|||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
|
|
||||||
readonly documentsInTrash = signal<Document[]>([])
|
public documentsInTrash: Document[] = []
|
||||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
public selectedDocuments: Set<number> = new Set()
|
||||||
readonly allToggled = signal(false)
|
public allToggled: boolean = false
|
||||||
readonly page = signal(1)
|
public page: number = 1
|
||||||
readonly totalDocuments = signal<number>(undefined)
|
public totalDocuments: number
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -54,19 +54,20 @@ export class TrashComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.trashService
|
this.trashService
|
||||||
.getTrash(this.page())
|
.getTrash(this.page)
|
||||||
.pipe(
|
.pipe(
|
||||||
tap((r) => {
|
tap((r) => {
|
||||||
this.documentsInTrash.set(r.results)
|
this.documentsInTrash = r.results
|
||||||
this.totalDocuments.set(r.count)
|
this.totalDocuments = r.count
|
||||||
this.selectedDocuments.set(new Set())
|
this.selectedDocuments.clear()
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
})
|
}),
|
||||||
|
delay(100)
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.show.set(true)
|
this.show = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export class TrashComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) deleted`)
|
this.toastService.showInfo($localize`Document(s) deleted`)
|
||||||
this.allToggled.set(false)
|
this.allToggled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
@@ -164,7 +165,7 @@ export class TrashComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) restored`)
|
this.toastService.showInfo($localize`Document(s) restored`)
|
||||||
this.allToggled.set(false)
|
this.allToggled = false
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -178,29 +179,24 @@ export class TrashComponent
|
|||||||
|
|
||||||
toggleAll(event: PointerEvent) {
|
toggleAll(event: PointerEvent) {
|
||||||
if ((event.target as HTMLInputElement).checked) {
|
if ((event.target as HTMLInputElement).checked) {
|
||||||
this.selectedDocuments.set(
|
this.selectedDocuments = new Set(this.documentsInTrash.map((t) => t.id))
|
||||||
new Set(this.documentsInTrash().map((t) => t.id))
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSelected(object: Document) {
|
toggleSelected(object: Document) {
|
||||||
const selectedDocuments = new Set(this.selectedDocuments())
|
this.selectedDocuments.has(object.id)
|
||||||
selectedDocuments.has(object.id)
|
? this.selectedDocuments.delete(object.id)
|
||||||
? selectedDocuments.delete(object.id)
|
: this.selectedDocuments.add(object.id)
|
||||||
: selectedDocuments.add(object.id)
|
|
||||||
this.selectedDocuments.set(selectedDocuments)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.allToggled.set(false)
|
this.allToggled = false
|
||||||
this.selectedDocuments.set(new Set())
|
this.selectedDocuments.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
getDaysRemaining(document: Document): number {
|
getDaysRemaining(document: Document): number {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
@if (canViewUsers && users()) {
|
@if (canViewUsers && users) {
|
||||||
<h4 class="d-flex">
|
<h4 class="d-flex">
|
||||||
<ng-container i18n>Users</ng-container>
|
<ng-container i18n>Users</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="col" i18n>Actions</div>
|
<div class="col" i18n>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@for (user of users(); track user) {
|
@for (user of users; track user) {
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (canViewGroups && groups()) {
|
@if (canViewGroups && groups) {
|
||||||
<h4 class="mt-4 d-flex">
|
<h4 class="mt-4 d-flex">
|
||||||
<ng-container i18n>Groups</ng-container>
|
<ng-container i18n>Groups</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<div class="col" i18n>Actions</div>
|
<div class="col" i18n>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@for (group of groups(); track group) {
|
@for (group of groups; track group) {
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||||
@@ -80,13 +80,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@if (groups().length === 0) {
|
@if (groups.length === 0) {
|
||||||
<li class="list-group-item" i18n>No groups defined</li>
|
<li class="list-group-item" i18n>No groups defined</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if ((canViewUsers && !users()) || (canViewGroups && !groups())) {
|
@if ((canViewUsers && !users) || (canViewGroups && !groups)) {
|
||||||
<div>
|
<div>
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
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,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } 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 { of, throwError } from 'rxjs'
|
||||||
@@ -52,7 +57,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set(users[0])
|
settingsService.currentUser = users[0]
|
||||||
userService = TestBed.inject(UserService)
|
userService = TestBed.inject(UserService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
@@ -104,7 +109,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
settingsService.currentUser.set(users[1]) // simulate logged in as different user
|
settingsService.currentUser = users[1] // simulate logged in as different user
|
||||||
editDialog.succeeded.emit(users[0])
|
editDialog.succeeded.emit(users[0])
|
||||||
expect(toastInfoSpy).toHaveBeenCalledWith(
|
expect(toastInfoSpy).toHaveBeenCalledWith(
|
||||||
`Saved user "${users[0].username}".`
|
`Saved user "${users[0].username}".`
|
||||||
@@ -133,8 +138,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
|
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should logout current user if password changed, after delay', () => {
|
it('should logout current user if password changed, after delay', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
completeSetup()
|
completeSetup()
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||||
@@ -144,15 +148,14 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||||
editDialog.passwordIsSet = true
|
editDialog.passwordIsSet = true
|
||||||
settingsService.currentUser.set(users[0]) // simulate logged in as same user
|
settingsService.currentUser = users[0] // simulate logged in as same user
|
||||||
editDialog.succeeded.emit(users[0])
|
editDialog.succeeded.emit(users[0])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
jest.advanceTimersByTime(2600)
|
tick(2600)
|
||||||
expect(navSpy).toHaveBeenCalledWith(
|
expect(navSpy).toHaveBeenCalledWith(
|
||||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||||
)
|
)
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support edit / create group, show error if needed', () => {
|
it('should support edit / create group, show error if needed', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, first, takeUntil } from 'rxjs'
|
import { Subject, first, takeUntil } from 'rxjs'
|
||||||
@@ -43,8 +43,8 @@ export class UsersAndGroupsComponent
|
|||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
|
|
||||||
readonly users = signal<User[]>(null)
|
users: User[]
|
||||||
readonly groups = signal<Group[]>(null)
|
groups: Group[]
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export class UsersAndGroupsComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (r) => {
|
next: (r) => {
|
||||||
this.users.set(r.results)
|
this.users = r.results
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error retrieving users`, e)
|
this.toastService.showError($localize`Error retrieving users`, e)
|
||||||
@@ -83,7 +83,7 @@ export class UsersAndGroupsComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (r) => {
|
next: (r) => {
|
||||||
this.groups.set(r.results)
|
this.groups = r.results
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error retrieving groups`, e)
|
this.toastService.showError($localize`Error retrieving groups`, e)
|
||||||
@@ -101,15 +101,15 @@ export class UsersAndGroupsComponent
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
})
|
})
|
||||||
modal.componentInstance.dialogMode.set(
|
modal.componentInstance.dialogMode = user
|
||||||
user ? EditDialogMode.EDIT : EditDialogMode.CREATE
|
? EditDialogMode.EDIT
|
||||||
)
|
: EditDialogMode.CREATE
|
||||||
modal.componentInstance.object = user
|
modal.componentInstance.object = user
|
||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newUser: User) => {
|
.subscribe((newUser: User) => {
|
||||||
if (
|
if (
|
||||||
newUser.id === this.settings.currentUser().id &&
|
newUser.id === this.settings.currentUser.id &&
|
||||||
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
||||||
) {
|
) {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
@@ -125,7 +125,7 @@ export class UsersAndGroupsComponent
|
|||||||
$localize`Saved user "${newUser.username}".`
|
$localize`Saved user "${newUser.username}".`
|
||||||
)
|
)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
this.users.set(r.results)
|
this.users = r.results
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -152,7 +152,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
|
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
this.users.set(r.results)
|
this.users = r.results
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
@@ -170,16 +170,16 @@ export class UsersAndGroupsComponent
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
})
|
})
|
||||||
modal.componentInstance.dialogMode.set(
|
modal.componentInstance.dialogMode = group
|
||||||
group ? EditDialogMode.EDIT : EditDialogMode.CREATE
|
? EditDialogMode.EDIT
|
||||||
)
|
: EditDialogMode.CREATE
|
||||||
modal.componentInstance.object = group
|
modal.componentInstance.object = group
|
||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newGroup) => {
|
.subscribe((newGroup) => {
|
||||||
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups.set(r.results)
|
this.groups = r.results
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
modal.componentInstance.failed
|
modal.componentInstance.failed
|
||||||
@@ -205,7 +205,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
|
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups.set(r.results)
|
this.groups = r.results
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
@@ -219,6 +219,6 @@ export class UsersAndGroupsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
getGroupName(id: number): string {
|
getGroupName(id: number): string {
|
||||||
return this.groups()?.find((g) => g.id === id)?.name ?? ''
|
return this.groups?.find((g) => g.id === id)?.name ?? ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
||||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="mobileSearchHidden = false; isMenuCollapsed = !isMenuCollapsed">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden">
|
||||||
<div class="col-12 col-md-7">
|
<div class="col-12 col-md-7">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search></pngx-global-search>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
|
||||||
[ngbCollapse]="isMenuCollapsed()">
|
[ngbCollapse]="isMenuCollapsed">
|
||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||||
@if (slimSidebarEnabled) {
|
@if (slimSidebarEnabled) {
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews()) {
|
@if (settingsService.organizingSidebarSavedViews) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,10 +329,10 @@
|
|||||||
{{ versionString }}
|
{{ versionString }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
|
||||||
<div class="version-check">
|
<div class="version-check">
|
||||||
<ng-template #updateAvailablePopContent>
|
<ng-template #updateAvailablePopContent>
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
|
||||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #updateCheckingNotEnabledPopContent>
|
<ng-template #updateCheckingNotEnabledPopContent>
|
||||||
@@ -350,13 +350,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@if (settingsService.updateCheckingIsSet) {
|
@if (settingsService.updateCheckingIsSet) {
|
||||||
@if (appRemoteVersion().update_available) {
|
@if (appRemoteVersion.update_available) {
|
||||||
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
||||||
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
||||||
container="body">
|
container="body">
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs>
|
||||||
@if (appRemoteVersion()?.update_available) {
|
@if (appRemoteVersion?.update_available) {
|
||||||
<ng-container i18n>Update available</ng-container>
|
<ng-container i18n>Update available</ng-container>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@@ -377,7 +377,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden"
|
||||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
HttpTestingController,
|
HttpTestingController,
|
||||||
provideHttpClientTesting,
|
provideHttpClientTesting,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { BrowserModule } from '@angular/platform-browser'
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
@@ -239,12 +244,11 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support toggling slim sidebar and saving', () => {
|
it('should support toggling slim sidebar and saving', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
const saveSettingSpy = jest.spyOn(settingsService, 'set')
|
const saveSettingSpy = jest.spyOn(settingsService, 'set')
|
||||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||||
expect(component.slimSidebarEnabled).toBeFalsy()
|
expect(component.slimSidebarEnabled).toBeFalsy()
|
||||||
expect(component.slimSidebarAnimating()).toBeFalsy()
|
expect(component.slimSidebarAnimating).toBeFalsy()
|
||||||
component.toggleSlimSidebar()
|
component.toggleSlimSidebar()
|
||||||
const requests = httpTestingController.match(
|
const requests = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
@@ -255,9 +259,9 @@ describe('AppFrameComponent', () => {
|
|||||||
requests[0].request.body.settings.attributes_sections_collapsed
|
requests[0].request.body.settings.attributes_sections_collapsed
|
||||||
).toEqual(['attributes'])
|
).toEqual(['attributes'])
|
||||||
requests[0].flush({ success: true })
|
requests[0].flush({ success: true })
|
||||||
expect(component.slimSidebarAnimating()).toBeTruthy()
|
expect(component.slimSidebarAnimating).toBeTruthy()
|
||||||
jest.advanceTimersByTime(200)
|
tick(200)
|
||||||
expect(component.slimSidebarAnimating()).toBeFalsy()
|
expect(component.slimSidebarAnimating).toBeFalsy()
|
||||||
expect(component.slimSidebarEnabled).toBeTruthy()
|
expect(component.slimSidebarEnabled).toBeTruthy()
|
||||||
expect(saveSettingSpy).toHaveBeenCalledWith(
|
expect(saveSettingSpy).toHaveBeenCalledWith(
|
||||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||||
@@ -267,8 +271,7 @@ describe('AppFrameComponent', () => {
|
|||||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
['attributes']
|
['attributes']
|
||||||
)
|
)
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
@@ -288,9 +291,9 @@ describe('AppFrameComponent', () => {
|
|||||||
fixture.nativeElement as HTMLDivElement
|
fixture.nativeElement as HTMLDivElement
|
||||||
).querySelector('button[data-toggle=collapse]')
|
).querySelector('button[data-toggle=collapse]')
|
||||||
button.dispatchEvent(new MouseEvent('click'))
|
button.dispatchEvent(new MouseEvent('click'))
|
||||||
expect(component.isMenuCollapsed()).toBeFalsy()
|
expect(component.isMenuCollapsed).toBeFalsy()
|
||||||
component.closeMenu()
|
component.closeMenu()
|
||||||
expect(component.isMenuCollapsed()).toBeTruthy()
|
expect(component.isMenuCollapsed).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
|
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
|
||||||
@@ -305,14 +308,14 @@ describe('AppFrameComponent', () => {
|
|||||||
value: 40,
|
value: 40,
|
||||||
})
|
})
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
expect(component.mobileSearchHidden()).toBe(true)
|
expect(component.mobileSearchHidden).toBe(true)
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'scrollY', {
|
Object.defineProperty(globalThis, 'scrollY', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 0,
|
value: 0,
|
||||||
})
|
})
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
expect(component.mobileSearchHidden()).toBe(false)
|
expect(component.mobileSearchHidden).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep mobile search visible on desktop scroll or resize', () => {
|
it('should keep mobile search visible on desktop scroll or resize', () => {
|
||||||
@@ -320,13 +323,13 @@ describe('AppFrameComponent', () => {
|
|||||||
value: 1024,
|
value: 1024,
|
||||||
})
|
})
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.mobileSearchHidden.set(true)
|
component.mobileSearchHidden = true
|
||||||
|
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
|
|
||||||
expect(component.mobileSearchHidden()).toBe(false)
|
expect(component.mobileSearchHidden).toBe(false)
|
||||||
|
|
||||||
component.mobileSearchHidden.set(true)
|
component.mobileSearchHidden = true
|
||||||
component.onWindowResize()
|
component.onWindowResize()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -335,7 +338,7 @@ describe('AppFrameComponent', () => {
|
|||||||
value: 767,
|
value: 767,
|
||||||
})
|
})
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.isMenuCollapsed.set(false)
|
component.isMenuCollapsed = false
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'scrollY', {
|
Object.defineProperty(globalThis, 'scrollY', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -343,7 +346,7 @@ describe('AppFrameComponent', () => {
|
|||||||
})
|
})
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
|
|
||||||
expect(component.mobileSearchHidden()).toBe(false)
|
expect(component.mobileSearchHidden).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support close document & navigate on close current doc', () => {
|
it('should support close document & navigate on close current doc', () => {
|
||||||
@@ -376,11 +379,11 @@ describe('AppFrameComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
||||||
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
|
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||||
component.onDragStart(null)
|
component.onDragStart(null)
|
||||||
expect(settingsService.globalDropzoneEnabled()).toBeFalsy()
|
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
|
||||||
component.onDragEnd(null)
|
component.onDragEnd(null)
|
||||||
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
|
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update saved view sorting on drag + drop, show info', () => {
|
it('should update saved view sorting on drag + drop, show info', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import { Component, HostListener, inject, OnInit } from '@angular/core'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@@ -90,10 +90,14 @@ export class AppFrameComponent
|
|||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private djangoMessagesService = inject(DjangoMessagesService)
|
private djangoMessagesService = inject(DjangoMessagesService)
|
||||||
|
|
||||||
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
appRemoteVersion: AppRemoteVersion
|
||||||
readonly isMenuCollapsed = signal(true)
|
|
||||||
readonly slimSidebarAnimating = signal(false)
|
isMenuCollapsed: boolean = true
|
||||||
readonly mobileSearchHidden = signal(false)
|
|
||||||
|
slimSidebarAnimating: boolean = false
|
||||||
|
|
||||||
|
public mobileSearchHidden: boolean = false
|
||||||
|
|
||||||
private lastScrollY: number = 0
|
private lastScrollY: number = 0
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -143,7 +147,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleSlimSidebar(): void {
|
toggleSlimSidebar(): void {
|
||||||
this.slimSidebarAnimating.set(true)
|
this.slimSidebarAnimating = true
|
||||||
const slimSidebarEnabled = !this.slimSidebarEnabled
|
const slimSidebarEnabled = !this.slimSidebarEnabled
|
||||||
this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
|
this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
|
||||||
if (slimSidebarEnabled) {
|
if (slimSidebarEnabled) {
|
||||||
@@ -163,7 +167,7 @@ export class AppFrameComponent
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.slimSidebarAnimating.set(false)
|
this.slimSidebarAnimating = false
|
||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,25 +177,11 @@ export class AppFrameComponent
|
|||||||
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
|
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMenuCollapsed(): void {
|
|
||||||
this.isMenuCollapsed.set(!this.isMenuCollapsed())
|
|
||||||
}
|
|
||||||
|
|
||||||
closeMobileSearch(): void {
|
|
||||||
this.mobileSearchHidden.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
setMobileSearchHidden(hidden: boolean): void {
|
|
||||||
this.mobileSearchHidden.set(hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +224,6 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +243,6 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.settingsService
|
return this.settingsService
|
||||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
||||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
?.includes(CollapsibleSection.ATTRIBUTES)
|
||||||
@@ -280,14 +268,13 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
onWindowResize(): void {
|
onWindowResize(): void {
|
||||||
if (!this.isMobileViewport()) {
|
if (!this.isMobileViewport()) {
|
||||||
this.mobileSearchHidden.set(false)
|
this.mobileSearchHidden = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,8 +282,8 @@ export class AppFrameComponent
|
|||||||
onWindowScroll(): void {
|
onWindowScroll(): void {
|
||||||
const currentScrollY = window.scrollY
|
const currentScrollY = window.scrollY
|
||||||
|
|
||||||
if (!this.isMobileViewport() || this.isMenuCollapsed() === false) {
|
if (!this.isMobileViewport() || this.isMenuCollapsed === false) {
|
||||||
this.mobileSearchHidden.set(false)
|
this.mobileSearchHidden = false
|
||||||
this.lastScrollY = currentScrollY
|
this.lastScrollY = currentScrollY
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -304,9 +291,9 @@ export class AppFrameComponent
|
|||||||
const delta = currentScrollY - this.lastScrollY
|
const delta = currentScrollY - this.lastScrollY
|
||||||
|
|
||||||
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
|
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
|
||||||
this.mobileSearchHidden.set(false)
|
this.mobileSearchHidden = false
|
||||||
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
|
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
|
||||||
this.mobileSearchHidden.set(true)
|
this.mobileSearchHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastScrollY = currentScrollY
|
this.lastScrollY = currentScrollY
|
||||||
@@ -317,7 +304,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeMenu() {
|
closeMenu() {
|
||||||
this.isMenuCollapsed.set(true)
|
this.isMenuCollapsed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
editProfile() {
|
editProfile() {
|
||||||
@@ -380,11 +367,11 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDragStart(event: CdkDragStart) {
|
onDragStart(event: CdkDragStart) {
|
||||||
this.settingsService.globalDropzoneEnabled.set(false)
|
this.settingsService.globalDropzoneEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onDragEnd(event: CdkDragEnd) {
|
onDragEnd(event: CdkDragEnd) {
|
||||||
this.settingsService.globalDropzoneEnabled.set(true)
|
this.settingsService.globalDropzoneEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<SavedView[]>) {
|
onDrop(event: CdkDragDrop<SavedView[]>) {
|
||||||
@@ -405,7 +392,7 @@ export class AppFrameComponent
|
|||||||
this.remoteVersionService
|
this.remoteVersionService
|
||||||
.checkForUpdates()
|
.checkForUpdates()
|
||||||
.subscribe((appRemoteVersion: AppRemoteVersion) => {
|
.subscribe((appRemoteVersion: AppRemoteVersion) => {
|
||||||
this.appRemoteVersion.set(appRemoteVersion)
|
this.appRemoteVersion = appRemoteVersion
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,10 +419,9 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showSidebarCounts(): boolean {
|
get showSidebarCounts(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return (
|
return (
|
||||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
||||||
!this.settingsService.organizingSidebarSavedViews()
|
!this.settingsService.organizingSidebarSavedViews
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,16 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
[(ngModel)]="query"
|
[(ngModel)]="query"
|
||||||
(ngModelChange)="queryDebounce.next($event)"
|
(ngModelChange)="this.queryDebounce.next($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
ngbDropdownAnchor>
|
ngbDropdownAnchor>
|
||||||
<div class="position-absolute top-50 end-0 translate-middle">
|
<div class="position-absolute top-50 end-0 translate-middle">
|
||||||
@if (loading()) {
|
@if (loading) {
|
||||||
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (query()) {
|
@if (query) {
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
|
||||||
@if (useAdvancedForFullSearch) {
|
@if (useAdvancedForFullSearch) {
|
||||||
<ng-container i18n>Advanced search</ng-container>
|
<ng-container i18n>Advanced search</ng-container>
|
||||||
@@ -77,88 +77,88 @@
|
|||||||
|
|
||||||
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
||||||
<div (keydown)="dropdownKeyDown($event)">
|
<div (keydown)="dropdownKeyDown($event)">
|
||||||
@if (searchResults()?.total === 0) {
|
@if (searchResults?.total === 0) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
||||||
} @else {
|
} @else {
|
||||||
@if (searchResults()?.documents.length) {
|
@if (searchResults?.documents.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
||||||
@for (document of searchResults().documents; track document.id) {
|
@for (document of searchResults.documents; track document.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (searchResults()?.saved_views.length) {
|
@if (searchResults?.saved_views.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
||||||
@for (saved_view of searchResults().saved_views; track saved_view.id) {
|
@for (saved_view of searchResults.saved_views; track saved_view.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.tags.length) {
|
@if (searchResults?.tags.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
||||||
@for (tag of searchResults().tags; track tag.id) {
|
@for (tag of searchResults.tags; track tag.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.correspondents.length) {
|
@if (searchResults?.correspondents.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
||||||
@for (correspondent of searchResults().correspondents; track correspondent.id) {
|
@for (correspondent of searchResults.correspondents; track correspondent.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.document_types.length) {
|
@if (searchResults?.document_types.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
||||||
@for (documentType of searchResults().document_types; track documentType.id) {
|
@for (documentType of searchResults.document_types; track documentType.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.storage_paths.length) {
|
@if (searchResults?.storage_paths.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
||||||
@for (storagePath of searchResults().storage_paths; track storagePath.id) {
|
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.users.length) {
|
@if (searchResults?.users.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
||||||
@for (user of searchResults().users; track user.id) {
|
@for (user of searchResults.users; track user.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.groups.length) {
|
@if (searchResults?.groups.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
||||||
@for (group of searchResults().groups; track group.id) {
|
@for (group of searchResults.groups; track group.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.custom_fields.length) {
|
@if (searchResults?.custom_fields.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
||||||
@for (customField of searchResults().custom_fields; track customField.id) {
|
@for (customField of searchResults.custom_fields; track customField.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.mail_accounts.length) {
|
@if (searchResults?.mail_accounts.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
||||||
@for (mailAccount of searchResults().mail_accounts; track mailAccount.id) {
|
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.mail_rules.length) {
|
@if (searchResults?.mail_rules.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
||||||
@for (mailRule of searchResults().mail_rules; track mailRule.id) {
|
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.workflows.length) {
|
@if (searchResults?.workflows.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
||||||
@for (workflow of searchResults().workflows; track workflow.id) {
|
@for (workflow of searchResults.workflows; track workflow.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ElementRef } from '@angular/core'
|
import { ElementRef } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -149,7 +154,6 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
searchService = TestBed.inject(SearchService)
|
searchService = TestBed.inject(SearchService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
@@ -166,7 +170,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
||||||
expect(focusSpy).toHaveBeenCalled()
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -234,26 +238,26 @@ describe('GlobalSearchComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(component['currentItemIndex']).toBe(0)
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
|
||||||
component.searchResults.set({ total: 1 } as any)
|
component.searchResults = { total: 1 } as any
|
||||||
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
|
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
|
||||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
expect(primaryActionSpy).toHaveBeenCalled()
|
expect(primaryActionSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.query.set('test')
|
component.query = 'test'
|
||||||
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||||
component.searchInputKeyDown(
|
component.searchInputKeyDown(
|
||||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
)
|
)
|
||||||
expect(resetSpy).toHaveBeenCalled()
|
expect(resetSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.query.set('')
|
component.query = ''
|
||||||
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
|
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
|
||||||
component.searchInputKeyDown(
|
component.searchInputKeyDown(
|
||||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
)
|
)
|
||||||
expect(blurSpy).toHaveBeenCalled()
|
expect(blurSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.searchResults.set({ total: 1 } as any)
|
component.searchResults = { total: 1 } as any
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
|
|
||||||
component.searchInputKeyDown(
|
component.searchInputKeyDown(
|
||||||
@@ -264,30 +268,28 @@ describe('GlobalSearchComponent', () => {
|
|||||||
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
component.query.set('test')
|
component.query = 'test'
|
||||||
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
|
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
|
||||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
expect(advancedSearchSpy).toHaveBeenCalled()
|
expect(advancedSearchSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should search on query debounce', () => {
|
it('should search on query debounce', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
const query = 'test'
|
const query = 'test'
|
||||||
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
||||||
searchSpy.mockReturnValue(of({} as any))
|
searchSpy.mockReturnValue(of({} as any))
|
||||||
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
|
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
|
||||||
component.queryDebounce.next(query)
|
component.queryDebounce.next(query)
|
||||||
jest.advanceTimersByTime(401)
|
tick(401)
|
||||||
expect(searchSpy).toHaveBeenCalledWith(query)
|
expect(searchSpy).toHaveBeenCalledWith(query)
|
||||||
expect(dropdownOpenSpy).toHaveBeenCalled()
|
expect(dropdownOpenSpy).toHaveBeenCalled()
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support primary action', () => {
|
it('should support primary action', () => {
|
||||||
const object = { id: 1 }
|
const object = { id: 1 }
|
||||||
const routerSpy = jest.mocked(router.navigate)
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
@@ -463,13 +465,13 @@ describe('GlobalSearchComponent', () => {
|
|||||||
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
||||||
component['reset'](true)
|
component['reset'](true)
|
||||||
expect(debounce).toHaveBeenCalledWith(null)
|
expect(debounce).toHaveBeenCalledWith(null)
|
||||||
expect(component.searchResults()).toBeNull()
|
expect(component.searchResults).toBeNull()
|
||||||
expect(component['currentItemIndex']).toBe(-1)
|
expect(component['currentItemIndex']).toBe(-1)
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support focus current item', () => {
|
it('should support focus current item', () => {
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const focusSpy = jest.spyOn(
|
const focusSpy = jest.spyOn(
|
||||||
component.primaryButtons.get(0).nativeElement,
|
component.primaryButtons.get(0).nativeElement,
|
||||||
@@ -487,7 +489,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should focus button on dropdown item hover', () => {
|
it('should focus button on dropdown item hover', () => {
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const item: ElementRef = component.resultItems.first
|
const item: ElementRef = component.resultItems.first
|
||||||
const focusSpy = jest.spyOn(
|
const focusSpy = jest.spyOn(
|
||||||
@@ -513,7 +515,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
component.primaryAction(DataType.Document, { id: 2 }, event as any)
|
component.primaryAction(DataType.Document, { id: 2 }, event as any)
|
||||||
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
|
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
|
||||||
|
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -540,7 +542,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
it('should support title content search and advanced search', () => {
|
it('should support title content search and advanced search', () => {
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
component.query.set('test')
|
component.query = 'test'
|
||||||
component.runFullSearch()
|
component.runFullSearch()
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
|
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
|
||||||
@@ -550,7 +552,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
||||||
GlobalSearchType.ADVANCED
|
GlobalSearchType.ADVANCED
|
||||||
)
|
)
|
||||||
component.query.set('test')
|
component.query = 'test'
|
||||||
component.runFullSearch()
|
component.runFullSearch()
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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 { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
@@ -83,12 +82,12 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
private locationStrategy = inject(LocationStrategy)
|
private locationStrategy = inject(LocationStrategy)
|
||||||
|
|
||||||
public DataType = DataType
|
public DataType = DataType
|
||||||
readonly query = signal<string>(null)
|
public query: string
|
||||||
public queryDebounce: Subject<string>
|
public queryDebounce: Subject<string>
|
||||||
readonly searchResults = signal<GlobalSearchResult>(null)
|
public searchResults: GlobalSearchResult
|
||||||
private currentItemIndex: number = -1
|
private currentItemIndex: number = -1
|
||||||
private domIndex: number = -1
|
private domIndex: number = -1
|
||||||
readonly loading = signal(false)
|
public loading: boolean = false
|
||||||
|
|
||||||
@ViewChild('searchInput') searchInput: ElementRef
|
@ViewChild('searchInput') searchInput: ElementRef
|
||||||
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
||||||
@@ -97,7 +96,6 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||||
|
|
||||||
get useAdvancedForFullSearch(): boolean {
|
get useAdvancedForFullSearch(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return (
|
return (
|
||||||
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
||||||
GlobalSearchType.ADVANCED
|
GlobalSearchType.ADVANCED
|
||||||
@@ -114,7 +112,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
)
|
)
|
||||||
.subscribe((text) => {
|
.subscribe((text) => {
|
||||||
this.query.set(text)
|
this.query = text
|
||||||
if (text) this.search(text)
|
if (text) this.search(text)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -128,10 +126,10 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private search(query: string) {
|
private search(query: string) {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.searchService.globalSearch(query.trim()).subscribe((results) => {
|
this.searchService.globalSearch(query.trim()).subscribe((results) => {
|
||||||
this.searchResults.set(results)
|
this.searchResults = results
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.resultsDropdown.open()
|
this.resultsDropdown.open()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -207,7 +205,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
editDialogComponent,
|
editDialogComponent,
|
||||||
{ size }
|
{ size }
|
||||||
)
|
)
|
||||||
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||||
modalRef.componentInstance.object = object
|
modalRef.componentInstance.object = object
|
||||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||||
@@ -245,7 +243,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
editDialogComponent,
|
editDialogComponent,
|
||||||
{ size }
|
{ size }
|
||||||
)
|
)
|
||||||
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||||
modalRef.componentInstance.object = object
|
modalRef.componentInstance.object = object
|
||||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||||
@@ -258,8 +256,8 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
|
|
||||||
private reset(close: boolean = false) {
|
private reset(close: boolean = false) {
|
||||||
this.queryDebounce.next(null)
|
this.queryDebounce.next(null)
|
||||||
this.query.set(null)
|
this.query = null
|
||||||
this.searchResults.set(null)
|
this.searchResults = null
|
||||||
this.currentItemIndex = -1
|
this.currentItemIndex = -1
|
||||||
if (close) {
|
if (close) {
|
||||||
this.resultsDropdown.close()
|
this.resultsDropdown.close()
|
||||||
@@ -294,7 +292,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
public searchInputKeyDown(event: KeyboardEvent) {
|
public searchInputKeyDown(event: KeyboardEvent) {
|
||||||
if (
|
if (
|
||||||
event.key === 'ArrowDown' &&
|
event.key === 'ArrowDown' &&
|
||||||
this.searchResults()?.total &&
|
this.searchResults?.total &&
|
||||||
this.resultsDropdown.isOpen()
|
this.resultsDropdown.isOpen()
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -302,22 +300,22 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
this.setCurrentItem()
|
this.setCurrentItem()
|
||||||
} else if (
|
} else if (
|
||||||
event.key === 'ArrowUp' &&
|
event.key === 'ArrowUp' &&
|
||||||
this.searchResults()?.total &&
|
this.searchResults?.total &&
|
||||||
this.resultsDropdown.isOpen()
|
this.resultsDropdown.isOpen()
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
this.currentItemIndex = this.searchResults()?.total - 1
|
this.currentItemIndex = this.searchResults.total - 1
|
||||||
this.setCurrentItem()
|
this.setCurrentItem()
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
if (this.searchResults()?.total === 1 && this.resultsDropdown.isOpen()) {
|
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
|
||||||
this.primaryButtons.first.nativeElement.click()
|
this.primaryButtons.first.nativeElement.click()
|
||||||
this.searchInput.nativeElement.blur()
|
this.searchInput.nativeElement.blur()
|
||||||
} else if (this.query()?.length) {
|
} else if (this.query?.length) {
|
||||||
this.runFullSearch()
|
this.runFullSearch()
|
||||||
this.reset(true)
|
this.reset(true)
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
|
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
|
||||||
if (this.query()?.length) {
|
if (this.query?.length) {
|
||||||
this.reset(true)
|
this.reset(true)
|
||||||
} else {
|
} else {
|
||||||
this.searchInput.nativeElement.blur()
|
this.searchInput.nativeElement.blur()
|
||||||
@@ -327,14 +325,14 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
|
|
||||||
public dropdownKeyDown(event: KeyboardEvent) {
|
public dropdownKeyDown(event: KeyboardEvent) {
|
||||||
if (
|
if (
|
||||||
this.searchResults()?.total &&
|
this.searchResults?.total &&
|
||||||
this.resultsDropdown.isOpen() &&
|
this.resultsDropdown.isOpen() &&
|
||||||
document.activeElement !== this.searchInput.nativeElement
|
document.activeElement !== this.searchInput.nativeElement
|
||||||
) {
|
) {
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
if (this.currentItemIndex < this.searchResults()?.total - 1) {
|
if (this.currentItemIndex < this.searchResults.total - 1) {
|
||||||
this.currentItemIndex++
|
this.currentItemIndex++
|
||||||
this.setCurrentItem()
|
this.setCurrentItem()
|
||||||
} else {
|
} else {
|
||||||
@@ -414,10 +412,10 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
? FILTER_FULLTEXT_QUERY
|
? FILTER_FULLTEXT_QUERY
|
||||||
: FILTER_SIMPLE_TEXT
|
: FILTER_SIMPLE_TEXT
|
||||||
this.documentService.searchQuery = this.useAdvancedForFullSearch
|
this.documentService.searchQuery = this.useAdvancedForFullSearch
|
||||||
? this.query()
|
? this.query
|
||||||
: ''
|
: ''
|
||||||
this.documentListViewService.quickFilter([
|
this.documentListViewService.quickFilter([
|
||||||
{ rule_type: ruleType, value: this.query() },
|
{ rule_type: ruleType, value: this.query },
|
||||||
])
|
])
|
||||||
this.reset(true)
|
this.reset(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
|
||||||
@if (toasts().length) {
|
@if (toasts.length) {
|
||||||
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts().length }}</span>
|
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
|
||||||
}
|
}
|
||||||
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
|
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
|
||||||
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
||||||
@@ -12,15 +12,15 @@
|
|||||||
<div class="btn-group ms-auto">
|
<div class="btn-group ms-auto">
|
||||||
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
|
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
|
||||||
(click)="toastService.clearToasts()"
|
(click)="toastService.clearToasts()"
|
||||||
[disabled]="toasts().length === 0"
|
[disabled]="toasts.length === 0"
|
||||||
i18n>Clear All</button>
|
i18n>Clear All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (toasts().length === 0) {
|
@if (toasts.length === 0) {
|
||||||
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
||||||
}
|
}
|
||||||
<div class="scroll-list">
|
<div class="scroll-list">
|
||||||
@for (toast of toasts(); track toast.id) {
|
@for (toast of toasts; track toast.id) {
|
||||||
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+36
-17
@@ -1,6 +1,12 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
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,
|
||||||
|
discardPeriodicTasks,
|
||||||
|
fakeAsync,
|
||||||
|
flush,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -36,8 +42,7 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
let component: ToastsDropdownComponent
|
let component: ToastsDropdownComponent
|
||||||
let fixture: ComponentFixture<ToastsDropdownComponent>
|
let fixture: ComponentFixture<ToastsDropdownComponent>
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let toastsSubject: Subject<Toast[]>
|
let toastsSubject: Subject<Toast[]> = new Subject()
|
||||||
let getToastsSpy: jest.SpyInstance
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -51,43 +56,57 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
toastService = TestBed.inject(ToastService)
|
|
||||||
toastsSubject = new Subject()
|
|
||||||
getToastsSpy = jest
|
|
||||||
.spyOn(toastService, 'getToasts')
|
|
||||||
.mockReturnValue(toastsSubject)
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ToastsDropdownComponent)
|
fixture = TestBed.createComponent(ToastsDropdownComponent)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
|
||||||
|
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call getToasts and return toasts', () => {
|
it('should call getToasts and return toasts', fakeAsync(() => {
|
||||||
|
const spy = jest.spyOn(toastService, 'getToasts')
|
||||||
|
|
||||||
|
component.ngOnInit()
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(getToastsSpy).toHaveBeenCalled()
|
expect(spy).toHaveBeenCalled()
|
||||||
expect(component.toasts()).toContainEqual({
|
expect(component.toasts).toContainEqual({
|
||||||
id: 'abc-123',
|
id: 'abc-123',
|
||||||
content: 'foo bar',
|
content: 'foo bar',
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
it('should show a toast', () => {
|
component.ngOnDestroy()
|
||||||
|
flush()
|
||||||
|
discardPeriodicTasks()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('should show a toast', fakeAsync(() => {
|
||||||
|
component.ngOnInit()
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('foo bar')
|
expect(fixture.nativeElement.textContent).toContain('foo bar')
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle suppressPopupToasts', () => {
|
component.ngOnDestroy()
|
||||||
|
flush()
|
||||||
|
discardPeriodicTasks()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('should toggle suppressPopupToasts', fakeAsync((finish) => {
|
||||||
|
component.ngOnInit()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
|
|
||||||
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
|
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
|
||||||
component.onOpenChange(true)
|
component.onOpenChange(true)
|
||||||
expect(spy).toHaveBeenCalledWith(true)
|
expect(spy).toHaveBeenCalledWith(true)
|
||||||
})
|
|
||||||
|
component.ngOnDestroy()
|
||||||
|
flush()
|
||||||
|
discardPeriodicTasks()
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { map } from 'rxjs'
|
import { Subscription } from 'rxjs'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastComponent } from '../../common/toast/toast.component'
|
import { ToastComponent } from '../../common/toast/toast.component'
|
||||||
|
|
||||||
@@ -20,13 +19,22 @@ import { ToastComponent } from '../../common/toast/toast.component'
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ToastsDropdownComponent {
|
export class ToastsDropdownComponent implements OnInit, OnDestroy {
|
||||||
toastService = inject(ToastService)
|
toastService = inject(ToastService)
|
||||||
|
|
||||||
readonly toasts = toSignal(
|
private subscription: Subscription
|
||||||
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
|
|
||||||
{ initialValue: [] as Toast[] }
|
public toasts: Toast[] = []
|
||||||
)
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subscription?.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
|
||||||
|
this.toasts = [...toasts]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onOpenChange(open: boolean): void {
|
onOpenChange(open: boolean): void {
|
||||||
this.toastService.suppressPopupToasts = open
|
this.toastService.suppressPopupToasts = open
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@if (active()) {
|
@if (active) {
|
||||||
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
||||||
@if (!isNumbered() && selected()) {
|
@if (!isNumbered && selected) {
|
||||||
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
||||||
}
|
}
|
||||||
@if (isNumbered()) {
|
@if (isNumbered) {
|
||||||
<div class="number">{{number()}}<span class="visually-hidden">selected</span></div>
|
<div class="number">{{number}}<span class="visually-hidden">selected</span></div>
|
||||||
}
|
}
|
||||||
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -21,20 +21,20 @@ describe('ClearableBadgeComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support selected', () => {
|
it('should support selected', () => {
|
||||||
fixture.componentRef.setInput('selected', true)
|
component.selected = true
|
||||||
expect(component.active()).toBeTruthy()
|
expect(component.active).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support numbered', () => {
|
it('should support numbered', () => {
|
||||||
fixture.componentRef.setInput('number', 3)
|
component.number = 3
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.active()).toBeTruthy()
|
expect(component.active).toBeTruthy()
|
||||||
expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
|
expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support selected', () => {
|
it('should support selected', () => {
|
||||||
let clearedResult
|
let clearedResult
|
||||||
fixture.componentRef.setInput('selected', true)
|
component.selected = true
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.cleared.subscribe((clear) => {
|
component.cleared.subscribe((clear) => {
|
||||||
clearedResult = clear
|
clearedResult = clear
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, computed, EventEmitter, input, Output } from '@angular/core'
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -8,15 +8,24 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|||||||
imports: [NgxBootstrapIconsModule],
|
imports: [NgxBootstrapIconsModule],
|
||||||
})
|
})
|
||||||
export class ClearableBadgeComponent {
|
export class ClearableBadgeComponent {
|
||||||
readonly number = input<number>(undefined)
|
constructor() {}
|
||||||
readonly selected = input<boolean>(undefined)
|
|
||||||
|
@Input()
|
||||||
|
number: number
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
selected: boolean
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
cleared: EventEmitter<boolean> = new EventEmitter()
|
cleared: EventEmitter<boolean> = new EventEmitter()
|
||||||
|
|
||||||
readonly active = computed(() => this.selected() || this.number() > -1)
|
get active(): boolean {
|
||||||
|
return this.selected || this.number > -1
|
||||||
|
}
|
||||||
|
|
||||||
readonly isNumbered = computed(() => this.number() > -1)
|
get isNumbered(): boolean {
|
||||||
|
return this.number > -1
|
||||||
|
}
|
||||||
|
|
||||||
onClick(event: PointerEvent) {
|
onClick(event: PointerEvent) {
|
||||||
this.cleared.emit(true)
|
this.cleared.emit(true)
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
|||||||
title = $localize`Confirmation`
|
title = $localize`Confirmation`
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
messageBold: string
|
messageBold
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
message: string
|
message
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
btnClass = 'btn-primary'
|
btnClass = 'btn-primary'
|
||||||
@@ -38,7 +38,7 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
|||||||
alternativeBtnClass = 'btn-secondary'
|
alternativeBtnClass = 'btn-secondary'
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
alternativeBtnCaption: string
|
alternativeBtnCaption
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
cancelBtnClass = 'btn-outline-secondary'
|
cancelBtnClass = 'btn-outline-secondary'
|
||||||
|
|||||||
+7
-7
@@ -9,9 +9,9 @@
|
|||||||
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
|
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
|
||||||
<ul class="list-group"
|
<ul class="list-group"
|
||||||
cdkDropList
|
cdkDropList
|
||||||
[cdkDropListData]="documentIDs()"
|
[cdkDropListData]="documentIDs"
|
||||||
(cdkDropListDropped)="onDrop($event)">
|
(cdkDropListDropped)="onDrop($event)">
|
||||||
@for (documentID of documentIDs(); track documentID) {
|
@for (documentID of documentIDs; track documentID) {
|
||||||
@let document = getDocument(documentID);
|
@let document = getDocument(documentID);
|
||||||
@if (document) {
|
@if (document) {
|
||||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||||
@@ -36,22 +36,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-4">
|
<div class="form-group mt-4">
|
||||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||||
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
<select class="form-select" [(ngModel)]="metadataDocumentID">
|
||||||
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||||
@for (document of documents(); track document.id) {
|
@for (document of documents; track document.id) {
|
||||||
<option [ngValue]="document.id">{{document.title}}</option>
|
<option [ngValue]="document.id">{{document.title}}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mt-4">
|
<div class="form-check form-switch mt-4">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [ngModel]="archiveFallback()" (ngModelChange)="archiveFallback.set($event)">
|
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [(ngModel)]="archiveFallback">
|
||||||
<label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
|
<label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mt-2">
|
<div class="form-check form-switch mt-2">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [ngModel]="deleteOriginals()" (ngModelChange)="deleteOriginals.set($event)" [disabled]="!userOwnsAllDocuments">
|
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments">
|
||||||
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
|
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
|
||||||
</div>
|
</div>
|
||||||
@if (!archiveFallback()) {
|
@if (!archiveFallback) {
|
||||||
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
|
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-4
@@ -50,12 +50,12 @@ describe('MergeConfirmDialogComponent', () => {
|
|||||||
|
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
expect(component.documents()).toEqual(documents)
|
expect(component.documents).toEqual(documents)
|
||||||
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs())
|
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should move documentIDs on drop', () => {
|
it('should move documentIDs on drop', () => {
|
||||||
component.documentIDs.set([1, 2, 3])
|
component.documentIDs = [1, 2, 3]
|
||||||
const event = {
|
const event = {
|
||||||
previousIndex: 1,
|
previousIndex: 1,
|
||||||
currentIndex: 2,
|
currentIndex: 2,
|
||||||
@@ -63,7 +63,7 @@ describe('MergeConfirmDialogComponent', () => {
|
|||||||
|
|
||||||
component.onDrop(event as any)
|
component.onDrop(event as any)
|
||||||
|
|
||||||
expect(component.documentIDs()).toEqual([1, 3, 2])
|
expect(component.documentIDs).toEqual([1, 3, 2])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get document by ID', () => {
|
it('should get document by ID', () => {
|
||||||
|
|||||||
+15
-13
@@ -4,7 +4,7 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe } from '@angular/common'
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
@@ -36,11 +36,15 @@ export class MergeConfirmDialogComponent
|
|||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private permissionService = inject(PermissionsService)
|
private permissionService = inject(PermissionsService)
|
||||||
|
|
||||||
readonly documentIDs = signal<number[]>([])
|
public documentIDs: number[] = []
|
||||||
readonly archiveFallback = signal(false)
|
public archiveFallback: boolean = false
|
||||||
readonly deleteOriginals = signal(false)
|
public deleteOriginals: boolean = false
|
||||||
readonly documents = signal<Document[]>([])
|
private _documents: Document[] = []
|
||||||
readonly metadataDocumentID = signal(-1)
|
get documents(): Document[] {
|
||||||
|
return this._documents
|
||||||
|
}
|
||||||
|
|
||||||
|
public metadataDocumentID: number = -1
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -48,25 +52,23 @@ export class MergeConfirmDialogComponent
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.documentService
|
this.documentService
|
||||||
.getFew(this.documentIDs())
|
.getFew(this.documentIDs)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((r) => {
|
.subscribe((r) => {
|
||||||
this.documents.set(r.results)
|
this._documents = r.results
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<number[]>) {
|
onDrop(event: CdkDragDrop<number[]>) {
|
||||||
const documentIDs = this.documentIDs().concat()
|
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
|
||||||
moveItemInArray(documentIDs, event.previousIndex, event.currentIndex)
|
|
||||||
this.documentIDs.set(documentIDs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getDocument(documentID: number): Document {
|
getDocument(documentID: number): Document {
|
||||||
return this.documents().find((d) => d.id === documentID)
|
return this.documents.find((d) => d.id === documentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
get userOwnsAllDocuments(): boolean {
|
get userOwnsAllDocuments(): boolean {
|
||||||
return this.documents().every((d) =>
|
return this.documents.every((d) =>
|
||||||
this.permissionService.currentUserOwnsObject(d)
|
this.permissionService.currentUserOwnsObject(d)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -1,4 +1,4 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component, Input } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
@@ -14,11 +14,18 @@ export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponen
|
|||||||
includeMetadata: boolean = true
|
includeMetadata: boolean = true
|
||||||
deleteOriginal: boolean = false
|
deleteOriginal: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
override title = $localize`Remove password protection`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
override message =
|
||||||
|
$localize`Create an unprotected copy or replace the existing file.`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
override btnCaption = $localize`Start`
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.title = $localize`Remove password protection`
|
|
||||||
this.message = $localize`Create an unprotected copy or replace the existing file.`
|
|
||||||
this.btnCaption = $localize`Start`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdateDocumentChange(updateDocument: boolean) {
|
onUpdateDocumentChange(updateDocument: boolean) {
|
||||||
|
|||||||
+3
-3
@@ -11,8 +11,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8 d-flex align-items-center">
|
<div class="col-8 d-flex align-items-center">
|
||||||
@if (documentID()) {
|
@if (documentID) {
|
||||||
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation()+'deg)'}" [src]="documentService.getThumbUrl(documentID())" />
|
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 d-flex">
|
<div class="col-2 d-flex">
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (showPDFNote()) {
|
@if (showPDFNote) {
|
||||||
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
|
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ describe('RotateConfirmDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support rotating the image', () => {
|
it('should support rotating the image', () => {
|
||||||
component.documentID.set(1)
|
component.documentID = 1
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.rotate()
|
component.rotate()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+8
-6
@@ -1,5 +1,5 @@
|
|||||||
import { NgStyle } from '@angular/common'
|
import { NgStyle } from '@angular/common'
|
||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
@@ -13,12 +13,14 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|||||||
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
||||||
documentService = inject(DocumentService)
|
documentService = inject(DocumentService)
|
||||||
|
|
||||||
readonly documentID = signal<number>(undefined)
|
public documentID: number
|
||||||
readonly showPDFNote = signal(true)
|
public showPDFNote: boolean = true
|
||||||
readonly rotation = signal(0)
|
|
||||||
|
// animation is better if we dont normalize yet
|
||||||
|
public rotation: number = 0
|
||||||
|
|
||||||
public get degrees(): number {
|
public get degrees(): number {
|
||||||
let degrees = this.rotation() % 360
|
let degrees = this.rotation % 360
|
||||||
if (degrees < 0) degrees += 360
|
if (degrees < 0) degrees += 360
|
||||||
return degrees
|
return degrees
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,6 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rotate(clockwise: boolean = true) {
|
rotate(clockwise: boolean = true) {
|
||||||
this.rotation.update((rotation) => rotation + (clockwise ? 90 : -90))
|
this.rotation += clockwise ? 90 : -90
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-19
@@ -1,6 +1,11 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
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,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +74,7 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 1, username: 'test' })
|
settingsService.currentUser = { id: 1, username: 'test' }
|
||||||
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
|
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -105,8 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support creating field, show error if necessary, then add', () => {
|
it('should support creating field, show error if necessary, then add', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
@@ -130,12 +134,11 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
editDialog.succeeded.emit(fields[0])
|
editDialog.succeeded.emit(fields[0])
|
||||||
jest.advanceTimersByTime(100)
|
tick(100)
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
expect(getFieldsSpy).toHaveBeenCalled()
|
expect(getFieldsSpy).toHaveBeenCalled()
|
||||||
expect(addFieldSpy).toHaveBeenCalled()
|
expect(addFieldSpy).toHaveBeenCalled()
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support creating field with name', () => {
|
it('should support creating field with name', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
@@ -147,13 +150,12 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
expect(editDialog.object.name).toEqual('Foo bar')
|
expect(editDialog.object.name).toEqual('Foo bar')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support arrow keyboard navigation', () => {
|
it('should support arrow keyboard navigation', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
jest.advanceTimersByTime(100)
|
tick(100)
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
@@ -191,16 +193,14 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||||
)
|
)
|
||||||
expect(document.activeElement).toEqual(itemButtons[0])
|
expect(document.activeElement).toEqual(itemButtons[0])
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support arrow keyboard navigation after tab keyboard navigation', () => {
|
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
jest.advanceTimersByTime(100)
|
tick(100)
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
@@ -229,10 +229,9 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||||
)
|
)
|
||||||
expect(document.activeElement).toEqual(itemButtons[1])
|
expect(document.activeElement).toEqual(itemButtons[1])
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support enter keyboard navigation', () => {
|
it('should support enter keyboard navigation', fakeAsync(() => {
|
||||||
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
|
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
|
||||||
const addFieldSpy = jest.spyOn(component, 'addField')
|
const addFieldSpy = jest.spyOn(component, 'addField')
|
||||||
const createFieldSpy = jest.spyOn(component, 'createField')
|
const createFieldSpy = jest.spyOn(component, 'createField')
|
||||||
@@ -251,5 +250,5 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
component.listFilterEnter()
|
component.listFilterEnter()
|
||||||
expect(createFieldSpy).not.toHaveBeenCalled()
|
expect(createFieldSpy).not.toHaveBeenCalled()
|
||||||
expect(addFieldSpy).not.toHaveBeenCalled()
|
expect(addFieldSpy).not.toHaveBeenCalled()
|
||||||
})
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
<ng-template #queryAtom let-atom="atom">
|
<ng-template #queryAtom let-atom="atom">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<ng-select #fieldSelects
|
<ng-select
|
||||||
class="paperless-input-select"
|
class="paperless-input-select"
|
||||||
[items]="customFields"
|
[items]="customFields"
|
||||||
[(ngModel)]="atom.field"
|
[(ngModel)]="atom.field"
|
||||||
|
|||||||
+10
-4
@@ -1,6 +1,11 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
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,
|
||||||
|
fakeAsync,
|
||||||
|
TestBed,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
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 { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
@@ -205,13 +210,14 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
expect(component.name).toBe('test_title')
|
expect(component.name).toBe('test_title')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add a default atom on open', async () => {
|
it('should add a default atom on open and focus the select field', fakeAsync(() => {
|
||||||
expect(component.selectionModel.queries.length).toBe(0)
|
expect(component.selectionModel.queries.length).toBe(0)
|
||||||
component.onOpenChange(true)
|
component.onOpenChange(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await fixture.whenStable()
|
tick()
|
||||||
expect(component.selectionModel.queries.length).toBe(1)
|
expect(component.selectionModel.queries.length).toBe(1)
|
||||||
})
|
expect(window.document.activeElement.tagName).toBe('INPUT')
|
||||||
|
}))
|
||||||
|
|
||||||
describe('CustomFieldQueriesModel', () => {
|
describe('CustomFieldQueriesModel', () => {
|
||||||
let model: CustomFieldQueriesModel
|
let model: CustomFieldQueriesModel
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
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,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
@@ -54,32 +59,27 @@ describe('DatesDropdownComponent', () => {
|
|||||||
expect(settingsSpy).toHaveBeenCalled()
|
expect(settingsSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support date input, emit change', () => {
|
it('should support date input, emit change', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
let result: string
|
let result: string
|
||||||
component.createdDateFromChange.subscribe((date) => (result = date))
|
component.createdDateFromChange.subscribe((date) => (result = date))
|
||||||
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
||||||
input.value = '5/30/2023'
|
input.value = '5/30/2023'
|
||||||
input.dispatchEvent(new Event('change'))
|
input.dispatchEvent(new Event('change'))
|
||||||
jest.advanceTimersByTime(500)
|
tick(500)
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support date select, emit datesSet change', () => {
|
it('should support date select, emit datesSet change', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
let result: DateSelection
|
let result: DateSelection
|
||||||
component.datesSet.subscribe((date) => (result = date))
|
component.datesSet.subscribe((date) => (result = date))
|
||||||
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
||||||
input.value = '5/30/2023'
|
input.value = '5/30/2023'
|
||||||
input.dispatchEvent(new Event('dateSelect'))
|
input.dispatchEvent(new Event('dateSelect'))
|
||||||
jest.advanceTimersByTime(500)
|
tick(500)
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support relative dates', () => {
|
it('should support relative dates', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
let result: DateSelection
|
let result: DateSelection
|
||||||
component.datesSet.subscribe((date) => (result = date))
|
component.datesSet.subscribe((date) => (result = date))
|
||||||
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
|
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
|
||||||
@@ -88,7 +88,7 @@ describe('DatesDropdownComponent', () => {
|
|||||||
} as any)
|
} as any)
|
||||||
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
|
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
|
||||||
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
|
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
|
||||||
jest.advanceTimersByTime(500)
|
tick(500)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
createdFrom: null,
|
createdFrom: null,
|
||||||
createdTo: null,
|
createdTo: null,
|
||||||
@@ -97,8 +97,7 @@ describe('DatesDropdownComponent', () => {
|
|||||||
addedTo: null,
|
addedTo: null,
|
||||||
addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
|
addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
|
||||||
})
|
})
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support report if active', () => {
|
it('should support report if active', () => {
|
||||||
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
|
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
|
||||||
@@ -178,12 +177,11 @@ describe('DatesDropdownComponent', () => {
|
|||||||
expect(eventSpy).toHaveBeenCalled()
|
expect(eventSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support debounce', () => {
|
it('should support debounce', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
let result: DateSelection
|
let result: DateSelection
|
||||||
component.datesSet.subscribe((date) => (result = date))
|
component.datesSet.subscribe((date) => (result = date))
|
||||||
component.onChangeDebounce()
|
component.onChangeDebounce()
|
||||||
jest.advanceTimersByTime(500)
|
tick(500)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
createdFrom: null,
|
createdFrom: null,
|
||||||
createdTo: null,
|
createdTo: null,
|
||||||
@@ -192,6 +190,5 @@ describe('DatesDropdownComponent', () => {
|
|||||||
addedTo: null,
|
addedTo: null,
|
||||||
addedRelativeDateID: null,
|
addedRelativeDateID: null,
|
||||||
})
|
})
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-3
@@ -41,20 +41,20 @@ describe('CorrespondentEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
|
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+8
-8
@@ -44,33 +44,33 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
|
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should disable data type select on edit', () => {
|
it('should disable data type select on edit', () => {
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.objectForm.get('data_type').disabled).toBeTruthy()
|
expect(component.objectForm.get('data_type').disabled).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize select options on edit', () => {
|
it('should initialize select options on edit', () => {
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
component.object = {
|
component.object = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Field 1',
|
name: 'Field 1',
|
||||||
@@ -91,7 +91,7 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support add / remove select options', () => {
|
it('should support add / remove select options', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(
|
expect(
|
||||||
@@ -115,7 +115,7 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
const selectOptionInputs = component[
|
const selectOptionInputs = component[
|
||||||
'selectOptionInputs'
|
'selectOptionInputs'
|
||||||
] as QueryList<ElementRef>
|
] as QueryList<ElementRef>
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
component.objectForm.get('data_type').setValue(CustomFieldDataType.Select)
|
component.objectForm.get('data_type').setValue(CustomFieldDataType.Select)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.ngAfterViewInit()
|
component.ngAfterViewInit()
|
||||||
@@ -125,7 +125,7 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should send all select options including those changed in form on save', () => {
|
it('should send all select options including those changed in form on save', () => {
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
component.object = {
|
component.object = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Field 1',
|
name: 'Field 1',
|
||||||
|
|||||||
+1
-1
@@ -149,7 +149,7 @@ export class CustomFieldEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get typeFieldDisabled(): boolean {
|
get typeFieldDisabled(): boolean {
|
||||||
return this.dialogMode() === EditDialogMode.EDIT
|
return this.dialogMode === EditDialogMode.EDIT
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSelectOptions() {
|
private updateSelectOptions() {
|
||||||
|
|||||||
+3
-3
@@ -41,20 +41,20 @@ describe('DocumentTypeEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
|
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
provideHttpClientTesting,
|
provideHttpClientTesting,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -117,7 +122,7 @@ describe('EditDialogComponent', () => {
|
|||||||
tagService = TestBed.inject(TagService)
|
tagService = TestBed.inject(TagService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set(currentUser as any)
|
settingsService.currentUser = currentUser
|
||||||
permissionsService.initialize([], currentUser as any)
|
permissionsService.initialize([], currentUser as any)
|
||||||
activeModal = TestBed.inject(NgbActiveModal)
|
activeModal = TestBed.inject(NgbActiveModal)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
@@ -131,7 +136,7 @@ describe('EditDialogComponent', () => {
|
|||||||
it('should interpolate object permissions', () => {
|
it('should interpolate object permissions', () => {
|
||||||
component.getMatchingAlgorithms() // coverage
|
component.getMatchingAlgorithms() // coverage
|
||||||
component.object = tag
|
component.object = tag
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
expect(component.objectForm.get('permissions_form').value).toEqual({
|
expect(component.objectForm.get('permissions_form').value).toEqual({
|
||||||
@@ -140,17 +145,15 @@ describe('EditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should delay close enabled', () => {
|
it('should delay close enabled', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
expect(component.closeEnabled).toBeFalsy()
|
expect(component.closeEnabled).toBeFalsy()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
jest.advanceTimersByTime(100)
|
tick(100)
|
||||||
expect(component.closeEnabled).toBeTruthy()
|
expect(component.closeEnabled).toBeTruthy()
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should set default owner when in create mode if unset', () => {
|
it('should set default owner when in create mode if unset', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.objectForm.get('permissions_form').value.owner).toEqual(
|
expect(component.objectForm.get('permissions_form').value.owner).toEqual(
|
||||||
currentUser.id
|
currentUser.id
|
||||||
@@ -161,7 +164,7 @@ describe('EditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set default perms when in create mode if set', () => {
|
it('should set default perms when in create mode if set', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_OWNER, 11)
|
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_OWNER, 11)
|
||||||
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, [1, 2])
|
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, [1, 2])
|
||||||
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, [3])
|
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, [3])
|
||||||
@@ -200,18 +203,18 @@ describe('EditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
component.getTitle()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
component.getTitle()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
// coverage
|
// coverage
|
||||||
component.dialogMode.set(null)
|
component.dialogMode = null
|
||||||
component.getTitle()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close on cancel', () => {
|
it('should close on cancel', () => {
|
||||||
@@ -222,14 +225,14 @@ describe('EditDialogComponent', () => {
|
|||||||
|
|
||||||
it('should update an object on save in edit mode', () => {
|
it('should update an object on save in edit mode', () => {
|
||||||
const updateSpy = jest.spyOn(tagService, 'update')
|
const updateSpy = jest.spyOn(tagService, 'update')
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
component.save()
|
component.save()
|
||||||
expect(updateSpy).toHaveBeenCalled()
|
expect(updateSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not submit owner or permissions for non-owner edits', () => {
|
it('should not submit owner or permissions for non-owner edits', () => {
|
||||||
component.object = tag
|
component.object = tag
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
component.objectForm.get('name').setValue('Updated tag')
|
component.objectForm.get('name').setValue('Updated tag')
|
||||||
@@ -248,7 +251,7 @@ describe('EditDialogComponent', () => {
|
|||||||
|
|
||||||
it('should create an object on save in edit mode', () => {
|
it('should create an object on save in edit mode', () => {
|
||||||
const createSpy = jest.spyOn(tagService, 'create')
|
const createSpy = jest.spyOn(tagService, 'create')
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
component.save()
|
component.save()
|
||||||
expect(createSpy).toHaveBeenCalled()
|
expect(createSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
inject,
|
inject,
|
||||||
model,
|
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormGroup } from '@angular/forms'
|
import { FormGroup } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -46,7 +45,10 @@ export abstract class EditDialogComponent<
|
|||||||
protected settingsService = inject(SettingsService)
|
protected settingsService = inject(SettingsService)
|
||||||
protected permissionsService = inject(PermissionsService)
|
protected permissionsService = inject(PermissionsService)
|
||||||
|
|
||||||
dialogMode = model(EditDialogMode.CREATE)
|
users: User[]
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
dialogMode: EditDialogMode = EditDialogMode.CREATE
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
object: T
|
object: T
|
||||||
@@ -57,20 +59,18 @@ export abstract class EditDialogComponent<
|
|||||||
@Output()
|
@Output()
|
||||||
failed = new EventEmitter()
|
failed = new EventEmitter()
|
||||||
|
|
||||||
users: User[]
|
|
||||||
|
|
||||||
networkActive = false
|
networkActive = false
|
||||||
|
|
||||||
closeEnabled = false
|
closeEnabled = false
|
||||||
|
|
||||||
error: any = null
|
error = null
|
||||||
|
|
||||||
abstract getForm(): FormGroup
|
abstract getForm(): FormGroup
|
||||||
|
|
||||||
objectForm: FormGroup = this.getForm()
|
objectForm: FormGroup = this.getForm()
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.object != null && this.dialogMode() !== EditDialogMode.CREATE) {
|
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
|
||||||
this.object['permissions_form'] = {
|
this.object['permissions_form'] = {
|
||||||
owner: (this.object as ObjectWithPermissions).owner,
|
owner: (this.object as ObjectWithPermissions).owner,
|
||||||
set_permissions: (this.object as ObjectWithPermissions).permissions,
|
set_permissions: (this.object as ObjectWithPermissions).permissions,
|
||||||
@@ -124,7 +124,7 @@ export abstract class EditDialogComponent<
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTitle() {
|
getTitle() {
|
||||||
switch (this.dialogMode()) {
|
switch (this.dialogMode) {
|
||||||
case EditDialogMode.CREATE:
|
case EditDialogMode.CREATE:
|
||||||
return this.getCreateTitle()
|
return this.getCreateTitle()
|
||||||
case EditDialogMode.EDIT:
|
case EditDialogMode.EDIT:
|
||||||
@@ -151,7 +151,7 @@ export abstract class EditDialogComponent<
|
|||||||
|
|
||||||
protected shouldSubmitPermissions(): boolean {
|
protected shouldSubmitPermissions(): boolean {
|
||||||
return (
|
return (
|
||||||
this.dialogMode() === EditDialogMode.CREATE ||
|
this.dialogMode === EditDialogMode.CREATE ||
|
||||||
this.permissionsService.currentUserOwnsObject(this.object)
|
this.permissionsService.currentUserOwnsObject(this.object)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ export abstract class EditDialogComponent<
|
|||||||
delete newObject['set_permissions']
|
delete newObject['set_permissions']
|
||||||
}
|
}
|
||||||
var serverResponse: Observable<T>
|
var serverResponse: Observable<T>
|
||||||
switch (this.dialogMode()) {
|
switch (this.dialogMode) {
|
||||||
case EditDialogMode.CREATE:
|
case EditDialogMode.CREATE:
|
||||||
serverResponse = this.service.create(newObject)
|
serverResponse = this.service.create(newObject)
|
||||||
break
|
break
|
||||||
|
|||||||
+3
-3
@@ -45,20 +45,20 @@ describe('GroupEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(GroupEditDialogComponent)
|
fixture = TestBed.createComponent(GroupEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -25,8 +25,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<div class="m-0 me-2">
|
<div class="m-0 me-2">
|
||||||
@if (testResult()) {
|
@if (testResult) {
|
||||||
<ngb-alert #testResultAlert [type]="testResult()" class="mb-0 py-2" (closed)="testResult.set(null)">{{testResultMessage}}</ngb-alert>
|
<ngb-alert #testResultAlert [type]="testResult" class="mb-0 py-2" (closed)="testResult = null">{{testResultMessage}}</ngb-alert>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive">
|
<button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive">
|
||||||
|
|||||||
+13
-10
@@ -3,7 +3,12 @@ import {
|
|||||||
HttpTestingController,
|
HttpTestingController,
|
||||||
provideHttpClientTesting,
|
provideHttpClientTesting,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
@@ -53,26 +58,25 @@ describe('MailAccountEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
|
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support test mail account and show appropriate expiring alert', () => {
|
it('should support test mail account and show appropriate expiring alert', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'example',
|
name: 'example',
|
||||||
imap_server: 'imap.example.com',
|
imap_server: 'imap.example.com',
|
||||||
@@ -93,7 +97,7 @@ describe('MailAccountEditDialogComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain(
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
'Successfully connected'
|
'Successfully connected'
|
||||||
)
|
)
|
||||||
jest.advanceTimersByTime(6000)
|
tick(6000)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
expect(fixture.nativeElement.textContent).not.toContain(
|
||||||
'Successfully connected'
|
'Successfully connected'
|
||||||
@@ -114,7 +118,6 @@ describe('MailAccountEditDialogComponent', () => {
|
|||||||
.flush({}, { status: 500, statusText: 'error' })
|
.flush({}, { status: 500, statusText: 'error' })
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.nativeElement.textContent).toContain('Unable to connect')
|
expect(fixture.nativeElement.textContent).toContain('Unable to connect')
|
||||||
jest.advanceTimersByTime(6000)
|
tick(6000)
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
import { Component, ViewChild, inject, signal } from '@angular/core'
|
import { Component, ViewChild, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -38,7 +38,7 @@ const IMAP_SECURITY_OPTIONS = [
|
|||||||
})
|
})
|
||||||
export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> {
|
export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> {
|
||||||
testActive: boolean = false
|
testActive: boolean = false
|
||||||
readonly testResult = signal<string>(undefined)
|
testResult: string
|
||||||
alertTimeout
|
alertTimeout
|
||||||
|
|
||||||
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
|
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
|
||||||
@@ -77,7 +77,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
|
|||||||
|
|
||||||
test() {
|
test() {
|
||||||
this.testActive = true
|
this.testActive = true
|
||||||
this.testResult.set(null)
|
this.testResult = null
|
||||||
clearTimeout(this.alertTimeout)
|
clearTimeout(this.alertTimeout)
|
||||||
const mailService = this.service as MailAccountService
|
const mailService = this.service as MailAccountService
|
||||||
const newObject = Object.assign(
|
const newObject = Object.assign(
|
||||||
@@ -87,19 +87,19 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
|
|||||||
mailService.test(newObject).subscribe({
|
mailService.test(newObject).subscribe({
|
||||||
next: (result: { success: boolean }) => {
|
next: (result: { success: boolean }) => {
|
||||||
this.testActive = false
|
this.testActive = false
|
||||||
this.testResult.set(result.success ? 'success' : 'danger')
|
this.testResult = result.success ? 'success' : 'danger'
|
||||||
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.testActive = false
|
this.testActive = false
|
||||||
this.testResult.set('danger')
|
this.testResult = 'danger'
|
||||||
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get testResultMessage() {
|
get testResultMessage() {
|
||||||
return this.testResult() === 'success'
|
return this.testResult === 'success'
|
||||||
? $localize`Successfully connected to the mail server`
|
? $localize`Successfully connected to the mail server`
|
||||||
: $localize`Unable to connect to the mail server`
|
: $localize`Unable to connect to the mail server`
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -13,7 +13,7 @@
|
|||||||
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts()" formControlName="account"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 pt-2">
|
<div class="col-md-2 pt-2">
|
||||||
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
|
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<pngx-input-tags [horizontal]="true" [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
|
<pngx-input-tags [horizontal]="true" [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes()" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
|
||||||
@if (showCorrespondentField) {
|
@if (showCorrespondentField) {
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents()" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -75,20 +75,20 @@ describe('MailRuleEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+25
-17
@@ -1,12 +1,11 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { map } from 'rxjs'
|
import { first } from 'rxjs'
|
||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
@@ -155,28 +154,37 @@ const METADATA_CORRESPONDENT_OPTIONS = [
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
||||||
private accountService = inject(MailAccountService)
|
private accountService: MailAccountService
|
||||||
private correspondentService = inject(CorrespondentService)
|
private correspondentService: CorrespondentService
|
||||||
private documentTypeService = inject(DocumentTypeService)
|
private documentTypeService: DocumentTypeService
|
||||||
|
|
||||||
readonly accounts = toSignal(
|
accounts: MailAccount[]
|
||||||
this.accountService.listAll().pipe(map((result) => result.results)),
|
correspondents: Correspondent[]
|
||||||
{ initialValue: undefined as MailAccount[] }
|
documentTypes: DocumentType[]
|
||||||
)
|
|
||||||
readonly correspondents = toSignal(
|
|
||||||
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
|
||||||
{ initialValue: undefined as Correspondent[] }
|
|
||||||
)
|
|
||||||
readonly documentTypes = toSignal(
|
|
||||||
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
|
||||||
{ initialValue: undefined as DocumentType[] }
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(MailRuleService)
|
this.service = inject(MailRuleService)
|
||||||
|
this.accountService = inject(MailAccountService)
|
||||||
|
this.correspondentService = inject(CorrespondentService)
|
||||||
|
this.documentTypeService = inject(DocumentTypeService)
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
|
|
||||||
|
this.accountService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.accounts = result.results))
|
||||||
|
|
||||||
|
this.correspondentService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.correspondents = result.results))
|
||||||
|
|
||||||
|
this.documentTypeService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.documentTypes = result.results))
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
|
|||||||
+3
-3
@@ -30,20 +30,20 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-3
@@ -67,11 +67,11 @@ export class StoragePathEditDialogComponent
|
|||||||
private testDocument: Document
|
private testDocument: Document
|
||||||
public testResult: string
|
public testResult: string
|
||||||
public testFailed: boolean = false
|
public testFailed: boolean = false
|
||||||
|
public loading = false
|
||||||
public testLoading = false
|
public testLoading = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.loading.set(false)
|
|
||||||
this.service = inject(StoragePathService)
|
this.service = inject(StoragePathService)
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
@@ -138,7 +138,7 @@ export class StoragePathEditDialogComponent
|
|||||||
this.documentsInput$.pipe(
|
this.documentsInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap(() => this.loading.set(true)),
|
tap(() => (this.loading = true)),
|
||||||
switchMap((title) =>
|
switchMap((title) =>
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -152,7 +152,7 @@ export class StoragePathEditDialogComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
map((result) => result.results),
|
map((result) => result.results),
|
||||||
catchError(() => of([])), // empty on error
|
catchError(() => of([])), // empty on error
|
||||||
tap(() => this.loading.set(false))
|
tap(() => (this.loading = false))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-3
@@ -48,20 +48,20 @@ describe('TagEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(TagEditDialogComponent)
|
fixture = TestBed.createComponent(TagEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pngx-input-select i18n-title title="Groups" [items]="groups()" multiple="true" formControlName="groups"></pngx-input-select>
|
<pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
|
||||||
|
|
||||||
@if (object?.is_mfa_enabled && currentUserIsSuperUser) {
|
@if (object?.is_mfa_enabled && currentUserIsSuperUser) {
|
||||||
<label class="form-label" i18n>Two-factor Authentication</label>
|
<label class="form-label" i18n>Two-factor Authentication</label>
|
||||||
@@ -42,7 +42,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>
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -73,7 +73,7 @@ describe('UserEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(UserEditDialogComponent)
|
fixture = TestBed.createComponent(UserEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
@@ -82,13 +82,13 @@ describe('UserEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -114,17 +114,17 @@ describe('UserEditDialogComponent', () => {
|
|||||||
it('should detect whether password was changed in form on save', () => {
|
it('should detect whether password was changed in form on save', () => {
|
||||||
component.objectForm.get('password').setValue(null)
|
component.objectForm.get('password').setValue(null)
|
||||||
component.save()
|
component.save()
|
||||||
expect(component.passwordIsSet()).toBeFalsy()
|
expect(component.passwordIsSet).toBeFalsy()
|
||||||
|
|
||||||
// unchanged pw
|
// unchanged pw
|
||||||
component.objectForm.get('password').setValue('*******')
|
component.objectForm.get('password').setValue('*******')
|
||||||
component.save()
|
component.save()
|
||||||
expect(component.passwordIsSet()).toBeFalsy()
|
expect(component.passwordIsSet).toBeFalsy()
|
||||||
|
|
||||||
// unchanged pw
|
// unchanged pw
|
||||||
component.objectForm.get('password').setValue('helloworld')
|
component.objectForm.get('password').setValue('helloworld')
|
||||||
component.save()
|
component.save()
|
||||||
expect(component.passwordIsSet()).toBeTruthy()
|
expect(component.passwordIsSet).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support deactivation of TOTP', () => {
|
it('should support deactivation of TOTP', () => {
|
||||||
|
|||||||
+17
-16
@@ -1,12 +1,11 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { first, map } from 'rxjs'
|
import { first } from 'rxjs'
|
||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
@@ -39,19 +38,22 @@ export class UserEditDialogComponent
|
|||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private groupsService = inject(GroupService)
|
private groupsService: GroupService
|
||||||
|
|
||||||
readonly groups = toSignal(
|
groups: Group[]
|
||||||
this.groupsService.listAll().pipe(map((result) => result.results)),
|
passwordIsSet: boolean = false
|
||||||
{ initialValue: undefined as Group[] }
|
public totpLoading: boolean = false
|
||||||
)
|
|
||||||
readonly passwordIsSet = signal(false)
|
|
||||||
readonly totpLoading = signal(false)
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(UserService)
|
this.service = inject(UserService)
|
||||||
|
this.groupsService = inject(GroupService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
|
|
||||||
|
this.groupsService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.groups = result.results))
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -101,15 +103,14 @@ export class UserEditDialogComponent
|
|||||||
if (!groupsVal) return []
|
if (!groupsVal) return []
|
||||||
else
|
else
|
||||||
return groupsVal.flatMap(
|
return groupsVal.flatMap(
|
||||||
(id) => this.groups()?.find((g) => g.id == id)?.permissions
|
(id) => this.groups.find((g) => g.id == id)?.permissions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
this.passwordIsSet.set(
|
this.passwordIsSet =
|
||||||
this.objectForm.get('password').value?.toString().replaceAll('*', '')
|
this.objectForm.get('password').value?.toString().replaceAll('*', '')
|
||||||
.length > 0
|
.length > 0
|
||||||
)
|
|
||||||
super.save()
|
super.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,13 +119,13 @@ export class UserEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
deactivateTotp() {
|
deactivateTotp() {
|
||||||
this.totpLoading.set(true)
|
this.totpLoading = true
|
||||||
;(this.service as UserService)
|
;(this.service as UserService)
|
||||||
.deactivateTotp(this.object)
|
.deactivateTotp(this.object)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.totpLoading.set(false)
|
this.totpLoading = false
|
||||||
if (result) {
|
if (result) {
|
||||||
this.toastService.showInfo($localize`Totp deactivated`)
|
this.toastService.showInfo($localize`Totp deactivated`)
|
||||||
this.object.is_mfa_enabled = false
|
this.object.is_mfa_enabled = false
|
||||||
@@ -133,7 +134,7 @@ export class UserEditDialogComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.totpLoading.set(false)
|
this.totpLoading = false
|
||||||
this.toastService.showError($localize`Totp deactivation failed`, e)
|
this.toastService.showError($localize`Totp deactivation failed`, e)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+10
-10
@@ -140,7 +140,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (formGroup.get('schedule_date_field').value === 'custom_field') {
|
@if (formGroup.get('schedule_date_field').value === 'custom_field') {
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<pngx-input-select i18n-title title="Custom field" formControlName="schedule_date_custom_field" [items]="dateCustomFields()" i18n-hint hint="Custom field to use for date." [error]="error?.schedule_date_custom_field"></pngx-input-select>
|
<pngx-input-select i18n-title title="Custom field" formControlName="schedule_date_custom_field" [items]="dateCustomFields" i18n-hint hint="Custom field to use for date." [error]="error?.schedule_date_custom_field"></pngx-input-select>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
||||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
|
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
|
||||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules()" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||||
}
|
}
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||||
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@@ -262,10 +262,10 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text>
|
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text>
|
||||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
|
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
|
||||||
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes()" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||||
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents()" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||||
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths()" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
||||||
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields()" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
|
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
|
||||||
<pngx-input-custom-fields-values formControlName="assign_custom_fields_values" [selectedFields]="formGroup.get('assign_custom_fields').value" (removeSelectedField)="removeSelectedCustomField($event, formGroup)"></pngx-input-custom-fields-values>
|
<pngx-input-custom-fields-values formControlName="assign_custom_fields_values" [selectedFields]="formGroup.get('assign_custom_fields').value" (removeSelectedField)="removeSelectedCustomField($event, formGroup)"></pngx-input-custom-fields-values>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -326,25 +326,25 @@
|
|||||||
<h6 class="form-label" i18n>Remove correspondents</h6>
|
<h6 class="form-label" i18n>Remove correspondents</h6>
|
||||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_correspondents"></pngx-input-switch>
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_correspondents"></pngx-input-switch>
|
||||||
<div class="mt-n3">
|
<div class="mt-n3">
|
||||||
<pngx-input-select i18n-title title="" multiple="true" [items]="correspondents()" formControlName="remove_correspondents"></pngx-input-select>
|
<pngx-input-select i18n-title title="" multiple="true" [items]="correspondents" formControlName="remove_correspondents"></pngx-input-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 class="form-label" i18n>Remove document types</h6>
|
<h6 class="form-label" i18n>Remove document types</h6>
|
||||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_document_types"></pngx-input-switch>
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_document_types"></pngx-input-switch>
|
||||||
<div class="mt-n3">
|
<div class="mt-n3">
|
||||||
<pngx-input-select i18n-title title="" multiple="true" [items]="documentTypes()" formControlName="remove_document_types"></pngx-input-select>
|
<pngx-input-select i18n-title title="" multiple="true" [items]="documentTypes" formControlName="remove_document_types"></pngx-input-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 class="form-label" i18n>Remove storage paths</h6>
|
<h6 class="form-label" i18n>Remove storage paths</h6>
|
||||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_storage_paths"></pngx-input-switch>
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_storage_paths"></pngx-input-switch>
|
||||||
<div class="mt-n3">
|
<div class="mt-n3">
|
||||||
<pngx-input-select i18n-title title="" multiple="true" [items]="storagePaths()" formControlName="remove_storage_paths"></pngx-input-select>
|
<pngx-input-select i18n-title title="" multiple="true" [items]="storagePaths" formControlName="remove_storage_paths"></pngx-input-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 class="form-label" i18n>Remove custom fields</h6>
|
<h6 class="form-label" i18n>Remove custom fields</h6>
|
||||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_custom_fields"></pngx-input-switch>
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_custom_fields"></pngx-input-switch>
|
||||||
<div class="mt-n3">
|
<div class="mt-n3">
|
||||||
<pngx-input-select i18n-title title="" multiple="true" [items]="customFields()" formControlName="remove_custom_fields"></pngx-input-select>
|
<pngx-input-select i18n-title title="" multiple="true" [items]="customFields" formControlName="remove_custom_fields"></pngx-input-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|||||||
+12
-8
@@ -187,14 +187,14 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(WorkflowEditDialogComponent)
|
fixture = TestBed.createComponent(WorkflowEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode = EditDialogMode.CREATE
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -209,7 +209,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
expect(component.object).not.toBeUndefined()
|
expect(component.object).not.toBeUndefined()
|
||||||
expect(component.object.triggers).toHaveLength(1)
|
expect(component.object.triggers).toHaveLength(1)
|
||||||
|
|
||||||
component.dialogMode.set(EditDialogMode.EDIT)
|
component.dialogMode = EditDialogMode.EDIT
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -771,21 +771,25 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
component.correspondents = [{ id: 1, name: 'C1' } as any]
|
||||||
|
component.documentTypes = [{ id: 2, name: 'DT' } as any]
|
||||||
|
component.storagePaths = [{ id: 3, name: 'SP' } as any]
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
|
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
|
||||||
).toEqual(component.correspondents())
|
).toEqual(component.correspondents)
|
||||||
expect(
|
expect(
|
||||||
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
|
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
|
||||||
).toEqual(component.documentTypes())
|
).toEqual(component.documentTypes)
|
||||||
expect(
|
expect(
|
||||||
component.getFilterSelectItems(TriggerFilterType.DocumentTypeAny)
|
component.getFilterSelectItems(TriggerFilterType.DocumentTypeAny)
|
||||||
).toEqual(component.documentTypes())
|
).toEqual(component.documentTypes)
|
||||||
expect(
|
expect(
|
||||||
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
|
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
|
||||||
).toEqual(component.storagePaths())
|
).toEqual(component.storagePaths)
|
||||||
expect(
|
expect(
|
||||||
component.getFilterSelectItems(TriggerFilterType.StoragePathAny)
|
component.getFilterSelectItems(TriggerFilterType.StoragePathAny)
|
||||||
).toEqual(component.storagePaths())
|
).toEqual(component.storagePaths)
|
||||||
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
|
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|||||||
+58
-44
@@ -4,8 +4,7 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgTemplateOutlet } from '@angular/common'
|
import { NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnInit, computed, inject, signal } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
FormArray,
|
FormArray,
|
||||||
@@ -16,7 +15,7 @@ import {
|
|||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subscription, map, takeUntil } from 'rxjs'
|
import { Subscription, first, takeUntil } from 'rxjs'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
@@ -471,40 +470,23 @@ export class WorkflowEditDialogComponent
|
|||||||
public TriggerFilterType = TriggerFilterType
|
public TriggerFilterType = TriggerFilterType
|
||||||
public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
|
public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
|
||||||
|
|
||||||
private correspondentService = inject(CorrespondentService)
|
private correspondentService: CorrespondentService
|
||||||
private documentTypeService = inject(DocumentTypeService)
|
private documentTypeService: DocumentTypeService
|
||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService: StoragePathService
|
||||||
private mailRuleService = inject(MailRuleService)
|
private mailRuleService: MailRuleService
|
||||||
private customFieldsService = inject(CustomFieldsService)
|
private customFieldsService: CustomFieldsService
|
||||||
|
|
||||||
readonly templates = signal<Workflow[]>(undefined)
|
templates: Workflow[]
|
||||||
readonly correspondents = toSignal(
|
correspondents: Correspondent[]
|
||||||
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
documentTypes: DocumentType[]
|
||||||
{ initialValue: undefined as Correspondent[] }
|
storagePaths: StoragePath[]
|
||||||
)
|
mailRules: MailRule[]
|
||||||
readonly documentTypes = toSignal(
|
customFields: CustomField[]
|
||||||
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
dateCustomFields: CustomField[]
|
||||||
{ initialValue: undefined as DocumentType[] }
|
|
||||||
)
|
|
||||||
readonly storagePaths = toSignal(
|
|
||||||
this.storagePathService.listAll().pipe(map((result) => result.results)),
|
|
||||||
{ initialValue: undefined as StoragePath[] }
|
|
||||||
)
|
|
||||||
readonly mailRules = toSignal(
|
|
||||||
this.mailRuleService.listAll().pipe(map((result) => result.results)),
|
|
||||||
{ initialValue: undefined as MailRule[] }
|
|
||||||
)
|
|
||||||
readonly customFields = toSignal(
|
|
||||||
this.customFieldsService.listAll().pipe(map((result) => result.results)),
|
|
||||||
{ initialValue: undefined as CustomField[] }
|
|
||||||
)
|
|
||||||
readonly dateCustomFields = computed(() =>
|
|
||||||
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
|
||||||
)
|
|
||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
readonly allowedActionTypes = signal([])
|
private allowedActionTypes = []
|
||||||
|
|
||||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||||
FormArray,
|
FormArray,
|
||||||
@@ -514,8 +496,43 @@ export class WorkflowEditDialogComponent
|
|||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(WorkflowService)
|
this.service = inject(WorkflowService)
|
||||||
|
this.correspondentService = inject(CorrespondentService)
|
||||||
|
this.documentTypeService = inject(DocumentTypeService)
|
||||||
|
this.storagePathService = inject(StoragePathService)
|
||||||
|
this.mailRuleService = inject(MailRuleService)
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
|
this.customFieldsService = inject(CustomFieldsService)
|
||||||
|
|
||||||
|
this.correspondentService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.correspondents = result.results))
|
||||||
|
|
||||||
|
this.documentTypeService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.documentTypes = result.results))
|
||||||
|
|
||||||
|
this.storagePathService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.storagePaths = result.results))
|
||||||
|
|
||||||
|
this.mailRuleService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.mailRules = result.results))
|
||||||
|
|
||||||
|
this.customFieldsService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => {
|
||||||
|
this.customFields = result.results
|
||||||
|
this.dateCustomFields = this.customFields?.filter(
|
||||||
|
(f) => f.data_type === CustomFieldDataType.Date
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
@@ -548,13 +565,11 @@ export class WorkflowEditDialogComponent
|
|||||||
this.checkRemovalActionFields.bind(this)
|
this.checkRemovalActionFields.bind(this)
|
||||||
)
|
)
|
||||||
this.checkRemovalActionFields(this.objectForm.value)
|
this.checkRemovalActionFields(this.objectForm.value)
|
||||||
this.allowedActionTypes.set(
|
this.allowedActionTypes = this.settingsService.get(
|
||||||
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
SETTINGS_KEYS.EMAIL_ENABLED
|
||||||
|
)
|
||||||
? WORKFLOW_ACTION_OPTIONS
|
? WORKFLOW_ACTION_OPTIONS
|
||||||
: WORKFLOW_ACTION_OPTIONS.filter(
|
: WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email)
|
||||||
(a) => a.id !== WorkflowActionType.Email
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkRemovalActionFields(formWorkflow: Workflow) {
|
private checkRemovalActionFields(formWorkflow: Workflow) {
|
||||||
@@ -939,11 +954,11 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
switch (definition.selectItems) {
|
switch (definition.selectItems) {
|
||||||
case 'correspondents':
|
case 'correspondents':
|
||||||
return this.correspondents()
|
return this.correspondents
|
||||||
case 'documentTypes':
|
case 'documentTypes':
|
||||||
return this.documentTypes()
|
return this.documentTypes
|
||||||
case 'storagePaths':
|
case 'storagePaths':
|
||||||
return this.storagePaths()
|
return this.storagePaths
|
||||||
default:
|
default:
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -1279,8 +1294,7 @@ export class WorkflowEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
this.settingsService.trackChanges()
|
return this.allowedActionTypes
|
||||||
return this.allowedActionTypes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||||
|
|||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title" i18n>{
|
<h4 class="modal-title" id="modal-basic-title" i18n>{
|
||||||
documentIds().length,
|
documentIds.length,
|
||||||
plural,
|
plural,
|
||||||
=1 {Email Document} other {Email {{documentIds().length}} Documents}
|
=1 {Email Document} other {Email {{documentIds.length}} Documents}
|
||||||
}</h4>
|
}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="input-group-text flex-grow-1">
|
<div class="input-group-text flex-grow-1">
|
||||||
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion()" [ngModel]="useArchiveVersion()" (ngModelChange)="useArchiveVersion.set($event)">
|
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
|
||||||
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
|
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading() || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
||||||
@if (loading()) {
|
@if (loading) {
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
}
|
}
|
||||||
<ng-container i18n>Send email</ng-container>
|
<ng-container i18n>Send email</ng-container>
|
||||||
|
|||||||
+8
-9
@@ -36,24 +36,23 @@ describe('EmailDocumentDialogComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
component.documentIds.set([1])
|
component.documentIds = [1]
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set hasArchiveVersion and useArchiveVersion', () => {
|
it('should set hasArchiveVersion and useArchiveVersion', () => {
|
||||||
expect(component.hasArchiveVersion()).toBeTruthy()
|
expect(component.hasArchiveVersion).toBeTruthy()
|
||||||
expect(component.useArchiveVersion()).toBeTruthy()
|
expect(component.useArchiveVersion).toBeTruthy()
|
||||||
|
|
||||||
component.hasArchiveVersion.set(false)
|
component.hasArchiveVersion = false
|
||||||
fixture.detectChanges()
|
expect(component.hasArchiveVersion).toBeFalsy()
|
||||||
expect(component.hasArchiveVersion()).toBeFalsy()
|
expect(component.useArchiveVersion).toBeFalsy()
|
||||||
expect(component.useArchiveVersion()).toBeFalsy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support sending single document via email, showing error if needed', () => {
|
it('should support sending single document via email, showing error if needed', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
component.documentIds.set([1])
|
component.documentIds = [1]
|
||||||
component.emailAddress = 'hello@paperless-ngx.com'
|
component.emailAddress = 'hello@paperless-ngx.com'
|
||||||
component.emailSubject = 'Hello'
|
component.emailSubject = 'Hello'
|
||||||
component.emailMessage = 'World'
|
component.emailMessage = 'World'
|
||||||
@@ -74,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
|
|||||||
it('should support sending multiple documents via email, showing appropriate messages', () => {
|
it('should support sending multiple documents via email, showing appropriate messages', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
component.documentIds.set([1, 2, 3])
|
component.documentIds = [1, 2, 3]
|
||||||
component.emailAddress = 'hello@paperless-ngx.com'
|
component.emailAddress = 'hello@paperless-ngx.com'
|
||||||
component.emailSubject = 'Hello'
|
component.emailSubject = 'Hello'
|
||||||
component.emailMessage = 'World'
|
component.emailMessage = 'World'
|
||||||
|
|||||||
+24
-14
@@ -1,4 +1,4 @@
|
|||||||
import { Component, effect, inject, signal } from '@angular/core'
|
import { Component, Input, inject } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
@@ -17,9 +17,22 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
|
|
||||||
readonly documentIds = signal<number[]>(undefined)
|
@Input()
|
||||||
readonly hasArchiveVersion = signal(true)
|
documentIds: number[]
|
||||||
readonly useArchiveVersion = signal(true)
|
|
||||||
|
private _hasArchiveVersion: boolean = true
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set hasArchiveVersion(value: boolean) {
|
||||||
|
this._hasArchiveVersion = value
|
||||||
|
this.useArchiveVersion = value
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasArchiveVersion(): boolean {
|
||||||
|
return this._hasArchiveVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
public useArchiveVersion: boolean = true
|
||||||
|
|
||||||
public emailAddress: string = ''
|
public emailAddress: string = ''
|
||||||
public emailSubject: string = ''
|
public emailSubject: string = ''
|
||||||
@@ -27,25 +40,22 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
effect(() => {
|
|
||||||
this.useArchiveVersion.set(this.hasArchiveVersion())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public emailDocuments() {
|
public emailDocuments() {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.documentService
|
this.documentService
|
||||||
.emailDocuments(
|
.emailDocuments(
|
||||||
this.documentIds(),
|
this.documentIds,
|
||||||
this.emailAddress,
|
this.emailAddress,
|
||||||
this.emailSubject,
|
this.emailSubject,
|
||||||
this.emailMessage,
|
this.emailMessage,
|
||||||
this.useArchiveVersion()
|
this.useArchiveVersion
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.emailAddress = ''
|
this.emailAddress = ''
|
||||||
this.emailSubject = ''
|
this.emailSubject = ''
|
||||||
this.emailMessage = ''
|
this.emailMessage = ''
|
||||||
@@ -53,9 +63,9 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
this.toastService.showInfo($localize`Email sent`)
|
this.toastService.showInfo($localize`Email sent`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
this.documentIds().length > 1
|
this.documentIds.length > 1
|
||||||
? $localize`Error emailing documents`
|
? $localize`Error emailing documents`
|
||||||
: $localize`Error emailing document`
|
: $localize`Error emailing document`
|
||||||
this.toastService.showError(errorMessage, e)
|
this.toastService.showError(errorMessage, e)
|
||||||
|
|||||||
+39
-69
@@ -1,7 +1,12 @@
|
|||||||
import { ScrollingModule } from '@angular/cdk/scrolling'
|
import { ScrollingModule } from '@angular/cdk/scrolling'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
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,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +52,6 @@ const negativeNullItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let selectionModel: FilterableDropdownSelectionModel
|
let selectionModel: FilterableDropdownSelectionModel
|
||||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
|
|
||||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
||||||
let component: FilterableDropdownComponent
|
let component: FilterableDropdownComponent
|
||||||
@@ -251,17 +255,14 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(applyResult).toEqual({ itemsToAdd: [items[0]], itemsToRemove: [] })
|
expect(applyResult).toEqual({ itemsToAdd: [items[0]], itemsToRemove: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should focus text filter on open, support filtering, clear on close', async () => {
|
it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(document.activeElement).toEqual(
|
expect(document.activeElement).toEqual(
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
)
|
)
|
||||||
@@ -269,16 +270,12 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
|
|
||||||
component.filterText = 'Tag2'
|
component.filterText = 'Tag2'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.buttonsViewport.checkViewportSize()
|
expect(component.buttonsViewport.getRenderedRange().end).toEqual(1) // filtered
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.scrollViewportHeight).toEqual(
|
|
||||||
component.FILTERABLE_BUTTON_HEIGHT_PX
|
|
||||||
) // filtered
|
|
||||||
component.dropdown.close()
|
component.dropdown.close()
|
||||||
expect(component.filterText).toHaveLength(0)
|
expect(component.filterText).toHaveLength(0)
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should toggle & close on enter inside filter field if 1 item remains', async () => {
|
it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||||
@@ -286,10 +283,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.filterText = 'Tag2'
|
component.filterText = 'Tag2'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
||||||
@@ -297,11 +291,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
new KeyboardEvent('keyup', { key: 'Enter' })
|
new KeyboardEvent('keyup', { key: 'Enter' })
|
||||||
)
|
)
|
||||||
expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
|
expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
|
||||||
await wait(300)
|
tick(300)
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should apply & close on enter inside filter field if 1 item remains if editing', async () => {
|
it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.editing = true
|
component.editing = true
|
||||||
@@ -312,31 +306,25 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.filterText = 'Tag2'
|
component.filterText = 'Tag2'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.listFilterTextInput.nativeElement.dispatchEvent(
|
component.listFilterTextInput.nativeElement.dispatchEvent(
|
||||||
new KeyboardEvent('keyup', { key: 'Enter' })
|
new KeyboardEvent('keyup', { key: 'Enter' })
|
||||||
)
|
)
|
||||||
expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
|
expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
|
||||||
await wait(300)
|
tick(300)
|
||||||
expect(applyResult).toEqual({ itemsToAdd: [items[1]], itemsToRemove: [] })
|
expect(applyResult).toEqual({ itemsToAdd: [items[1]], itemsToRemove: [] })
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should support arrow keyboard navigation', async () => {
|
it('should support arrow keyboard navigation', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
component.buttonsViewport?.checkViewportSize()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
@@ -374,19 +362,16 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||||
)
|
)
|
||||||
expect(document.activeElement).toEqual(itemButtons[0])
|
expect(document.activeElement).toEqual(itemButtons[0])
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should support arrow keyboard navigation after tab keyboard navigation', async () => {
|
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
component.buttonsViewport?.checkViewportSize()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
@@ -415,19 +400,16 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||||
)
|
)
|
||||||
expect(document.activeElement).toEqual(itemButtons[1])
|
expect(document.activeElement).toEqual(itemButtons[1])
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should support arrow keyboard navigation after click', async () => {
|
it('should support arrow keyboard navigation after click', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
component.buttonsViewport?.checkViewportSize()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
@@ -445,9 +427,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||||
)
|
)
|
||||||
expect(document.activeElement).toEqual(itemButtons[1])
|
expect(document.activeElement).toEqual(itemButtons[1])
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should toggle logical operator', async () => {
|
it('should toggle logical operator', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.selectionModel.manyToOne = true
|
component.selectionModel.manyToOne = true
|
||||||
@@ -463,10 +445,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.modifierToggleEnabled).toBeTruthy()
|
expect(component.modifierToggleEnabled).toBeTruthy()
|
||||||
const operatorButtons: HTMLInputElement[] = Array.from(
|
const operatorButtons: HTMLInputElement[] = Array.from(
|
||||||
@@ -477,9 +456,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(selectionModel.logicalOperator).toEqual(LogicalOperator.Or)
|
expect(selectionModel.logicalOperator).toEqual(LogicalOperator.Or)
|
||||||
expect(changedResult.logicalOperator).toEqual(LogicalOperator.Or)
|
expect(changedResult.logicalOperator).toEqual(LogicalOperator.Or)
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should toggle intersection include / exclude', async () => {
|
it('should toggle intersection include / exclude', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
selectionModel.set(items[0].id, ToggleableItemState.Selected)
|
selectionModel.set(items[0].id, ToggleableItemState.Selected)
|
||||||
@@ -494,10 +473,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.modifierToggleEnabled).toBeTruthy()
|
expect(component.modifierToggleEnabled).toBeTruthy()
|
||||||
const intersectionButtons: HTMLInputElement[] = Array.from(
|
const intersectionButtons: HTMLInputElement[] = Array.from(
|
||||||
@@ -510,7 +486,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(changedResult.intersection).toEqual(Intersection.Exclude)
|
expect(changedResult.intersection).toEqual(Intersection.Exclude)
|
||||||
expect(changedResult.getSelectedItems()).toEqual([])
|
expect(changedResult.getSelectedItems()).toEqual([])
|
||||||
expect(changedResult.getExcludedItems()).toEqual(items)
|
expect(changedResult.getExcludedItems()).toEqual(items)
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should update null item selection on toggleIntersection', () => {
|
it('should update null item selection on toggleIntersection', () => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
@@ -843,7 +819,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set support create, keep open model and call createRef method', async () => {
|
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.selectionModel = selectionModel
|
component.selectionModel = selectionModel
|
||||||
@@ -851,10 +827,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
component.filterText = 'Test Filter Text'
|
component.filterText = 'Test Filter Text'
|
||||||
component.createRef = jest.fn()
|
component.createRef = jest.fn()
|
||||||
@@ -864,9 +837,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
const openSpy = jest.spyOn(component.dropdown, 'open')
|
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||||
component.dropdownOpenChange(false)
|
component.dropdownOpenChange(false)
|
||||||
expect(openSpy).toHaveBeenCalled() // should keep open
|
expect(openSpy).toHaveBeenCalled() // should keep open
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
|
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.editing = true
|
component.editing = true
|
||||||
@@ -876,15 +849,12 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
await wait(100)
|
tick(100)
|
||||||
fixture.detectChanges()
|
|
||||||
component.buttonsViewport?.checkViewportSize()
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.filterText = 'FooBar'
|
component.filterText = 'FooBar'
|
||||||
component.listFilterEnter()
|
component.listFilterEnter()
|
||||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||||
expect(createSpy).toHaveBeenCalled()
|
expect(createSpy).toHaveBeenCalled()
|
||||||
})
|
}))
|
||||||
|
|
||||||
it('should exclude item and trigger change event', () => {
|
it('should exclude item and trigger change event', () => {
|
||||||
const id = 1
|
const id = 1
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (key of hotkeys().entries(); track key[0]) {
|
@for (key of hotkeys.entries(); track key[0]) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ key[1] }}</td>
|
<td>{{ key[1] }}</td>
|
||||||
<td class="d-flex justify-content-end">
|
<td class="d-flex justify-content-end">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
|
||||||
const SYMBOLS = {
|
const SYMBOLS = {
|
||||||
@@ -20,8 +20,9 @@ const SYMBOLS = {
|
|||||||
})
|
})
|
||||||
export class HotkeyDialogComponent {
|
export class HotkeyDialogComponent {
|
||||||
activeModal = inject(NgbActiveModal)
|
activeModal = inject(NgbActiveModal)
|
||||||
readonly title = signal($localize`Keyboard shortcuts`)
|
|
||||||
readonly hotkeys = signal<Map<string, string>>(new Map())
|
public title: string = $localize`Keyboard shortcuts`
|
||||||
|
public hotkeys: Map<string, string> = new Map()
|
||||||
|
|
||||||
public close(): void {
|
public close(): void {
|
||||||
this.activeModal.close()
|
this.activeModal.close()
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<ng-select name="inputId" [(ngModel)]="value"
|
<ng-select name="inputId" [(ngModel)]="value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
clearable="true"
|
clearable="true"
|
||||||
[items]="groups()"
|
[items]="groups"
|
||||||
multiple="true"
|
multiple="true"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="id"
|
bindValue="id"
|
||||||
|
|||||||
+12
-7
@@ -1,12 +1,11 @@
|
|||||||
import { Component, forwardRef, inject } from '@angular/core'
|
import { Component, forwardRef, inject } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NG_VALUE_ACCESSOR,
|
NG_VALUE_ACCESSOR,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
import { map } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { AbstractInputComponent } from '../../abstract-input'
|
import { AbstractInputComponent } from '../../abstract-input'
|
||||||
@@ -25,9 +24,15 @@ import { AbstractInputComponent } from '../../abstract-input'
|
|||||||
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
|
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
|
||||||
})
|
})
|
||||||
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
|
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
|
||||||
private readonly groupService = inject(GroupService)
|
groups: Group[]
|
||||||
readonly groups = toSignal(
|
|
||||||
this.groupService.listAll().pipe(map((result) => result.results)),
|
constructor() {
|
||||||
{ initialValue: undefined as Group[] }
|
const groupService = inject(GroupService)
|
||||||
)
|
|
||||||
|
super()
|
||||||
|
groupService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.groups = result.results))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<ng-select name="inputId" [(ngModel)]="value"
|
<ng-select name="inputId" [(ngModel)]="value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
clearable="true"
|
clearable="true"
|
||||||
[items]="users()"
|
[items]="users"
|
||||||
multiple="true"
|
multiple="true"
|
||||||
bindLabel="username"
|
bindLabel="username"
|
||||||
bindValue="id"
|
bindValue="id"
|
||||||
|
|||||||
+12
-7
@@ -1,12 +1,11 @@
|
|||||||
import { Component, forwardRef, inject } from '@angular/core'
|
import { Component, forwardRef, inject } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NG_VALUE_ACCESSOR,
|
NG_VALUE_ACCESSOR,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
import { map } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { AbstractInputComponent } from '../../abstract-input'
|
import { AbstractInputComponent } from '../../abstract-input'
|
||||||
@@ -25,9 +24,15 @@ import { AbstractInputComponent } from '../../abstract-input'
|
|||||||
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
|
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
|
||||||
})
|
})
|
||||||
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
|
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
|
||||||
private readonly userService = inject(UserService)
|
users: User[]
|
||||||
readonly users = toSignal(
|
|
||||||
this.userService.listAll().pipe(map((result) => result.results)),
|
constructor() {
|
||||||
{ initialValue: undefined as User[] }
|
const userService = inject(UserService)
|
||||||
)
|
|
||||||
|
super()
|
||||||
|
userService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.users = result.results))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NG_VALUE_ACCESSOR,
|
NG_VALUE_ACCESSOR,
|
||||||
@@ -116,14 +121,12 @@ describe('SelectComponent', () => {
|
|||||||
).toBeFalsy()
|
).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should clear search term on blur after delay', () => {
|
it('should clear search term on blur after delay', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
|
const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
|
||||||
component.onBlur()
|
component.onBlur()
|
||||||
jest.advanceTimersByTime(3000)
|
tick(3000)
|
||||||
expect(clearSpy).toHaveBeenCalled()
|
expect(clearSpy).toHaveBeenCalled()
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should emit filtered documents', () => {
|
it('should emit filtered documents', () => {
|
||||||
component.value = 10
|
component.value = 10
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ describe('TagsComponent', () => {
|
|||||||
|
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 1 } as any)
|
settingsService.currentUser = { id: 1 }
|
||||||
fixture = TestBed.createComponent(TagsComponent)
|
fixture = TestBed.createComponent(TagsComponent)
|
||||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
@@ -136,13 +136,20 @@ describe('TagsComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support create new using last search term and open a modal', () => {
|
it('should support create new using last search term and open a modal', () => {
|
||||||
settingsService.currentUser.set({ id: 1 })
|
settingsService.currentUser = { id: 1 }
|
||||||
let activeInstances: NgbModalRef[]
|
let activeInstances: NgbModalRef[]
|
||||||
modalService.activeInstances.subscribe((v) => (activeInstances = v))
|
modalService.activeInstances.subscribe((v) => (activeInstances = v))
|
||||||
component.select.filter('foobar')
|
component.select.filter('foobar')
|
||||||
component.createTag()
|
component.createTag()
|
||||||
expect(modalService.hasOpenModals()).toBeTruthy()
|
expect(modalService.hasOpenModals()).toBeTruthy()
|
||||||
expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')
|
expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')
|
||||||
|
const editDialog = activeInstances[0]
|
||||||
|
.componentInstance as TagEditDialogComponent
|
||||||
|
editDialog.save() // create is mocked
|
||||||
|
fixture.detectChanges()
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
expect(fixture.debugElement.nativeElement.textContent).toContain('foobar')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('support remove tags', () => {
|
it('support remove tags', () => {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
var modal = this.modalService.open(TagEditDialogComponent, {
|
var modal = this.modalService.open(TagEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
|
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||||
if (name) modal.componentInstance.object = { name: name }
|
if (name) modal.componentInstance.object = { name: name }
|
||||||
else if (this.select.searchTerm)
|
else if (this.select.searchTerm)
|
||||||
modal.componentInstance.object = { name: this.select.searchTerm }
|
modal.componentInstance.object = { name: this.select.searchTerm }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@if (customLogo) {
|
@if (customLogo) {
|
||||||
<img src="{{customLogo}}" [class]="getClasses()" [attr.style]="'height:'+height()" />
|
<img src="{{customLogo}}" [class]="getClasses()" [attr.style]="'height:'+height" />
|
||||||
} @else {
|
} @else {
|
||||||
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2670 860" [attr.style]="'height:'+height()">
|
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2670 860" [attr.style]="'height:'+height">
|
||||||
<path class="leaf" style="fill:#005616;" d="M2227.4,821.2c-6.1-17.8-18.1-53.6-19.2-53.4-174.7-77.8-159.8-201.2-117.5-304.2,26.3,120.1,235.3,130.3,128,294.1-.7,2,8.8,24.3,17.1,44.9,19.9-45.4,51.3-101.1,48.8-105.7-199.9-357.4,278.8-444.7,350.7-690.2,72.6,220.1,46.5,577.5-330.4,713.3-1.8,1.2-55.6,130-58.5,131.4-.2-1.9-29.1,2.5-26.4-7.6,1.4-6.2,4.2-14.2,7.2-22.4h0v-.2h.2,0ZM2211.7,731.2c42.3-62.9-11.1-105.7-49.8-133.2,71,94,58.1,105.7,49.8,133.2h0Z"/>
|
<path class="leaf" style="fill:#005616;" d="M2227.4,821.2c-6.1-17.8-18.1-53.6-19.2-53.4-174.7-77.8-159.8-201.2-117.5-304.2,26.3,120.1,235.3,130.3,128,294.1-.7,2,8.8,24.3,17.1,44.9,19.9-45.4,51.3-101.1,48.8-105.7-199.9-357.4,278.8-444.7,350.7-690.2,72.6,220.1,46.5,577.5-330.4,713.3-1.8,1.2-55.6,130-58.5,131.4-.2-1.9-29.1,2.5-26.4-7.6,1.4-6.2,4.2-14.2,7.2-22.4h0v-.2h.2,0ZM2211.7,731.2c42.3-62.9-11.1-105.7-49.8-133.2,71,94,58.1,105.7,49.8,133.2h0Z"/>
|
||||||
<g class="text" style="fill: #000;">
|
<g class="text" style="fill: #000;">
|
||||||
<path class="st1" d="M654.6,393.2l-.7,137.7h-85.5V188.7h85.4c.4,11.3-.3,21.7,1.3,33.8,23.1-34.1,62.3-50,101.1-38.3,16.5,5,29.6,16.4,39.7,30,34.4,46.5,35.1,134,3.6,182.2-10.1,14.4-22.5,26.9-39,33.4-39.5,15.7-81,1.1-105.9-36.6h0ZM721,362.2c21-26.1,21-82.7-.4-108.4-13.2-15.9-36.4-16.1-49.9-.4-22.2,25.8-21.7,85.3.5,110.1,13.6,15.2,36.6,15,49.7-1.3h.1Z"/>
|
<path class="st1" d="M654.6,393.2l-.7,137.7h-85.5V188.7h85.4c.4,11.3-.3,21.7,1.3,33.8,23.1-34.1,62.3-50,101.1-38.3,16.5,5,29.6,16.4,39.7,30,34.4,46.5,35.1,134,3.6,182.2-10.1,14.4-22.5,26.9-39,33.4-39.5,15.7-81,1.1-105.9-36.6h0ZM721,362.2c21-26.1,21-82.7-.4-108.4-13.2-15.9-36.4-16.1-49.9-.4-22.2,25.8-21.7,85.3.5,110.1,13.6,15.2,36.6,15,49.7-1.3h.1Z"/>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('LogoComponent', () => {
|
|||||||
|
|
||||||
it('should support extra classes', () => {
|
it('should support extra classes', () => {
|
||||||
expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(0)
|
expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(0)
|
||||||
fixture.componentRef.setInput('extra_classes', 'foo')
|
component.extra_classes = 'foo'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(1)
|
expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(1)
|
||||||
})
|
})
|
||||||
@@ -37,7 +37,7 @@ describe('LogoComponent', () => {
|
|||||||
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
|
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
|
||||||
'height:6em'
|
'height:6em'
|
||||||
)
|
)
|
||||||
fixture.componentRef.setInput('height', '10em')
|
component.height = '10em'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
|
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
|
||||||
'height:10em'
|
'height:10em'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject, input } from '@angular/core'
|
import { Component, Input, inject } from '@angular/core'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
@@ -10,8 +10,12 @@ import { environment } from 'src/environments/environment'
|
|||||||
})
|
})
|
||||||
export class LogoComponent {
|
export class LogoComponent {
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
readonly extra_classes = input<string>(undefined)
|
|
||||||
readonly height = input('6em')
|
@Input()
|
||||||
|
extra_classes: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
height = '6em'
|
||||||
|
|
||||||
get customLogo(): string {
|
get customLogo(): string {
|
||||||
return this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
return this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
||||||
@@ -23,6 +27,6 @@ export class LogoComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getClasses() {
|
getClasses() {
|
||||||
return ['logo'].concat(this.extra_classes()).join(' ')
|
return ['logo'].concat(this.extra_classes).join(' ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||||
<div class="col-md text-truncate">
|
<div class="col-md text-truncate">
|
||||||
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
|
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
|
||||||
<span class="text-truncate">{{title()}}</span>
|
<span class="text-truncate">{{title}}</span>
|
||||||
@if (id()) {
|
@if (id) {
|
||||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
||||||
@if (copied()) {
|
@if (copied) {
|
||||||
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
||||||
} @else {
|
} @else {
|
||||||
ID: {{id()}}
|
ID: {{id}}
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@if (subTitle()) {
|
@if (subTitle) {
|
||||||
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||||
}
|
}
|
||||||
@if (info()) {
|
@if (info) {
|
||||||
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||||
<i-bs name="question-circle"></i-bs>
|
<i-bs name="question-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #infoPopover>
|
<ng-template #infoPopover>
|
||||||
<p [class.mb-0]="!infoLink()" [innerHTML]="info()"></p>
|
<p [class.mb-0]="!infoLink" [innerHTML]="info"></p>
|
||||||
@if (infoLink()) {
|
@if (infoLink) {
|
||||||
<a href="https://docs.paperless-ngx.com/{{infoLink()}}" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
|
<a href="https://docs.paperless-ngx.com/{{infoLink}}" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
|
||||||
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
|
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
}
|
}
|
||||||
@if (loading()) {
|
@if (loading) {
|
||||||
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
|
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
|
||||||
}
|
}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { Title } from '@angular/platform-browser'
|
import { Title } from '@angular/platform-browser'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { PageHeaderComponent } from './page-header.component'
|
import { PageHeaderComponent } from './page-header.component'
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ describe('PageHeaderComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [],
|
providers: [],
|
||||||
imports: [NgxBootstrapIconsModule.pick(allIcons), PageHeaderComponent],
|
imports: [PageHeaderComponent],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
titleService = TestBed.inject(Title)
|
titleService = TestBed.inject(Title)
|
||||||
@@ -24,13 +23,9 @@ describe('PageHeaderComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should display title + subtitle', () => {
|
it('should display title + subtitle', () => {
|
||||||
fixture.componentRef.setInput('title', 'Foo')
|
component.title = 'Foo'
|
||||||
fixture.componentRef.setInput('subTitle', 'Bar')
|
component.subTitle = 'Bar'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.nativeElement.textContent).toContain('Foo')
|
expect(fixture.nativeElement.textContent).toContain('Foo')
|
||||||
expect(fixture.nativeElement.textContent).toContain('Bar')
|
expect(fixture.nativeElement.textContent).toContain('Bar')
|
||||||
@@ -38,20 +33,19 @@ describe('PageHeaderComponent', () => {
|
|||||||
|
|
||||||
it('should set html title', () => {
|
it('should set html title', () => {
|
||||||
const titleSpy = jest.spyOn(titleService, 'setTitle')
|
const titleSpy = jest.spyOn(titleService, 'setTitle')
|
||||||
fixture.componentRef.setInput('title', 'Foo Bar')
|
component.title = 'Foo Bar'
|
||||||
fixture.detectChanges()
|
|
||||||
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
|
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should copy id to clipboard, reset after 3 seconds', () => {
|
it('should copy id to clipboard, reset after 3 seconds', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
fixture.componentRef.setInput('id', 42)
|
component.id = 42 as any
|
||||||
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||||
component.copyID()
|
component.copyID()
|
||||||
expect(clipboard.copy).toHaveBeenCalledWith('42')
|
expect(clipboard.copy).toHaveBeenCalledWith('42')
|
||||||
expect(component.copied()).toBe(true)
|
expect(component.copied).toBe(true)
|
||||||
|
|
||||||
jest.advanceTimersByTime(3000)
|
jest.advanceTimersByTime(3000)
|
||||||
expect(component.copied()).toBe(false)
|
expect(component.copied).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { Component, effect, inject, input, signal } from '@angular/core'
|
import { Component, Input, inject } from '@angular/core'
|
||||||
import { Title } from '@angular/platform-browser'
|
import { Title } from '@angular/platform-browser'
|
||||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
@@ -16,26 +16,40 @@ export class PageHeaderComponent {
|
|||||||
private titleService = inject(Title)
|
private titleService = inject(Title)
|
||||||
private clipboard = inject(Clipboard)
|
private clipboard = inject(Clipboard)
|
||||||
|
|
||||||
readonly id = input<number>(undefined)
|
private _title = ''
|
||||||
readonly subTitle = input('')
|
public copied: boolean = false
|
||||||
readonly info = input<string>(undefined)
|
|
||||||
readonly infoLink = input<string>(undefined)
|
|
||||||
readonly loading = input(false)
|
|
||||||
readonly title = input('')
|
|
||||||
readonly copied = signal(false)
|
|
||||||
private copyTimeout: any
|
private copyTimeout: any
|
||||||
|
|
||||||
constructor() {
|
@Input()
|
||||||
effect(() => {
|
set title(title: string) {
|
||||||
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`)
|
this._title = title
|
||||||
})
|
this.titleService.setTitle(`${this.title} - ${environment.appTitle}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return this._title
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
id: number
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
subTitle: string = ''
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
info: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
infoLink: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
loading: boolean = false
|
||||||
|
|
||||||
public copyID() {
|
public copyID() {
|
||||||
this.copied.set(this.clipboard.copy(this.id().toString()))
|
this.copied = this.clipboard.copy(this.id.toString())
|
||||||
clearTimeout(this.copyTimeout)
|
clearTimeout(this.copyTimeout)
|
||||||
this.copyTimeout = setTimeout(() => {
|
this.copyTimeout = setTimeout(() => {
|
||||||
this.copied.set(false)
|
this.copied = false
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ describe('PDFEditorComponent', () => {
|
|||||||
const previewSpy = jest
|
const previewSpy = jest
|
||||||
.spyOn(documentService, 'getPreviewUrl')
|
.spyOn(documentService, 'getPreviewUrl')
|
||||||
.mockReturnValue('preview-version')
|
.mockReturnValue('preview-version')
|
||||||
component.documentID.set(3)
|
component.documentID = 3
|
||||||
component.versionID.set(10)
|
component.versionID = 10
|
||||||
|
|
||||||
expect(component.pdfSrc).toBe('preview-version')
|
expect(component.pdfSrc).toBe('preview-version')
|
||||||
expect(previewSpy).toHaveBeenCalledWith(3, false, 10)
|
expect(previewSpy).toHaveBeenCalledWith(3, false, 10)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
DragDropModule,
|
DragDropModule,
|
||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
@@ -45,9 +45,8 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
|||||||
private readonly settingsService = inject(SettingsService)
|
private readonly settingsService = inject(SettingsService)
|
||||||
activeModal: NgbActiveModal = inject(NgbActiveModal)
|
activeModal: NgbActiveModal = inject(NgbActiveModal)
|
||||||
|
|
||||||
readonly documentID = signal<number>(undefined)
|
documentID: number
|
||||||
readonly versionID = signal<number>(undefined)
|
versionID?: number
|
||||||
|
|
||||||
pages: PageOperation[] = []
|
pages: PageOperation[] = []
|
||||||
totalPages = 0
|
totalPages = 0
|
||||||
editMode: PdfEditorEditMode = this.settingsService.get(
|
editMode: PdfEditorEditMode = this.settingsService.get(
|
||||||
@@ -58,9 +57,9 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
|||||||
|
|
||||||
get pdfSrc(): string {
|
get pdfSrc(): string {
|
||||||
return this.documentService.getPreviewUrl(
|
return this.documentService.getPreviewUrl(
|
||||||
this.documentID(),
|
this.documentID,
|
||||||
false,
|
false,
|
||||||
this.versionID()
|
this.versionID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,5 +1,5 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()">
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<form [formGroup]="form">
|
<form [formGroup]="form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-4">
|
<div class="form-group mt-4">
|
||||||
<div class="offset-lg-3 row">
|
<div class="offset-lg-3 row">
|
||||||
@@ -16,18 +16,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if (note()) {
|
@if (note) {
|
||||||
<div class="small text-muted fst-italic mt-2">
|
<div class="small text-muted fst-italic mt-2">
|
||||||
{{ note() }}
|
{{ note }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@if (!buttonsEnabled()) {
|
@if (!buttonsEnabled) {
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
<span class="visually-hidden" i18n>Loading...</span>
|
<span class="visually-hidden" i18n>Loading...</span>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled()" i18n>Cancel</button>
|
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled()" i18n>Confirm</button>
|
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled" i18n>Confirm</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ describe('PermissionsDialogComponent', () => {
|
|||||||
permissions: set_permissions.set_permissions,
|
permissions: set_permissions.set_permissions,
|
||||||
}
|
}
|
||||||
component.object = obj
|
component.object = obj
|
||||||
expect(component.title()).toEqual(`Edit permissions for ${obj.name}`)
|
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
|
||||||
expect(component.permissions).toEqual(set_permissions)
|
expect(component.permissions).toEqual(set_permissions)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+15
-18
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
||||||
Component,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
Output,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -14,7 +6,6 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { map } from 'rxjs'
|
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
@@ -36,22 +27,26 @@ export class PermissionsDialogComponent {
|
|||||||
activeModal = inject(NgbActiveModal)
|
activeModal = inject(NgbActiveModal)
|
||||||
private userService = inject(UserService)
|
private userService = inject(UserService)
|
||||||
|
|
||||||
readonly users = toSignal(
|
users: User[]
|
||||||
this.userService.listAll().pipe(map((r) => r.results)),
|
|
||||||
{ initialValue: undefined as User[] }
|
|
||||||
)
|
|
||||||
readonly title = signal($localize`Set permissions`)
|
|
||||||
readonly note = signal<string>(null)
|
|
||||||
readonly buttonsEnabled = signal(true)
|
|
||||||
private o: ObjectWithPermissions = undefined
|
private o: ObjectWithPermissions = undefined
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.userService.listAll().subscribe((r) => (this.users = r.results))
|
||||||
|
}
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
public confirmClicked = new EventEmitter()
|
public confirmClicked = new EventEmitter()
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
title = $localize`Set permissions`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
note: string = null
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
set object(o: ObjectWithPermissions) {
|
set object(o: ObjectWithPermissions) {
|
||||||
this.o = o
|
this.o = o
|
||||||
this.title.set($localize`Edit permissions for ` + o['name'])
|
this.title = $localize`Edit permissions for ` + o['name']
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
merge: true,
|
merge: true,
|
||||||
permissions_form: {
|
permissions_form: {
|
||||||
@@ -70,6 +65,8 @@ export class PermissionsDialogComponent {
|
|||||||
merge: new FormControl(true),
|
merge: new FormControl(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
buttonsEnabled: boolean = true
|
||||||
|
|
||||||
get permissions() {
|
get permissions() {
|
||||||
return {
|
return {
|
||||||
owner: this.form.get('permissions_form').value?.owner ?? null,
|
owner: this.form.get('permissions_form').value?.owner ?? null,
|
||||||
|
|||||||
+2
-2
@@ -64,9 +64,9 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
{
|
{
|
||||||
provide: SettingsService,
|
provide: SettingsService,
|
||||||
useValue: {
|
useValue: {
|
||||||
currentUser: () => ({
|
currentUser: {
|
||||||
id: currentUserID,
|
id: currentUserID,
|
||||||
}),
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
|||||||
+3
-3
@@ -117,12 +117,12 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers = []
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers = []
|
||||||
this.selectionModel.userID = this.settingsService.currentUser().id
|
this.selectionModel.userID = this.settingsService.currentUser.id
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned = false
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.userID = null
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers = []
|
||||||
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
|
this.selectionModel.excludeUsers = [this.settingsService.currentUser.id]
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned = false
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.userID = null
|
||||||
@@ -132,7 +132,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
} else if (
|
} else if (
|
||||||
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
||||||
) {
|
) {
|
||||||
this.selectionModel.userID = this.settingsService.currentUser()?.id
|
this.selectionModel.userID = this.settingsService.currentUser.id
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers = []
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers = []
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned = false
|
||||||
|
|||||||
+13
-13
@@ -7,38 +7,38 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error()?.email"></pngx-input-text>
|
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
|
||||||
<div ngbAccordion>
|
<div ngbAccordion>
|
||||||
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm()" class="border-0 bg-transparent">
|
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
|
||||||
<div ngbAccordionCollapse>
|
<div ngbAccordionCollapse>
|
||||||
<div ngbAccordionBody class="p-0 pb-3">
|
<div ngbAccordionBody class="p-0 pb-3">
|
||||||
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error()?.email_confirm"></pngx-input-text>
|
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error()?.password"></pngx-input-password>
|
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
|
||||||
<div ngbAccordion>
|
<div ngbAccordion>
|
||||||
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm()" class="border-0 bg-transparent">
|
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
|
||||||
<div ngbAccordionCollapse>
|
<div ngbAccordionCollapse>
|
||||||
<div ngbAccordionBody class="p-0 pb-3">
|
<div ngbAccordionBody class="p-0 pb-3">
|
||||||
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error()?.password_confirm"></pngx-input-password>
|
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error()?.first_name"></pngx-input-text>
|
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
|
||||||
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error()?.first_name"></pngx-input-text>
|
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label" i18n>API Auth Token</label>
|
<label class="form-label" i18n>API Auth Token</label>
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" formControlName="auth_token" readonly>
|
<input type="text" class="form-control" formControlName="auth_token" readonly>
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
|
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
|
||||||
@if (!copied()) {
|
@if (!copied) {
|
||||||
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
|
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
|
||||||
}
|
}
|
||||||
@if (copied()) {
|
@if (copied) {
|
||||||
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
|
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
|
||||||
}
|
}
|
||||||
<span class="visually-hidden" i18n>Copy</span>
|
<span class="visually-hidden" i18n>Copy</span>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
(confirm)="generateAuthToken()">
|
(confirm)="generateAuthToken()">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied()" i18n>Copied!</span>
|
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
|
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,10 +155,10 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
|
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
|
||||||
@if (!codesCopied()) {
|
@if (!codesCopied) {
|
||||||
<i-bs width="1em" height="1em" name="clipboard-fill" class="me-1"></i-bs><span i18n>Copy codes</span>
|
<i-bs width="1em" height="1em" name="clipboard-fill" class="me-1"></i-bs><span i18n>Copy codes</span>
|
||||||
}
|
}
|
||||||
@if (codesCopied()) {
|
@if (codesCopied) {
|
||||||
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary me-1"></i-bs><span class="text-primary" i18n>Copied!</span>
|
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary me-1"></i-bs><span class="text-primary" i18n>Copied!</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+17
-18
@@ -1,4 +1,9 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
|
||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
@@ -186,8 +191,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(component.saveDisabled).toBeFalsy()
|
expect(component.saveDisabled).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should logout on save if password changed', () => {
|
it('should logout on save if password changed', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
getSpy.mockReturnValue(of(profile))
|
getSpy.mockReturnValue(of(profile))
|
||||||
const getProvidersSpy = jest.spyOn(
|
const getProvidersSpy = jest.spyOn(
|
||||||
@@ -207,15 +211,13 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
component.save()
|
component.save()
|
||||||
expect(updateSpy).toHaveBeenCalled()
|
expect(updateSpy).toHaveBeenCalled()
|
||||||
jest.advanceTimersByTime(2600)
|
tick(2600)
|
||||||
expect(navSpy).toHaveBeenCalledWith(
|
expect(navSpy).toHaveBeenCalledWith(
|
||||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||||
)
|
)
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support auth token copy', () => {
|
it('should support auth token copy', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
getSpy.mockReturnValue(of(profile))
|
getSpy.mockReturnValue(of(profile))
|
||||||
const getProvidersSpy = jest.spyOn(
|
const getProvidersSpy = jest.spyOn(
|
||||||
@@ -227,11 +229,10 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||||
component.copyAuthToken()
|
component.copyAuthToken()
|
||||||
expect(copySpy).toHaveBeenCalledWith(profile.auth_token)
|
expect(copySpy).toHaveBeenCalledWith(profile.auth_token)
|
||||||
expect(component.copied()).toBeTruthy()
|
expect(component.copied).toBeTruthy()
|
||||||
jest.advanceTimersByTime(3000)
|
tick(3000)
|
||||||
expect(component.copied()).toBeFalsy()
|
expect(component.copied).toBeFalsy()
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('should support generate token, display error if needed', () => {
|
it('should support generate token, display error if needed', () => {
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
@@ -365,13 +366,11 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(component.isTotpEnabled).toBeFalsy()
|
expect(component.isTotpEnabled).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should copy recovery codes', () => {
|
it('should copy recovery codes', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||||
component.recoveryCodes = ['1', '2', '3']
|
component.recoveryCodes = ['1', '2', '3']
|
||||||
component.copyRecoveryCodes()
|
component.copyRecoveryCodes()
|
||||||
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
||||||
jest.advanceTimersByTime(3000)
|
tick(3000)
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+32
-43
@@ -1,5 +1,5 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -50,12 +50,8 @@ export class ProfileEditDialogComponent
|
|||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private clipboard = inject(Clipboard)
|
private clipboard = inject(Clipboard)
|
||||||
|
|
||||||
readonly networkActive = signal(false)
|
public networkActive: boolean = false
|
||||||
readonly error = signal<any>(undefined)
|
public error: any
|
||||||
readonly showPasswordConfirm = signal(false)
|
|
||||||
readonly showEmailConfirm = signal(false)
|
|
||||||
readonly copied = signal(false)
|
|
||||||
readonly codesCopied = signal(false)
|
|
||||||
|
|
||||||
public form = new FormGroup({
|
public form = new FormGroup({
|
||||||
email: new FormControl(''),
|
email: new FormControl(''),
|
||||||
@@ -71,18 +67,23 @@ export class ProfileEditDialogComponent
|
|||||||
private currentPassword: string
|
private currentPassword: string
|
||||||
private newPassword: string
|
private newPassword: string
|
||||||
private passwordConfirm: string
|
private passwordConfirm: string
|
||||||
|
public showPasswordConfirm: boolean = false
|
||||||
public hasUsablePassword: boolean = false
|
public hasUsablePassword: boolean = false
|
||||||
|
|
||||||
private currentEmail: string
|
private currentEmail: string
|
||||||
private newEmail: string
|
private newEmail: string
|
||||||
private emailConfirm: string
|
private emailConfirm: string
|
||||||
|
public showEmailConfirm: boolean = false
|
||||||
|
|
||||||
public isTotpEnabled: boolean = false
|
public isTotpEnabled: boolean = false
|
||||||
public totpSettings: TotpSettings
|
public totpSettings: TotpSettings
|
||||||
public totpSettingsLoading: boolean = false
|
public totpSettingsLoading: boolean = false
|
||||||
public totpLoading: boolean = false
|
public totpLoading: boolean = false
|
||||||
public recoveryCodes: string[]
|
public recoveryCodes: string[]
|
||||||
|
|
||||||
|
public copied: boolean = false
|
||||||
|
public codesCopied: boolean = false
|
||||||
|
|
||||||
public socialAccounts: SocialAccount[] = []
|
public socialAccounts: SocialAccount[] = []
|
||||||
public socialAccountProviders: SocialAccountProvider[] = []
|
public socialAccountProviders: SocialAccountProvider[] = []
|
||||||
|
|
||||||
@@ -94,12 +95,12 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.networkActive.set(true)
|
this.networkActive = true
|
||||||
this.profileService
|
this.profileService
|
||||||
.get()
|
.get()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((profile) => {
|
.subscribe((profile) => {
|
||||||
this.networkActive.set(false)
|
this.networkActive = false
|
||||||
this.form.patchValue(profile)
|
this.form.patchValue(profile)
|
||||||
this.currentEmail = profile.email
|
this.currentEmail = profile.email
|
||||||
this.form.get('email').valueChanges.subscribe((newEmail) => {
|
this.form.get('email').valueChanges.subscribe((newEmail) => {
|
||||||
@@ -125,7 +126,7 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get saveDisabled(): boolean {
|
get saveDisabled(): boolean {
|
||||||
return this.error()?.password_confirm || this.error()?.email_confirm
|
return this.error?.password_confirm || this.error?.email_confirm
|
||||||
}
|
}
|
||||||
|
|
||||||
onEmailKeyUp(event: KeyboardEvent): void {
|
onEmailKeyUp(event: KeyboardEvent): void {
|
||||||
@@ -139,17 +140,18 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onEmailChange(): void {
|
onEmailChange(): void {
|
||||||
this.showEmailConfirm.set(this.currentEmail !== this.newEmail)
|
this.showEmailConfirm = this.currentEmail !== this.newEmail
|
||||||
if (this.showEmailConfirm()) {
|
if (this.showEmailConfirm) {
|
||||||
this.form.get('email_confirm').enable()
|
this.form.get('email_confirm').enable()
|
||||||
if (this.newEmail !== this.emailConfirm) {
|
if (this.newEmail !== this.emailConfirm) {
|
||||||
this.setFieldError('email_confirm', $localize`Emails must match`)
|
if (!this.error) this.error = {}
|
||||||
|
this.error.email_confirm = $localize`Emails must match`
|
||||||
} else {
|
} else {
|
||||||
this.clearFieldError('email_confirm')
|
delete this.error?.email_confirm
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.form.get('email_confirm').disable()
|
this.form.get('email_confirm').disable()
|
||||||
this.clearFieldError('email_confirm')
|
delete this.error?.email_confirm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,42 +167,29 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onPasswordChange(): void {
|
onPasswordChange(): void {
|
||||||
this.showPasswordConfirm.set(this.currentPassword !== this.newPassword)
|
this.showPasswordConfirm = this.currentPassword !== this.newPassword
|
||||||
|
|
||||||
if (this.showPasswordConfirm()) {
|
if (this.showPasswordConfirm) {
|
||||||
this.form.get('password_confirm').enable()
|
this.form.get('password_confirm').enable()
|
||||||
if (this.newPassword !== this.passwordConfirm) {
|
if (this.newPassword !== this.passwordConfirm) {
|
||||||
this.setFieldError('password_confirm', $localize`Passwords must match`)
|
if (!this.error) this.error = {}
|
||||||
|
this.error.password_confirm = $localize`Passwords must match`
|
||||||
} else {
|
} else {
|
||||||
this.clearFieldError('password_confirm')
|
delete this.error?.password_confirm
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.form.get('password_confirm').disable()
|
this.form.get('password_confirm').disable()
|
||||||
this.clearFieldError('password_confirm')
|
delete this.error?.password_confirm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setFieldError(fieldName: string, message: string): void {
|
|
||||||
this.error.set({
|
|
||||||
...(this.error() ?? {}),
|
|
||||||
[fieldName]: message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearFieldError(fieldName: string): void {
|
|
||||||
if (!this.error()) return
|
|
||||||
const error = { ...this.error() }
|
|
||||||
delete error[fieldName]
|
|
||||||
this.error.set(Object.keys(error).length ? error : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
const passwordChanged =
|
const passwordChanged =
|
||||||
this.newPassword && this.currentPassword !== this.newPassword
|
this.newPassword && this.currentPassword !== this.newPassword
|
||||||
const profile = Object.assign({}, this.form.value)
|
const profile = Object.assign({}, this.form.value)
|
||||||
delete profile.totp_code
|
delete profile.totp_code
|
||||||
this.error.set(null)
|
this.error = null
|
||||||
this.networkActive.set(true)
|
this.networkActive = true
|
||||||
this.profileService
|
this.profileService
|
||||||
.update(profile)
|
.update(profile)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
@@ -221,8 +210,8 @@ export class ProfileEditDialogComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError($localize`Error saving profile`, error)
|
this.toastService.showError($localize`Error saving profile`, error)
|
||||||
this.error.set(error?.error)
|
this.error = error?.error
|
||||||
this.networkActive.set(false)
|
this.networkActive = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -247,9 +236,9 @@ export class ProfileEditDialogComponent
|
|||||||
|
|
||||||
copyAuthToken(): void {
|
copyAuthToken(): void {
|
||||||
this.clipboard.copy(this.form.get('auth_token').value)
|
this.clipboard.copy(this.form.get('auth_token').value)
|
||||||
this.copied.set(true)
|
this.copied = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.copied.set(false)
|
this.copied = false
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,9 +330,9 @@ export class ProfileEditDialogComponent
|
|||||||
|
|
||||||
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 = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.codesCopied.set(false)
|
this.codesCopied = false
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -8,18 +8,18 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="mb-1">
|
<p class="mb-1">
|
||||||
<ng-container i18n>Selected documents:</ng-container>
|
<ng-container i18n>Selected documents:</ng-container>
|
||||||
{{ selectionCount() }}
|
{{ selectionCount }}
|
||||||
</p>
|
</p>
|
||||||
@if (documentPreview().length > 0) {
|
@if (documentPreview.length > 0) {
|
||||||
<ul class="list-unstyled small mb-0">
|
<ul class="list-unstyled small mb-0">
|
||||||
@for (doc of documentPreview(); track doc.id) {
|
@for (doc of documentPreview; track doc.id) {
|
||||||
<li>
|
<li>
|
||||||
<strong>{{ doc.title | documentTitle }}</strong>
|
<strong>{{ doc.title | documentTitle }}</strong>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@if (selectionCount() > documentPreview().length) {
|
@if (selectionCount > documentPreview.length) {
|
||||||
<li>
|
<li>
|
||||||
<ng-container i18n>+ {{ selectionCount() - documentPreview().length }} more…</ng-container>
|
<ng-container i18n>+ {{ selectionCount - documentPreview.length }} more…</ng-container>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -72,10 +72,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
(click)="copy(createdBundle)"
|
(click)="copy(createdBundle)"
|
||||||
>
|
>
|
||||||
@if (copied()) {
|
@if (copied) {
|
||||||
<i-bs name="clipboard-check"></i-bs>
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
}
|
}
|
||||||
@if (!copied()) {
|
@if (!copied) {
|
||||||
<i-bs name="clipboard"></i-bs>
|
<i-bs name="clipboard"></i-bs>
|
||||||
}
|
}
|
||||||
<span class="visually-hidden" i18n>Copy link</span>
|
<span class="visually-hidden" i18n>Copy link</span>
|
||||||
@@ -118,8 +118,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[disabled]="loading() || !buttonsEnabled">
|
[disabled]="loading || !buttonsEnabled">
|
||||||
@if (loading()) {
|
@if (loading) {
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
}
|
}
|
||||||
{{ btnCaption }}
|
{{ btnCaption }}
|
||||||
|
|||||||
+17
-14
@@ -1,5 +1,10 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { FileVersion } from 'src/app/data/share-link'
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
@@ -53,10 +58,10 @@ describe('ShareLinkBundleDialogComponent', () => {
|
|||||||
|
|
||||||
it('builds payload and emits confirm on submit', () => {
|
it('builds payload and emits confirm on submit', () => {
|
||||||
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
|
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||||
component.setDocuments([
|
component.documents = [
|
||||||
{ id: 1, title: 'Doc 1' } as any,
|
{ id: 1, title: 'Doc 1' } as any,
|
||||||
{ id: 2, title: 'Doc 2' } as any,
|
{ id: 2, title: 'Doc 2' } as any,
|
||||||
])
|
]
|
||||||
component.form.setValue({
|
component.form.setValue({
|
||||||
shareArchiveVersion: false,
|
shareArchiveVersion: false,
|
||||||
expirationDays: 3,
|
expirationDays: 3,
|
||||||
@@ -96,15 +101,14 @@ describe('ShareLinkBundleDialogComponent', () => {
|
|||||||
const docs = Array.from({ length: 12 }).map((_, index) => ({
|
const docs = Array.from({ length: 12 }).map((_, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
}))
|
}))
|
||||||
component.setDocuments(docs as any)
|
component.documents = docs as any
|
||||||
|
|
||||||
expect(component.selectionCount()).toBe(12)
|
expect(component.selectionCount).toBe(12)
|
||||||
expect(component.documentPreview()).toHaveLength(10)
|
expect(component.documentPreview).toHaveLength(10)
|
||||||
expect(component.documentPreview()[0].id).toBe(1)
|
expect(component.documentPreview[0].id).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('copies share link and resets state after timeout', () => {
|
it('copies share link and resets state after timeout', fakeAsync(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||||
const bundle = {
|
const bundle = {
|
||||||
slug: 'bundle-slug',
|
slug: 'bundle-slug',
|
||||||
@@ -114,13 +118,12 @@ describe('ShareLinkBundleDialogComponent', () => {
|
|||||||
component.copy(bundle)
|
component.copy(bundle)
|
||||||
|
|
||||||
expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle))
|
expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle))
|
||||||
expect(component.copied()).toBe(true)
|
expect(component.copied).toBe(true)
|
||||||
expect(toastService.showInfo).toHaveBeenCalled()
|
expect(toastService.showInfo).toHaveBeenCalled()
|
||||||
|
|
||||||
jest.advanceTimersByTime(3000)
|
tick(3000)
|
||||||
expect(component.copied()).toBe(false)
|
expect(component.copied).toBe(false)
|
||||||
jest.useRealTimers()
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
it('generates share URLs based on API base URL', () => {
|
it('generates share URLs based on API base URL', () => {
|
||||||
environment.apiBaseUrl = 'https://example.com/api/'
|
environment.apiBaseUrl = 'https://example.com/api/'
|
||||||
|
|||||||
+14
-14
@@ -1,6 +1,6 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, Input, inject } from '@angular/core'
|
||||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'
|
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
@@ -38,11 +38,10 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
|||||||
private readonly clipboard = inject(Clipboard)
|
private readonly clipboard = inject(Clipboard)
|
||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
|
|
||||||
readonly documents = signal<Document[]>([])
|
private _documents: Document[] = []
|
||||||
readonly selectionCount = signal(0)
|
|
||||||
readonly documentPreview = signal<Document[]>([])
|
|
||||||
readonly copied = signal(false)
|
|
||||||
|
|
||||||
|
selectionCount = 0
|
||||||
|
documentPreview: Document[] = []
|
||||||
form: FormGroup = this.formBuilder.group({
|
form: FormGroup = this.formBuilder.group({
|
||||||
shareArchiveVersion: true,
|
shareArchiveVersion: true,
|
||||||
expirationDays: [7],
|
expirationDays: [7],
|
||||||
@@ -52,27 +51,28 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
|||||||
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
|
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
|
||||||
|
|
||||||
createdBundle: ShareLinkBundleSummary | null = null
|
createdBundle: ShareLinkBundleSummary | null = null
|
||||||
|
copied = false
|
||||||
onOpenManage?: () => void
|
onOpenManage?: () => void
|
||||||
readonly statuses = ShareLinkBundleStatus
|
readonly statuses = ShareLinkBundleStatus
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.title = $localize`Create share link bundle`
|
this.title = $localize`Create share link bundle`
|
||||||
this.btnCaption = $localize`Create link`
|
this.btnCaption = $localize`Create link`
|
||||||
}
|
}
|
||||||
|
|
||||||
setDocuments(docs: Document[]) {
|
@Input()
|
||||||
const documents = docs.concat()
|
set documents(docs: Document[]) {
|
||||||
this.documents.set(documents)
|
this._documents = docs.concat()
|
||||||
this.selectionCount.set(documents.length)
|
this.selectionCount = this._documents.length
|
||||||
this.documentPreview.set(documents.slice(0, 10))
|
this.documentPreview = this._documents.slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
if (this.createdBundle) return
|
if (this.createdBundle) return
|
||||||
this.payload = {
|
this.payload = {
|
||||||
document_ids: this.documents().map((doc) => doc.id),
|
document_ids: this._documents.map((doc) => doc.id),
|
||||||
file_version: this.form.value.shareArchiveVersion
|
file_version: this.form.value.shareArchiveVersion
|
||||||
? FileVersion.Archive
|
? FileVersion.Archive
|
||||||
: FileVersion.Original,
|
: FileVersion.Original,
|
||||||
@@ -92,10 +92,10 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
|||||||
copy(bundle: ShareLinkBundleSummary): void {
|
copy(bundle: ShareLinkBundleSummary): void {
|
||||||
const success = this.clipboard.copy(this.getShareUrl(bundle))
|
const success = this.clipboard.copy(this.getShareUrl(bundle))
|
||||||
if (success) {
|
if (success) {
|
||||||
this.copied.set(true)
|
this.copied = true
|
||||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.copied.set(false)
|
this.copied = false
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user