mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'dev' into feature-nested-tags2
This commit is contained in:
		
							
								
								
									
										1
									
								
								src-ui/.npmrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src-ui/.npmrc
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| shamefully-hoist=true | ||||
							
								
								
									
										13
									
								
								src-ui/__mocks__/pdfjs-dist.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src-ui/__mocks__/pdfjs-dist.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| export const getDocument = jest.fn(() => ({ | ||||
|   promise: Promise.resolve({ numPages: 3 }), | ||||
| })) | ||||
|  | ||||
| export const GlobalWorkerOptions = { workerSrc: '' } | ||||
| export const VerbosityLevel = { ERRORS: 0 } | ||||
|  | ||||
| globalThis.pdfjsLib = { | ||||
|   getDocument, | ||||
|   GlobalWorkerOptions, | ||||
|   VerbosityLevel, | ||||
|   AbortException: class AbortException extends Error {}, | ||||
| } | ||||
| @@ -27,6 +27,7 @@ | ||||
|           "el-GR": "src/locale/messages.el_GR.xlf", | ||||
|           "en-GB": "src/locale/messages.en_GB.xlf", | ||||
|           "es-ES": "src/locale/messages.es_ES.xlf", | ||||
|           "fa-IR": "src/locale/messages.fa_IR.xlf", | ||||
|           "fi-FI": "src/locale/messages.fi_FI.xlf", | ||||
|           "fr-FR": "src/locale/messages.fr_FR.xlf", | ||||
|           "hu-HU": "src/locale/messages.hu_HU.xlf", | ||||
| @@ -47,7 +48,9 @@ | ||||
|           "sv-SE": "src/locale/messages.sv_SE.xlf", | ||||
|           "tr-TR": "src/locale/messages.tr_TR.xlf", | ||||
|           "uk-UA": "src/locale/messages.uk_UA.xlf", | ||||
|           "zh-CN": "src/locale/messages.zh_CN.xlf" | ||||
|           "vi-VN": "src/locale/messages.vi_VN.xlf", | ||||
|           "zh-CN": "src/locale/messages.zh_CN.xlf", | ||||
|           "zh-TW": "src/locale/messages.zh_TW.xlf" | ||||
|         } | ||||
|       }, | ||||
|       "architect": { | ||||
| @@ -58,10 +61,12 @@ | ||||
|                 "path": "./extra-webpack.config.ts" | ||||
|             }, | ||||
|             "outputPath": "dist/paperless-ui", | ||||
|             "main": "src/main.ts", | ||||
|             "outputHashing": "none", | ||||
|             "index": "src/index.html", | ||||
|             "main": "src/main.ts", | ||||
|             "polyfills": "src/polyfills.ts", | ||||
|             "polyfills": [ | ||||
|               "src/polyfills.ts" | ||||
|             ], | ||||
|             "tsConfig": "tsconfig.app.json", | ||||
|             "localize": true, | ||||
|             "assets": [ | ||||
| @@ -84,12 +89,15 @@ | ||||
|               "file-saver", | ||||
|               "utif" | ||||
|             ], | ||||
|             "vendorChunk": true, | ||||
|             "extractLicenses": false, | ||||
|             "buildOptimizer": false, | ||||
|             "sourceMap": true, | ||||
|             "optimization": false, | ||||
|             "namedChunks": true | ||||
|             "namedChunks": true, | ||||
|             "stylePreprocessorOptions": { | ||||
|               "includePaths": [ | ||||
|                 "." | ||||
|               ] | ||||
|             } | ||||
|           }, | ||||
|           "configurations": { | ||||
|             "production": { | ||||
| @@ -105,8 +113,6 @@ | ||||
|               "sourceMap": false, | ||||
|               "namedChunks": false, | ||||
|               "extractLicenses": true, | ||||
|               "vendorChunk": false, | ||||
|               "buildOptimizer": true, | ||||
|               "budgets": [ | ||||
|                 { | ||||
|                   "type": "initial", | ||||
| @@ -177,7 +183,8 @@ | ||||
|     "schematicCollections": [ | ||||
|       "@angular-eslint/schematics" | ||||
|     ], | ||||
|     "analytics": false | ||||
|     "analytics": false, | ||||
|     "packageManager": "pnpm" | ||||
|   }, | ||||
|   "schematics": { | ||||
|     "@angular-eslint/schematics:application": { | ||||
| @@ -185,6 +192,30 @@ | ||||
|     }, | ||||
|     "@angular-eslint/schematics:library": { | ||||
|       "setParserOptionsProject": true | ||||
|     }, | ||||
|     "@schematics/angular:component": { | ||||
|       "type": "component" | ||||
|     }, | ||||
|     "@schematics/angular:directive": { | ||||
|       "type": "directive" | ||||
|     }, | ||||
|     "@schematics/angular:service": { | ||||
|       "type": "service" | ||||
|     }, | ||||
|     "@schematics/angular:guard": { | ||||
|       "typeSeparator": "." | ||||
|     }, | ||||
|     "@schematics/angular:interceptor": { | ||||
|       "typeSeparator": "." | ||||
|     }, | ||||
|     "@schematics/angular:module": { | ||||
|       "typeSeparator": "." | ||||
|     }, | ||||
|     "@schematics/angular:pipe": { | ||||
|       "typeSeparator": "." | ||||
|     }, | ||||
|     "@schematics/angular:resolver": { | ||||
|       "typeSeparator": "." | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -83,10 +83,17 @@ test('date filtering', async ({ page }) => { | ||||
|   await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) | ||||
|   await page.goto('/documents') | ||||
|   await page.getByRole('button', { name: 'Dates' }).click() | ||||
|   await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() | ||||
|   await page.locator('.ng-arrow-wrapper').first().click() | ||||
|   await page.getByRole('option', { name: 'Within 3 months' }).click() | ||||
|   await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) | ||||
|   await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() | ||||
|   await page.getByLabel('Datesselected').getByRole('button').first().click() | ||||
|   await page | ||||
|     .getByRole('menuitem', { name: 'Relative dates' }) | ||||
|     .locator('span') | ||||
|     .first() | ||||
|     .click() | ||||
|   await page.getByRole('option', { name: 'Within 3 months' }).click() | ||||
|   await page.getByLabel('Dates selected').locator('button').first().click() | ||||
|   await page.getByLabel('Dates selected').locator('button').first().click() | ||||
|   await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') | ||||
|   await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') | ||||
|   await page.getByText('11', { exact: true }).click() | ||||
|   | ||||
| @@ -7,9 +7,20 @@ module.exports = { | ||||
|     'abstract-name-filter-service', | ||||
|     'abstract-paperless-service', | ||||
|   ], | ||||
|   transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`], | ||||
|   transformIgnorePatterns: [ | ||||
|     `<rootDir>/node_modules/.pnpm/(?!.*\\.mjs$|lodash-es|@angular\\+common.*locales)`, | ||||
|   ], | ||||
|   moduleNameMapper: { | ||||
|     '^src/(.*)': '<rootDir>/src/$1', | ||||
|   }, | ||||
|   workerIdleMemoryLimit: '512MB', | ||||
|   reporters: [ | ||||
|     'default', | ||||
|     [ | ||||
|       'jest-junit', | ||||
|       { | ||||
|         classNameTemplate: '{filepath}/{classname}: {title}', | ||||
|       }, | ||||
|     ], | ||||
|   ], | ||||
| } | ||||
|   | ||||
							
								
								
									
										2494
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										2494
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										19090
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19090
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,74 +1,82 @@ | ||||
| { | ||||
|   "name": "paperless-ui", | ||||
|   "version": "0.0.0", | ||||
|   "name": "paperless-ngx-ui", | ||||
|   "version": "2.18.1", | ||||
|   "scripts": { | ||||
|     "preinstall": "npx only-allow pnpm", | ||||
|     "ng": "ng", | ||||
|     "start": "ng serve", | ||||
|     "build": "ng build", | ||||
|     "test": "ng test --no-watch --coverage", | ||||
|     "lint": "ng lint", | ||||
|     "postinstall": "patch-package" | ||||
|     "lint": "ng lint" | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@angular/cdk": "^19.1.2", | ||||
|     "@angular/common": "~19.1.4", | ||||
|     "@angular/compiler": "~19.1.4", | ||||
|     "@angular/core": "~19.1.4", | ||||
|     "@angular/forms": "~19.1.4", | ||||
|     "@angular/localize": "~19.1.4", | ||||
|     "@angular/platform-browser": "~19.1.4", | ||||
|     "@angular/platform-browser-dynamic": "~19.1.4", | ||||
|     "@angular/router": "~19.1.4", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^18.0.0", | ||||
|     "@ng-select/ng-select": "^14.2.0", | ||||
|     "@angular/cdk": "^20.1.4", | ||||
|     "@angular/common": "~20.1.4", | ||||
|     "@angular/compiler": "~20.1.4", | ||||
|     "@angular/core": "~20.1.4", | ||||
|     "@angular/forms": "~20.1.4", | ||||
|     "@angular/localize": "~20.1.4", | ||||
|     "@angular/platform-browser": "~20.1.4", | ||||
|     "@angular/platform-browser-dynamic": "~20.1.4", | ||||
|     "@angular/router": "~20.1.4", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^19.0.1", | ||||
|     "@ng-select/ng-select": "^20.0.1", | ||||
|     "@ngneat/dirty-check-forms": "^3.0.3", | ||||
|     "@popperjs/core": "^2.11.8", | ||||
|     "bootstrap": "^5.3.3", | ||||
|     "bootstrap": "^5.3.7", | ||||
|     "file-saver": "^2.0.5", | ||||
|     "mime-names": "^1.0.0", | ||||
|     "ng2-pdf-viewer": "^10.4.0", | ||||
|     "ngx-bootstrap-icons": "^1.9.3", | ||||
|     "ngx-color": "^9.0.0", | ||||
|     "ngx-cookie-service": "^19.1.0", | ||||
|     "ngx-device-detector": "^9.0.0", | ||||
|     "ngx-file-drop": "^16.0.0", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^16.0.0", | ||||
|     "rxjs": "^7.8.1", | ||||
|     "ngx-color": "^10.0.0", | ||||
|     "ngx-cookie-service": "^20.0.1", | ||||
|     "ngx-device-detector": "^10.0.2", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^17.0.1", | ||||
|     "rxjs": "^7.8.2", | ||||
|     "tslib": "^2.8.1", | ||||
|     "utif": "^3.1.0", | ||||
|     "uuid": "^11.0.5", | ||||
|     "zone.js": "^0.15.0" | ||||
|     "uuid": "^11.1.0", | ||||
|     "zone.js": "^0.15.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@angular-builders/custom-webpack": "^19.0.0", | ||||
|     "@angular-builders/jest": "^19.0.0", | ||||
|     "@angular-devkit/build-angular": "^19.0.4", | ||||
|     "@angular-devkit/core": "^19.1.5", | ||||
|     "@angular-devkit/schematics": "^19.1.5", | ||||
|     "@angular-eslint/builder": "19.0.2", | ||||
|     "@angular-eslint/eslint-plugin": "19.0.2", | ||||
|     "@angular-eslint/eslint-plugin-template": "19.0.2", | ||||
|     "@angular-eslint/schematics": "19.0.2", | ||||
|     "@angular-eslint/template-parser": "19.0.2", | ||||
|     "@angular/cli": "~19.1.5", | ||||
|     "@angular/compiler-cli": "~19.1.4", | ||||
|     "@codecov/webpack-plugin": "^1.8.0", | ||||
|     "@playwright/test": "^1.50.1", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/node": "^22.13.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.22.0", | ||||
|     "@typescript-eslint/parser": "^8.22.0", | ||||
|     "@typescript-eslint/utils": "^8.0.0", | ||||
|     "eslint": "^9.19.0", | ||||
|     "jest": "29.7.0", | ||||
|     "jest-environment-jsdom": "^29.7.0", | ||||
|     "jest-preset-angular": "^14.4.2", | ||||
|     "@angular-builders/custom-webpack": "^20.0.0", | ||||
|     "@angular-builders/jest": "^20.0.0", | ||||
|     "@angular-devkit/core": "^20.1.4", | ||||
|     "@angular-devkit/schematics": "^20.1.4", | ||||
|     "@angular-eslint/builder": "20.1.1", | ||||
|     "@angular-eslint/eslint-plugin": "20.1.1", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.1.1", | ||||
|     "@angular-eslint/schematics": "20.1.1", | ||||
|     "@angular-eslint/template-parser": "20.1.1", | ||||
|     "@angular/build": "^20.1.4", | ||||
|     "@angular/cli": "~20.1.4", | ||||
|     "@angular/compiler-cli": "~20.1.4", | ||||
|     "@codecov/webpack-plugin": "^1.9.1", | ||||
|     "@playwright/test": "^1.54.2", | ||||
|     "@types/jest": "^30.0.0", | ||||
|     "@types/node": "^24.1.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.38.0", | ||||
|     "@typescript-eslint/parser": "^8.38.0", | ||||
|     "@typescript-eslint/utils": "^8.38.0", | ||||
|     "eslint": "^9.32.0", | ||||
|     "jest": "30.0.5", | ||||
|     "jest-environment-jsdom": "^30.0.5", | ||||
|     "jest-junit": "^16.0.0", | ||||
|     "jest-preset-angular": "^15.0.0", | ||||
|     "jest-websocket-mock": "^2.5.0", | ||||
|     "patch-package": "^8.0.0", | ||||
|     "prettier-plugin-organize-imports": "^4.1.0", | ||||
|     "prettier-plugin-organize-imports": "^4.2.0", | ||||
|     "ts-node": "~10.9.1", | ||||
|     "typescript": "^5.5.4" | ||||
|     "typescript": "^5.8.3", | ||||
|     "webpack": "^5.101.0" | ||||
|   }, | ||||
|   "typings": "./src/typings.d.ts" | ||||
|   "pnpm": { | ||||
|     "onlyBuiltDependencies": [ | ||||
|       "@parcel/watcher", | ||||
|       "canvas", | ||||
|       "esbuild", | ||||
|       "lmdb", | ||||
|       "msgpackr-extract" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -21,7 +21,7 @@ export default defineConfig({ | ||||
|   /* Run your local dev server before starting the tests */ | ||||
|   webServer: { | ||||
|     port, | ||||
|     command: 'npm run start', | ||||
|     command: 'pnpm run start', | ||||
|     reuseExistingServer: !process.env.CI, | ||||
|     timeout: 2 * 60 * 1000, | ||||
|   }, | ||||
|   | ||||
							
								
								
									
										14028
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										14028
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,12 +1,16 @@ | ||||
| import '@angular/localize/init' | ||||
| import { jest } from '@jest/globals' | ||||
| import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone' | ||||
| import { TextDecoder, TextEncoder } from 'util' | ||||
| import { TextDecoder, TextEncoder } from 'node:util' | ||||
| if (process.env.NODE_ENV === 'test') { | ||||
|   setupZoneTestEnv() | ||||
| } | ||||
| global.TextEncoder = TextEncoder | ||||
| global.TextDecoder = TextDecoder | ||||
| ;(globalThis as any).TextEncoder = TextEncoder as unknown as { | ||||
|   new (): TextEncoder | ||||
| } | ||||
| ;(globalThis as any).TextDecoder = TextDecoder as unknown as { | ||||
|   new (): TextDecoder | ||||
| } | ||||
|  | ||||
| import { registerLocaleData } from '@angular/common' | ||||
| import localeAf from '@angular/common/locales/af' | ||||
| @@ -20,6 +24,7 @@ import localeDe from '@angular/common/locales/de' | ||||
| import localeEl from '@angular/common/locales/el' | ||||
| import localeEnGb from '@angular/common/locales/en-GB' | ||||
| import localeEs from '@angular/common/locales/es' | ||||
| import localeFa from '@angular/common/locales/fa' | ||||
| import localeFi from '@angular/common/locales/fi' | ||||
| import localeFr from '@angular/common/locales/fr' | ||||
| import localeHu from '@angular/common/locales/hu' | ||||
| @@ -39,7 +44,9 @@ import localeSr from '@angular/common/locales/sr' | ||||
| import localeSv from '@angular/common/locales/sv' | ||||
| import localeTr from '@angular/common/locales/tr' | ||||
| import localeUk from '@angular/common/locales/uk' | ||||
| import localeVi from '@angular/common/locales/vi' | ||||
| import localeZh from '@angular/common/locales/zh' | ||||
| import localeZhHant from '@angular/common/locales/zh-Hant' | ||||
|  | ||||
| registerLocaleData(localeAf) | ||||
| registerLocaleData(localeAr) | ||||
| @@ -52,6 +59,7 @@ registerLocaleData(localeDe) | ||||
| registerLocaleData(localeEl) | ||||
| registerLocaleData(localeEnGb) | ||||
| registerLocaleData(localeEs) | ||||
| registerLocaleData(localeFa) | ||||
| registerLocaleData(localeFi) | ||||
| registerLocaleData(localeFr) | ||||
| registerLocaleData(localeHu) | ||||
| @@ -72,7 +80,9 @@ registerLocaleData(localeSr) | ||||
| registerLocaleData(localeSv) | ||||
| registerLocaleData(localeTr) | ||||
| registerLocaleData(localeUk) | ||||
| registerLocaleData(localeVi) | ||||
| registerLocaleData(localeZh) | ||||
| registerLocaleData(localeZhHant) | ||||
|  | ||||
| /* global mocks for jsdom */ | ||||
| const mock = () => { | ||||
| @@ -110,11 +120,29 @@ if (!URL.revokeObjectURL) { | ||||
|   Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() }) | ||||
| } | ||||
| Object.defineProperty(window, 'ResizeObserver', { value: mock() }) | ||||
| Object.defineProperty(window, 'location', { | ||||
|   configurable: true, | ||||
|   value: { reload: jest.fn() }, | ||||
| }) | ||||
|  | ||||
| if (typeof IntersectionObserver === 'undefined') { | ||||
|   class MockIntersectionObserver { | ||||
|     constructor( | ||||
|       public callback: IntersectionObserverCallback, | ||||
|       public options?: IntersectionObserverInit | ||||
|     ) {} | ||||
|  | ||||
|     observe = jest.fn() | ||||
|     unobserve = jest.fn() | ||||
|     disconnect = jest.fn() | ||||
|     takeRecords = jest.fn() | ||||
|   } | ||||
|  | ||||
|   Object.defineProperty(window, 'IntersectionObserver', { | ||||
|     writable: true, | ||||
|     configurable: true, | ||||
|     value: MockIntersectionObserver, | ||||
|   }) | ||||
| } | ||||
|  | ||||
| HTMLCanvasElement.prototype.getContext = < | ||||
|   typeof HTMLCanvasElement.prototype.getContext | ||||
| >jest.fn() | ||||
|  | ||||
| jest.mock('pdfjs-dist') | ||||
|   | ||||
| @@ -36,7 +36,13 @@ export const routes: Routes = [ | ||||
|     component: AppFrameComponent, | ||||
|     canDeactivate: [DirtyDocGuard], | ||||
|     children: [ | ||||
|       { path: 'dashboard', component: DashboardComponent }, | ||||
|       { | ||||
|         path: 'dashboard', | ||||
|         component: DashboardComponent, | ||||
|         data: { | ||||
|           componentName: 'AppFrameComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
|         path: 'documents', | ||||
|         component: DocumentListComponent, | ||||
| @@ -47,6 +53,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.Document, | ||||
|           }, | ||||
|           componentName: 'DocumentListComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -59,6 +66,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.SavedView, | ||||
|           }, | ||||
|           componentName: 'DocumentListComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -70,6 +78,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.Document, | ||||
|           }, | ||||
|           componentName: 'DocumentDetailComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -81,6 +90,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.Document, | ||||
|           }, | ||||
|           componentName: 'DocumentDetailComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -92,6 +102,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.Document, | ||||
|           }, | ||||
|           componentName: 'DocumentAsnComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -103,6 +114,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.Tag, | ||||
|           }, | ||||
|           componentName: 'TagListComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -114,6 +126,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.DocumentType, | ||||
|           }, | ||||
|           componentName: 'DocumentTypeListComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -125,6 +138,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.Correspondent, | ||||
|           }, | ||||
|           componentName: 'CorrespondentListComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -136,6 +150,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.StoragePath, | ||||
|           }, | ||||
|           componentName: 'StoragePathListComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -144,6 +159,7 @@ export const routes: Routes = [ | ||||
|         canActivate: [PermissionsGuard], | ||||
|         data: { | ||||
|           requireAdmin: true, | ||||
|           componentName: 'LogsComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -155,6 +171,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.Delete, | ||||
|             type: PermissionType.Document, | ||||
|           }, | ||||
|           componentName: 'TrashComponent', | ||||
|         }, | ||||
|       }, | ||||
|       // redirect old paths | ||||
| @@ -180,6 +197,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.Change, | ||||
|             type: PermissionType.UISettings, | ||||
|           }, | ||||
|           componentName: 'SettingsComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -192,6 +210,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.UISettings, | ||||
|           }, | ||||
|           componentName: 'SettingsComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -203,6 +222,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.Change, | ||||
|             type: PermissionType.AppConfig, | ||||
|           }, | ||||
|           componentName: 'ConfigComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -214,6 +234,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.PaperlessTask, | ||||
|           }, | ||||
|           componentName: 'TasksComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -225,6 +246,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.CustomField, | ||||
|           }, | ||||
|           componentName: 'CustomFieldsComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -236,6 +258,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.Workflow, | ||||
|           }, | ||||
|           componentName: 'WorkflowsComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -247,6 +270,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.MailAccount, | ||||
|           }, | ||||
|           componentName: 'MailComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -258,6 +282,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.User, | ||||
|           }, | ||||
|           componentName: 'UsersAndGroupsComponent', | ||||
|         }, | ||||
|       }, | ||||
|       { | ||||
| @@ -269,6 +294,7 @@ export const routes: Routes = [ | ||||
|             action: PermissionAction.View, | ||||
|             type: PermissionType.SavedView, | ||||
|           }, | ||||
|           componentName: 'SavedViewsComponent', | ||||
|         }, | ||||
|       }, | ||||
|     ], | ||||
|   | ||||
| @@ -9,7 +9,6 @@ import { | ||||
| import { Router, RouterModule } from '@angular/router' | ||||
| import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { NgxFileDropModule } from 'ngx-file-drop' | ||||
| import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap' | ||||
| import { Subject } from 'rxjs' | ||||
| import { routes } from './app-routing.module' | ||||
| @@ -43,7 +42,6 @@ describe('AppComponent', () => { | ||||
|       imports: [ | ||||
|         TourNgBootstrapModule, | ||||
|         RouterModule.forRoot(routes), | ||||
|         NgxFileDropModule, | ||||
|         NgbModalModule, | ||||
|         AppComponent, | ||||
|         ToastsComponent, | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, OnDestroy, OnInit, Renderer2 } from '@angular/core' | ||||
| import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core' | ||||
| import { Router, RouterOutlet } from '@angular/router' | ||||
| import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap' | ||||
| import { first, Subscription } from 'rxjs' | ||||
| @@ -29,22 +29,22 @@ import { WebsocketStatusService } from './services/websocket-status.service' | ||||
|   ], | ||||
| }) | ||||
| export class AppComponent implements OnInit, OnDestroy { | ||||
|   private settings = inject(SettingsService) | ||||
|   private websocketStatusService = inject(WebsocketStatusService) | ||||
|   private toastService = inject(ToastService) | ||||
|   private router = inject(Router) | ||||
|   private tasksService = inject(TasksService) | ||||
|   tourService = inject(TourService) | ||||
|   private renderer = inject(Renderer2) | ||||
|   private permissionsService = inject(PermissionsService) | ||||
|   private hotKeyService = inject(HotKeyService) | ||||
|   private componentRouterService = inject(ComponentRouterService) | ||||
|  | ||||
|   newDocumentSubscription: Subscription | ||||
|   successSubscription: Subscription | ||||
|   failedSubscription: Subscription | ||||
|  | ||||
|   constructor( | ||||
|     private settings: SettingsService, | ||||
|     private websocketStatusService: WebsocketStatusService, | ||||
|     private toastService: ToastService, | ||||
|     private router: Router, | ||||
|     private tasksService: TasksService, | ||||
|     public tourService: TourService, | ||||
|     private renderer: Renderer2, | ||||
|     private permissionsService: PermissionsService, | ||||
|     private hotKeyService: HotKeyService, | ||||
|     private componentRouterService: ComponentRouterService | ||||
|   ) { | ||||
|   constructor() { | ||||
|     let anyWindow = window as any | ||||
|     anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.mjs' | ||||
|     this.settings.updateAppearanceSettings() | ||||
|   | ||||
| @@ -50,7 +50,7 @@ | ||||
|     <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> | ||||
|     <div class="btn-toolbar" role="toolbar"> | ||||
|         <div class="btn-group me-2"> | ||||
|             <button type="button" (click)="discardChanges()" class="btn btn-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button> | ||||
|             <button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button> | ||||
|         </div> | ||||
|         <div class="btn-group"> | ||||
|             <button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button> | ||||
|   | ||||
| @@ -105,9 +105,9 @@ describe('ConfigComponent', () => { | ||||
|  | ||||
|   it('should support JSON validation for e.g. user_args', () => { | ||||
|     component.configForm.patchValue({ user_args: '{ foo bar }' }) | ||||
|     expect(component.errors).toEqual({ user_args: 'Invalid JSON' }) | ||||
|     expect(component.errors['user_args']).toEqual('Invalid JSON') | ||||
|     component.configForm.patchValue({ user_args: '{ "foo": "bar" }' }) | ||||
|     expect(component.errors).toEqual({ user_args: null }) | ||||
|     expect(component.errors['user_args']).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should upload file, show error if necessary', () => { | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { AsyncPipe } from '@angular/common' | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core' | ||||
| import { Component, OnDestroy, OnInit, inject } from '@angular/core' | ||||
| import { | ||||
|   AbstractControl, | ||||
|   FormControl, | ||||
| @@ -57,6 +57,10 @@ export class ConfigComponent | ||||
|   extends LoadingComponentWithPermissions | ||||
|   implements OnInit, OnDestroy, DirtyComponent | ||||
| { | ||||
|   private configService = inject(ConfigService) | ||||
|   private toastService = inject(ToastService) | ||||
|   private settingsService = inject(SettingsService) | ||||
|  | ||||
|   public readonly ConfigOptionType = ConfigOptionType | ||||
|  | ||||
|   // generated dynamically | ||||
| @@ -77,11 +81,7 @@ export class ConfigComponent | ||||
|   storeSub: Subscription | ||||
|   isDirty$: Observable<boolean> | ||||
|  | ||||
|   constructor( | ||||
|     private configService: ConfigService, | ||||
|     private toastService: ToastService, | ||||
|     private settingsService: SettingsService | ||||
|   ) { | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.configForm.addControl('id', new FormControl()) | ||||
|     PaperlessConfigOptions.forEach((option) => { | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import { | ||||
|   OnDestroy, | ||||
|   OnInit, | ||||
|   ViewChild, | ||||
|   inject, | ||||
| } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| @@ -28,12 +29,8 @@ export class LogsComponent | ||||
|   extends LoadingComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   constructor( | ||||
|     private logService: LogService, | ||||
|     private changedetectorRef: ChangeDetectorRef | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|   private logService = inject(LogService) | ||||
|   private changedetectorRef = inject(ChangeDetectorRef) | ||||
|  | ||||
|   public logs: string[] = [] | ||||
|  | ||||
|   | ||||
| @@ -118,7 +118,7 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col-md-3 col-form-label pt-0"> | ||||
|                 <span i18n>Sidebar</span> | ||||
|               </div> | ||||
| @@ -129,7 +129,7 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col-md-3 col-form-label pt-0"> | ||||
|                 <span i18n>Dark mode</span> | ||||
|               </div> | ||||
| @@ -165,7 +165,7 @@ | ||||
|                   <p i18n> | ||||
|                     Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. | ||||
|                   </p> | ||||
|                   <p> | ||||
|                   <p class="mb-0"> | ||||
|                     <em i18n>No tracking data is collected by the app in any way.</em> | ||||
|                   </p> | ||||
|                 </ng-template> | ||||
| @@ -173,9 +173,10 @@ | ||||
|             </div> | ||||
|  | ||||
|             <h5 class="mt-3" i18n>Saved Views</h5> | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check> | ||||
|                 <pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
| @@ -183,15 +184,15 @@ | ||||
|           <div class="col-xl-6 ps-xl-5"> | ||||
|             <h5 class="mt-3 mt-md-0" i18n>Document editing</h5> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|               <div class="col-2"> | ||||
|                 <span i18n>Default zoom:</span> | ||||
|             <div class="row"> | ||||
|               <div class="col-md-3 col-form-label pt-0"> | ||||
|                 <span i18n>Default zoom</span> | ||||
|               </div> | ||||
|               <div class="col"> | ||||
|                 <select class="form-select" formControlName="pdfViewerDefaultZoom"> | ||||
| @@ -202,7 +203,7 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check> | ||||
|               </div> | ||||
| @@ -214,10 +215,22 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <h5 class="mt-3" i18n>Notes</h5> | ||||
|             <div class="row mb-3"> | ||||
|             <h5 class="mt-3" i18n>Global search</h5> | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check> | ||||
|                 <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|               <div class="col-md-3 col-form-label pt-0"> | ||||
|                 <span i18n>Full search links to</span> | ||||
|               </div> | ||||
|               <div class="col mb-3"> | ||||
|                 <select class="form-select" formControlName="searchLink"> | ||||
|                   <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option> | ||||
|                   <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option> | ||||
|                 </select> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
| @@ -229,26 +242,10 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <h5 class="mt-3" i18n>Global search</h5> | ||||
|             <h5 class="mt-3" i18n>Notes</h5> | ||||
|             <div class="row mb-3"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|               <div class="col"> | ||||
|                 <div class="row"> | ||||
|                   <div class="col-md-3 col-form-label pt-0"> | ||||
|                     <span i18n>Full search links to</span> | ||||
|                   </div> | ||||
|                   <div class="col"> | ||||
|                     <select class="form-select" formControlName="searchLink"> | ||||
|                       <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option> | ||||
|                       <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option> | ||||
|                     </select> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
| @@ -267,8 +264,8 @@ | ||||
|         <div class="row mb-3"> | ||||
|           <div class="col"> | ||||
|             <p i18n> | ||||
|             Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI | ||||
|           </p> | ||||
|               Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI. | ||||
|             </p> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="row mb-3"> | ||||
| @@ -307,7 +304,7 @@ | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="row mb-3"> | ||||
|         <div class="row"> | ||||
|           <div class="col-md-3 col-form-label pt-0"> | ||||
|             <span i18n>Default Edit Permissions</span> | ||||
|           </div> | ||||
| @@ -346,7 +343,7 @@ | ||||
|  | ||||
|         <h5 i18n>Document processing</h5> | ||||
|  | ||||
|         <div class="row mb-3"> | ||||
|         <div class="row"> | ||||
|           <div class="col"> | ||||
|             <pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check> | ||||
|             <pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check> | ||||
| @@ -361,6 +358,6 @@ | ||||
|  | ||||
|   <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> | ||||
|  | ||||
|   <button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button> | ||||
|   <button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button> | ||||
|   <button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button> | ||||
|   <button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button> | ||||
| </form> | ||||
|   | ||||
| @@ -31,10 +31,12 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| import { Toast, ToastService } from 'src/app/services/toast.service' | ||||
| import * as navUtils from 'src/app/utils/navigation' | ||||
| import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { CheckComponent } from '../../common/input/check/check.component' | ||||
| @@ -72,6 +74,7 @@ describe('SettingsComponent', () => { | ||||
|   let groupService: GroupService | ||||
|   let modalService: NgbModal | ||||
|   let systemStatusService: SystemStatusService | ||||
|   let savedViewsService: SavedViewService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -122,6 +125,7 @@ describe('SettingsComponent', () => { | ||||
|     permissionsService = TestBed.inject(PermissionsService) | ||||
|     modalService = TestBed.inject(NgbModal) | ||||
|     systemStatusService = TestBed.inject(SystemStatusService) | ||||
|     savedViewsService = TestBed.inject(SavedViewService) | ||||
|     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|     jest | ||||
|       .spyOn(permissionsService, 'currentUserHasObjectPermissions') | ||||
| @@ -212,7 +216,7 @@ describe('SettingsComponent', () => { | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     expect(storeSpy).toHaveBeenCalled() | ||||
|     expect(appearanceSettingsSpy).not.toHaveBeenCalled() | ||||
|     expect(setSpy).toHaveBeenCalledTimes(29) | ||||
|     expect(setSpy).toHaveBeenCalledTimes(30) | ||||
|  | ||||
|     // succeed | ||||
|     storeSpy.mockReturnValueOnce(of(true)) | ||||
| @@ -222,6 +226,9 @@ describe('SettingsComponent', () => { | ||||
|   }) | ||||
|  | ||||
|   it('should offer reload if settings changes require', () => { | ||||
|     const reloadSpy = jest | ||||
|       .spyOn(navUtils, 'locationReload') | ||||
|       .mockImplementation(() => {}) | ||||
|     completeSetup() | ||||
|     let toast: Toast | ||||
|     toastService.getToasts().subscribe((t) => (toast = t[0])) | ||||
| @@ -238,6 +245,7 @@ describe('SettingsComponent', () => { | ||||
|  | ||||
|     expect(toast.actionName).toEqual('Reload now') | ||||
|     toast.action() | ||||
|     expect(reloadSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should allow setting theme color, visually apply change immediately but not save', () => { | ||||
| @@ -266,7 +274,7 @@ describe('SettingsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(userService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load groups failure', () => { | ||||
| @@ -278,7 +286,7 @@ describe('SettingsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(groupService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should load system status on initialize, show errors if needed', () => { | ||||
| @@ -303,12 +311,17 @@ describe('SettingsComponent', () => { | ||||
|         redis_error: | ||||
|           'Error 61 connecting to localhost:6379. Connection refused.', | ||||
|         celery_status: SystemStatusItemStatus.ERROR, | ||||
|         celery_url: 'celery@localhost', | ||||
|         celery_error: 'Error connecting to celery@localhost', | ||||
|         index_status: SystemStatusItemStatus.OK, | ||||
|         index_last_modified: new Date().toISOString(), | ||||
|         index_error: null, | ||||
|         classifier_status: SystemStatusItemStatus.OK, | ||||
|         classifier_last_trained: new Date().toISOString(), | ||||
|         classifier_error: null, | ||||
|         sanity_check_status: SystemStatusItemStatus.ERROR, | ||||
|         sanity_check_last_run: new Date().toISOString(), | ||||
|         sanity_check_error: 'Error running sanity check.', | ||||
|       }, | ||||
|     } | ||||
|     jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) | ||||
| @@ -320,6 +333,8 @@ describe('SettingsComponent', () => { | ||||
|     component['systemStatus'].database.status = SystemStatusItemStatus.OK | ||||
|     component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK | ||||
|     component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK | ||||
|     component['systemStatus'].tasks.sanity_check_status = | ||||
|       SystemStatusItemStatus.OK | ||||
|     expect(component.systemStatusHasErrors).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
| @@ -338,4 +353,14 @@ describe('SettingsComponent', () => { | ||||
|     component.reset() | ||||
|     expect(component.settingsForm.get('themeColor').value).toEqual('') | ||||
|   }) | ||||
|  | ||||
|   it('should trigger maybeRefreshDocumentCounts on settings save', () => { | ||||
|     completeSetup() | ||||
|     const maybeRefreshSpy = jest.spyOn( | ||||
|       savedViewsService, | ||||
|       'maybeRefreshDocumentCounts' | ||||
|     ) | ||||
|     settingsService.settingsSaved.emit(true) | ||||
|     expect(maybeRefreshSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -2,10 +2,10 @@ import { AsyncPipe, ViewportScroller } from '@angular/common' | ||||
| import { | ||||
|   AfterViewInit, | ||||
|   Component, | ||||
|   Inject, | ||||
|   LOCALE_ID, | ||||
|   OnDestroy, | ||||
|   OnInit, | ||||
|   inject, | ||||
| } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
| @@ -49,6 +49,7 @@ import { | ||||
|   PermissionsService, | ||||
| } from 'src/app/services/permissions.service' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { | ||||
|   LanguageOption, | ||||
| @@ -56,6 +57,7 @@ import { | ||||
| } from 'src/app/services/settings.service' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| import { Toast, ToastService } from 'src/app/services/toast.service' | ||||
| import { locationReload } from 'src/app/utils/navigation' | ||||
| import { CheckComponent } from '../../common/input/check/check.component' | ||||
| import { ColorComponent } from '../../common/input/color/color.component' | ||||
| import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component' | ||||
| @@ -104,6 +106,21 @@ export class SettingsComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, AfterViewInit, OnDestroy, DirtyComponent | ||||
| { | ||||
|   private documentListViewService = inject(DocumentListViewService) | ||||
|   private toastService = inject(ToastService) | ||||
|   private settings = inject(SettingsService) | ||||
|   currentLocale = inject(LOCALE_ID) | ||||
|   private viewportScroller = inject(ViewportScroller) | ||||
|   private activatedRoute = inject(ActivatedRoute) | ||||
|   readonly tourService = inject(TourService) | ||||
|   private usersService = inject(UserService) | ||||
|   private groupsService = inject(GroupService) | ||||
|   private router = inject(Router) | ||||
|   permissionsService = inject(PermissionsService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private systemStatusService = inject(SystemStatusService) | ||||
|   private savedViewsService = inject(SavedViewService) | ||||
|  | ||||
|   activeNavID: number | ||||
|  | ||||
|   settingsForm = new FormGroup({ | ||||
| @@ -138,6 +155,7 @@ export class SettingsComponent | ||||
|     notificationsConsumerSuppressOnDashboard: new FormControl(null), | ||||
|  | ||||
|     savedViewsWarnOnUnsavedChange: new FormControl(null), | ||||
|     sidebarViewsShowCount: new FormControl(null), | ||||
|   }) | ||||
|  | ||||
|   SettingsNavIDs = SettingsNavIDs | ||||
| @@ -164,7 +182,10 @@ export class SettingsComponent | ||||
|       this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR | ||||
|       this.systemStatus.tasks.classifier_status === | ||||
|         SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.sanity_check_status === | ||||
|         SystemStatusItemStatus.ERROR | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| @@ -176,24 +197,11 @@ export class SettingsComponent | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     private toastService: ToastService, | ||||
|     private settings: SettingsService, | ||||
|     @Inject(LOCALE_ID) public currentLocale: string, | ||||
|     private viewportScroller: ViewportScroller, | ||||
|     private activatedRoute: ActivatedRoute, | ||||
|     public readonly tourService: TourService, | ||||
|     private usersService: UserService, | ||||
|     private groupsService: GroupService, | ||||
|     private router: Router, | ||||
|     public permissionsService: PermissionsService, | ||||
|     private modalService: NgbModal, | ||||
|     private systemStatusService: SystemStatusService | ||||
|   ) { | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.settings.settingsSaved.subscribe(() => { | ||||
|       if (!this.savePending) this.initialize() | ||||
|       this.savedViewsService.maybeRefreshDocumentCounts() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
| @@ -305,6 +313,9 @@ export class SettingsComponent | ||||
|       savedViewsWarnOnUnsavedChange: this.settings.get( | ||||
|         SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE | ||||
|       ), | ||||
|       sidebarViewsShowCount: this.settings.get( | ||||
|         SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT | ||||
|       ), | ||||
|       defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER), | ||||
|       defaultPermsViewUsers: this.settings.get( | ||||
|         SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS | ||||
| @@ -482,6 +493,10 @@ export class SettingsComponent | ||||
|       SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE, | ||||
|       this.settingsForm.value.savedViewsWarnOnUnsavedChange | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT, | ||||
|       this.settingsForm.value.sidebarViewsShowCount | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DEFAULT_PERMS_OWNER, | ||||
|       this.settingsForm.value.defaultPermsOwner | ||||
| @@ -536,7 +551,7 @@ export class SettingsComponent | ||||
|             savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.` | ||||
|             savedToast.actionName = $localize`Reload now` | ||||
|             savedToast.action = () => { | ||||
|               location.reload() | ||||
|               locationReload() | ||||
|             } | ||||
|           } | ||||
|  | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { routes } from 'src/app/app-routing.module' | ||||
| import { | ||||
|   PaperlessTask, | ||||
|   PaperlessTaskName, | ||||
|   PaperlessTaskStatus, | ||||
|   PaperlessTaskType, | ||||
| } from 'src/app/data/paperless-task' | ||||
| @@ -39,7 +40,8 @@ const tasks: PaperlessTask[] = [ | ||||
|     task_file_name: 'test.pdf', | ||||
|     date_created: new Date('2023-03-01T10:26:03.093116Z'), | ||||
|     date_done: new Date('2023-03-01T10:26:07.223048Z'), | ||||
|     type: PaperlessTaskType.File, | ||||
|     type: PaperlessTaskType.Auto, | ||||
|     task_name: PaperlessTaskName.ConsumeFile, | ||||
|     status: PaperlessTaskStatus.Failed, | ||||
|     result: 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)', | ||||
|     acknowledged: false, | ||||
| @@ -51,7 +53,8 @@ const tasks: PaperlessTask[] = [ | ||||
|     task_file_name: '191092.pdf', | ||||
|     date_created: new Date('2023-03-01T09:26:03.093116Z'), | ||||
|     date_done: new Date('2023-03-01T09:26:07.223048Z'), | ||||
|     type: PaperlessTaskType.File, | ||||
|     type: PaperlessTaskType.Auto, | ||||
|     task_name: PaperlessTaskName.ConsumeFile, | ||||
|     status: PaperlessTaskStatus.Failed, | ||||
|     result: | ||||
|       '191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)', | ||||
| @@ -64,7 +67,8 @@ const tasks: PaperlessTask[] = [ | ||||
|     task_file_name: 'Scan Jun 6, 2023 at 3.19 PM.pdf', | ||||
|     date_created: new Date('2023-06-06T15:22:05.722323-07:00'), | ||||
|     date_done: new Date('2023-06-06T15:22:14.564305-07:00'), | ||||
|     type: PaperlessTaskType.File, | ||||
|     type: PaperlessTaskType.Auto, | ||||
|     task_name: PaperlessTaskName.ConsumeFile, | ||||
|     status: PaperlessTaskStatus.Pending, | ||||
|     result: null, | ||||
|     acknowledged: false, | ||||
| @@ -76,7 +80,8 @@ const tasks: PaperlessTask[] = [ | ||||
|     task_file_name: 'paperless-mail-l4dkg8ir', | ||||
|     date_created: new Date('2023-06-04T11:24:32.898089-07:00'), | ||||
|     date_done: new Date('2023-06-04T11:24:44.678605-07:00'), | ||||
|     type: PaperlessTaskType.File, | ||||
|     type: PaperlessTaskType.Auto, | ||||
|     task_name: PaperlessTaskName.ConsumeFile, | ||||
|     status: PaperlessTaskStatus.Complete, | ||||
|     result: 'Success. New document id 422 created', | ||||
|     acknowledged: false, | ||||
| @@ -88,7 +93,8 @@ const tasks: PaperlessTask[] = [ | ||||
|     task_file_name: 'onlinePaymentSummary.pdf', | ||||
|     date_created: new Date('2023-06-01T13:49:51.631305-07:00'), | ||||
|     date_done: new Date('2023-06-01T13:49:54.190220-07:00'), | ||||
|     type: PaperlessTaskType.File, | ||||
|     type: PaperlessTaskType.Auto, | ||||
|     task_name: PaperlessTaskName.ConsumeFile, | ||||
|     status: PaperlessTaskStatus.Complete, | ||||
|     result: 'Success. New document id 421 created', | ||||
|     acknowledged: false, | ||||
| @@ -100,7 +106,8 @@ const tasks: PaperlessTask[] = [ | ||||
|     task_file_name: 'paperless-mail-_rrpmqk6', | ||||
|     date_created: new Date('2023-06-07T02:54:35.694916Z'), | ||||
|     date_done: null, | ||||
|     type: PaperlessTaskType.File, | ||||
|     type: PaperlessTaskType.Auto, | ||||
|     task_name: PaperlessTaskName.ConsumeFile, | ||||
|     status: PaperlessTaskStatus.Started, | ||||
|     result: null, | ||||
|     acknowledged: false, | ||||
| @@ -155,7 +162,9 @@ describe('TasksComponent', () => { | ||||
|     jest.useFakeTimers() | ||||
|     fixture.detectChanges() | ||||
|     httpTestingController | ||||
|       .expectOne(`${environment.apiBaseUrl}tasks/`) | ||||
|       .expectOne( | ||||
|         `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false` | ||||
|       ) | ||||
|       .flush(tasks) | ||||
|   }) | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { NgTemplateOutlet, SlicePipe } from '@angular/common' | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core' | ||||
| import { Component, inject, OnDestroy, OnInit } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { Router } from '@angular/router' | ||||
| import { | ||||
| @@ -69,6 +69,10 @@ export class TasksComponent | ||||
|   extends LoadingComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   tasksService = inject(TasksService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private readonly router = inject(Router) | ||||
|  | ||||
|   public activeTab: TaskTab | ||||
|   public selectedTasks: Set<number> = new Set() | ||||
|   public togggleAll: boolean = false | ||||
| @@ -105,14 +109,6 @@ export class TasksComponent | ||||
|       : $localize`Dismiss all` | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     public tasksService: TasksService, | ||||
|     private modalService: NgbModal, | ||||
|     private readonly router: Router | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   ngOnInit() { | ||||
|     this.tasksService.reload() | ||||
|     timer(5000, 5000) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, OnDestroy } from '@angular/core' | ||||
| import { Component, OnDestroy, inject } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { Router } from '@angular/router' | ||||
| import { | ||||
| @@ -36,19 +36,19 @@ export class TrashComponent | ||||
|   extends LoadingComponentWithPermissions | ||||
|   implements OnDestroy | ||||
| { | ||||
|   private trashService = inject(TrashService) | ||||
|   private toastService = inject(ToastService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private settingsService = inject(SettingsService) | ||||
|   private router = inject(Router) | ||||
|  | ||||
|   public documentsInTrash: Document[] = [] | ||||
|   public selectedDocuments: Set<number> = new Set() | ||||
|   public allToggled: boolean = false | ||||
|   public page: number = 1 | ||||
|   public totalDocuments: number | ||||
|  | ||||
|   constructor( | ||||
|     private trashService: TrashService, | ||||
|     private toastService: ToastService, | ||||
|     private modalService: NgbModal, | ||||
|     private settingsService: SettingsService, | ||||
|     private router: Router | ||||
|   ) { | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.reload() | ||||
|   } | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import * as navUtils from 'src/app/utils/navigation' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' | ||||
| import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' | ||||
| @@ -107,7 +108,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     settingsService.currentUser = users[1] // simulate logged in as different user | ||||
|     editDialog.succeeded.emit(users[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
| @@ -130,7 +131,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       throwError(() => new Error('error deleting user')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
| @@ -142,19 +143,18 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.editUser(users[0]) | ||||
|     const navSpy = jest | ||||
|       .spyOn(navUtils, 'setLocationHref') | ||||
|       .mockImplementation(() => {}) | ||||
|     const editDialog = modal.componentInstance as UserEditDialogComponent | ||||
|     editDialog.passwordIsSet = true | ||||
|     settingsService.currentUser = users[0] // simulate logged in as same user | ||||
|     editDialog.succeeded.emit(users[0]) | ||||
|     fixture.detectChanges() | ||||
|     Object.defineProperty(window, 'location', { | ||||
|       value: { | ||||
|         href: 'http://localhost/', | ||||
|       }, | ||||
|       writable: true, // possibility to override | ||||
|     }) | ||||
|     tick(2600) | ||||
|     expect(window.location.href).toContain('logout') | ||||
|     expect(navSpy).toHaveBeenCalledWith( | ||||
|       `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|     ) | ||||
|   })) | ||||
|  | ||||
|   it('should support edit / create group, show error if needed', () => { | ||||
| @@ -166,7 +166,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     editDialog.succeeded.emit(groups[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved group "${groups[0].name}".` | ||||
| @@ -188,7 +188,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       throwError(() => new Error('error deleting group')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
| @@ -210,7 +210,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(userService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load groups failure', () => { | ||||
| @@ -222,6 +222,6 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(groupService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core' | ||||
| import { Component, OnDestroy, OnInit, inject } from '@angular/core' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Subject, first, takeUntil } from 'rxjs' | ||||
| @@ -10,6 +10,7 @@ import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { setLocationHref } from 'src/app/utils/navigation' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' | ||||
| import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' | ||||
| @@ -31,22 +32,18 @@ export class UsersAndGroupsComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   private usersService = inject(UserService) | ||||
|   private groupsService = inject(GroupService) | ||||
|   private toastService = inject(ToastService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   permissionsService = inject(PermissionsService) | ||||
|   private settings = inject(SettingsService) | ||||
|  | ||||
|   users: User[] | ||||
|   groups: Group[] | ||||
|  | ||||
|   unsubscribeNotifier: Subject<any> = new Subject() | ||||
|  | ||||
|   constructor( | ||||
|     private usersService: UserService, | ||||
|     private groupsService: GroupService, | ||||
|     private toastService: ToastService, | ||||
|     private modalService: NgbModal, | ||||
|     public permissionsService: PermissionsService, | ||||
|     private settings: SettingsService | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.usersService | ||||
|       .listAll(null, null, { full_perms: true }) | ||||
| @@ -97,7 +94,9 @@ export class UsersAndGroupsComponent | ||||
|             $localize`Password has been changed, you will be logged out momentarily.` | ||||
|           ) | ||||
|           setTimeout(() => { | ||||
|             window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|             setLocationHref( | ||||
|               `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|             ) | ||||
|           }, 2500) | ||||
|         } else { | ||||
|           this.toastService.showInfo( | ||||
|   | ||||
| @@ -15,7 +15,7 @@ | ||||
|     </svg> | ||||
|     <div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled"> | ||||
|       @if (customAppTitle?.length) { | ||||
|         <div class="d-flex flex-column align-items-start"> | ||||
|         <div class="d-flex flex-column align-items-start custom-title"> | ||||
|           <span class="title">{{customAppTitle}}</span> | ||||
|           <span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span> | ||||
|         </div> | ||||
| @@ -108,11 +108,19 @@ | ||||
|                 <li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews" | ||||
|                   cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)" | ||||
|                   (cdkDragEnded)="onDragEnd($event)"> | ||||
|                   <a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" | ||||
|                   <a class="nav-link" routerLink="view/{{view.id}}" | ||||
|                     routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" | ||||
|                     [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" | ||||
|                     popoverClass="popover-slim"> | ||||
|                     <i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span> | ||||
|                     <i-bs class="me-1" name="funnel"></i-bs> | ||||
|                       <span> <div class="d-inline-flex view-name"><span [class.text-truncate]="!slimSidebarEnabled">{{view.name}}</span></div> | ||||
|                         @if (showSidebarCounts && !slimSidebarEnabled) { | ||||
|                           <span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span> | ||||
|                         } | ||||
|                       </span> | ||||
|                     @if (showSidebarCounts && slimSidebarEnabled) { | ||||
|                       <span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span> | ||||
|                     } | ||||
|                   </a> | ||||
|                   @if (settingsService.organizingSidebarSavedViews) { | ||||
|                     <div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle> | ||||
|   | ||||
| @@ -19,6 +19,10 @@ | ||||
|     height: 0.8em; | ||||
|   } | ||||
|  | ||||
|   .view-name { | ||||
|     max-width: calc(100% - 50px) | ||||
|   } | ||||
|  | ||||
|   .nav-group:not(:has(.app-link)) .sidebar-heading { | ||||
|     display: none !important; | ||||
|   } | ||||
| @@ -244,12 +248,26 @@ main { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media screen and (min-width: 366px) and (max-width: 768px) { | ||||
|   .navbar-toggler { | ||||
|     // compensate for 2 buttons on the right | ||||
|     margin-right: 45px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media screen and (min-width: 768px) { | ||||
|   .navbar-brand.slim { | ||||
|     max-width: 50px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media screen and (max-width: 345px) { | ||||
|   .custom-title { | ||||
|     max-width: 110px; | ||||
|     overflow: hidden; | ||||
|   } | ||||
| } | ||||
|  | ||||
| :host ::ng-deep .dropdown.show .dropdown-toggle, | ||||
| :host ::ng-deep .dropdown-toggle:hover { | ||||
|   opacity: 0.7; | ||||
|   | ||||
| @@ -92,6 +92,7 @@ describe('AppFrameComponent', () => { | ||||
|   let router: Router | ||||
|   let savedViewSpy | ||||
|   let modalService: NgbModal | ||||
|   let maybeRefreshSpy | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -113,7 +114,11 @@ describe('AppFrameComponent', () => { | ||||
|         { | ||||
|           provide: SavedViewService, | ||||
|           useValue: { | ||||
|             reload: () => {}, | ||||
|             reload: (fn: any) => { | ||||
|               if (fn) { | ||||
|                 fn() | ||||
|               } | ||||
|             }, | ||||
|             listAll: () => | ||||
|               of({ | ||||
|                 all: [saved_views.map((v) => v.id)], | ||||
| @@ -121,6 +126,8 @@ describe('AppFrameComponent', () => { | ||||
|                 results: saved_views, | ||||
|               }), | ||||
|             sidebarViews: saved_views.filter((v) => v.show_in_sidebar), | ||||
|             getDocumentCount: (view: SavedView) => 5, | ||||
|             maybeRefreshDocumentCounts: () => {}, | ||||
|           }, | ||||
|         }, | ||||
|         PermissionsService, | ||||
| @@ -169,6 +176,7 @@ describe('AppFrameComponent', () => { | ||||
|     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|  | ||||
|     savedViewSpy = jest.spyOn(savedViewService, 'reload') | ||||
|     maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts') | ||||
|  | ||||
|     fixture = TestBed.createComponent(AppFrameComponent) | ||||
|     component = fixture.componentInstance | ||||
| @@ -359,4 +367,8 @@ describe('AppFrameComponent', () => { | ||||
|     expect(toastErrorSpy).toHaveBeenCalledTimes(2) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledTimes(3) | ||||
|   }) | ||||
|  | ||||
|   it('should call maybeRefreshDocumentCounts after saved views reload', () => { | ||||
|     expect(maybeRefreshSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { | ||||
|   moveItemInArray, | ||||
| } from '@angular/cdk/drag-drop' | ||||
| import { NgClass } from '@angular/common' | ||||
| import { Component, HostListener, OnInit } from '@angular/core' | ||||
| import { Component, HostListener, inject, OnInit } from '@angular/core' | ||||
| import { ActivatedRoute, Router, RouterModule } from '@angular/router' | ||||
| import { | ||||
|   NgbCollapseModule, | ||||
| @@ -74,27 +74,27 @@ export class AppFrameComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, ComponentCanDeactivate | ||||
| { | ||||
|   versionString = `${environment.appTitle} ${environment.version}` | ||||
|   router = inject(Router) | ||||
|   private activatedRoute = inject(ActivatedRoute) | ||||
|   private openDocumentsService = inject(OpenDocumentsService) | ||||
|   savedViewService = inject(SavedViewService) | ||||
|   private remoteVersionService = inject(RemoteVersionService) | ||||
|   settingsService = inject(SettingsService) | ||||
|   tasksService = inject(TasksService) | ||||
|   private readonly toastService = inject(ToastService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   permissionsService = inject(PermissionsService) | ||||
|   private djangoMessagesService = inject(DjangoMessagesService) | ||||
|  | ||||
|   appRemoteVersion: AppRemoteVersion | ||||
|  | ||||
|   isMenuCollapsed: boolean = true | ||||
|  | ||||
|   slimSidebarAnimating: boolean = false | ||||
|  | ||||
|   constructor( | ||||
|     public router: Router, | ||||
|     private activatedRoute: ActivatedRoute, | ||||
|     private openDocumentsService: OpenDocumentsService, | ||||
|     public savedViewService: SavedViewService, | ||||
|     private remoteVersionService: RemoteVersionService, | ||||
|     public settingsService: SettingsService, | ||||
|     public tasksService: TasksService, | ||||
|     private readonly toastService: ToastService, | ||||
|     private modalService: NgbModal, | ||||
|     public permissionsService: PermissionsService, | ||||
|     private djangoMessagesService: DjangoMessagesService | ||||
|   ) { | ||||
|   constructor() { | ||||
|     super() | ||||
|     const permissionsService = this.permissionsService | ||||
|  | ||||
|     if ( | ||||
|       permissionsService.currentUserCan( | ||||
| @@ -102,7 +102,9 @@ export class AppFrameComponent | ||||
|         PermissionType.SavedView | ||||
|       ) | ||||
|     ) { | ||||
|       this.savedViewService.reload() | ||||
|       this.savedViewService.reload(() => { | ||||
|         this.savedViewService.maybeRefreshDocumentCounts() | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -142,6 +144,10 @@ export class AppFrameComponent | ||||
|     }, 200) // slightly longer than css animation for slim sidebar | ||||
|   } | ||||
|  | ||||
|   get versionString(): string { | ||||
|     return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}` | ||||
|   } | ||||
|  | ||||
|   get customAppTitle(): string { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.APP_TITLE) | ||||
|   } | ||||
| @@ -279,4 +285,8 @@ export class AppFrameComponent | ||||
|   onLogout() { | ||||
|     this.openDocumentsService.closeAll() | ||||
|   } | ||||
|  | ||||
|   get showSidebarCounts(): boolean { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -89,7 +89,7 @@ | ||||
|                 @if (searchResults?.documents.length) { | ||||
|                     <h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6> | ||||
|                     @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.added}"></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) { | ||||
|   | ||||
| @@ -405,7 +405,7 @@ describe('GlobalSearchComponent', () => { | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|  | ||||
|     // succeed | ||||
|     editDialog.succeeded.emit(true) | ||||
|     editDialog.succeeded.emit(object as any) | ||||
|     expect(toastInfoSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
| @@ -456,7 +456,7 @@ describe('GlobalSearchComponent', () => { | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|  | ||||
|     // succeed | ||||
|     editDialog.succeeded.emit(true) | ||||
|     editDialog.succeeded.emit(searchResults.tags[0] as any) | ||||
|     expect(toastInfoSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
| @@ -529,6 +529,17 @@ describe('GlobalSearchComponent', () => { | ||||
|     expect(dispatchSpy).toHaveBeenCalledTimes(2) // once for keydown, second for click | ||||
|   }) | ||||
|  | ||||
|   it('should support using base href in navigateOrOpenInNewWindow', () => { | ||||
|     jest | ||||
|       .spyOn(component['locationStrategy'], 'getBaseHref') | ||||
|       .mockReturnValue('/base/') | ||||
|     const openSpy = jest.spyOn(window, 'open') | ||||
|     const event = new Event('click') | ||||
|     event['ctrlKey'] = true | ||||
|     component.primaryAction(DataType.Document, { id: 1 }, event as any) | ||||
|     expect(openSpy).toHaveBeenCalledWith('/base/documents/1', '_blank') | ||||
|   }) | ||||
|  | ||||
|   it('should support title content search and advanced search', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.query = 'test' | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { NgTemplateOutlet } from '@angular/common' | ||||
| import { LocationStrategy, NgTemplateOutlet } from '@angular/common' | ||||
| import { | ||||
|   Component, | ||||
|   ElementRef, | ||||
| @@ -6,6 +6,7 @@ import { | ||||
|   QueryList, | ||||
|   ViewChild, | ||||
|   ViewChildren, | ||||
|   inject, | ||||
| } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { Router } from '@angular/router' | ||||
| @@ -69,6 +70,17 @@ import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-e | ||||
|   ], | ||||
| }) | ||||
| export class GlobalSearchComponent implements OnInit { | ||||
|   searchService = inject(SearchService) | ||||
|   private router = inject(Router) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private documentService = inject(DocumentService) | ||||
|   private documentListViewService = inject(DocumentListViewService) | ||||
|   private permissionsService = inject(PermissionsService) | ||||
|   private toastService = inject(ToastService) | ||||
|   private hotkeyService = inject(HotKeyService) | ||||
|   private settingsService = inject(SettingsService) | ||||
|   private locationStrategy = inject(LocationStrategy) | ||||
|  | ||||
|   public DataType = DataType | ||||
|   public query: string | ||||
|   public queryDebounce: Subject<string> | ||||
| @@ -90,17 +102,7 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     public searchService: SearchService, | ||||
|     private router: Router, | ||||
|     private modalService: NgbModal, | ||||
|     private documentService: DocumentService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     private permissionsService: PermissionsService, | ||||
|     private toastService: ToastService, | ||||
|     private hotkeyService: HotKeyService, | ||||
|     private settingsService: SettingsService | ||||
|   ) { | ||||
|   constructor() { | ||||
|     this.queryDebounce = new Subject<string>() | ||||
|  | ||||
|     this.queryDebounce | ||||
| @@ -421,10 +423,13 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     extras: Object = {} | ||||
|   ) { | ||||
|     if (newWindow) { | ||||
|       const url = this.router.serializeUrl( | ||||
|       const serializedUrl = this.router.serializeUrl( | ||||
|         this.router.createUrlTree(commands, extras) | ||||
|       ) | ||||
|       window.open(url, '_blank') | ||||
|       const baseHref = this.locationStrategy.getBaseHref() | ||||
|       const fullUrl = | ||||
|         baseHref.replace(/\/+$/, '') + '/' + serializedUrl.replace(/^\/+/, '') | ||||
|       window.open(fullUrl, '_blank') | ||||
|     } else { | ||||
|       this.router.navigate(commands, extras) | ||||
|     } | ||||
|   | ||||
| @@ -21,7 +21,7 @@ | ||||
|     } | ||||
|     <div class="scroll-list"> | ||||
|       @for (toast of toasts; track toast.id) { | ||||
|         <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast> | ||||
|         <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast> | ||||
|       } | ||||
|       </div> | ||||
|   </div> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core' | ||||
| import { Component, OnDestroy, OnInit, inject } from '@angular/core' | ||||
| import { | ||||
|   NgbDropdownModule, | ||||
|   NgbProgressbarModule, | ||||
| @@ -20,7 +20,7 @@ import { ToastComponent } from '../../common/toast/toast.component' | ||||
|   ], | ||||
| }) | ||||
| export class ToastsDropdownComponent implements OnInit, OnDestroy { | ||||
|   constructor(public toastService: ToastService) {} | ||||
|   toastService = inject(ToastService) | ||||
|  | ||||
|   private subscription: Subscription | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { DecimalPipe } from '@angular/common' | ||||
| import { Component, EventEmitter, Input, Output } from '@angular/core' | ||||
| import { Component, EventEmitter, Input, Output, inject } from '@angular/core' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { Subject } from 'rxjs' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| @@ -12,9 +12,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading | ||||
|   imports: [DecimalPipe, SafeHtmlPipe], | ||||
| }) | ||||
| export class ConfirmDialogComponent extends LoadingComponentWithPermissions { | ||||
|   constructor(public activeModal: NgbActiveModal) { | ||||
|     super() | ||||
|   } | ||||
|   activeModal = inject(NgbActiveModal) | ||||
|  | ||||
|   @Output() | ||||
|   public confirmClicked = new EventEmitter() | ||||
|   | ||||
| @@ -1,54 +0,0 @@ | ||||
| <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|     <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"> | ||||
|     </button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|     <div class="row"> | ||||
|         <div class="col"> | ||||
|             <div class="btn-toolbar flex-nowrap"> | ||||
|                 <div class="input-group input-group-sm"> | ||||
|                     <div class="input-group-text" i18n>Page</div> | ||||
|                     <input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" /> | ||||
|                     <div class="input-group-text" i18n>of {{totalPages}}</div> | ||||
|                 </div> | ||||
|                 <div class="input-group input-group-sm ms-auto"> | ||||
|                     <span class="input-group-text" i18n>Pages to remove</span> | ||||
|                     <input [ngModel]="pagesString" class="form-control" disabled /> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="pdf-viewer-container w-100 mt-3"> | ||||
|                 <pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage" | ||||
|                 [original-size]="false" | ||||
|                 [zoom]="1" | ||||
|                 zoom-scale="page-fit" | ||||
|                 [render-text]="false" | ||||
|                 (pagerendered)="pageRendered($event)" | ||||
|                 (after-load-complete)="pdfPreviewLoaded($event)"> | ||||
|                 </pdf-viewer> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| <div class="modal-footer flex-nowrap"> | ||||
|     <div> | ||||
|         @if (message) { | ||||
|             <p [innerHTML]="message | safeHtml"></p> | ||||
|         } | ||||
|         @if (messageBold) { | ||||
|             <p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p> | ||||
|         } | ||||
|     </div> | ||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||
|             <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> | ||||
|         </button> | ||||
|     <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|         {{btnCaption}} | ||||
|     </button> | ||||
| </div> | ||||
|  | ||||
| <ng-template #pageCheckOverlay let-page="page" let-pages="pages"> | ||||
|     <div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)"> | ||||
|         <input type="checkbox" class="form-check-input" /> | ||||
|     </div> | ||||
| </ng-template> | ||||
| @@ -1,28 +0,0 @@ | ||||
| .pdf-viewer-container { | ||||
|   background-color: gray; | ||||
|   height: 550px; | ||||
|  | ||||
|   pdf-viewer { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .mw-60 { | ||||
|   max-width: 60px; | ||||
| } | ||||
|  | ||||
| div.position-absolute:has(.form-check-input:checked) { | ||||
|   background-color: rgba(var(--bs-dark-rgb), 0.4); | ||||
| } | ||||
|  | ||||
| .form-check-input { | ||||
|   &:checked { | ||||
|     background-color: var(--bs-danger); | ||||
|     border-color: var(--bs-danger); | ||||
|   } | ||||
|   &:focus { | ||||
|     box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha)); | ||||
|     border-color: var(--bs-danger); | ||||
|   } | ||||
| } | ||||
| @@ -1,60 +0,0 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component' | ||||
|  | ||||
| describe('DeletePagesConfirmDialogComponent', () => { | ||||
|   let component: DeletePagesConfirmDialogComponent | ||||
|   let fixture: ComponentFixture<DeletePagesConfirmDialogComponent> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       declarations: [], | ||||
|       imports: [ | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         DeletePagesConfirmDialogComponent, | ||||
|       ], | ||||
|       providers: [ | ||||
|         NgbActiveModal, | ||||
|         SafeHtmlPipe, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|     fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should return a string with comma-separated pages', () => { | ||||
|     component.pages = [1, 2, 3, 4] | ||||
|     expect(component.pagesString).toEqual('1, 2, 3, 4') | ||||
|   }) | ||||
|  | ||||
|   it('should update totalPages when pdf is loaded', () => { | ||||
|     component.pdfPreviewLoaded({ numPages: 5 } as any) | ||||
|     expect(component.totalPages).toEqual(5) | ||||
|   }) | ||||
|  | ||||
|   it('should update checks when page is rendered', () => { | ||||
|     const event = { | ||||
|       target: document.createElement('div'), | ||||
|       detail: { pageNumber: 1 }, | ||||
|     } as any | ||||
|     component.pageRendered(event) | ||||
|     expect(component['checks'].length).toEqual(1) | ||||
|   }) | ||||
|  | ||||
|   it('should update pages when page check is changed', () => { | ||||
|     component.pageCheckChanged(1) | ||||
|     expect(component.pages).toEqual([1]) | ||||
|     component.pageCheckChanged(1) | ||||
|     expect(component.pages).toEqual([]) | ||||
|   }) | ||||
| }) | ||||
| @@ -1,71 +0,0 @@ | ||||
| import { Component, TemplateRef, ViewChild } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { | ||||
|   PDFDocumentProxy, | ||||
|   PdfViewerComponent, | ||||
|   PdfViewerModule, | ||||
| } from 'ng2-pdf-viewer' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-delete-pages-confirm-dialog', | ||||
|   templateUrl: './delete-pages-confirm-dialog.component.html', | ||||
|   styleUrl: './delete-pages-confirm-dialog.component.scss', | ||||
|   imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe], | ||||
| }) | ||||
| export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent { | ||||
|   public documentID: number | ||||
|   public pages: number[] = [] | ||||
|   public currentPage: number = 1 | ||||
|   public totalPages: number | ||||
|  | ||||
|   @ViewChild('pdfViewer') pdfViewer: PdfViewerComponent | ||||
|   @ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any> | ||||
|   private checks: HTMLElement[] = [] | ||||
|  | ||||
|   public get pagesString(): string { | ||||
|     return this.pages.join(', ') | ||||
|   } | ||||
|  | ||||
|   public get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     activeModal: NgbActiveModal, | ||||
|     private documentService: DocumentService | ||||
|   ) { | ||||
|     super(activeModal) | ||||
|   } | ||||
|  | ||||
|   public pdfPreviewLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|   } | ||||
|  | ||||
|   pageRendered(event: CustomEvent) { | ||||
|     const pageDiv = event.target as HTMLDivElement | ||||
|     const check = this.pageCheckOverlay.createEmbeddedView({ | ||||
|       page: event.detail.pageNumber, | ||||
|     }) | ||||
|     this.checks[event.detail.pageNumber - 1] = check.rootNodes[0] | ||||
|     pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild) | ||||
|     this.updateChecks() | ||||
|   } | ||||
|  | ||||
|   pageCheckChanged(pageNumber: number) { | ||||
|     if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber) | ||||
|     else if (this.pages.includes(pageNumber)) | ||||
|       this.pages.splice(this.pages.indexOf(pageNumber), 1) | ||||
|     this.updateChecks() | ||||
|   } | ||||
|  | ||||
|   private updateChecks() { | ||||
|     this.checks.forEach((check, i) => { | ||||
|       const input = check.getElementsByTagName('input')[0] | ||||
|       input.checked = this.pages.includes(i + 1) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @@ -28,10 +28,16 @@ | ||||
|         </select> | ||||
|     </div> | ||||
|     <div class="form-check form-switch mt-4"> | ||||
|       <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> | ||||
|     </div> | ||||
|     <div class="form-check form-switch mt-2"> | ||||
|       <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> | ||||
|     </div> | ||||
|     <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> | ||||
|     @if (!archiveFallback) { | ||||
|       <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> | ||||
|     } | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||
|   | ||||
| @@ -3,9 +3,8 @@ import { | ||||
|   DragDropModule, | ||||
|   moveItemInArray, | ||||
| } from '@angular/cdk/drag-drop' | ||||
| import { Component, OnInit } from '@angular/core' | ||||
| import { Component, OnInit, inject } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { takeUntil } from 'rxjs' | ||||
| import { Document } from 'src/app/data/document' | ||||
| @@ -28,7 +27,11 @@ export class MergeConfirmDialogComponent | ||||
|   extends ConfirmDialogComponent | ||||
|   implements OnInit | ||||
| { | ||||
|   private documentService = inject(DocumentService) | ||||
|   private permissionService = inject(PermissionsService) | ||||
|  | ||||
|   public documentIDs: number[] = [] | ||||
|   public archiveFallback: boolean = false | ||||
|   public deleteOriginals: boolean = false | ||||
|   private _documents: Document[] = [] | ||||
|   get documents(): Document[] { | ||||
| @@ -37,12 +40,8 @@ export class MergeConfirmDialogComponent | ||||
|  | ||||
|   public metadataDocumentID: number = -1 | ||||
|  | ||||
|   constructor( | ||||
|     activeModal: NgbActiveModal, | ||||
|     private documentService: DocumentService, | ||||
|     private permissionService: PermissionsService | ||||
|   ) { | ||||
|     super(activeModal) | ||||
|   constructor() { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   ngOnInit() { | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import { NgStyle } from '@angular/common' | ||||
| import { Component } from '@angular/core' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| @@ -13,6 +12,8 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component' | ||||
|   imports: [NgStyle, NgxBootstrapIconsModule, SafeHtmlPipe], | ||||
| }) | ||||
| export class RotateConfirmDialogComponent extends ConfirmDialogComponent { | ||||
|   documentService = inject(DocumentService) | ||||
|  | ||||
|   public documentID: number | ||||
|   public showPDFNote: boolean = true | ||||
|  | ||||
| @@ -25,11 +26,8 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent { | ||||
|     return degrees | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     activeModal: NgbActiveModal, | ||||
|     public documentService: DocumentService | ||||
|   ) { | ||||
|     super(activeModal) | ||||
|   constructor() { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   rotate(clockwise: boolean = true) { | ||||
|   | ||||
| @@ -1,59 +0,0 @@ | ||||
| <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|     <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"> | ||||
|     </button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|     <p>{{message}}</p> | ||||
|     <div class="row mb-2"> | ||||
|         <div class="col-7"> | ||||
|             <div class="input-group input-group-sm"> | ||||
|                 <div class="input-group-text" i18n>Page</div> | ||||
|                 <input class="form-control" type="number" min="1" [(ngModel)]="page" /> | ||||
|                 <div class="input-group-text" i18n>of {{totalPages}}</div> | ||||
|             </div> | ||||
|             <div class="pdf-viewer-container w-100 mt-3"> | ||||
|                 <pdf-viewer [src]="pdfSrc" [(page)]="page" | ||||
|                 [original-size]="false" | ||||
|                 [zoom]="1" | ||||
|                 zoom-scale="page-fit" | ||||
|                 (after-load-complete)="pdfPreviewLoaded($event)"> | ||||
|                 </pdf-viewer> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="col-5"> | ||||
|             <div class="d-grid"> | ||||
|                 <button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit"> | ||||
|                     <i-bs name="plus-circle"></i-bs>  | ||||
|                     <span i18n>Add Split</span> | ||||
|                 </button> | ||||
|             </div> | ||||
|  | ||||
|             <ul class="list-group mt-3"> | ||||
|                 @for (pageStr of pagesString.split(','); track pageStr; let i = $index) { | ||||
|                     <li class="list-group-item d-flex align-items-center"> | ||||
|                         {{pageStr}} | ||||
|                         @if (pagesString.split(',').length > 1) { | ||||
|                               | ||||
|                             <button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)"> | ||||
|                                 <i-bs name="trash"></i-bs> | ||||
|                             </button> | ||||
|                         } | ||||
|                     </li> | ||||
|                 } | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|     <div class="form-check form-switch me-auto"> | ||||
|        <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument"> | ||||
|        <label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label> | ||||
|      </div> | ||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||
|             <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> | ||||
|         </button> | ||||
|     <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|         {{btnCaption}} | ||||
|     </button> | ||||
| </div> | ||||
| @@ -1,9 +0,0 @@ | ||||
| .pdf-viewer-container { | ||||
|     background-color: gray; | ||||
|     height: 500px; | ||||
|  | ||||
|     pdf-viewer { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|     } | ||||
|   } | ||||
| @@ -1,107 +0,0 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
|  | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { of } from 'rxjs' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SplitConfirmDialogComponent } from './split-confirm-dialog.component' | ||||
|  | ||||
| describe('SplitConfirmDialogComponent', () => { | ||||
|   let component: SplitConfirmDialogComponent | ||||
|   let fixture: ComponentFixture<SplitConfirmDialogComponent> | ||||
|   let documentService: DocumentService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [ | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|         ReactiveFormsModule, | ||||
|         FormsModule, | ||||
|         PdfViewerModule, | ||||
|         SplitConfirmDialogComponent, | ||||
|       ], | ||||
|       providers: [ | ||||
|         NgbActiveModal, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(SplitConfirmDialogComponent) | ||||
|     documentService = TestBed.inject(DocumentService) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should load document on init', () => { | ||||
|     const getSpy = jest.spyOn(documentService, 'get') | ||||
|     component.documentID = 1 | ||||
|     getSpy.mockReturnValue(of({ id: 1 } as any)) | ||||
|     component.ngOnInit() | ||||
|     expect(documentService.get).toHaveBeenCalledWith(1) | ||||
|   }) | ||||
|  | ||||
|   it('should update pagesString when pages are added', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-5') | ||||
|     component.page = 4 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-4,5') | ||||
|   }) | ||||
|  | ||||
|   it('should update pagesString when pages are removed', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     component.page = 4 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-4,5') | ||||
|     component.removeSplit(0) | ||||
|     expect(component.pagesString).toEqual('1-4,5') | ||||
|   }) | ||||
|  | ||||
|   it('should enable confirm button when pages are added', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     expect(component.confirmButtonEnabled).toBeTruthy() | ||||
|   }) | ||||
|  | ||||
|   it('should disable confirm button when all pages are removed', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     component.removeSplit(0) | ||||
|     expect(component.confirmButtonEnabled).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should not add split if page is the last page', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 5 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-5') | ||||
|   }) | ||||
|  | ||||
|   it('should update totalPages when pdf is loaded', () => { | ||||
|     component.pdfPreviewLoaded({ numPages: 5 } as any) | ||||
|     expect(component.totalPages).toEqual(5) | ||||
|   }) | ||||
|  | ||||
|   it('should correctly disable split button', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 1 | ||||
|     expect(component.canSplit).toBeTruthy() | ||||
|     component.page = 5 | ||||
|     expect(component.canSplit).toBeFalsy() | ||||
|     component.page = 4 | ||||
|     expect(component.canSplit).toBeTruthy() | ||||
|     component['pages'] = new Set([1, 2, 3, 4]) | ||||
|     expect(component.canSplit).toBeFalsy() | ||||
|   }) | ||||
| }) | ||||
| @@ -1,100 +0,0 @@ | ||||
| import { Component, OnInit } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-split-confirm-dialog', | ||||
|   templateUrl: './split-confirm-dialog.component.html', | ||||
|   styleUrl: './split-confirm-dialog.component.scss', | ||||
|   imports: [ | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     PdfViewerModule, | ||||
|   ], | ||||
| }) | ||||
| export class SplitConfirmDialogComponent | ||||
|   extends ConfirmDialogComponent | ||||
|   implements OnInit | ||||
| { | ||||
|   public get pagesString(): string { | ||||
|     let pagesStr = '' | ||||
|  | ||||
|     let lastPage = 1 | ||||
|     for (let i = 1; i <= this.totalPages; i++) { | ||||
|       if (this.pages.has(i) || i === this.totalPages) { | ||||
|         if (lastPage === i) { | ||||
|           pagesStr += `${i},` | ||||
|           lastPage = Math.min(i + 1, this.totalPages) | ||||
|         } else { | ||||
|           pagesStr += `${lastPage}-${i},` | ||||
|           lastPage = Math.min(i + 1, this.totalPages) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return pagesStr.replace(/,$/, '') | ||||
|   } | ||||
|  | ||||
|   private pages: Set<number> = new Set() | ||||
|  | ||||
|   public documentID: number | ||||
|   private document: Document | ||||
|   public page: number = 1 | ||||
|   public totalPages: number | ||||
|   public deleteOriginal: boolean = false | ||||
|  | ||||
|   public get canSplit(): boolean { | ||||
|     return ( | ||||
|       this.page < this.totalPages && | ||||
|       this.pages.size < this.totalPages - 1 && | ||||
|       !this.pages.has(this.page) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     activeModal: NgbActiveModal, | ||||
|     private documentService: DocumentService, | ||||
|     private permissionService: PermissionsService | ||||
|   ) { | ||||
|     super(activeModal) | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.documentService.get(this.documentID).subscribe((r) => { | ||||
|       this.document = r | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   pdfPreviewLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|   } | ||||
|  | ||||
|   addSplit() { | ||||
|     if (this.page === this.totalPages) return | ||||
|     this.pages.add(this.page) | ||||
|     this.pages = new Set(Array.from(this.pages).sort()) | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   removeSplit(i: number) { | ||||
|     let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)] | ||||
|     this.pages.delete(page) | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   get userOwnsDocument(): boolean { | ||||
|     return this.permissionService.currentUserOwnsObject(this.document) | ||||
|   } | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { CurrencyPipe, getLocaleCurrencyCode } from '@angular/common' | ||||
| import { Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core' | ||||
| import { Component, Input, LOCALE_ID, OnInit, inject } from '@angular/core' | ||||
| import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { takeUntil } from 'rxjs' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| @@ -20,6 +20,9 @@ export class CustomFieldDisplayComponent | ||||
|   extends LoadingComponentWithPermissions | ||||
|   implements OnInit | ||||
| { | ||||
|   private customFieldService = inject(CustomFieldsService) | ||||
|   private documentService = inject(DocumentService) | ||||
|  | ||||
|   CustomFieldDataType = CustomFieldDataType | ||||
|  | ||||
|   private _document: Document | ||||
| @@ -63,11 +66,9 @@ export class CustomFieldDisplayComponent | ||||
|  | ||||
|   private defaultCurrencyCode: any | ||||
|  | ||||
|   constructor( | ||||
|     private customFieldService: CustomFieldsService, | ||||
|     private documentService: DocumentService, | ||||
|     @Inject(LOCALE_ID) currentLocale: string | ||||
|   ) { | ||||
|   constructor() { | ||||
|     const currentLocale = inject(LOCALE_ID) | ||||
|  | ||||
|     super() | ||||
|     this.defaultCurrencyCode = getLocaleCurrencyCode(currentLocale) | ||||
|     this.customFieldService.listAll().subscribe((r) => { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)"> | ||||
| <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions" placement="bottom-end"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> | ||||
|       <i-bs name="ui-radios"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div> | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import { | ||||
|   QueryList, | ||||
|   ViewChild, | ||||
|   ViewChildren, | ||||
|   inject, | ||||
| } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| @@ -21,6 +22,7 @@ import { | ||||
| } from 'src/app/services/permissions.service' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
| import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' | ||||
|  | ||||
| @@ -36,6 +38,13 @@ import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit | ||||
|   ], | ||||
| }) | ||||
| export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissions { | ||||
|   private customFieldsService = inject(CustomFieldsService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private toastService = inject(ToastService) | ||||
|   private permissionsService = inject(PermissionsService) | ||||
|  | ||||
|   public popperOptions = pngxPopperOptions | ||||
|  | ||||
|   @Input() | ||||
|   documentId: number | ||||
|  | ||||
| @@ -75,12 +84,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     private customFieldsService: CustomFieldsService, | ||||
|     private modalService: NgbModal, | ||||
|     private toastService: ToastService, | ||||
|     private permissionsService: PermissionsService | ||||
|   ) { | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.getFields() | ||||
|   } | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import { NgTemplateOutlet } from '@angular/common' | ||||
| import { | ||||
|   Component, | ||||
|   EventEmitter, | ||||
|   inject, | ||||
|   Input, | ||||
|   Output, | ||||
|   QueryList, | ||||
| @@ -34,7 +35,7 @@ import { | ||||
|   CustomFieldQueryElement, | ||||
|   CustomFieldQueryExpression, | ||||
| } from 'src/app/utils/custom-field-query-element' | ||||
| import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||
| import { DocumentLinkComponent } from '../input/document-link/document-link.component' | ||||
| @@ -178,12 +179,14 @@ export class CustomFieldQueriesModel { | ||||
|   ], | ||||
| }) | ||||
| export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPermissions { | ||||
|   protected customFieldsService = inject(CustomFieldsService) | ||||
|  | ||||
|   public CustomFieldQueryComponentType = CustomFieldQueryElementType | ||||
|   public CustomFieldQueryOperator = CustomFieldQueryOperator | ||||
|   public CustomFieldDataType = CustomFieldDataType | ||||
|   public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH | ||||
|   public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS | ||||
|   public popperOptions = popperOptionsReenablePreventOverflow | ||||
|   public popperOptions = pngxPopperOptions | ||||
|  | ||||
|   @Input() | ||||
|   title: string | ||||
| @@ -243,9 +246,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm | ||||
|  | ||||
|   customFields: CustomField[] = [] | ||||
|  | ||||
|   public readonly today: string = new Date().toISOString().split('T')[0] | ||||
|   public readonly today: string = new Date().toLocaleDateString('en-CA') | ||||
|  | ||||
|   constructor(protected customFieldsService: CustomFieldsService) { | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.selectionModel = new CustomFieldQueriesModel() | ||||
|     this.getFields() | ||||
|   | ||||
| @@ -1,161 +1,158 @@ | ||||
| <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions"> | ||||
| <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement"> | ||||
|   <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> | ||||
|     <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs> | ||||
|     <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|     <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> | ||||
|   </button> | ||||
|   <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> | ||||
|     <div class="row d-flex"> | ||||
|       <div class="col border-end"> | ||||
|         <div class="list-group list-group-flush"> | ||||
|           <h6 class="dropdown-header border-bottom" i18n>Created</h6> | ||||
|           @for (rd of relativeDates; track rd) { | ||||
|             <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)"> | ||||
|               <div class="selected-icon"> | ||||
|                 @if (createdRelativeDate === rd.id) { | ||||
|                   <i-bs width="1em" height="1em" name="check"></i-bs> | ||||
|                 } | ||||
|               </div> | ||||
|               <div class="d-flex justify-content-between w-100 align-items-center ps-2"> | ||||
|                 <div class="pe-4"> | ||||
|                   {{rd.name}} | ||||
|                 </div> | ||||
|                 <div class="text-muted small pe-2"> | ||||
|                   <span class="small"> | ||||
|                     {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container> | ||||
|                   </span> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </button> | ||||
|     <h6 class="dropdown-header border-bottom" i18n>Created</h6> | ||||
|     <div class="list-group list-group-flush"> | ||||
|       <div class="list-group-item d-flex p-2 select-item" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (createdRelativeDate) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|  | ||||
|             <div class="selected-icon"> | ||||
|               @if (createdDateFrom) { | ||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> | ||||
|                   <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|                   <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|                 </a> | ||||
|               } | ||||
|             </div> | ||||
|             <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|               <span class="input-group-text w-25 small text-muted" i18n>From</span> | ||||
|               <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|                 maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate"> | ||||
|               <button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button"> | ||||
|                 <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|               </button> | ||||
|               <ng-template #createdFromFooterTemplate> | ||||
|                 <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|                   <button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button> | ||||
|                   <button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|             </div> | ||||
|  | ||||
|           </div> | ||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|  | ||||
|             <div class="selected-icon"> | ||||
|               @if (createdDateTo) { | ||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()"> | ||||
|                   <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|                   <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|                 </a> | ||||
|               } | ||||
|             </div> | ||||
|             <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|               <span class="input-group-text w-25 small text-muted" i18n>To</span> | ||||
|               <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|                 maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate"> | ||||
|               <button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button"> | ||||
|                 <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|               </button> | ||||
|               <ng-template #createdToFooterTemplate> | ||||
|                 <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|                   <button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button> | ||||
|                   <button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|             </div> | ||||
|  | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <ng-select class="w-100" name="createdRelativeDate" | ||||
|           [items]="relativeDates" [(ngModel)]="createdRelativeDate" | ||||
|           bindValue="id" | ||||
|           bindLabel="name" | ||||
|           clearable="false" | ||||
|           placeholder="Relative dates" | ||||
|           i18n-placeholder | ||||
|           (change)="onSetCreatedRelativeDate($event)"> | ||||
|           <ng-template ng-option-tmp let-item="item"> | ||||
|             <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container></span></div> | ||||
|           </ng-template> | ||||
|           </ng-select> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="col"> | ||||
|         <h6 class="dropdown-header border-bottom" i18n>Added</h6> | ||||
|         <div class="list-group list-group-flush"> | ||||
|           @for (rd of relativeDates; track rd) { | ||||
|             <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)"> | ||||
|               <div class="selected-icon"> | ||||
|                 @if (addedRelativeDate === rd.id) { | ||||
|                   <i-bs width="1em" height="1em" name="check"></i-bs> | ||||
|                 } | ||||
|               </div> | ||||
|               <div class="d-flex justify-content-between w-100 align-items-center ps-2"> | ||||
|                 <div class="pe-4"> | ||||
|                   {{rd.name}} | ||||
|                 </div> | ||||
|                 <div class="text-muted small pe-2"> | ||||
|                   <span class="small"> | ||||
|                     {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container> | ||||
|                   </span> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </button> | ||||
|       <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (createdDateFrom) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|  | ||||
|             <div class="selected-icon"> | ||||
|               @if (addedDateFrom) { | ||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> | ||||
|                   <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|                   <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|                 </a> | ||||
|               } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <span class="input-group-text w-25 small text-muted" i18n>From</span> | ||||
|           <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|             maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate"> | ||||
|           <button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button"> | ||||
|             <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|           </button> | ||||
|           <ng-template #createdFromFooterTemplate> | ||||
|             <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|               <button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button> | ||||
|               <button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button> | ||||
|             </div> | ||||
|             <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|               <span class="input-group-text w-25 small text-muted" i18n>From</span> | ||||
|               <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|                 maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate"> | ||||
|               <button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button"> | ||||
|                 <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|               </button> | ||||
|               <ng-template #addedFromFooterTemplate> | ||||
|                 <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|                   <button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button> | ||||
|                   <button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|           </ng-template> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (createdDateTo) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <span class="input-group-text w-25 small text-muted" i18n>To</span> | ||||
|           <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|             maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate"> | ||||
|           <button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button"> | ||||
|             <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|           </button> | ||||
|           <ng-template #createdToFooterTemplate> | ||||
|             <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|               <button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button> | ||||
|               <button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button> | ||||
|             </div> | ||||
|           </ng-template> | ||||
|         </div> | ||||
|  | ||||
|           </div> | ||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|  | ||||
|             <div class="selected-icon"> | ||||
|               @if (addedDateTo) { | ||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> | ||||
|                   <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|                   <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|                 </a> | ||||
|               } | ||||
|       </div> | ||||
|     </div> | ||||
|     <h6 class="dropdown-header border-bottom" i18n>Added</h6> | ||||
|     <div class="list-group list-group-flush"> | ||||
|       <div class="list-group-item d-flex p-2 select-item" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (addedRelativeDate) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <ng-select class="w-100" name="addedRelativeDate" | ||||
|             [items]="relativeDates" [(ngModel)]="addedRelativeDate" | ||||
|             bindValue="id" | ||||
|             bindLabel="name" | ||||
|             clearable="false" | ||||
|             placeholder="Relative dates" | ||||
|             i18n-placeholder | ||||
|             (change)="onSetAddedRelativeDate($event)"> | ||||
|             <ng-template ng-option-tmp let-item="item"> | ||||
|               <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container></span></div> | ||||
|             </ng-template> | ||||
|           </ng-select> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (addedDateFrom) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <span class="input-group-text w-25 small text-muted" i18n>From</span> | ||||
|           <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|             maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate"> | ||||
|           <button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button"> | ||||
|             <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|           </button> | ||||
|           <ng-template #addedFromFooterTemplate> | ||||
|             <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|               <button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button> | ||||
|               <button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button> | ||||
|             </div> | ||||
|             <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|               <span class="input-group-text w-25 small text-muted" i18n>To</span> | ||||
|               <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|                 maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate"> | ||||
|               <button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button"> | ||||
|                 <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|               </button> | ||||
|               <ng-template #addedToFooterTemplate> | ||||
|                 <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|                   <button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button> | ||||
|                   <button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|           </ng-template> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (addedDateTo) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <span class="input-group-text w-25 small text-muted" i18n>To</span> | ||||
|           <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|             maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate"> | ||||
|           <button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button"> | ||||
|             <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|           </button> | ||||
|           <ng-template #addedToFooterTemplate> | ||||
|             <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|               <button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button> | ||||
|               <button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button> | ||||
|             </div> | ||||
|  | ||||
|           </div> | ||||
|           </ng-template> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   | ||||
| @@ -1,16 +1,7 @@ | ||||
| .date-dropdown { | ||||
|   --bs-dropdown-min-width: 22rem; | ||||
|   white-space: nowrap; | ||||
|  | ||||
|   @media(min-width: 768px) { | ||||
|     --bs-dropdown-min-width: 40rem; | ||||
|   } | ||||
|  | ||||
|   @media screen and (max-width: 767px) { | ||||
|     .border-end { | ||||
|       border: none !important; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .btn-link { | ||||
|     line-height: 1; | ||||
|   } | ||||
| @@ -21,6 +12,10 @@ | ||||
|   min-height: 1em; | ||||
| } | ||||
|  | ||||
| .select-item .selected-icon { | ||||
|   line-height: 2em; | ||||
| } | ||||
|  | ||||
| .input-group-sm { | ||||
|   .form-control { | ||||
|     font-size: 0.875rem; | ||||
|   | ||||
| @@ -82,10 +82,12 @@ describe('DatesDropdownComponent', () => { | ||||
|   it('should support relative dates', fakeAsync(() => { | ||||
|     let result: DateSelection | ||||
|     component.datesSet.subscribe((date) => (result = date)) | ||||
|     component.setCreatedRelativeDate(null) | ||||
|     component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK) | ||||
|     component.setAddedRelativeDate(null) | ||||
|     component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK) | ||||
|     component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown | ||||
|     component.onSetCreatedRelativeDate({ | ||||
|       id: RelativeDate.WITHIN_1_WEEK, | ||||
|     } as any) | ||||
|     component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown | ||||
|     component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any) | ||||
|     tick(500) | ||||
|     expect(result).toEqual({ | ||||
|       createdFrom: null, | ||||
| @@ -147,8 +149,19 @@ describe('DatesDropdownComponent', () => { | ||||
|     expect(component.addedDateTo).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should support clearRelativeDate', () => { | ||||
|     component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK | ||||
|     component.clearCreatedRelativeDate() | ||||
|     expect(component.createdRelativeDate).toBeNull() | ||||
|  | ||||
|     component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK | ||||
|     component.clearAddedRelativeDate() | ||||
|     expect(component.addedRelativeDate).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should limit keyboard events', () => { | ||||
|     const input: HTMLInputElement = fixture.nativeElement.querySelector('input') | ||||
|     const input: HTMLInputElement = | ||||
|       fixture.nativeElement.querySelector('input.form-control') | ||||
|     let event: KeyboardEvent = new KeyboardEvent('keypress', { | ||||
|       key: '9', | ||||
|     }) | ||||
| @@ -163,4 +176,19 @@ describe('DatesDropdownComponent', () => { | ||||
|     input.dispatchEvent(event) | ||||
|     expect(eventSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support debounce', fakeAsync(() => { | ||||
|     let result: DateSelection | ||||
|     component.datesSet.subscribe((date) => (result = date)) | ||||
|     component.onChangeDebounce() | ||||
|     tick(500) | ||||
|     expect(result).toEqual({ | ||||
|       createdFrom: null, | ||||
|       createdTo: null, | ||||
|       createdRelativeDateID: null, | ||||
|       addedFrom: null, | ||||
|       addedTo: null, | ||||
|       addedRelativeDateID: null, | ||||
|     }) | ||||
|   })) | ||||
| }) | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { | ||||
|   OnDestroy, | ||||
|   OnInit, | ||||
|   Output, | ||||
|   inject, | ||||
| } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { | ||||
| @@ -13,13 +14,14 @@ import { | ||||
|   NgbDatepickerModule, | ||||
|   NgbDropdownModule, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Subject, Subscription } from 'rxjs' | ||||
| import { debounceTime } from 'rxjs/operators' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' | ||||
| import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | ||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||
|  | ||||
| export interface DateSelection { | ||||
| @@ -32,10 +34,14 @@ export interface DateSelection { | ||||
| } | ||||
|  | ||||
| export enum RelativeDate { | ||||
|   WITHIN_1_WEEK = 0, | ||||
|   WITHIN_1_MONTH = 1, | ||||
|   WITHIN_3_MONTHS = 2, | ||||
|   WITHIN_1_YEAR = 3, | ||||
|   WITHIN_1_WEEK = 1, | ||||
|   WITHIN_1_MONTH = 2, | ||||
|   WITHIN_3_MONTHS = 3, | ||||
|   WITHIN_1_YEAR = 4, | ||||
|   THIS_YEAR = 5, | ||||
|   THIS_MONTH = 6, | ||||
|   TODAY = 7, | ||||
|   YESTERDAY = 8, | ||||
| } | ||||
|  | ||||
| @Component({ | ||||
| @@ -49,15 +55,18 @@ export enum RelativeDate { | ||||
|     NgxBootstrapIconsModule, | ||||
|     NgbDatepickerModule, | ||||
|     NgbDropdownModule, | ||||
|     NgSelectModule, | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgClass, | ||||
|   ], | ||||
| }) | ||||
| export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|   public popperOptions = popperOptionsReenablePreventOverflow | ||||
|   public popperOptions = pngxPopperOptions | ||||
|  | ||||
|   constructor() { | ||||
|     const settings = inject(SettingsService) | ||||
|  | ||||
|   constructor(settings: SettingsService) { | ||||
|     this.datePlaceHolder = settings.getLocalizedDateInputFormat() | ||||
|   } | ||||
|  | ||||
| @@ -82,44 +91,64 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|       name: $localize`Within 1 year`, | ||||
|       date: new Date().setFullYear(new Date().getFullYear() - 1), | ||||
|     }, | ||||
|     { | ||||
|       id: RelativeDate.THIS_YEAR, | ||||
|       name: $localize`This year`, | ||||
|       date: new Date('1/1/' + new Date().getFullYear()), | ||||
|     }, | ||||
|     { | ||||
|       id: RelativeDate.THIS_MONTH, | ||||
|       name: $localize`This month`, | ||||
|       date: new Date().setDate(1), | ||||
|     }, | ||||
|     { | ||||
|       id: RelativeDate.TODAY, | ||||
|       name: $localize`Today`, | ||||
|       date: new Date().setHours(0, 0, 0, 0), | ||||
|     }, | ||||
|     { | ||||
|       id: RelativeDate.YESTERDAY, | ||||
|       name: $localize`Yesterday`, | ||||
|       date: new Date().setDate(new Date().getDate() - 1), | ||||
|     }, | ||||
|   ] | ||||
|  | ||||
|   datePlaceHolder: string | ||||
|  | ||||
|   // created | ||||
|   @Input() | ||||
|   createdDateTo: string | ||||
|   createdDateTo: string = null | ||||
|  | ||||
|   @Output() | ||||
|   createdDateToChange = new EventEmitter<string>() | ||||
|  | ||||
|   @Input() | ||||
|   createdDateFrom: string | ||||
|   createdDateFrom: string = null | ||||
|  | ||||
|   @Output() | ||||
|   createdDateFromChange = new EventEmitter<string>() | ||||
|  | ||||
|   @Input() | ||||
|   createdRelativeDate: RelativeDate | ||||
|   createdRelativeDate: RelativeDate = null | ||||
|  | ||||
|   @Output() | ||||
|   createdRelativeDateChange = new EventEmitter<number>() | ||||
|  | ||||
|   // added | ||||
|   @Input() | ||||
|   addedDateTo: string | ||||
|   addedDateTo: string = null | ||||
|  | ||||
|   @Output() | ||||
|   addedDateToChange = new EventEmitter<string>() | ||||
|  | ||||
|   @Input() | ||||
|   addedDateFrom: string | ||||
|   addedDateFrom: string = null | ||||
|  | ||||
|   @Output() | ||||
|   addedDateFromChange = new EventEmitter<string>() | ||||
|  | ||||
|   @Input() | ||||
|   addedRelativeDate: RelativeDate | ||||
|   addedRelativeDate: RelativeDate = null | ||||
|  | ||||
|   @Output() | ||||
|   addedRelativeDateChange = new EventEmitter<number>() | ||||
| @@ -133,7 +162,10 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|   @Input() | ||||
|   disabled: boolean = false | ||||
|  | ||||
|   public readonly today: string = new Date().toISOString().split('T')[0] | ||||
|   @Input() | ||||
|   placement: string = 'bottom-start' | ||||
|  | ||||
|   public readonly today: string = new Date().toLocaleDateString('en-CA') | ||||
|  | ||||
|   get isActive(): boolean { | ||||
|     return ( | ||||
| @@ -172,17 +204,17 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   setCreatedRelativeDate(rd: RelativeDate) { | ||||
|   onSetCreatedRelativeDate(rd: { id: number; name: string; date: number }) { | ||||
|     // createdRelativeDate is set by ngModel | ||||
|     this.createdDateTo = null | ||||
|     this.createdDateFrom = null | ||||
|     this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   setAddedRelativeDate(rd: RelativeDate) { | ||||
|   onSetAddedRelativeDate(rd: { id: number; name: string; date: number }) { | ||||
|     // addedRelativeDate is set by ngModel | ||||
|     this.addedDateTo = null | ||||
|     this.addedDateFrom = null | ||||
|     this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
| @@ -224,6 +256,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   clearCreatedRelativeDate() { | ||||
|     this.createdRelativeDate = null | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   clearAddedTo() { | ||||
|     this.addedDateTo = null | ||||
|     this.onChange() | ||||
| @@ -234,6 +271,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   clearAddedRelativeDate() { | ||||
|     this.addedRelativeDate = null | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   // prevent chars other than numbers and separators | ||||
|   onKeyPress(event: KeyboardEvent) { | ||||
|     if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) { | ||||
|   | ||||
| @@ -13,8 +13,6 @@ | ||||
|     <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|     @if (patternRequired) { | ||||
|       <pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> | ||||
|     } | ||||
|     @if (patternRequired) { | ||||
|       <pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></pngx-input-check> | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||
| import { Correspondent } from 'src/app/data/correspondent' | ||||
| import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | ||||
| @@ -13,6 +12,7 @@ import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' | ||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { CheckComponent } from '../../input/check/check.component' | ||||
| import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component' | ||||
| import { SelectComponent } from '../../input/select/select.component' | ||||
| import { TextComponent } from '../../input/text/text.component' | ||||
| @@ -22,6 +22,7 @@ import { TextComponent } from '../../input/text/text.component' | ||||
|   templateUrl: './correspondent-edit-dialog.component.html', | ||||
|   styleUrls: ['./correspondent-edit-dialog.component.scss'], | ||||
|   imports: [ | ||||
|     CheckComponent, | ||||
|     SelectComponent, | ||||
|     PermissionsFormComponent, | ||||
|     TextComponent, | ||||
| @@ -31,13 +32,11 @@ import { TextComponent } from '../../input/text/text.component' | ||||
|   ], | ||||
| }) | ||||
| export class CorrespondentEditDialogComponent extends EditDialogComponent<Correspondent> { | ||||
|   constructor( | ||||
|     service: CorrespondentService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(CorrespondentService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import { | ||||
|   OnInit, | ||||
|   QueryList, | ||||
|   ViewChildren, | ||||
|   inject, | ||||
| } from '@angular/core' | ||||
| import { | ||||
|   FormArray, | ||||
| @@ -13,7 +14,6 @@ import { | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { takeUntil } from 'rxjs' | ||||
| import { | ||||
| @@ -54,13 +54,11 @@ export class CustomFieldEditDialogComponent | ||||
|       .select_options as FormArray | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     service: CustomFieldsService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(CustomFieldsService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|   | ||||
| @@ -14,8 +14,6 @@ | ||||
|       <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|       @if (patternRequired) { | ||||
|         <pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> | ||||
|       } | ||||
|       @if (patternRequired) { | ||||
|         <pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check> | ||||
|       } | ||||
|     </div> | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||
| import { DocumentType } from 'src/app/data/document-type' | ||||
| import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | ||||
| @@ -13,6 +12,7 @@ import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' | ||||
| import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { CheckComponent } from '../../input/check/check.component' | ||||
| import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component' | ||||
| import { SelectComponent } from '../../input/select/select.component' | ||||
| import { TextComponent } from '../../input/text/text.component' | ||||
| @@ -22,6 +22,7 @@ import { TextComponent } from '../../input/text/text.component' | ||||
|   templateUrl: './document-type-edit-dialog.component.html', | ||||
|   styleUrls: ['./document-type-edit-dialog.component.scss'], | ||||
|   imports: [ | ||||
|     CheckComponent, | ||||
|     SelectComponent, | ||||
|     PermissionsFormComponent, | ||||
|     TextComponent, | ||||
| @@ -31,13 +32,11 @@ import { TextComponent } from '../../input/text/text.component' | ||||
|   ], | ||||
| }) | ||||
| export class DocumentTypeEditDialogComponent extends EditDialogComponent<DocumentType> { | ||||
|   constructor( | ||||
|     service: DocumentTypeService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(DocumentTypeService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
|   | ||||
| @@ -41,13 +41,9 @@ import { EditDialogComponent, EditDialogMode } from './edit-dialog.component' | ||||
|   imports: [FormsModule, ReactiveFormsModule], | ||||
| }) | ||||
| class TestComponent extends EditDialogComponent<Tag> { | ||||
|   constructor( | ||||
|     service: TagService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = TestBed.inject(TagService) | ||||
|   } | ||||
|  | ||||
|   getForm(): FormGroup<any> { | ||||
|   | ||||
| @@ -1,4 +1,11 @@ | ||||
| import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core' | ||||
| import { | ||||
|   Directive, | ||||
|   EventEmitter, | ||||
|   Input, | ||||
|   OnInit, | ||||
|   Output, | ||||
|   inject, | ||||
| } from '@angular/core' | ||||
| import { FormGroup } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { Observable } from 'rxjs' | ||||
| @@ -29,14 +36,12 @@ export abstract class EditDialogComponent< | ||||
|   extends LoadingComponentWithPermissions | ||||
|   implements OnInit | ||||
| { | ||||
|   constructor( | ||||
|     protected service: AbstractPaperlessService<T>, | ||||
|     private activeModal: NgbActiveModal, | ||||
|     private userService: UserService, | ||||
|     protected settingsService: SettingsService | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|   protected service = inject<AbstractPaperlessService<T>>( | ||||
|     AbstractPaperlessService | ||||
|   ) | ||||
|   protected activeModal = inject(NgbActiveModal) | ||||
|   protected userService = inject(UserService) | ||||
|   protected settingsService = inject(SettingsService) | ||||
|  | ||||
|   users: User[] | ||||
|  | ||||
| @@ -47,7 +52,7 @@ export abstract class EditDialogComponent< | ||||
|   object: T | ||||
|  | ||||
|   @Output() | ||||
|   succeeded = new EventEmitter() | ||||
|   succeeded = new EventEmitter<T>() | ||||
|  | ||||
|   @Output() | ||||
|   failed = new EventEmitter() | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||
| import { Group } from 'src/app/data/group' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| @@ -26,13 +25,11 @@ import { PermissionsSelectComponent } from '../../permissions-select/permissions | ||||
|   ], | ||||
| }) | ||||
| export class GroupEditDialogComponent extends EditDialogComponent<Group> { | ||||
|   constructor( | ||||
|     service: GroupService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(GroupService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -46,7 +43,7 @@ export class GroupEditDialogComponent extends EditDialogComponent<Group> { | ||||
|   getForm(): FormGroup { | ||||
|     return new FormGroup({ | ||||
|       name: new FormControl(''), | ||||
|       permissions: new FormControl(null), | ||||
|       permissions: new FormControl([]), | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,15 +1,11 @@ | ||||
| import { Component, ViewChild } from '@angular/core' | ||||
| import { Component, ViewChild, inject } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { | ||||
|   NgbActiveModal, | ||||
|   NgbAlert, | ||||
|   NgbAlertModule, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||
| import { IMAPSecurity, MailAccount } from 'src/app/data/mail-account' | ||||
| import { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||
| @@ -47,13 +43,11 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco | ||||
|  | ||||
|   @ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert | ||||
|  | ||||
|   constructor( | ||||
|     service: MailAccountService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(MailAccountService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { first } from 'rxjs' | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||
| import { Correspondent } from 'src/app/data/correspondent' | ||||
| @@ -155,32 +154,34 @@ const METADATA_CORRESPONDENT_OPTIONS = [ | ||||
|   ], | ||||
| }) | ||||
| export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> { | ||||
|   private accountService: MailAccountService | ||||
|   private correspondentService: CorrespondentService | ||||
|   private documentTypeService: DocumentTypeService | ||||
|  | ||||
|   accounts: MailAccount[] | ||||
|   correspondents: Correspondent[] | ||||
|   documentTypes: DocumentType[] | ||||
|  | ||||
|   constructor( | ||||
|     service: MailRuleService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     accountService: MailAccountService, | ||||
|     correspondentService: CorrespondentService, | ||||
|     documentTypeService: DocumentTypeService, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(MailRuleService) | ||||
|     this.accountService = inject(MailAccountService) | ||||
|     this.correspondentService = inject(CorrespondentService) | ||||
|     this.documentTypeService = inject(DocumentTypeService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|  | ||||
|     accountService | ||||
|     this.accountService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.accounts = result.results)) | ||||
|  | ||||
|     correspondentService | ||||
|     this.correspondentService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.correspondents = result.results)) | ||||
|  | ||||
|     documentTypeService | ||||
|     this.documentTypeService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.documentTypes = result.results)) | ||||
|   | ||||
| @@ -64,8 +64,6 @@ | ||||
|     <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|     @if (patternRequired) { | ||||
|       <pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> | ||||
|     } | ||||
|     @if (patternRequired) { | ||||
|       <pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check> | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { AsyncPipe, NgTemplateOutlet } from '@angular/common' | ||||
| import { Component, OnDestroy } from '@angular/core' | ||||
| import { Component, OnDestroy, inject } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbAccordionModule, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectComponent } from '@ng-select/ng-select' | ||||
| import { | ||||
|   Observable, | ||||
| @@ -60,6 +60,8 @@ export class StoragePathEditDialogComponent | ||||
|   extends EditDialogComponent<StoragePath> | ||||
|   implements OnDestroy | ||||
| { | ||||
|   private documentsService = inject(DocumentService) | ||||
|  | ||||
|   public documentsInput$ = new Subject<string>() | ||||
|   public foundDocuments$: Observable<Document[]> | ||||
|   private testDocument: Document | ||||
| @@ -68,14 +70,11 @@ export class StoragePathEditDialogComponent | ||||
|   public loading = false | ||||
|   public testLoading = false | ||||
|  | ||||
|   constructor( | ||||
|     service: StoragePathService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService, | ||||
|     private documentsService: DocumentService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(StoragePathService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|     this.initPathObservables() | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -18,8 +18,6 @@ | ||||
|     <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|     @if (patternRequired) { | ||||
|       <pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> | ||||
|     } | ||||
|     @if (patternRequired) { | ||||
|       <pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check> | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||
| import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | ||||
| import { Tag } from 'src/app/data/tag' | ||||
| @@ -38,14 +37,11 @@ import { TextComponent } from '../../input/text/text.component' | ||||
| export class TagEditDialogComponent extends EditDialogComponent<Tag> { | ||||
|   tags: Tag[] | ||||
|  | ||||
|   constructor( | ||||
|     service: TagService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(TagService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|     this.service.listAll().subscribe((result) => { | ||||
|       this.tags = result.results | ||||
|     }) | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import { Component, OnInit } from '@angular/core' | ||||
| import { Component, OnInit, inject } from '@angular/core' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { first } from 'rxjs' | ||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||
| import { Group } from 'src/app/data/group' | ||||
| @@ -37,21 +36,21 @@ export class UserEditDialogComponent | ||||
|   extends EditDialogComponent<User> | ||||
|   implements OnInit | ||||
| { | ||||
|   private toastService = inject(ToastService) | ||||
|   private permissionsService = inject(PermissionsService) | ||||
|   private groupsService: GroupService | ||||
|  | ||||
|   groups: Group[] | ||||
|   passwordIsSet: boolean = false | ||||
|   public totpLoading: boolean = false | ||||
|  | ||||
|   constructor( | ||||
|     service: UserService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     groupsService: GroupService, | ||||
|     settingsService: SettingsService, | ||||
|     private toastService: ToastService, | ||||
|     private permissionsService: PermissionsService | ||||
|   ) { | ||||
|     super(service, activeModal, service, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(UserService) | ||||
|     this.groupsService = inject(GroupService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|  | ||||
|     groupsService | ||||
|     this.groupsService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.groups = result.results)) | ||||
|   | ||||
| @@ -123,7 +123,15 @@ | ||||
|       <p class="small" i18n>Set scheduled trigger offset and which date field to use.</p> | ||||
|       <div class="row"> | ||||
|         <div class="col-4"> | ||||
|           <pngx-input-number i18n-title title="Offset days" formControlName="schedule_offset_days" [showAdd]="false" [error]="error?.schedule_offset_days"></pngx-input-number> | ||||
|           <pngx-input-number | ||||
|             i18n-title | ||||
|             title="Offset days" | ||||
|             formControlName="schedule_offset_days" | ||||
|             [showAdd]="false" | ||||
|             [error]="error?.schedule_offset_days" | ||||
|             hint="Positive values will trigger after the date, negative values before." | ||||
|             i18n-hint | ||||
|           ></pngx-input-number> | ||||
|         </div> | ||||
|         <div class="col-4"> | ||||
|           <pngx-input-select i18n-title title="Relative to" formControlName="schedule_date_field" [items]="scheduleDateFieldOptions" [error]="error?.schedule_date_field"></pngx-input-select> | ||||
| @@ -189,6 +197,7 @@ | ||||
|             <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 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> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|             <pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select> | ||||
|   | ||||
| @@ -2,7 +2,12 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop' | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { of } from 'rxjs' | ||||
| @@ -369,4 +374,19 @@ describe('WorkflowEditDialogComponent', () => { | ||||
|     expect(component.objectForm.get('actions').value[0].email).toBeNull() | ||||
|     expect(component.objectForm.get('actions').value[0].webhook).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should remove selected custom field from the form group', () => { | ||||
|     const formGroup = new FormGroup({ | ||||
|       assign_custom_fields: new FormControl([1, 2, 3]), | ||||
|     }) | ||||
|  | ||||
|     component.removeSelectedCustomField(2, formGroup) | ||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([1, 3]) | ||||
|  | ||||
|     component.removeSelectedCustomField(1, formGroup) | ||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([3]) | ||||
|  | ||||
|     component.removeSelectedCustomField(3, formGroup) | ||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([]) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { | ||||
|   moveItemInArray, | ||||
| } from '@angular/cdk/drag-drop' | ||||
| import { NgTemplateOutlet } from '@angular/common' | ||||
| import { Component, OnInit } from '@angular/core' | ||||
| import { Component, OnInit, inject } from '@angular/core' | ||||
| import { | ||||
|   FormArray, | ||||
|   FormControl, | ||||
| @@ -12,7 +12,7 @@ import { | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbAccordionModule, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { first } from 'rxjs' | ||||
| import { Correspondent } from 'src/app/data/correspondent' | ||||
| @@ -47,6 +47,7 @@ import { WorkflowService } from 'src/app/services/rest/workflow.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' | ||||
| import { CheckComponent } from '../../input/check/check.component' | ||||
| import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component' | ||||
| import { EntriesComponent } from '../../input/entries/entries.component' | ||||
| import { NumberComponent } from '../../input/number/number.component' | ||||
| import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' | ||||
| @@ -71,6 +72,10 @@ export const DOCUMENT_SOURCE_OPTIONS = [ | ||||
|     id: DocumentSource.MailFetch, | ||||
|     name: $localize`Mail Fetch`, | ||||
|   }, | ||||
|   { | ||||
|     id: DocumentSource.WebUI, | ||||
|     name: $localize`Web UI`, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| export const SCHEDULE_DATE_FIELD_OPTIONS = [ | ||||
| @@ -147,6 +152,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( | ||||
|     SelectComponent, | ||||
|     TextAreaComponent, | ||||
|     TagsComponent, | ||||
|     CustomFieldsValuesComponent, | ||||
|     PermissionsGroupComponent, | ||||
|     PermissionsUserComponent, | ||||
|     ConfirmButtonComponent, | ||||
| @@ -165,6 +171,12 @@ export class WorkflowEditDialogComponent | ||||
|   public WorkflowTriggerType = WorkflowTriggerType | ||||
|   public WorkflowActionType = WorkflowActionType | ||||
|  | ||||
|   private correspondentService: CorrespondentService | ||||
|   private documentTypeService: DocumentTypeService | ||||
|   private storagePathService: StoragePathService | ||||
|   private mailRuleService: MailRuleService | ||||
|   private customFieldsService: CustomFieldsService | ||||
|  | ||||
|   templates: Workflow[] | ||||
|   correspondents: Correspondent[] | ||||
|   documentTypes: DocumentType[] | ||||
| @@ -177,40 +189,38 @@ export class WorkflowEditDialogComponent | ||||
|  | ||||
|   private allowedActionTypes = [] | ||||
|  | ||||
|   constructor( | ||||
|     service: WorkflowService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     correspondentService: CorrespondentService, | ||||
|     documentTypeService: DocumentTypeService, | ||||
|     storagePathService: StoragePathService, | ||||
|     mailRuleService: MailRuleService, | ||||
|     userService: UserService, | ||||
|     settingsService: SettingsService, | ||||
|     customFieldsService: CustomFieldsService | ||||
|   ) { | ||||
|     super(service, activeModal, userService, settingsService) | ||||
|   constructor() { | ||||
|     super() | ||||
|     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.settingsService = inject(SettingsService) | ||||
|     this.customFieldsService = inject(CustomFieldsService) | ||||
|  | ||||
|     correspondentService | ||||
|     this.correspondentService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.correspondents = result.results)) | ||||
|  | ||||
|     documentTypeService | ||||
|     this.documentTypeService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.documentTypes = result.results)) | ||||
|  | ||||
|     storagePathService | ||||
|     this.storagePathService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.storagePaths = result.results)) | ||||
|  | ||||
|     mailRuleService | ||||
|     this.mailRuleService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.mailRules = result.results)) | ||||
|  | ||||
|     customFieldsService | ||||
|     this.customFieldsService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => { | ||||
| @@ -435,6 +445,9 @@ export class WorkflowEditDialogComponent | ||||
|         assign_change_users: new FormControl(action.assign_change_users), | ||||
|         assign_change_groups: new FormControl(action.assign_change_groups), | ||||
|         assign_custom_fields: new FormControl(action.assign_custom_fields), | ||||
|         assign_custom_fields_values: new FormControl( | ||||
|           action.assign_custom_fields_values | ||||
|         ), | ||||
|         remove_tags: new FormControl(action.remove_tags), | ||||
|         remove_all_tags: new FormControl(action.remove_all_tags), | ||||
|         remove_document_types: new FormControl(action.remove_document_types), | ||||
| @@ -561,6 +574,7 @@ export class WorkflowEditDialogComponent | ||||
|       assign_change_users: [], | ||||
|       assign_change_groups: [], | ||||
|       assign_custom_fields: [], | ||||
|       assign_custom_fields_values: {}, | ||||
|       remove_tags: [], | ||||
|       remove_all_tags: false, | ||||
|       remove_document_types: [], | ||||
| @@ -639,4 +653,12 @@ export class WorkflowEditDialogComponent | ||||
|       }) | ||||
|     super.save() | ||||
|   } | ||||
|  | ||||
|   public removeSelectedCustomField(fieldId: number, group: FormGroup) { | ||||
|     group | ||||
|       .get('assign_custom_fields') | ||||
|       .setValue( | ||||
|         group.get('assign_custom_fields').value.filter((id) => id !== fieldId) | ||||
|       ) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,32 @@ | ||||
| <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|     <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|     <div class="mb-1"> | ||||
|         <label for="email" class="form-label" i18n>Email address(es)</label> | ||||
|         <input type="email" class="form-control" id="email" [(ngModel)]="emailAddress"> | ||||
|     </div> | ||||
|     <div class="mb-1"> | ||||
|         <label for="email" class="form-label" i18n>Subject</label> | ||||
|         <input type="email" class="form-control" id="subject" [(ngModel)]="emailSubject"> | ||||
|     </div> | ||||
|     <div> | ||||
|         <label for="message" class="form-label" i18n>Message</label> | ||||
|         <textarea class="form-control" id="message" rows="3" [(ngModel)]="emailMessage"></textarea> | ||||
|     </div> | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|     <div class="input-group"> | ||||
|         <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"> | ||||
|             <label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label> | ||||
|         </div> | ||||
|         <button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0"> | ||||
|             @if (loading) { | ||||
|                 <div class="spinner-border spinner-border-sm me-2" role="status"></div> | ||||
|             } | ||||
|             <ng-container i18n>Send email</ng-container> | ||||
|         </button> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -0,0 +1,72 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
|  | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { of, throwError } from 'rxjs' | ||||
| import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { EmailDocumentDialogComponent } from './email-document-dialog.component' | ||||
|  | ||||
| describe('EmailDocumentDialogComponent', () => { | ||||
|   let component: EmailDocumentDialogComponent | ||||
|   let fixture: ComponentFixture<EmailDocumentDialogComponent> | ||||
|   let documentService: DocumentService | ||||
|   let permissionsService: PermissionsService | ||||
|   let toastService: ToastService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [ | ||||
|         EmailDocumentDialogComponent, | ||||
|         IfPermissionsDirective, | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|       ], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|         NgbActiveModal, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(EmailDocumentDialogComponent) | ||||
|     documentService = TestBed.inject(DocumentService) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should set hasArchiveVersion and useArchiveVersion', () => { | ||||
|     expect(component.hasArchiveVersion).toBeTruthy() | ||||
|     component.hasArchiveVersion = false | ||||
|     expect(component.hasArchiveVersion).toBeFalsy() | ||||
|     expect(component.useArchiveVersion).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should support sending document via email, showing error if needed', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     component.emailAddress = 'hello@paperless-ngx.com' | ||||
|     component.emailSubject = 'Hello' | ||||
|     component.emailMessage = 'World' | ||||
|     jest | ||||
|       .spyOn(documentService, 'emailDocument') | ||||
|       .mockReturnValue(throwError(() => new Error('Unable to email document'))) | ||||
|     component.emailDocument() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|  | ||||
|     jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true)) | ||||
|     component.emailDocument() | ||||
|     expect(toastSuccessSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should close the dialog', () => { | ||||
|     const activeModal = TestBed.inject(NgbActiveModal) | ||||
|     const closeSpy = jest.spyOn(activeModal, 'close') | ||||
|     component.close() | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,78 @@ | ||||
| import { Component, Input, inject } from '@angular/core' | ||||
| import { FormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-email-document-dialog', | ||||
|   templateUrl: './email-document-dialog.component.html', | ||||
|   styleUrl: './email-document-dialog.component.scss', | ||||
|   imports: [FormsModule, NgxBootstrapIconsModule], | ||||
| }) | ||||
| export class EmailDocumentDialogComponent extends LoadingComponentWithPermissions { | ||||
|   private activeModal = inject(NgbActiveModal) | ||||
|   private documentService = inject(DocumentService) | ||||
|   private toastService = inject(ToastService) | ||||
|  | ||||
|   @Input() | ||||
|   title = $localize`Email Document` | ||||
|  | ||||
|   @Input() | ||||
|   documentId: number | ||||
|  | ||||
|   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 emailSubject: string = '' | ||||
|   public emailMessage: string = '' | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.loading = false | ||||
|   } | ||||
|  | ||||
|   public emailDocument() { | ||||
|     this.loading = true | ||||
|     this.documentService | ||||
|       .emailDocument( | ||||
|         this.documentId, | ||||
|         this.emailAddress, | ||||
|         this.emailSubject, | ||||
|         this.emailMessage, | ||||
|         this.useArchiveVersion | ||||
|       ) | ||||
|       .subscribe({ | ||||
|         next: () => { | ||||
|           this.loading = false | ||||
|           this.emailAddress = '' | ||||
|           this.emailSubject = '' | ||||
|           this.emailMessage = '' | ||||
|           this.close() | ||||
|           this.toastService.showInfo($localize`Email sent`) | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.loading = false | ||||
|           this.toastService.showError($localize`Error emailing document`, e) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public close() { | ||||
|     this.activeModal.close() | ||||
|   } | ||||
| } | ||||
| @@ -30,7 +30,7 @@ | ||||
|       } | ||||
|       <div class="list-group-item"> | ||||
|         <div class="input-group input-group-sm"> | ||||
|           <input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput> | ||||
|           <input class="form-control" type="text" spellcheck="false" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput> | ||||
|         </div> | ||||
|       </div> | ||||
|       @if (selectionModel.items) { | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import { | ||||
|   tick, | ||||
| } from '@angular/core/testing' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' | ||||
| import { | ||||
|   DEFAULT_MATCHING_ALGORITHM, | ||||
|   MATCH_ALL, | ||||
| @@ -44,6 +45,11 @@ const nullItem = { | ||||
|   name: 'Not assigned', | ||||
| } | ||||
|  | ||||
| const negativeNullItem = { | ||||
|   id: NEGATIVE_NULL_FILTER_VALUE, | ||||
|   name: 'Not assigned', | ||||
| } | ||||
|  | ||||
| let selectionModel: FilterableDropdownSelectionModel | ||||
|  | ||||
| describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => { | ||||
| @@ -64,6 +70,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|     hotkeyService = TestBed.inject(HotKeyService) | ||||
|     fixture = TestBed.createComponent(FilterableDropdownComponent) | ||||
|     component = fixture.componentInstance | ||||
|     component.selectionModel = new FilterableDropdownSelectionModel() | ||||
|     selectionModel = new FilterableDropdownSelectionModel() | ||||
|   }) | ||||
|  | ||||
| @@ -74,7 +81,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should support reset', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel = selectionModel | ||||
|     selectionModel.set(items[0].id, ToggleableItemState.Selected) | ||||
|     expect(selectionModel.getSelectedItems()).toHaveLength(1) | ||||
| @@ -96,7 +103,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should emit change when items selected', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel = selectionModel | ||||
|     let newModel: FilterableDropdownSelectionModel | ||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||
| @@ -110,11 +117,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|     selectionModel.set(items[0].id, ToggleableItemState.NotSelected) | ||||
|     expect(newModel.getSelectedItems()).toEqual([]) | ||||
|  | ||||
|     expect(component.items).toEqual([nullItem, ...items]) | ||||
|     expect(component.selectionModel.items).toEqual([nullItem, ...items]) | ||||
|   }) | ||||
|  | ||||
|   it('should emit change when items excluded', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel = selectionModel | ||||
|     let newModel: FilterableDropdownSelectionModel | ||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||
| @@ -124,7 +131,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should emit change when items excluded', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel = selectionModel | ||||
|     let newModel: FilterableDropdownSelectionModel | ||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||
| @@ -139,8 +146,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should exclude items when excluded and not editing', () => { | ||||
|     component.items = items | ||||
|     component.manyToOne = true | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel.manyToOne = true | ||||
|     component.selectionModel = selectionModel | ||||
|     selectionModel.set(items[0].id, ToggleableItemState.Selected) | ||||
|     component.excludeClicked(items[0].id) | ||||
| @@ -149,8 +156,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should toggle when items excluded and editing', () => { | ||||
|     component.items = items | ||||
|     component.manyToOne = true | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel.manyToOne = true | ||||
|     component.editing = true | ||||
|     component.selectionModel = selectionModel | ||||
|     selectionModel.set(items[0].id, ToggleableItemState.NotSelected) | ||||
| @@ -160,8 +167,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should hide count for item if adding will increase size of set', () => { | ||||
|     component.items = items | ||||
|     component.manyToOne = true | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel.manyToOne = true | ||||
|     component.selectionModel = selectionModel | ||||
|     expect(component.hideCount(items[0])).toBeFalsy() | ||||
|     selectionModel.logicalOperator = LogicalOperator.Or | ||||
| @@ -170,7 +177,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|  | ||||
|   it('should enforce single select when editing', () => { | ||||
|     component.editing = true | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel = selectionModel | ||||
|     let newModel: FilterableDropdownSelectionModel | ||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||
| @@ -182,11 +189,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should support manyToOne selecting', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     selectionModel.manyToOne = false | ||||
|     component.selectionModel = selectionModel | ||||
|     component.manyToOne = true | ||||
|     expect(component.manyToOne).toBeTruthy() | ||||
|     component.selectionModel.manyToOne = true | ||||
|     expect(component.selectionModel.manyToOne).toBeTruthy() | ||||
|     let newModel: FilterableDropdownSelectionModel | ||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||
|  | ||||
| @@ -197,12 +204,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should dynamically enable / disable modifier toggle', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel = selectionModel | ||||
|     expect(component.modifierToggleEnabled).toBeTruthy() | ||||
|     selectionModel.toggle(null) | ||||
|     expect(component.modifierToggleEnabled).toBeFalsy() | ||||
|     component.manyToOne = true | ||||
|     component.selectionModel.manyToOne = true | ||||
|     expect(component.modifierToggleEnabled).toBeFalsy() | ||||
|     selectionModel.toggle(items[0].id) | ||||
|     selectionModel.toggle(items[1].id) | ||||
| @@ -210,7 +215,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should apply changes and close when apply button clicked', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     component.editing = true | ||||
|     component.selectionModel = selectionModel | ||||
| @@ -232,7 +237,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should apply on close if enabled', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     component.editing = true | ||||
|     component.applyOnClose = true | ||||
| @@ -250,7 +255,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     fixture.nativeElement | ||||
|       .querySelector('button') | ||||
| @@ -277,7 +282,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   })) | ||||
|  | ||||
|   it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     expect(component.selectionModel.getSelectedItems()).toEqual([]) | ||||
|     fixture.nativeElement | ||||
| @@ -297,7 +302,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   })) | ||||
|  | ||||
|   it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     component.editing = true | ||||
|     let applyResult: ChangedItems | ||||
| @@ -319,7 +324,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   })) | ||||
|  | ||||
|   it('should support arrow keyboard navigation', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     fixture.nativeElement | ||||
|       .querySelector('button') | ||||
| @@ -364,7 +369,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   })) | ||||
|  | ||||
|   it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     fixture.nativeElement | ||||
|       .querySelector('button') | ||||
| @@ -400,7 +405,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   })) | ||||
|  | ||||
|   it('should support arrow keyboard navigation after click', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     fixture.nativeElement | ||||
|       .querySelector('button') | ||||
| @@ -425,9 +430,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   })) | ||||
|  | ||||
|   it('should toggle logical operator', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     component.manyToOne = true | ||||
|     component.selectionModel.manyToOne = true | ||||
|     selectionModel.set(items[0].id, ToggleableItemState.Selected) | ||||
|     selectionModel.set(items[1].id, ToggleableItemState.Selected) | ||||
|     component.selectionModel = selectionModel | ||||
| @@ -454,7 +459,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   })) | ||||
|  | ||||
|   it('should toggle intersection include / exclude', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     selectionModel.set(items[0].id, ToggleableItemState.Selected) | ||||
|     selectionModel.set(items[1].id, ToggleableItemState.Selected) | ||||
| @@ -483,22 +488,53 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|     expect(changedResult.getExcludedItems()).toEqual(items) | ||||
|   })) | ||||
|  | ||||
|   it('selection model should sort items by state', () => { | ||||
|     component.items = items.concat([{ id: null, name: 'Null B' }]) | ||||
|   it('should update null item selection on toggleIntersection', () => { | ||||
|     component.selectionModel.items = items | ||||
|     component.selectionModel = selectionModel | ||||
|     component.selectionModel.intersection = Intersection.Include | ||||
|     component.selectionModel.set(null, ToggleableItemState.Selected) | ||||
|     component.selectionModel.intersection = Intersection.Exclude | ||||
|     component.selectionModel.toggleIntersection() | ||||
|     expect(component.selectionModel.getExcludedItems()).toEqual([ | ||||
|       negativeNullItem, | ||||
|     ]) | ||||
|  | ||||
|     component.selectionModel.intersection = Intersection.Include | ||||
|     component.selectionModel.toggleIntersection() | ||||
|     expect(component.selectionModel.getSelectedItems()).toEqual([nullItem]) | ||||
|   }) | ||||
|  | ||||
|   it('selection model should sort items by state', () => { | ||||
|     component.selectionModel = selectionModel | ||||
|     component.selectionModel.items = items.concat([{ id: 3, name: 'Item3' }]) | ||||
|     selectionModel.toggle(items[1].id) | ||||
|     selectionModel.apply() | ||||
|     expect(selectionModel.items.length).toEqual(4) | ||||
|     expect(selectionModel.items).toEqual([ | ||||
|       nullItem, | ||||
|       { id: null, name: 'Null B' }, | ||||
|       items[1], | ||||
|       { id: 3, name: 'Item3' }, | ||||
|       items[0], | ||||
|     ]) | ||||
|  | ||||
|     selectionModel.intersection = Intersection.Exclude | ||||
|     selectionModel.toggleIntersection() | ||||
|     selectionModel.apply() | ||||
|     expect(selectionModel.items).toEqual([ | ||||
|       negativeNullItem, | ||||
|       items[1], | ||||
|       { id: 3, name: 'Item3' }, | ||||
|       items[0], | ||||
|     ]) | ||||
|  | ||||
|     // coverage | ||||
|     selectionModel.items = selectionModel.items.reverse() | ||||
|     selectionModel.apply() | ||||
|   }) | ||||
|  | ||||
|   it('selection model should sort items by state and document counts = 0, if set', () => { | ||||
|     const tagA = { id: 4, name: 'Tag A' } | ||||
|     component.items = items.concat([tagA]) | ||||
|     component.selectionModel.items = items.concat([tagA]) | ||||
|     component.selectionModel = selectionModel | ||||
|     component.documentCounts = [ | ||||
|       { id: 1, document_count: 0 }, // Tag1 | ||||
| @@ -529,7 +565,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should set support create, keep open model and call createRef method', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     component.selectionModel = selectionModel | ||||
|     fixture.nativeElement | ||||
| @@ -549,7 +585,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   })) | ||||
|  | ||||
|   it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     component.editing = true | ||||
|     component.createRef = jest.fn() | ||||
| @@ -569,7 +605,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|     const id = 1 | ||||
|     const state = ToggleableItemState.Selected | ||||
|     component.selectionModel = selectionModel | ||||
|     component.manyToOne = true | ||||
|     component.selectionModel.manyToOne = true | ||||
|     component.selectionModel.singleSelect = true | ||||
|     component.selectionModel.intersection = Intersection.Include | ||||
|     component.selectionModel['temporarySelectionStates'].set(id, state) | ||||
| @@ -596,7 +632,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should support shortcut keys', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     component.shortcutKey = 't' | ||||
|     fixture.detectChanges() | ||||
| @@ -606,7 +642,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|   }) | ||||
|  | ||||
|   it('should support an extra button and not apply changes when clicked', () => { | ||||
|     component.items = items | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|     component.extraButtonTitle = 'Extra' | ||||
|     component.selectionModel = selectionModel | ||||
|   | ||||
| @@ -7,17 +7,19 @@ import { | ||||
|   OnInit, | ||||
|   Output, | ||||
|   ViewChild, | ||||
|   inject, | ||||
| } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Subject, filter, takeUntil } from 'rxjs' | ||||
| import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' | ||||
| import { MatchingModel } from 'src/app/data/matching-model' | ||||
| import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' | ||||
| import { FilterPipe } from 'src/app/pipes/filter.pipe' | ||||
| import { HotKeyService } from 'src/app/services/hot-key.service' | ||||
| import { SelectionDataItem } from 'src/app/services/rest/document.service' | ||||
| import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||
| import { | ||||
| @@ -61,15 +63,56 @@ export class FilterableDropdownSelectionModel { | ||||
|   } | ||||
|  | ||||
|   set items(items: MatchingModel[]) { | ||||
|     this._items = items | ||||
|     this.sortItems() | ||||
|     if (items) { | ||||
|       this._items = Array.from(items) | ||||
|       this.sortItems() | ||||
|       this.setNullItem() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private setNullItem() { | ||||
|     if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) { | ||||
|       if (this._items[0]?.id === null) { | ||||
|         this._items.shift() | ||||
|       } | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const item = { | ||||
|       name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`, | ||||
|       id: | ||||
|         this.manyToOne || this.intersection === Intersection.Include | ||||
|           ? null | ||||
|           : NEGATIVE_NULL_FILTER_VALUE, | ||||
|     } | ||||
|  | ||||
|     if ( | ||||
|       this._items[0]?.id === null || | ||||
|       this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE | ||||
|     ) { | ||||
|       this._items[0] = item | ||||
|     } else if (this._items) { | ||||
|       this._items.unshift(item) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   constructor(manyToOne: boolean = false) { | ||||
|     this.manyToOne = manyToOne | ||||
|   } | ||||
|  | ||||
|   private sortItems() { | ||||
|     this._items.sort((a, b) => { | ||||
|       if (a.id == null && b.id != null) { | ||||
|       if ( | ||||
|         (a.id == null && b.id != null) || | ||||
|         (a.id == NEGATIVE_NULL_FILTER_VALUE && | ||||
|           b.id != NEGATIVE_NULL_FILTER_VALUE) | ||||
|       ) { | ||||
|         return -1 | ||||
|       } else if (a.id != null && b.id == null) { | ||||
|       } else if ( | ||||
|         (a.id != null && b.id == null) || | ||||
|         (a.id != NEGATIVE_NULL_FILTER_VALUE && | ||||
|           b.id == NEGATIVE_NULL_FILTER_VALUE) | ||||
|       ) { | ||||
|         return 1 | ||||
|       } else if ( | ||||
|         this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && | ||||
| @@ -230,6 +273,7 @@ export class FilterableDropdownSelectionModel { | ||||
|  | ||||
|   set logicalOperator(operator: LogicalOperator) { | ||||
|     this.temporaryLogicalOperator = operator | ||||
|     this.setNullItem() | ||||
|   } | ||||
|  | ||||
|   toggleOperator() { | ||||
| @@ -242,6 +286,7 @@ export class FilterableDropdownSelectionModel { | ||||
|  | ||||
|   set intersection(intersection: Intersection) { | ||||
|     this.temporaryIntersection = intersection | ||||
|     this.setNullItem() | ||||
|   } | ||||
|  | ||||
|   toggleIntersection() { | ||||
| @@ -250,9 +295,20 @@ export class FilterableDropdownSelectionModel { | ||||
|       this.intersection == Intersection.Include | ||||
|         ? ToggleableItemState.Selected | ||||
|         : ToggleableItemState.Excluded | ||||
|  | ||||
|     this.temporarySelectionStates.forEach((state, key) => { | ||||
|       this.temporarySelectionStates.set(key, newState) | ||||
|       if (key === null && this.intersection === Intersection.Exclude) { | ||||
|         this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState) | ||||
|       } else if ( | ||||
|         key === NEGATIVE_NULL_FILTER_VALUE && | ||||
|         this.intersection === Intersection.Include | ||||
|       ) { | ||||
|         this.temporarySelectionStates.set(null, newState) | ||||
|       } else { | ||||
|         this.temporarySelectionStates.set(key, newState) | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     this.changed.next(this) | ||||
|   } | ||||
|  | ||||
| @@ -274,6 +330,7 @@ export class FilterableDropdownSelectionModel { | ||||
|     this.temporarySelectionStates.clear() | ||||
|     this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And | ||||
|     this.temporaryIntersection = this._intersection = Intersection.Include | ||||
|     this.setNullItem() | ||||
|     if (fireEvent) { | ||||
|       this.changed.next(this) | ||||
|     } | ||||
| @@ -305,8 +362,10 @@ export class FilterableDropdownSelectionModel { | ||||
|  | ||||
|   isNoneSelected() { | ||||
|     return ( | ||||
|       this.selectionSize() == 1 && | ||||
|       this.get(null) == ToggleableItemState.Selected | ||||
|       (this.selectionSize() == 1 && | ||||
|         this.get(null) == ToggleableItemState.Selected) || | ||||
|       (this.intersection == Intersection.Exclude && | ||||
|         this.get(NEGATIVE_NULL_FILTER_VALUE) == ToggleableItemState.Excluded) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| @@ -376,33 +435,24 @@ export class FilterableDropdownComponent | ||||
|   extends LoadingComponentWithPermissions | ||||
|   implements OnInit | ||||
| { | ||||
|   private filterPipe = inject(FilterPipe) | ||||
|   private hotkeyService = inject(HotKeyService) | ||||
|  | ||||
|   @ViewChild('listFilterTextInput') listFilterTextInput: ElementRef | ||||
|   @ViewChild('dropdown') dropdown: NgbDropdown | ||||
|   @ViewChild('buttonItems') buttonItems: ElementRef | ||||
|  | ||||
|   public popperOptions = popperOptionsReenablePreventOverflow | ||||
|   public popperOptions = pngxPopperOptions | ||||
|  | ||||
|   filterText: string | ||||
|  | ||||
|   @Input() | ||||
|   set items(items: MatchingModel[]) { | ||||
|     if (items) { | ||||
|       this._selectionModel.items = Array.from(items) | ||||
|       this._selectionModel.items.unshift({ | ||||
|         name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`, | ||||
|         id: null, | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|   _selectionModel: FilterableDropdownSelectionModel | ||||
|  | ||||
|   get items(): MatchingModel[] { | ||||
|     return this._selectionModel.items | ||||
|   } | ||||
|  | ||||
|   _selectionModel: FilterableDropdownSelectionModel = | ||||
|     new FilterableDropdownSelectionModel() | ||||
|  | ||||
|   @Input() | ||||
|   @Input({ required: true }) | ||||
|   set selectionModel(model: FilterableDropdownSelectionModel) { | ||||
|     if (this.selectionModel) { | ||||
|       this.selectionModel.changed.complete() | ||||
| @@ -423,11 +473,6 @@ export class FilterableDropdownComponent | ||||
|   @Output() | ||||
|   selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>() | ||||
|  | ||||
|   @Input() | ||||
|   set manyToOne(manyToOne: boolean) { | ||||
|     this.selectionModel.manyToOne = manyToOne | ||||
|   } | ||||
|  | ||||
|   get manyToOne() { | ||||
|     return this.selectionModel.manyToOne | ||||
|   } | ||||
| @@ -484,7 +529,7 @@ export class FilterableDropdownComponent | ||||
|     return this.manyToOne | ||||
|       ? this.selectionModel.selectionSize() > 1 && | ||||
|           this.selectionModel.getExcludedItems().length == 0 | ||||
|       : !this.selectionModel.isNoneSelected() | ||||
|       : true | ||||
|   } | ||||
|  | ||||
|   get name(): string { | ||||
| @@ -495,10 +540,7 @@ export class FilterableDropdownComponent | ||||
|  | ||||
|   private keyboardIndex: number | ||||
|  | ||||
|   constructor( | ||||
|     private filterPipe: FilterPipe, | ||||
|     private hotkeyService: HotKeyService | ||||
|   ) { | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.selectionModelChange.subscribe((updatedModel) => { | ||||
|       this.modelIsDirty = updatedModel.isDirty() | ||||
| @@ -545,6 +587,8 @@ export class FilterableDropdownComponent | ||||
|         this.selectionModel.reset() | ||||
|         this.modelIsDirty = false | ||||
|       } | ||||
|       this.selectionModel.singleSelect = | ||||
|         this.editing && !this.selectionModel.manyToOne | ||||
|       this.opened.next(this) | ||||
|     } else { | ||||
|       if (this.creating) { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
|  | ||||
| const SYMBOLS = { | ||||
| @@ -19,11 +19,11 @@ const SYMBOLS = { | ||||
|   styleUrl: './hotkey-dialog.component.scss', | ||||
| }) | ||||
| export class HotkeyDialogComponent { | ||||
|   activeModal = inject(NgbActiveModal) | ||||
|  | ||||
|   public title: string = $localize`Keyboard shortcuts` | ||||
|   public hotkeys: Map<string, string> = new Map() | ||||
|  | ||||
|   constructor(public activeModal: NgbActiveModal) {} | ||||
|  | ||||
|   public close(): void { | ||||
|     this.activeModal.close() | ||||
|   } | ||||
|   | ||||
| @@ -0,0 +1,77 @@ | ||||
| <div class="list-group mt-3 selected-fields"> | ||||
|   @for (fieldId of selectedFields; track fieldId) { | ||||
|     <div class="list-group-item | ||||
|       d-flex | ||||
|       justify-content-between | ||||
|       align-items-center"> | ||||
|       @switch (getCustomField(fieldId)?.data_type) { | ||||
|         @case (CustomFieldDataType.String) { | ||||
|           <pngx-input-text [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-text> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Date) { | ||||
|           <pngx-input-date [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-date> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Integer) { | ||||
|           <pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true" | ||||
|           [showAdd]="false"></pngx-input-number> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Float) { | ||||
|           <pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true" | ||||
|           [showAdd]="false" | ||||
|           [step]=".1"></pngx-input-number> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Monetary) { | ||||
|           <pngx-input-monetary [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [defaultCurrency]="getCustomField(fieldId)?.extra_data?.default_currency" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-monetary> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Boolean) { | ||||
|           <pngx-input-check [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-check> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Url) { | ||||
|           <pngx-input-url [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-url> | ||||
|         } | ||||
|         @case (CustomFieldDataType.DocumentLink) { | ||||
|           <pngx-input-document-link [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-document-link> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Select) { | ||||
|           <pngx-input-select [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [items]="getCustomField(fieldId)?.extra_data.select_options" | ||||
|           class="flex-grow-1" | ||||
|           bindLabel="label" | ||||
|           [allowNull]="true" | ||||
|           [horizontal]="true"></pngx-input-select> | ||||
|         } | ||||
|       } | ||||
|       <button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)"> | ||||
|         <i-bs name="trash"></i-bs> | ||||
|       </button> | ||||
|     </div> | ||||
|   } | ||||
| </div> | ||||
| @@ -0,0 +1,3 @@ | ||||
| :host ::ng-deep .list-group-item .mb-3 { | ||||
|   margin-bottom: 0 !important; | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { of } from 'rxjs' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { CustomFieldsValuesComponent } from './custom-fields-values.component' | ||||
|  | ||||
| describe('CustomFieldsValuesComponent', () => { | ||||
|   let component: CustomFieldsValuesComponent | ||||
|   let fixture: ComponentFixture<CustomFieldsValuesComponent> | ||||
|   let customFieldsService: CustomFieldsService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       imports: [FormsModule, ReactiveFormsModule, CustomFieldsValuesComponent], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(CustomFieldsValuesComponent) | ||||
|     fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) | ||||
|     component = fixture.componentInstance | ||||
|     customFieldsService = TestBed.inject(CustomFieldsService) | ||||
|     jest.spyOn(customFieldsService, 'listAll').mockReturnValue( | ||||
|       of({ | ||||
|         all: [1], | ||||
|         count: 1, | ||||
|         results: [ | ||||
|           { | ||||
|             id: 1, | ||||
|             name: 'Field 1', | ||||
|             data_type: CustomFieldDataType.String, | ||||
|           } as CustomField, | ||||
|         ], | ||||
|       }) | ||||
|     ) | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     fixture = TestBed.createComponent(CustomFieldsValuesComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should set selectedFields and map values correctly', () => { | ||||
|     component.value = { 1: 'value1' } | ||||
|     component.selectedFields = [1, 2] | ||||
|     expect(component.selectedFields).toEqual([1, 2]) | ||||
|     expect(component.value).toEqual({ 1: 'value1', 2: null }) | ||||
|   }) | ||||
|  | ||||
|   it('should return the correct custom field by id', () => { | ||||
|     const field = component.getCustomField(1) | ||||
|     expect(field).toEqual({ | ||||
|       id: 1, | ||||
|       name: 'Field 1', | ||||
|       data_type: CustomFieldDataType.String, | ||||
|     } as CustomField) | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,93 @@ | ||||
| import { | ||||
|   Component, | ||||
|   EventEmitter, | ||||
|   forwardRef, | ||||
|   inject, | ||||
|   Input, | ||||
|   Output, | ||||
| } from '@angular/core' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { RouterModule } from '@angular/router' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { AbstractInputComponent } from '../abstract-input' | ||||
| import { CheckComponent } from '../check/check.component' | ||||
| import { DateComponent } from '../date/date.component' | ||||
| import { DocumentLinkComponent } from '../document-link/document-link.component' | ||||
| import { MonetaryComponent } from '../monetary/monetary.component' | ||||
| import { NumberComponent } from '../number/number.component' | ||||
| import { SelectComponent } from '../select/select.component' | ||||
| import { TextComponent } from '../text/text.component' | ||||
| import { UrlComponent } from '../url/url.component' | ||||
|  | ||||
| @Component({ | ||||
|   providers: [ | ||||
|     { | ||||
|       provide: NG_VALUE_ACCESSOR, | ||||
|       useExisting: forwardRef(() => CustomFieldsValuesComponent), | ||||
|       multi: true, | ||||
|     }, | ||||
|   ], | ||||
|   selector: 'pngx-input-custom-fields-values', | ||||
|   templateUrl: './custom-fields-values.component.html', | ||||
|   styleUrl: './custom-fields-values.component.scss', | ||||
|   imports: [ | ||||
|     TextComponent, | ||||
|     DateComponent, | ||||
|     NumberComponent, | ||||
|     DocumentLinkComponent, | ||||
|     UrlComponent, | ||||
|     SelectComponent, | ||||
|     MonetaryComponent, | ||||
|     CheckComponent, | ||||
|     NgSelectModule, | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     RouterModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|   ], | ||||
| }) | ||||
| export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> { | ||||
|   public CustomFieldDataType = CustomFieldDataType | ||||
|  | ||||
|   constructor() { | ||||
|     const customFieldsService = inject(CustomFieldsService) | ||||
|  | ||||
|     super() | ||||
|     customFieldsService.listAll().subscribe((items) => { | ||||
|       this.fields = items.results | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   private fields: CustomField[] | ||||
|  | ||||
|   private _selectedFields: number[] | ||||
|  | ||||
|   @Input() | ||||
|   set selectedFields(newFields: number[]) { | ||||
|     this._selectedFields = newFields | ||||
|     // map the selected fields to an object with field_id as key and value as value | ||||
|     this.value = newFields.reduce((acc, fieldId) => { | ||||
|       acc[fieldId] = this.value?.[fieldId] || null | ||||
|       return acc | ||||
|     }, {}) | ||||
|     this.onChange(this.value) | ||||
|   } | ||||
|  | ||||
|   get selectedFields(): number[] { | ||||
|     return this._selectedFields | ||||
|   } | ||||
|  | ||||
|   @Output() | ||||
|   public removeSelectedField: EventEmitter<number> = new EventEmitter<number>() | ||||
|  | ||||
|   public getCustomField(id: number): CustomField { | ||||
|     return this.fields.find((field) => field.id === id) | ||||
|   } | ||||
| } | ||||
| @@ -2,6 +2,7 @@ import { | ||||
|   Component, | ||||
|   EventEmitter, | ||||
|   forwardRef, | ||||
|   inject, | ||||
|   Input, | ||||
|   OnInit, | ||||
|   Output, | ||||
| @@ -45,13 +46,9 @@ export class DateComponent | ||||
|   extends AbstractInputComponent<string> | ||||
|   implements OnInit | ||||
| { | ||||
|   constructor( | ||||
|     private settings: SettingsService, | ||||
|     private ngbDateParserFormatter: NgbDateParserFormatter, | ||||
|     private isoDateAdapter: NgbDateAdapter<string> | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|   private settings = inject(SettingsService) | ||||
|   private ngbDateParserFormatter = inject(NgbDateParserFormatter) | ||||
|   private isoDateAdapter = inject<NgbDateAdapter<string>>(NgbDateAdapter) | ||||
|  | ||||
|   @Input() | ||||
|   suggestions: string[] | ||||
| @@ -62,7 +59,7 @@ export class DateComponent | ||||
|   @Output() | ||||
|   filterDocuments = new EventEmitter<NgbDateStruct[]>() | ||||
|  | ||||
|   public readonly today: string = new Date().toISOString().split('T')[0] | ||||
|   public readonly today: string = new Date().toLocaleDateString('en-CA') | ||||
|  | ||||
|   getSuggestions() { | ||||
|     return this.suggestions == null | ||||
|   | ||||
| @@ -30,25 +30,24 @@ | ||||
|     [placeholder]="placeholder" | ||||
|     [notFoundText]="notFoundText" | ||||
|     [multiple]="true" | ||||
|     bindValue="id" | ||||
|     [compareWith]="compareDocuments" | ||||
|     [trackByFn]="trackByFn" | ||||
|     [minTermLength]="2" | ||||
|     [loading]="loading" | ||||
|     [typeahead]="documentsInput$" | ||||
|     (mousedown)="$event.stopImmediatePropagation()" | ||||
|     (change)="onChange(selectedDocuments)"> | ||||
|     (change)="onChange(selectedDocumentIDs)"> | ||||
|     <ng-template ng-label-tmp let-document="item"> | ||||
|       <div class="d-flex align-items-center"> | ||||
|         @if (!disabled) { | ||||
|           <button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button> | ||||
|           <button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><i-bs name="x"></i-bs></button> | ||||
|         } | ||||
|         @if (document.title) { | ||||
|           <a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title> | ||||
|             <i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{document.title}}</span> | ||||
|           </a> | ||||
|         } @else { | ||||
|           <span class="badge bg-light text-muted" (click)="unselect(document)" title="Remove link" i18n-title> | ||||
|           <span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title> | ||||
|             <i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill"></i-bs> <span i18n>Not found</span> | ||||
|           </span> | ||||
|         } | ||||
|   | ||||
| @@ -74,6 +74,11 @@ describe('DocumentLinkComponent', () => { | ||||
|     expect(component.selectedDocuments).toEqual([documents[1], documents[0]]) | ||||
|   }) | ||||
|  | ||||
|   it('should retrieve document IDs from selected documents', () => { | ||||
|     component.selectedDocuments = documents | ||||
|     expect(component.selectedDocumentIDs).toEqual([1, 12, 16, 23]) | ||||
|   }) | ||||
|  | ||||
|   it('should search API on select text input', () => { | ||||
|     const listSpy = jest.spyOn(documentService, 'listFiltered') | ||||
|     listSpy.mockImplementation( | ||||
|   | ||||
| @@ -1,5 +1,12 @@ | ||||
| import { AsyncPipe, NgTemplateOutlet } from '@angular/common' | ||||
| import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core' | ||||
| import { | ||||
|   Component, | ||||
|   forwardRef, | ||||
|   inject, | ||||
|   Input, | ||||
|   OnDestroy, | ||||
|   OnInit, | ||||
| } from '@angular/core' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
| @@ -52,6 +59,8 @@ export class DocumentLinkComponent | ||||
|   extends AbstractInputComponent<any[]> | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   private documentsService = inject(DocumentService) | ||||
|  | ||||
|   documentsInput$ = new Subject<string>() | ||||
|   foundDocuments$: Observable<Document[]> | ||||
|   loading = false | ||||
| @@ -71,8 +80,8 @@ export class DocumentLinkComponent | ||||
|   @Input() | ||||
|   placeholder: string = $localize`Search for documents` | ||||
|  | ||||
|   constructor(private documentsService: DocumentService) { | ||||
|     super() | ||||
|   get selectedDocumentIDs(): number[] { | ||||
|     return this.selectedDocuments.map((d) => d.id) | ||||
|   } | ||||
|  | ||||
|   ngOnInit() { | ||||
| @@ -90,8 +99,8 @@ export class DocumentLinkComponent | ||||
|         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe((documentResults) => { | ||||
|           this.loading = false | ||||
|           this.selectedDocuments = documentIDs.map((id) => | ||||
|             documentResults.results.find((d) => d.id === id) | ||||
|           this.selectedDocuments = documentIDs.map( | ||||
|             (id) => documentResults.results.find((d) => d.id === id) ?? {} | ||||
|           ) | ||||
|           super.writeValue(documentIDs) | ||||
|         }) | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <div class="d-flex flex-row mt-2 align-items-center"> | ||||
|     <span class="me-2">{{title}}:</span> | ||||
|     <div class="d-flex flex-row gap-2 w-100 mh-1" style="min-height: 1em;" | ||||
|     <div class="d-flex flex-wrap flex-row gap-2 w-100 mh-1" style="min-height: 1em;" | ||||
|         cdkDropList #selectedList="cdkDropList" | ||||
|         cdkDropListOrientation="horizontal" | ||||
|         cdkDropListOrientation="mixed" | ||||
|         (cdkDropListDropped)="drop($event)" | ||||
|         [cdkDropListConnectedTo]="[unselectedList]"> | ||||
|         @for (item of selectedItems; track item.id) { | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { LOCALE_ID } from '@angular/core' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { NG_VALUE_ACCESSOR } from '@angular/forms' | ||||
| import { MonetaryComponent } from './monetary.component' | ||||
| @@ -41,8 +42,6 @@ describe('MonetaryComponent', () => { | ||||
|  | ||||
|   it('should set the default currency code based on LOCALE_ID', () => { | ||||
|     expect(component.defaultCurrencyCode).toEqual('USD') // default | ||||
|     component = new MonetaryComponent('pt-BR') | ||||
|     expect(component.defaultCurrencyCode).toEqual('BRL') | ||||
|   }) | ||||
|  | ||||
|   it('should support setting a default currency code', () => { | ||||
| @@ -87,3 +86,28 @@ describe('MonetaryComponent', () => { | ||||
|     expect(component.value).toEqual('USD0.00') | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| describe('MonetaryComponent (Alternate Locale)', () => { | ||||
|   let component: MonetaryComponent | ||||
|   let fixture: ComponentFixture<MonetaryComponent> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [MonetaryComponent], | ||||
|       providers: [ | ||||
|         { provide: LOCALE_ID, useValue: 'pt-BR' }, // Brazilian Portuguese | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(MonetaryComponent) | ||||
|     fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should set the default currency code based on LOCALE_ID', () => { | ||||
|     expect(component.defaultCurrencyCode).toEqual('BRL') | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { CurrencyPipe, getLocaleCurrencyCode } from '@angular/common' | ||||
| import { Component, forwardRef, Inject, Input, LOCALE_ID } from '@angular/core' | ||||
| import { Component, forwardRef, inject, Input, LOCALE_ID } from '@angular/core' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
| @@ -27,6 +27,8 @@ import { AbstractInputComponent } from '../abstract-input' | ||||
|   ], | ||||
| }) | ||||
| export class MonetaryComponent extends AbstractInputComponent<string> { | ||||
|   currentLocale = inject(LOCALE_ID) | ||||
|  | ||||
|   public currency: string = '' | ||||
|  | ||||
|   public _monetaryValue: string = '' | ||||
| @@ -45,11 +47,10 @@ export class MonetaryComponent extends AbstractInputComponent<string> { | ||||
|     if (currency) this.defaultCurrencyCode = currency | ||||
|   } | ||||
|  | ||||
|   constructor(@Inject(LOCALE_ID) currentLocale: string) { | ||||
|   constructor() { | ||||
|     super() | ||||
|  | ||||
|     this.currency = this.defaultCurrencyCode = | ||||
|       this.defaultCurrency ?? getLocaleCurrencyCode(currentLocale) | ||||
|       this.defaultCurrency ?? getLocaleCurrencyCode(this.currentLocale) | ||||
|   } | ||||
|  | ||||
|   writeValue(newValue: any): void { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, forwardRef, Input } from '@angular/core' | ||||
| import { Component, forwardRef, inject, Input } from '@angular/core' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
| @@ -22,16 +22,14 @@ import { AbstractInputComponent } from '../abstract-input' | ||||
|   imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule], | ||||
| }) | ||||
| export class NumberComponent extends AbstractInputComponent<number> { | ||||
|   private documentService = inject(DocumentService) | ||||
|  | ||||
|   @Input() | ||||
|   showAdd: boolean = true | ||||
|  | ||||
|   @Input() | ||||
|   step: number = 1 | ||||
|  | ||||
|   constructor(private documentService: DocumentService) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   nextAsn() { | ||||
|     if (this.value) { | ||||
|       return | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, forwardRef } from '@angular/core' | ||||
| import { Component, forwardRef, inject } from '@angular/core' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
| @@ -26,7 +26,9 @@ import { AbstractInputComponent } from '../../abstract-input' | ||||
| export class PermissionsGroupComponent extends AbstractInputComponent<Group> { | ||||
|   groups: Group[] | ||||
|  | ||||
|   constructor(groupService: GroupService) { | ||||
|   constructor() { | ||||
|     const groupService = inject(GroupService) | ||||
|  | ||||
|     super() | ||||
|     groupService | ||||
|       .listAll() | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, forwardRef } from '@angular/core' | ||||
| import { Component, forwardRef, inject } from '@angular/core' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
| @@ -8,7 +8,6 @@ import { NgSelectComponent } from '@ng-select/ng-select' | ||||
| import { first } from 'rxjs/operators' | ||||
| import { User } from 'src/app/data/user' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { AbstractInputComponent } from '../../abstract-input' | ||||
|  | ||||
| @Component({ | ||||
| @@ -27,7 +26,9 @@ import { AbstractInputComponent } from '../../abstract-input' | ||||
| export class PermissionsUserComponent extends AbstractInputComponent<User[]> { | ||||
|   users: User[] | ||||
|  | ||||
|   constructor(userService: UserService, settings: SettingsService) { | ||||
|   constructor() { | ||||
|     const userService = inject(UserService) | ||||
|  | ||||
|     super() | ||||
|     userService | ||||
|       .listAll() | ||||
|   | ||||
| @@ -18,7 +18,7 @@ | ||||
|           (change)="onChange(value)"> | ||||
|  | ||||
|           <ng-template ng-label-tmp let-item="item"> | ||||
|             <button class="tag-wrap btn p-0 d-flex align-items-center" (click)="removeTag($event, item.id)" title="Remove tag" i18n-title> | ||||
|             <button class="tag-wrap btn p-0 d-flex align-items-center" (click)="removeTag(item.id)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove tag" i18n-title> | ||||
|               <i-bs name="x" style="margin-inline-end: 1px;"></i-bs> | ||||
|               @if (item.id && tags) { | ||||
|                 <pngx-tag style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag> | ||||
| @@ -34,7 +34,7 @@ | ||||
|           </ng-template> | ||||
|         </ng-select> | ||||
|         @if (allowCreate && !hideAddButton) { | ||||
|           <button class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled"> | ||||
|           <button class="btn btn-outline-secondary" type="button" (click)="createTag(null, true)" [disabled]="disabled"> | ||||
|             <i-bs width="1.2em" height="1.2em" name="plus"></i-bs> | ||||
|           </button> | ||||
|         } | ||||
|   | ||||
| @@ -154,11 +154,11 @@ describe('TagsComponent', () => { | ||||
|   it('support remove tags', () => { | ||||
|     component.tags = tags | ||||
|     component.value = [1, 2] | ||||
|     component.removeTag(new PointerEvent('point'), 2) | ||||
|     component.removeTag(2) | ||||
|     expect(component.value).toEqual([1]) | ||||
|  | ||||
|     component.disabled = true | ||||
|     component.removeTag(new PointerEvent('point'), 1) | ||||
|     component.removeTag(1) | ||||
|     expect(component.value).toEqual([1]) | ||||
|   }) | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import { | ||||
|   Component, | ||||
|   EventEmitter, | ||||
|   forwardRef, | ||||
|   inject, | ||||
|   Input, | ||||
|   OnInit, | ||||
|   Output, | ||||
| @@ -45,10 +46,10 @@ import { TagComponent } from '../../tag/tag.component' | ||||
|   ], | ||||
| }) | ||||
| export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|   constructor( | ||||
|     private tagService: TagService, | ||||
|     private modalService: NgbModal | ||||
|   ) { | ||||
|   private tagService = inject(TagService) | ||||
|   private modalService = inject(NgbModal) | ||||
|  | ||||
|   constructor() { | ||||
|     this.createTagRef = this.createTag.bind(this) | ||||
|   } | ||||
|  | ||||
| @@ -121,15 +122,12 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   removeTag(event: PointerEvent, id: number) { | ||||
|   removeTag(tagID: number) { | ||||
|     if (this.disabled) return | ||||
|  | ||||
|     // prevent opening dropdown | ||||
|     event.stopImmediatePropagation() | ||||
|  | ||||
|     let index = this.value.indexOf(id) | ||||
|     let index = this.value.indexOf(tagID) | ||||
|     if (index > -1) { | ||||
|       const tag = this.getTag(id) | ||||
|       const tag = this.getTag(tagID) | ||||
|  | ||||
|       // remove tag | ||||
|       let oldValue = this.value | ||||
| @@ -163,7 +161,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   createTag(name: string = null) { | ||||
|   createTag(name: string = null, add: boolean = false) { | ||||
|     var modal = this.modalService.open(TagEditDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
| @@ -176,9 +174,10 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|     return firstValueFrom( | ||||
|       (modal.componentInstance as TagEditDialogComponent).succeeded.pipe( | ||||
|         first(), | ||||
|         tap(() => { | ||||
|         tap((newTag) => { | ||||
|           this.tagService.listAll().subscribe((tags) => { | ||||
|             this.tags = tags.results | ||||
|             add && this.addTag(newTag.id) | ||||
|           }) | ||||
|         }) | ||||
|       ) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, Input } from '@angular/core' | ||||
| import { Component, Input, inject } from '@angular/core' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/ui-settings' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| @@ -9,6 +9,8 @@ import { environment } from 'src/environments/environment' | ||||
|   styleUrls: ['./logo.component.scss'], | ||||
| }) | ||||
| export class LogoComponent { | ||||
|   private settingsService = inject(SettingsService) | ||||
|  | ||||
|   @Input() | ||||
|   extra_classes: string | ||||
|  | ||||
| @@ -24,8 +26,6 @@ export class LogoComponent { | ||||
|       : null | ||||
|   } | ||||
|  | ||||
|   constructor(private settingsService: SettingsService) {} | ||||
|  | ||||
|   getClasses() { | ||||
|     return ['logo'].concat(this.extra_classes).join(' ') | ||||
|   } | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon