mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Feature: customizable fields display for documents, saved views & dashboard widgets (#6439)
This commit is contained in:
		| @@ -124,7 +124,7 @@ | ||||
|           "content": { | ||||
|             "size": -1, | ||||
|             "mimeType": "application/json", | ||||
|             "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}" | ||||
|             "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}" | ||||
|           }, | ||||
|           "headersSize": -1, | ||||
|           "bodySize": -1, | ||||
|   | ||||
| @@ -124,7 +124,7 @@ | ||||
|           "content": { | ||||
|             "size": -1, | ||||
|             "mimeType": "application/json", | ||||
|             "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}" | ||||
|             "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}" | ||||
|           }, | ||||
|           "headersSize": -1, | ||||
|           "bodySize": -1, | ||||
|   | ||||
| @@ -124,7 +124,7 @@ | ||||
|           "content": { | ||||
|             "size": -1, | ||||
|             "mimeType": "application/json", | ||||
|             "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}" | ||||
|             "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}" | ||||
|           }, | ||||
|           "headersSize": -1, | ||||
|           "bodySize": -1, | ||||
|   | ||||
| @@ -124,7 +124,7 @@ | ||||
|           "content": { | ||||
|             "size": -1, | ||||
|             "mimeType": "application/json", | ||||
|             "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true}]}" | ||||
|             "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}" | ||||
|           }, | ||||
|           "headersSize": -1, | ||||
|           "bodySize": -1, | ||||
|   | ||||
| @@ -138,11 +138,11 @@ test('sorting', async ({ page }) => { | ||||
| test('change views', async ({ page }) => { | ||||
|   await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' }) | ||||
|   await page.goto('/documents') | ||||
|   await page.locator('pngx-page-header label').first().click() | ||||
|   await page.locator('.btn-group label').first().click() | ||||
|   await expect(page.locator('pngx-document-list table')).toBeVisible() | ||||
|   await page.locator('pngx-page-header label').nth(1).click() | ||||
|   await page.locator('.btn-group label').nth(1).click() | ||||
|   await expect(page.locator('pngx-document-card-small').first()).toBeAttached() | ||||
|   await page.locator('pngx-page-header label').nth(2).click() | ||||
|   await page.locator('.btn-group label').nth(2).click() | ||||
|   await expect(page.locator('pngx-document-card-large').first()).toBeAttached() | ||||
| }) | ||||
|  | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -120,6 +120,8 @@ import { RotateConfirmDialogComponent } from './components/common/confirm-dialog | ||||
| import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' | ||||
| import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' | ||||
| import { DocumentHistoryComponent } from './components/document-history/document-history.component' | ||||
| import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.component' | ||||
| import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component' | ||||
| import { | ||||
|   airplane, | ||||
|   archive, | ||||
| @@ -139,6 +141,7 @@ import { | ||||
|   calendar, | ||||
|   calendarEvent, | ||||
|   cardChecklist, | ||||
|   cardHeading, | ||||
|   caretDown, | ||||
|   caretUp, | ||||
|   chatLeftText, | ||||
| @@ -233,6 +236,7 @@ const icons = { | ||||
|   calendar, | ||||
|   calendarEvent, | ||||
|   cardChecklist, | ||||
|   cardHeading, | ||||
|   caretDown, | ||||
|   caretUp, | ||||
|   chatLeftText, | ||||
| @@ -474,6 +478,8 @@ function initializeApp(settings: SettingsService) { | ||||
|     MergeConfirmDialogComponent, | ||||
|     SplitConfirmDialogComponent, | ||||
|     DocumentHistoryComponent, | ||||
|     DragDropSelectComponent, | ||||
|     CustomFieldDisplayComponent, | ||||
|   ], | ||||
|   imports: [ | ||||
|     BrowserModule, | ||||
|   | ||||
| @@ -320,52 +320,71 @@ | ||||
|         </div> | ||||
|  | ||||
|         <h4 i18n>Views</h4> | ||||
|         <div formGroupName="savedViews"> | ||||
|         <ul class="list-group" formGroupName="savedViews"> | ||||
|  | ||||
|           @for (view of savedViews; track view) { | ||||
|             <li class="list-group-item py-3"> | ||||
|             <div [formGroupName]="view.id" class="row"> | ||||
|               <div class="mb-3 col"> | ||||
|                 <label class="form-label" for="name_{{view.id}}" i18n>Name</label> | ||||
|                 <input type="text" class="form-control" formControlName="name" id="name_{{view.id}}"> | ||||
|               </div> | ||||
|               <div class="mb-2 col"> | ||||
|                 <label class="form-label" for="show_on_dashboard_{{view.id}}" i18n> <span class="visually-hidden">Appears on</span></label> | ||||
|                 <div class="form-check form-switch"> | ||||
|                   <input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard"> | ||||
|                   <label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label> | ||||
|               <div class="row"> | ||||
|                 <div class="col"> | ||||
|                   <pngx-input-text title="Name" formControlName="name"></pngx-input-text> | ||||
|                 </div> | ||||
|                 <div class="form-check form-switch"> | ||||
|                   <input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar"> | ||||
|                   <label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label> | ||||
|                 <div class="col"> | ||||
|                   <div class="form-check form-switch mt-3"> | ||||
|                     <input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard"> | ||||
|                     <label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label> | ||||
|                   </div> | ||||
|                   <div class="form-check form-switch"> | ||||
|                     <input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar"> | ||||
|                     <label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <div class="col-auto"> | ||||
|                   <label class="form-label" for="name_{{view.id}}" i18n>Actions</label> | ||||
|                   <pngx-confirm-button | ||||
|                     label="Delete" | ||||
|                     i18n-label | ||||
|                     (confirm)="deleteSavedView(view)" | ||||
|                     *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" | ||||
|                     buttonClasses="btn-sm btn-outline-danger form-control" | ||||
|                     iconName="trash"> | ||||
|                   </pngx-confirm-button> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div class="mb-2 col-auto"> | ||||
|                 <label class="form-label" for="name_{{view.id}}" i18n>Actions</label> | ||||
|  | ||||
|                 <pngx-confirm-button | ||||
|                   label="Delete" | ||||
|                   i18n-label | ||||
|                   (confirm)="deleteSavedView(view)" | ||||
|                   *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" | ||||
|                   buttonClasses="btn-sm btn-outline-danger form-control" | ||||
|                   iconName="trash"> | ||||
|                 </pngx-confirm-button> | ||||
|               </div> | ||||
|               <div class="row"> | ||||
|                 <div class="col"> | ||||
|                   <pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number> | ||||
|                 </div> | ||||
|                 <div class="col"> | ||||
|                   <label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label> | ||||
|                   <select class="form-select" formControlName="display_mode"> | ||||
|                     <option [ngValue]="DisplayMode.TABLE" i18n>Table</option> | ||||
|                     <option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option> | ||||
|                     <option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option> | ||||
|                   </select> | ||||
|                 </div> | ||||
|                   @if (displayFields) { | ||||
|                     <pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select> | ||||
|                   } | ||||
|                 </div> | ||||
|             </div> | ||||
|             </li> | ||||
|           } | ||||
|  | ||||
|           @if (savedViews && savedViews.length === 0) { | ||||
|             <div i18n>No saved views defined.</div> | ||||
|             <li class="list-group-item"> | ||||
|               <div i18n>No saved views defined.</div> | ||||
|             </li> | ||||
|           } | ||||
|  | ||||
|           @if (!savedViews) { | ||||
|             <div> | ||||
|             <li class="list-group-item"> | ||||
|               <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|               <div class="visually-hidden" i18n>Loading...</div> | ||||
|             </div> | ||||
|             </li> | ||||
|           } | ||||
|  | ||||
|         </div> | ||||
|         </ul> | ||||
|  | ||||
|       </ng-template> | ||||
|     </li> | ||||
| @@ -374,4 +393,5 @@ | ||||
|   <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> | ||||
| </form> | ||||
|   | ||||
| @@ -48,6 +48,8 @@ import { | ||||
|   InstallType, | ||||
|   SystemStatusItemStatus, | ||||
| } from 'src/app/data/system-status' | ||||
| import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component' | ||||
| import { DragDropModule } from '@angular/cdk/drag-drop' | ||||
|  | ||||
| const savedViews = [ | ||||
|   { id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true }, | ||||
| @@ -96,6 +98,7 @@ describe('SettingsComponent', () => { | ||||
|         PermissionsGroupComponent, | ||||
|         IfOwnerDirective, | ||||
|         ConfirmButtonComponent, | ||||
|         DragDropSelectComponent, | ||||
|       ], | ||||
|       providers: [CustomDatePipe, DatePipe, PermissionsGuard], | ||||
|       imports: [ | ||||
| @@ -108,6 +111,7 @@ describe('SettingsComponent', () => { | ||||
|         NgSelectModule, | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|         NgbModalModule, | ||||
|         DragDropModule, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
| @@ -437,4 +441,11 @@ describe('SettingsComponent', () => { | ||||
|       size: 'xl', | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   it('should support reset', () => { | ||||
|     completeSetup() | ||||
|     component.settingsForm.get('themeColor').setValue('#ff0000') | ||||
|     component.reset() | ||||
|     expect(component.settingsForm.get('themeColor').value).toEqual('') | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -50,6 +50,7 @@ import { | ||||
|   SystemStatusItemStatus, | ||||
|   SystemStatus, | ||||
| } from 'src/app/data/system-status' | ||||
| import { DisplayMode } from 'src/app/data/document' | ||||
|  | ||||
| enum SettingsNavIDs { | ||||
|   General = 1, | ||||
| @@ -73,8 +74,8 @@ export class SettingsComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, AfterViewInit, OnDestroy, DirtyComponent | ||||
| { | ||||
|   SettingsNavIDs = SettingsNavIDs | ||||
|   activeNavID: number | ||||
|   DisplayMode = DisplayMode | ||||
|  | ||||
|   savedViewGroup = new FormGroup({}) | ||||
|  | ||||
| @@ -110,6 +111,10 @@ export class SettingsComponent | ||||
|   }) | ||||
|  | ||||
|   savedViews: SavedView[] | ||||
|   SettingsNavIDs = SettingsNavIDs | ||||
|   get displayFields() { | ||||
|     return this.settings.allDisplayFields | ||||
|   } | ||||
|  | ||||
|   store: BehaviorSubject<any> | ||||
|   storeSub: Subscription | ||||
| @@ -340,6 +345,9 @@ export class SettingsComponent | ||||
|           name: view.name, | ||||
|           show_on_dashboard: view.show_on_dashboard, | ||||
|           show_in_sidebar: view.show_in_sidebar, | ||||
|           page_size: view.page_size, | ||||
|           display_mode: view.display_mode, | ||||
|           display_fields: view.display_fields, | ||||
|         } | ||||
|         this.savedViewGroup.addControl( | ||||
|           view.id.toString(), | ||||
| @@ -348,6 +356,9 @@ export class SettingsComponent | ||||
|             name: new FormControl(null), | ||||
|             show_on_dashboard: new FormControl(null), | ||||
|             show_in_sidebar: new FormControl(null), | ||||
|             page_size: new FormControl(null), | ||||
|             display_mode: new FormControl(null), | ||||
|             display_fields: new FormControl([]), | ||||
|           }) | ||||
|         ) | ||||
|       } | ||||
| @@ -530,8 +541,8 @@ export class SettingsComponent | ||||
|       .subscribe({ | ||||
|         next: () => { | ||||
|           this.store.next(this.settingsForm.value) | ||||
|           this.documentListViewService.updatePageSize() | ||||
|           this.settings.updateAppearanceSettings() | ||||
|           this.settings.initializeDisplayFields() | ||||
|           let savedToast: Toast = { | ||||
|             content: $localize`Settings were saved successfully.`, | ||||
|             delay: 5000, | ||||
| @@ -592,6 +603,10 @@ export class SettingsComponent | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   reset() { | ||||
|     this.settingsForm.patchValue(this.store.getValue()) | ||||
|   } | ||||
|  | ||||
|   clearThemeColor() { | ||||
|     this.settingsForm.get('themeColor').patchValue('') | ||||
|   } | ||||
|   | ||||
| @@ -111,7 +111,7 @@ | ||||
|             </h6> | ||||
|           } | ||||
|           <ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)"> | ||||
|             @for (view of savedViewService.sidebarViews; track view) { | ||||
|             @for (view of savedViewService.sidebarViews; track view.id) { | ||||
|               <li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews" | ||||
|                 cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)" | ||||
|                 (cdkDragEnded)="onDragEnd($event)"> | ||||
|   | ||||
| @@ -0,0 +1,25 @@ | ||||
| @if (field) { | ||||
|     @switch (field.data_type) { | ||||
|         @case (CustomFieldDataType.Monetary) { | ||||
|             <span>{{value | currency: currency}}</span> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Date) { | ||||
|             <span>{{value | customDate}}</span> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Url) { | ||||
|             <a [href]="value" class="btn-link text-dark text-decoration-none" target="_blank">{{value}}</a> | ||||
|         } | ||||
|         @case (CustomFieldDataType.DocumentLink) { | ||||
|             <div class="d-flex gap-1 flex-wrap"> | ||||
|                 @for (docId of value; track docId) { | ||||
|                     <a routerLink="/documents/{{docId}}" class="badge bg-dark text-primary" title="View" i18n-title> | ||||
|                         <i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{ getDocumentTitle(docId) }}</span> | ||||
|                     </a> | ||||
|                 } | ||||
|             </div> | ||||
|         } | ||||
|         @default { | ||||
|           <span>{{value}}</span> | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,89 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { of } from 'rxjs' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { CustomFieldDisplayComponent } from './custom-field-display.component' | ||||
| import { DisplayField, Document } from 'src/app/data/document' | ||||
| import { HttpClientTestingModule } from '@angular/common/http/testing' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
|  | ||||
| const customFields: CustomField[] = [ | ||||
|   { id: 1, name: 'Field 1', data_type: CustomFieldDataType.String }, | ||||
|   { id: 2, name: 'Field 2', data_type: CustomFieldDataType.Monetary }, | ||||
|   { id: 3, name: 'Field 3', data_type: CustomFieldDataType.DocumentLink }, | ||||
| ] | ||||
| const document: Document = { | ||||
|   id: 1, | ||||
|   title: 'Doc 1', | ||||
|   custom_fields: [ | ||||
|     { field: 1, document: 1, created: null, value: 'Text value' }, | ||||
|     { field: 2, document: 1, created: null, value: '100 USD' }, | ||||
|     { field: 3, document: 1, created: null, value: '1,2,3' }, | ||||
|   ], | ||||
| } | ||||
|  | ||||
| describe('CustomFieldDisplayComponent', () => { | ||||
|   let component: CustomFieldDisplayComponent | ||||
|   let fixture: ComponentFixture<CustomFieldDisplayComponent> | ||||
|   let documentService: DocumentService | ||||
|   let customFieldService: CustomFieldsService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       declarations: [CustomFieldDisplayComponent], | ||||
|       providers: [DocumentService], | ||||
|       imports: [HttpClientTestingModule], | ||||
|     }).compileComponents() | ||||
|   }) | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     documentService = TestBed.inject(DocumentService) | ||||
|     customFieldService = TestBed.inject(CustomFieldsService) | ||||
|     jest | ||||
|       .spyOn(customFieldService, 'listAll') | ||||
|       .mockReturnValue(of({ results: customFields } as any)) | ||||
|     fixture = TestBed.createComponent(CustomFieldDisplayComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should create', () => { | ||||
|     expect(component).toBeTruthy() | ||||
|   }) | ||||
|  | ||||
|   it('should initialize component', () => { | ||||
|     jest | ||||
|       .spyOn(documentService, 'getFew') | ||||
|       .mockReturnValue(of({ results: [] } as any)) | ||||
|  | ||||
|     component.fieldDisplayKey = DisplayField.CUSTOM_FIELD + '2' | ||||
|     expect(component.fieldId).toEqual(2) | ||||
|     component.document = document | ||||
|     expect(component.document.title).toEqual('Doc 1') | ||||
|  | ||||
|     expect(component.field).toEqual(customFields[1]) | ||||
|     expect(component.value).toEqual(100) | ||||
|     expect(component.currency).toEqual('USD') | ||||
|   }) | ||||
|  | ||||
|   it('should get document titles', () => { | ||||
|     const docLinkDocuments: Document[] = [ | ||||
|       { id: 1, title: 'Document 1' } as any, | ||||
|       { id: 2, title: 'Document 2' } as any, | ||||
|       { id: 3, title: 'Document 3' } as any, | ||||
|     ] | ||||
|     jest | ||||
|       .spyOn(documentService, 'getFew') | ||||
|       .mockReturnValue(of({ results: docLinkDocuments } as any)) | ||||
|     component.fieldId = 3 | ||||
|     component.document = document | ||||
|  | ||||
|     const title1 = component.getDocumentTitle(1) | ||||
|     const title2 = component.getDocumentTitle(2) | ||||
|     const title3 = component.getDocumentTitle(3) | ||||
|  | ||||
|     expect(title1).toEqual('Document 1') | ||||
|     expect(title2).toEqual('Document 2') | ||||
|     expect(title3).toEqual('Document 3') | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,105 @@ | ||||
| import { Component, Input, OnDestroy, OnInit } from '@angular/core' | ||||
| import { Subject, takeUntil } from 'rxjs' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { DisplayField, Document } from 'src/app/data/document' | ||||
| import { Results } from 'src/app/data/results' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-custom-field-display', | ||||
|   templateUrl: './custom-field-display.component.html', | ||||
|   styleUrl: './custom-field-display.component.scss', | ||||
| }) | ||||
| export class CustomFieldDisplayComponent implements OnInit, OnDestroy { | ||||
|   CustomFieldDataType = CustomFieldDataType | ||||
|  | ||||
|   private _document: Document | ||||
|   @Input() | ||||
|   set document(document: Document) { | ||||
|     this._document = document | ||||
|     this.init() | ||||
|   } | ||||
|  | ||||
|   get document(): Document { | ||||
|     return this._document | ||||
|   } | ||||
|  | ||||
|   private _fieldId: number | ||||
|   @Input() | ||||
|   set fieldId(id: number) { | ||||
|     this._fieldId = id | ||||
|     this.init() | ||||
|   } | ||||
|  | ||||
|   get fieldId(): number { | ||||
|     return this._fieldId | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   set fieldDisplayKey(key: string) { | ||||
|     this.fieldId = parseInt(key.replace(DisplayField.CUSTOM_FIELD, ''), 10) | ||||
|   } | ||||
|  | ||||
|   value: any | ||||
|   currency: string | ||||
|  | ||||
|   private customFields: CustomField[] = [] | ||||
|  | ||||
|   public field: CustomField | ||||
|  | ||||
|   private docLinkDocuments: Document[] = [] | ||||
|  | ||||
|   private unsubscribeNotifier: Subject<any> = new Subject() | ||||
|  | ||||
|   constructor( | ||||
|     private customFieldService: CustomFieldsService, | ||||
|     private documentService: DocumentService | ||||
|   ) { | ||||
|     this.customFieldService.listAll().subscribe((r) => { | ||||
|       this.customFields = r.results | ||||
|       this.init() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.init() | ||||
|   } | ||||
|  | ||||
|   private init() { | ||||
|     if (this.value || !this._fieldId || !this._document || !this.customFields) { | ||||
|       return | ||||
|     } | ||||
|     this.field = this.customFields.find((f) => f.id === this._fieldId) | ||||
|     this.value = this._document.custom_fields.find( | ||||
|       (f) => f.field === this._fieldId | ||||
|     )?.value | ||||
|     if (this.value && this.field.data_type === CustomFieldDataType.Monetary) { | ||||
|       this.currency = this.value.match(/([A-Z]{3})/)?.[0] | ||||
|       this.value = parseFloat(this.value.replace(this.currency, '')) | ||||
|     } else if ( | ||||
|       this.value?.length && | ||||
|       this.field.data_type === CustomFieldDataType.DocumentLink | ||||
|     ) { | ||||
|       this.getDocuments() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private getDocuments() { | ||||
|     this.documentService | ||||
|       .getFew(this.value, { fields: 'id,title' }) | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((result: Results<Document>) => { | ||||
|         this.docLinkDocuments = result.results | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public getDocumentTitle(docId: number): string { | ||||
|     return this.docLinkDocuments?.find((d) => d.id === docId)?.title | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy(): void { | ||||
|     this.unsubscribeNotifier.next(true) | ||||
|     this.unsubscribeNotifier.complete() | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| <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;" | ||||
|         cdkDropList #selectedList="cdkDropList" | ||||
|         cdkDropListOrientation="horizontal" | ||||
|         (cdkDropListDropped)="drop($event)" | ||||
|         [cdkDropListConnectedTo]="[unselectedList]"> | ||||
|         @for (item of selectedItems; track item.id) { | ||||
|             <div class="badge bg-primary" cdkDrag>{{item.name}}</div> | ||||
|         } | ||||
|         @if (selectedItems.length === 0) { | ||||
|             <div class="badge bg-light fst-italic" i18n>{{emptyText}}</div> | ||||
|         } | ||||
|     </div> | ||||
| </div> | ||||
| <div class="d-flex flex-row mt-2 align-items-center bg-light p-2"> | ||||
|     <div class="d-flex flex-row gap-2 w-100 mh-1" style="min-height: 1em;" | ||||
|         cdkDropList #unselectedList="cdkDropList" | ||||
|         cdkDropListOrientation="horizontal" | ||||
|         (cdkDropListDropped)="drop($event)" | ||||
|         [cdkDropListConnectedTo]="[selectedList]"> | ||||
|         @for (item of unselectedItems; track item.id) { | ||||
|             <div class="badge bg-secondary opacity-50" cdkDrag>{{item.name}}</div> | ||||
|         } | ||||
|     </div> | ||||
| </div> | ||||
| @@ -0,0 +1,7 @@ | ||||
| .badge { | ||||
|     cursor: move; | ||||
| } | ||||
|  | ||||
| .d-flex { | ||||
|     overflow-x: scroll; | ||||
| } | ||||
| @@ -0,0 +1,102 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { DragDropModule } from '@angular/cdk/drag-drop' | ||||
| import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms' | ||||
| import { DragDropSelectComponent } from './drag-drop-select.component' | ||||
|  | ||||
| describe('DragDropSelectComponent', () => { | ||||
|   let component: DragDropSelectComponent | ||||
|   let fixture: ComponentFixture<DragDropSelectComponent> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [DragDropModule, FormsModule], | ||||
|       declarations: [DragDropSelectComponent], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(DragDropSelectComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should update selectedItems when writeValue is called', () => { | ||||
|     const newValue = ['1', '2', '3'] | ||||
|     component.items = [ | ||||
|       { id: '1', name: 'Item 1' }, | ||||
|       { id: '2', name: 'Item 2' }, | ||||
|       { id: '3', name: 'Item 3' }, | ||||
|     ] | ||||
|     component.writeValue(newValue) | ||||
|     expect(component.selectedItems).toEqual([ | ||||
|       { id: '1', name: 'Item 1' }, | ||||
|       { id: '2', name: 'Item 2' }, | ||||
|       { id: '3', name: 'Item 3' }, | ||||
|     ]) | ||||
|  | ||||
|     component.writeValue(null) | ||||
|     expect(component.selectedItems).toEqual([]) | ||||
|   }) | ||||
|  | ||||
|   it('should update selectedItems when an item is dropped within selectedList', () => { | ||||
|     component.items = [ | ||||
|       { id: '1', name: 'Item 1' }, | ||||
|       { id: '2', name: 'Item 2' }, | ||||
|       { id: '3', name: 'Item 3' }, | ||||
|       { id: '4', name: 'Item 4' }, | ||||
|     ] | ||||
|     component.writeValue(['1', '2', '3']) | ||||
|     const event = { | ||||
|       previousContainer: component.selectedList, | ||||
|       container: component.selectedList, | ||||
|       previousIndex: 1, | ||||
|       currentIndex: 2, | ||||
|     } | ||||
|     component.drop(event as any) | ||||
|     expect(component.selectedItems).toEqual([ | ||||
|       { id: '1', name: 'Item 1' }, | ||||
|       { id: '3', name: 'Item 3' }, | ||||
|       { id: '2', name: 'Item 2' }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should update selectedItems when an item is dropped from unselectedList to selectedList', () => { | ||||
|     component.items = [ | ||||
|       { id: '1', name: 'Item 1' }, | ||||
|       { id: '2', name: 'Item 2' }, | ||||
|       { id: '3', name: 'Item 3' }, | ||||
|     ] | ||||
|     component.writeValue(['1', '2']) | ||||
|     const event = { | ||||
|       previousContainer: component.unselectedList, | ||||
|       container: component.selectedList, | ||||
|       previousIndex: 0, | ||||
|       currentIndex: 2, | ||||
|     } | ||||
|     component.drop(event as any) | ||||
|     expect(component.selectedItems).toEqual([ | ||||
|       { id: '1', name: 'Item 1' }, | ||||
|       { id: '2', name: 'Item 2' }, | ||||
|       { id: '3', name: 'Item 3' }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should update selectedItems when an item is dropped from selectedList to unselectedList', () => { | ||||
|     component.items = [ | ||||
|       { id: '1', name: 'Item 1' }, | ||||
|       { id: '2', name: 'Item 2' }, | ||||
|       { id: '3', name: 'Item 3' }, | ||||
|     ] | ||||
|     component.writeValue(['1', '2', '3']) | ||||
|     const event = { | ||||
|       previousContainer: component.selectedList, | ||||
|       container: component.unselectedList, | ||||
|       previousIndex: 1, | ||||
|       currentIndex: 0, | ||||
|     } | ||||
|     component.drop(event as any) | ||||
|     expect(component.selectedItems).toEqual([ | ||||
|       { id: '1', name: 'Item 1' }, | ||||
|       { id: '3', name: 'Item 3' }, | ||||
|     ]) | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,68 @@ | ||||
| import { Component, Input, ViewChild, forwardRef } from '@angular/core' | ||||
| import { NG_VALUE_ACCESSOR } from '@angular/forms' | ||||
| import { AbstractInputComponent } from '../abstract-input' | ||||
| import { | ||||
|   CdkDragDrop, | ||||
|   CdkDropList, | ||||
|   moveItemInArray, | ||||
| } from '@angular/cdk/drag-drop' | ||||
|  | ||||
| @Component({ | ||||
|   providers: [ | ||||
|     { | ||||
|       provide: NG_VALUE_ACCESSOR, | ||||
|       useExisting: forwardRef(() => DragDropSelectComponent), | ||||
|       multi: true, | ||||
|     }, | ||||
|   ], | ||||
|   selector: 'pngx-input-drag-drop-select', | ||||
|   templateUrl: './drag-drop-select.component.html', | ||||
|   styleUrl: './drag-drop-select.component.scss', | ||||
| }) | ||||
| export class DragDropSelectComponent extends AbstractInputComponent<string[]> { | ||||
|   @Input() title: string = $localize`Selected items` | ||||
|  | ||||
|   @Input() items: { id: string; name: string }[] = [] | ||||
|   public selectedItems: { id: string; name: string }[] = [] | ||||
|  | ||||
|   @Input() | ||||
|   emptyText = $localize`No items selected` | ||||
|  | ||||
|   @ViewChild('selectedList') selectedList: CdkDropList | ||||
|   @ViewChild('unselectedList') unselectedList: CdkDropList | ||||
|  | ||||
|   get unselectedItems(): { id: string; name: string }[] { | ||||
|     return this.items.filter((i) => !this.selectedItems.includes(i)) | ||||
|   } | ||||
|  | ||||
|   writeValue(newValue: string[]): void { | ||||
|     super.writeValue(newValue) | ||||
|     this.selectedItems = | ||||
|       newValue?.map((id) => this.items.find((i) => i.id === id)) ?? [] | ||||
|   } | ||||
|  | ||||
|   public drop(event: CdkDragDrop<string[]>) { | ||||
|     if ( | ||||
|       event.previousContainer === event.container && | ||||
|       event.container === this.selectedList | ||||
|     ) { | ||||
|       moveItemInArray( | ||||
|         this.selectedItems, | ||||
|         event.previousIndex, | ||||
|         event.currentIndex | ||||
|       ) | ||||
|     } else if (event.container === this.selectedList) { | ||||
|       this.selectedItems.splice( | ||||
|         event.currentIndex, | ||||
|         0, | ||||
|         this.unselectedItems[event.previousIndex] | ||||
|       ) | ||||
|     } else if ( | ||||
|       event.container === this.unselectedList && | ||||
|       event.previousContainer === this.selectedList | ||||
|     ) { | ||||
|       this.selectedItems.splice(event.previousIndex, 1) | ||||
|     } | ||||
|     this.onChange(this.selectedItems.map((i) => i.id)) | ||||
|   } | ||||
| } | ||||
| @@ -23,7 +23,7 @@ | ||||
|       } | ||||
|  | ||||
|       <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> | ||||
|         @for (v of dashboardViews; track v) { | ||||
|         @for (v of dashboardViews; track v.id) { | ||||
|           <div class="col"> | ||||
|             <pngx-saved-view-widget | ||||
|               [savedView]="v" | ||||
|   | ||||
| @@ -9,58 +9,114 @@ | ||||
|     <a class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a> | ||||
|   } | ||||
|  | ||||
|   @if (documents.length) { | ||||
|     <table content class="table table-hover mb-0 align-middle"> | ||||
|   @if (documents.length && displayMode === DisplayMode.TABLE) { | ||||
|     <table content class="table table-hover mb-0 mt-n2 align-middle"> | ||||
|       <thead> | ||||
|         <tr> | ||||
|           <th scope="col" i18n>Created</th> | ||||
|           <th scope="col" i18n>Title</th> | ||||
|           @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||
|             <th scope="col" class="d-none d-md-table-cell" i18n>Tags</th> | ||||
|           } | ||||
|           @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|             <th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th> | ||||
|           } @else { | ||||
|             <th scope="col" class="d-none d-md-table-cell"></th> | ||||
|           @for (field of displayFields; track field; let i = $index) { | ||||
|             @if (displayFields.includes(field)) { | ||||
|               <th | ||||
|                 scope="col" | ||||
|                 [ngClass]="{ | ||||
|                   'd-none d-md-table-cell': i > 1, | ||||
|                   'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED | ||||
|                 }"> | ||||
|                 {{ getColumnTitle(field) }} | ||||
|               </th> | ||||
|             } | ||||
|           } | ||||
|         </tr> | ||||
|       </thead> | ||||
|       <tbody> | ||||
|         @for (doc of documents; track doc) { | ||||
|           <tr (mouseleave)="maybeClosePopover()"> | ||||
|             <td class="py-2 py-md-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a></td> | ||||
|             <td class="py-2 py-md-3"> | ||||
|               <a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a> | ||||
|             </td> | ||||
|             @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||
|               <td class="py-2 py-md-3 d-none d-md-table-cell"> | ||||
|                 @for (t of doc.tags$ | async; track t) { | ||||
|                   <pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag> | ||||
|         @for (doc of documents; track doc.id) { | ||||
|           <tr> | ||||
|             @for (field of displayFields; track field; let i = $index) { | ||||
|               <td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': i > 1 }"> | ||||
|                 @switch (field) { | ||||
|                   @case (DisplayField.ADDED) { | ||||
|                     <a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.added | customDate}}</a> | ||||
|                   } | ||||
|                   @case (DisplayField.CREATED) { | ||||
|                     <a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a> | ||||
|                   } | ||||
|                   @case (DisplayField.TITLE) { | ||||
|                     <a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a> | ||||
|                   } | ||||
|                   @case (DisplayField.CORRESPONDENT) { | ||||
|                     @if (doc.correspondent) { | ||||
|                       <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickCorrespondent(doc.correspondent, $event)">{{(doc.correspondent$ | async)?.name}}</a> | ||||
|                     } | ||||
|                   } | ||||
|                   @case (DisplayField.TAGS) { | ||||
|                     @for (t of doc.tags$ | async; track t) { | ||||
|                       <pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t.id, $event)"></pngx-tag> | ||||
|                     } | ||||
|                   } | ||||
|                   @case (DisplayField.DOCUMENT_TYPE) { | ||||
|                     @if (doc.document_type) { | ||||
|                       <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickDocType(doc.document_type, $event)">{{(doc.document_type$ | async)?.name}}</a> | ||||
|                     } | ||||
|                   } | ||||
|                   @case (DisplayField.STORAGE_PATH) { | ||||
|                     @if (doc.storage_path) { | ||||
|                       <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickStoragePath(doc.storage_path, $event)">{{(doc.storage_path$ | async)?.name}}</a> | ||||
|                     } | ||||
|                   } | ||||
|                 } | ||||
|                 @if (field.startsWith(DisplayField.CUSTOM_FIELD)) { | ||||
|                   <pngx-custom-field-display [document]="doc" [fieldDisplayKey]="field"></pngx-custom-field-display> | ||||
|                 } | ||||
|                 @if (i === displayFields.length - 1) { | ||||
|                   <div class="btn-group position-absolute top-50 end-0 translate-middle-y"> | ||||
|                     <a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle" | ||||
|                       [ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle" | ||||
|                       autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreviewButton(doc)" (mouseleave)="mouseLeavePreviewButton()" #popover="ngbPopover"> | ||||
|                       <i-bs width="0.8em" height="0.8em" name="eye"></i-bs> | ||||
|                     </a> | ||||
|                     <ng-template #previewContent> | ||||
|                       <pngx-preview-popup [document]="doc" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()"></pngx-preview-popup> | ||||
|                     </ng-template> | ||||
|                     <a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()"> | ||||
|                       <i-bs width="0.8em" height="0.8em" name="download"></i-bs> | ||||
|                     </a> | ||||
|                   </div> | ||||
|                 } | ||||
|               </td> | ||||
|             } | ||||
|             <td class="position-relative py-2 py-md-3 d-none d-md-table-cell"> | ||||
|               @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && doc.correspondent !== null) { | ||||
|                 <a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a> | ||||
|               } | ||||
|               <div class="btn-group position-absolute top-50 end-0 translate-middle-y"> | ||||
|                 <a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle" | ||||
|                   [ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle" | ||||
|                   autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreviewButton(doc)" (mouseleave)="mouseLeavePreviewButton()" #popover="ngbPopover"> | ||||
|                   <i-bs width="0.8em" height="0.8em" name="eye"></i-bs> | ||||
|                 </a> | ||||
|                 <ng-template #previewContent> | ||||
|                   <pngx-preview-popup [document]="doc" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()"></pngx-preview-popup> | ||||
|                 </ng-template> | ||||
|                 <a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()"> | ||||
|                   <i-bs width="0.8em" height="0.8em" name="download"></i-bs> | ||||
|                 </a> | ||||
|               </div> | ||||
|             </td> | ||||
|           </tr> | ||||
|         } | ||||
|       </tbody> | ||||
|     </table> | ||||
|   } @else if (documents.length && displayMode === DisplayMode.SMALL_CARDS) { | ||||
|     <div class="row row-cols-paperless-cards my-n2"> | ||||
|       @for (d of documents; track d.id) { | ||||
|         <pngx-document-card-small | ||||
|           class="p-0" | ||||
|           (dblClickDocument)="openDocumentDetail(d)" | ||||
|           [document]="d" | ||||
|           [displayFields]="displayFields" | ||||
|           (clickTag)="clickTag($event)" | ||||
|           (clickCorrespondent)="clickCorrespondent($event)" | ||||
|           (clickStoragePath)="clickStoragePath($event)" | ||||
|           (clickDocumentType)="clickDocumentType($event)"> | ||||
|         </pngx-document-card-small> | ||||
|       } | ||||
|     </div> | ||||
|   } @else if (documents.length && displayMode === DisplayMode.LARGE_CARDS) { | ||||
|     <div class="row my-n2"> | ||||
|       @for (d of documents; track d.id) { | ||||
|         <pngx-document-card-large | ||||
|           (dblClickDocument)="openDocumentDetail(d)" | ||||
|           [document]="d" | ||||
|           [displayFields]="displayFields" | ||||
|           (clickTag)="clickTag($event)" | ||||
|           (clickCorrespondent)="clickCorrespondent($event)" | ||||
|           (clickStoragePath)="clickStoragePath($event)" | ||||
|           (clickDocumentType)="clickDocumentType($event)" | ||||
|           (clickMoreLike)="clickMoreLike(d.id)"> | ||||
|         </pngx-document-card-large> | ||||
|       } | ||||
|     </div> | ||||
|   } @else { | ||||
|     <p i18n class="text-center text-muted mb-0 fst-italic">No documents</p> | ||||
|   } | ||||
|   | ||||
| @@ -3,10 +3,9 @@ table { | ||||
|   table-layout: fixed; | ||||
| } | ||||
|  | ||||
| th:first-child { | ||||
|   width: 25%; | ||||
|   @media (min-width: 768px) { | ||||
|     width: 15%; | ||||
| @media (min-width: 768px) { | ||||
|   th.w-25 { | ||||
|     width: 15% !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -30,3 +29,45 @@ td.py-3 { | ||||
|   padding-top: 0.75em !important; | ||||
|   padding-bottom: 0.75em !important; | ||||
| } | ||||
|  | ||||
| $paperless-card-breakpoints: ( | ||||
|   // 0: 2, // xs is manual for slim-sidebar | ||||
|   768px: 2, //md | ||||
|   992px: 2, //lg | ||||
|   1200px: 3, //xl | ||||
|   1600px: 4, | ||||
|   1800px: 5, | ||||
|   2000px: 6 | ||||
| ); | ||||
|  | ||||
| .row-cols-paperless-cards { | ||||
|   // xs, we dont want in .col-slim block | ||||
|   > * { | ||||
|     flex: 0 0 auto; | ||||
|     width: calc(100% / 2); | ||||
|   } | ||||
|  | ||||
|   @each $width, $n_cols in $paperless-card-breakpoints { | ||||
|     @media(min-width: $width) { | ||||
|       > * { | ||||
|         flex: 0 0 auto; | ||||
|         width: calc(100% / $n-cols); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| ::ng-deep .col-slim .row-cols-paperless-cards { | ||||
|   @each $width, $n_cols in $paperless-card-breakpoints { | ||||
|     @media(min-width: $width) { | ||||
|       > * { | ||||
|         flex: 0 0 auto; | ||||
|         width: calc(100% / ($n-cols + 1)) !important; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| ::ng-deep .document-card-check { | ||||
|   display: none !important; // override for dashboard | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,13 @@ import { RouterTestingModule } from '@angular/router/testing' | ||||
| import { NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { of, Subject } from 'rxjs' | ||||
| import { routes } from 'src/app/app-routing.module' | ||||
| import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' | ||||
| import { | ||||
|   FILTER_CORRESPONDENT, | ||||
|   FILTER_DOCUMENT_TYPE, | ||||
|   FILTER_FULLTEXT_MORELIKE, | ||||
|   FILTER_HAS_TAGS_ALL, | ||||
|   FILTER_STORAGE_PATH, | ||||
| } from 'src/app/data/filter-rule-type' | ||||
| import { SavedView } from 'src/app/data/saved-view' | ||||
| import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | ||||
| import { PermissionsGuard } from 'src/app/guards/permissions.guard' | ||||
| @@ -31,6 +37,10 @@ import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe' | ||||
| import { DragDropModule } from '@angular/cdk/drag-drop' | ||||
| import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component' | ||||
| import { DisplayMode, DisplayField } from 'src/app/data/document' | ||||
|  | ||||
| const savedView: SavedView = { | ||||
|   id: 1, | ||||
| @@ -45,17 +55,53 @@ const savedView: SavedView = { | ||||
|       value: '1,2', | ||||
|     }, | ||||
|   ], | ||||
|   page_size: 20, | ||||
|   display_mode: DisplayMode.TABLE, | ||||
|   display_fields: [ | ||||
|     DisplayField.CREATED, | ||||
|     DisplayField.TITLE, | ||||
|     DisplayField.TAGS, | ||||
|     DisplayField.CORRESPONDENT, | ||||
|     DisplayField.DOCUMENT_TYPE, | ||||
|     DisplayField.STORAGE_PATH, | ||||
|     `${DisplayField.CUSTOM_FIELD}11` as any, | ||||
|     `${DisplayField.CUSTOM_FIELD}15` as any, | ||||
|   ], | ||||
| } | ||||
|  | ||||
| const documentResults = [ | ||||
|   { | ||||
|     id: 2, | ||||
|     title: 'doc2', | ||||
|     custom_fields: [ | ||||
|       { id: 1, field: 11, created: new Date(), value: 'custom', document: 2 }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     id: 3, | ||||
|     title: 'doc3', | ||||
|     correspondent: 0, | ||||
|     custom_fields: [], | ||||
|   }, | ||||
|   { | ||||
|     id: 4, | ||||
|     title: 'doc4', | ||||
|     custom_fields: [ | ||||
|       { id: 32, field: 3, created: new Date(), value: 'EUR123', document: 4 }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     id: 5, | ||||
|     title: 'doc5', | ||||
|     custom_fields: [ | ||||
|       { | ||||
|         id: 22, | ||||
|         field: 15, | ||||
|         created: new Date(), | ||||
|         value: [123, 456, 789], | ||||
|         document: 5, | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| @@ -77,6 +123,7 @@ describe('SavedViewWidgetComponent', () => { | ||||
|         DocumentTitlePipe, | ||||
|         SafeUrlPipe, | ||||
|         PreviewPopupComponent, | ||||
|         CustomFieldDisplayComponent, | ||||
|       ], | ||||
|       providers: [ | ||||
|         PermissionsGuard, | ||||
| @@ -89,6 +136,33 @@ describe('SavedViewWidgetComponent', () => { | ||||
|         }, | ||||
|         CustomDatePipe, | ||||
|         DatePipe, | ||||
|         { | ||||
|           provide: CustomFieldsService, | ||||
|           useValue: { | ||||
|             listAll: () => | ||||
|               of({ | ||||
|                 all: [3, 11, 15], | ||||
|                 count: 3, | ||||
|                 results: [ | ||||
|                   { | ||||
|                     id: 3, | ||||
|                     name: 'Custom field 3', | ||||
|                     data_type: CustomFieldDataType.Monetary, | ||||
|                   }, | ||||
|                   { | ||||
|                     id: 11, | ||||
|                     name: 'Custom Field 11', | ||||
|                     data_type: CustomFieldDataType.String, | ||||
|                   }, | ||||
|                   { | ||||
|                     id: 15, | ||||
|                     name: 'Custom Field 15', | ||||
|                     data_type: CustomFieldDataType.DocumentLink, | ||||
|                   }, | ||||
|                 ], | ||||
|               }), | ||||
|           }, | ||||
|         }, | ||||
|       ], | ||||
|       imports: [ | ||||
|         HttpClientTestingModule, | ||||
| @@ -170,7 +244,7 @@ describe('SavedViewWidgetComponent', () => { | ||||
|     component.ngOnInit() | ||||
|     expect(listAllSpy).toHaveBeenCalledWith( | ||||
|       1, | ||||
|       10, | ||||
|       20, | ||||
|       savedView.sort_field, | ||||
|       savedView.sort_reverse, | ||||
|       savedView.filter_rules, | ||||
| @@ -204,11 +278,78 @@ describe('SavedViewWidgetComponent', () => { | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   it('should navigate to document', () => { | ||||
|     const routerSpy = jest.spyOn(router, 'navigate') | ||||
|     component.openDocumentDetail(documentResults[0]) | ||||
|     expect(routerSpy).toHaveBeenCalledWith(['documents', documentResults[0].id]) | ||||
|   }) | ||||
|  | ||||
|   it('should navigate via quickfilter on click tag', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.clickTag({ id: 11, name: 'Tag11' }, new MouseEvent('click')) | ||||
|     component.clickTag(11, new MouseEvent('click')) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: '11' }, | ||||
|     ]) | ||||
|     component.clickTag(11) // coverage | ||||
|   }) | ||||
|  | ||||
|   it('should navigate via quickfilter on click correspondent', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.clickCorrespondent(11, new MouseEvent('click')) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_CORRESPONDENT, value: '11' }, | ||||
|     ]) | ||||
|     component.clickCorrespondent(11) // coverage | ||||
|   }) | ||||
|  | ||||
|   it('should navigate via quickfilter on click doc type', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.clickDocType(11, new MouseEvent('click')) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_DOCUMENT_TYPE, value: '11' }, | ||||
|     ]) | ||||
|     component.clickDocType(11) // coverage | ||||
|   }) | ||||
|  | ||||
|   it('should navigate via quickfilter on click storage path', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.clickStoragePath(11, new MouseEvent('click')) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_STORAGE_PATH, value: '11' }, | ||||
|     ]) | ||||
|     component.clickStoragePath(11) // coverage | ||||
|   }) | ||||
|  | ||||
|   it('should navigate via quickfilter on click more like', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.clickMoreLike(11) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_FULLTEXT_MORELIKE, value: '11' }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should get correct column title', () => { | ||||
|     expect(component.getColumnTitle(DisplayField.TITLE)).toEqual('Title') | ||||
|     expect(component.getColumnTitle(DisplayField.CREATED)).toEqual('Created') | ||||
|     expect(component.getColumnTitle(DisplayField.ADDED)).toEqual('Added') | ||||
|     expect(component.getColumnTitle(DisplayField.TAGS)).toEqual('Tags') | ||||
|     expect(component.getColumnTitle(DisplayField.CORRESPONDENT)).toEqual( | ||||
|       'Correspondent' | ||||
|     ) | ||||
|     expect(component.getColumnTitle(DisplayField.DOCUMENT_TYPE)).toEqual( | ||||
|       'Document type' | ||||
|     ) | ||||
|     expect(component.getColumnTitle(DisplayField.STORAGE_PATH)).toEqual( | ||||
|       'Storage path' | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should get correct column title for custom field', () => { | ||||
|     expect( | ||||
|       component.getColumnTitle((DisplayField.CUSTOM_FIELD + 11) as any) | ||||
|     ).toEqual('Custom Field 11') | ||||
|     expect( | ||||
|       component.getColumnTitle((DisplayField.CUSTOM_FIELD + 15) as any) | ||||
|     ).toEqual('Custom Field 15') | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -6,23 +6,38 @@ import { | ||||
|   QueryList, | ||||
|   ViewChildren, | ||||
| } from '@angular/core' | ||||
| import { Params, Router } from '@angular/router' | ||||
| import { Router } from '@angular/router' | ||||
| import { Subject, takeUntil } from 'rxjs' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { | ||||
|   DEFAULT_DASHBOARD_DISPLAY_FIELDS, | ||||
|   DEFAULT_DASHBOARD_VIEW_PAGE_SIZE, | ||||
|   DEFAULT_DISPLAY_FIELDS, | ||||
|   DisplayField, | ||||
|   DisplayMode, | ||||
|   Document, | ||||
| } from 'src/app/data/document' | ||||
| import { SavedView } from 'src/app/data/saved-view' | ||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { Tag } from 'src/app/data/tag' | ||||
| import { | ||||
|   FILTER_CORRESPONDENT, | ||||
|   FILTER_DOCUMENT_TYPE, | ||||
|   FILTER_FULLTEXT_MORELIKE, | ||||
|   FILTER_HAS_TAGS_ALL, | ||||
|   FILTER_STORAGE_PATH, | ||||
| } from 'src/app/data/filter-rule-type' | ||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' | ||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { queryParamsFromFilterRules } from 'src/app/utils/query-params' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { | ||||
|   PermissionAction, | ||||
|   PermissionType, | ||||
|   PermissionsService, | ||||
| } from 'src/app/services/permissions.service' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-saved-view-widget', | ||||
| @@ -33,8 +48,14 @@ export class SavedViewWidgetComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   public DisplayMode = DisplayMode | ||||
|   public DisplayField = DisplayField | ||||
|   public CustomFieldDataType = CustomFieldDataType | ||||
|  | ||||
|   loading: boolean = true | ||||
|  | ||||
|   private customFields: CustomField[] = [] | ||||
|  | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private router: Router, | ||||
| @@ -42,7 +63,9 @@ export class SavedViewWidgetComponent | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     public openDocumentsService: OpenDocumentsService, | ||||
|     public documentListViewService: DocumentListViewService, | ||||
|     public permissionsService: PermissionsService | ||||
|     public permissionsService: PermissionsService, | ||||
|     private settingsService: SettingsService, | ||||
|     private customFieldService: CustomFieldsService | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
| @@ -60,14 +83,44 @@ export class SavedViewWidgetComponent | ||||
|   mouseOnPreview = false | ||||
|   popoverHidden = true | ||||
|  | ||||
|   displayMode: DisplayMode | ||||
|  | ||||
|   displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.reload() | ||||
|     this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE | ||||
|     this.consumerStatusService | ||||
|       .onDocumentConsumptionFinished() | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         this.reload() | ||||
|       }) | ||||
|  | ||||
|     if ( | ||||
|       this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.CustomField | ||||
|       ) | ||||
|     ) { | ||||
|       this.customFieldService | ||||
|         .listAll() | ||||
|         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe((customFields) => { | ||||
|           this.customFields = customFields.results | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     if (this.savedView.display_fields) { | ||||
|       this.displayFields = this.savedView.display_fields | ||||
|     } | ||||
|  | ||||
|     // filter by perms etc | ||||
|     this.displayFields = this.displayFields.filter( | ||||
|       (field) => | ||||
|         this.settingsService.allDisplayFields.find((f) => f.id === field) !== | ||||
|         undefined | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy(): void { | ||||
| @@ -80,7 +133,7 @@ export class SavedViewWidgetComponent | ||||
|     this.documentService | ||||
|       .listFiltered( | ||||
|         1, | ||||
|         10, | ||||
|         this.savedView.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE, | ||||
|         this.savedView.sort_field, | ||||
|         this.savedView.sort_reverse, | ||||
|         this.savedView.filter_rules, | ||||
| @@ -103,15 +156,52 @@ export class SavedViewWidgetComponent | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   clickTag(tag: Tag, event: MouseEvent) { | ||||
|     event.preventDefault() | ||||
|     event.stopImmediatePropagation() | ||||
|   clickTag(tagID: number, event: MouseEvent = null) { | ||||
|     event?.preventDefault() | ||||
|     event?.stopImmediatePropagation() | ||||
|  | ||||
|     this.list.quickFilter([ | ||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, | ||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: tagID.toString() }, | ||||
|     ]) | ||||
|   } | ||||
|  | ||||
|   clickCorrespondent(correspondentId: number, event: MouseEvent = null) { | ||||
|     event?.preventDefault() | ||||
|     event?.stopImmediatePropagation() | ||||
|  | ||||
|     this.list.quickFilter([ | ||||
|       { rule_type: FILTER_CORRESPONDENT, value: correspondentId.toString() }, | ||||
|     ]) | ||||
|   } | ||||
|  | ||||
|   clickDocType(docTypeId: number, event: MouseEvent = null) { | ||||
|     event?.preventDefault() | ||||
|     event?.stopImmediatePropagation() | ||||
|  | ||||
|     this.list.quickFilter([ | ||||
|       { rule_type: FILTER_DOCUMENT_TYPE, value: docTypeId.toString() }, | ||||
|     ]) | ||||
|   } | ||||
|  | ||||
|   clickStoragePath(storagePathId: number, event: MouseEvent = null) { | ||||
|     event?.preventDefault() | ||||
|     event?.stopImmediatePropagation() | ||||
|  | ||||
|     this.list.quickFilter([ | ||||
|       { rule_type: FILTER_STORAGE_PATH, value: storagePathId.toString() }, | ||||
|     ]) | ||||
|   } | ||||
|  | ||||
|   clickMoreLike(documentID: number) { | ||||
|     this.list.quickFilter([ | ||||
|       { rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() }, | ||||
|     ]) | ||||
|   } | ||||
|  | ||||
|   openDocumentDetail(document: Document) { | ||||
|     this.router.navigate(['documents', document.id]) | ||||
|   } | ||||
|  | ||||
|   getPreviewUrl(document: Document): string { | ||||
|     return this.documentService.getPreviewUrl(document.id) | ||||
|   } | ||||
| @@ -161,14 +251,11 @@ export class SavedViewWidgetComponent | ||||
|     }, 300) | ||||
|   } | ||||
|  | ||||
|   getCorrespondentQueryParams(correspondentId: number): Params { | ||||
|     return correspondentId !== undefined | ||||
|       ? queryParamsFromFilterRules([ | ||||
|           { | ||||
|             rule_type: FILTER_CORRESPONDENT, | ||||
|             value: correspondentId.toString(), | ||||
|           }, | ||||
|         ]) | ||||
|       : null | ||||
|   public getColumnTitle(field: DisplayField): string { | ||||
|     if (field.startsWith(DisplayField.CUSTOM_FIELD)) { | ||||
|       const id = field.split('_')[2] | ||||
|       return this.customFields.find((f) => f.id === parseInt(id))?.name | ||||
|     } | ||||
|     return DEFAULT_DISPLAY_FIELDS.find((f) => f.id === field)?.name | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -13,11 +13,11 @@ | ||||
|         <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|         <div class="visually-hidden" i18n>Loading...</div> | ||||
|       } | ||||
|       <ng-content select ="[header-buttons]"></ng-content> | ||||
|       <ng-content select="[header-buttons]"></ng-content> | ||||
|     </div> | ||||
|  | ||||
|   </div> | ||||
|   <div class="card-body text-dark"> | ||||
|     <ng-content select ="[content]"></ng-content> | ||||
|     <ng-content select="[content]"></ng-content> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -15,7 +15,7 @@ | ||||
|       <div class="card-body"> | ||||
|         <div class="d-flex justify-content-between align-items-center"> | ||||
|           <h5 class="card-title"> | ||||
|             @if (document.correspondent) { | ||||
|             @if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) { | ||||
|               @if (clickCorrespondent.observers.length ) { | ||||
|                 <a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a> | ||||
|               } @else { | ||||
| @@ -23,14 +23,18 @@ | ||||
|               } | ||||
|               : | ||||
|             } | ||||
|             {{document.title | documentTitle}} | ||||
|             @for (t of document.tags$ | async; track t) { | ||||
|               <pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag> | ||||
|             @if (displayFields.includes(DisplayField.TITLE)) { | ||||
|               {{document.title | documentTitle}} | ||||
|             } | ||||
|             @if (displayFields.includes(DisplayField.TAGS)) { | ||||
|               @for (t of document.tags$ | async; track t) { | ||||
|                 <pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag> | ||||
|               } | ||||
|             } | ||||
|           </h5> | ||||
|         </div> | ||||
|         <p class="card-text"> | ||||
|           @if (document.__search_hit__ && document.__search_hit__.highlights) { | ||||
|           @if (document.__search_hit__?.score && document.__search_hit__.highlights) { | ||||
|             <span [innerHtml]="document.__search_hit__.highlights"></span> | ||||
|           } | ||||
|           @for (highlight of searchNoteHighlights; track highlight) { | ||||
| @@ -39,7 +43,7 @@ | ||||
|               <span [innerHtml]="highlight"></span> | ||||
|             </span> | ||||
|           } | ||||
|           @if (!document.__search_hit__) { | ||||
|           @if (!document.__search_hit__?.score) { | ||||
|             <span class="result-content">{{contentTrimmed}}</span> | ||||
|           } | ||||
|         </p> | ||||
| @@ -66,44 +70,53 @@ | ||||
|             </div> | ||||
|  | ||||
|             <div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0"> | ||||
|               @if (notesEnabled && document.notes.length) { | ||||
|               @if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) { | ||||
|                 <button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title> | ||||
|                   <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small> | ||||
|                 </button> | ||||
|               } | ||||
|               @if (document.document_type) { | ||||
|               @if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) { | ||||
|                 <button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title | ||||
|                   (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> | ||||
|                   <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small> | ||||
|                 </button> | ||||
|               } | ||||
|               @if (document.storage_path) { | ||||
|               @if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) { | ||||
|                 <button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title | ||||
|                   (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()"> | ||||
|                   <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small> | ||||
|                 </button> | ||||
|               } | ||||
|               @if (document.archive_serial_number | isNumber) { | ||||
|               @if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) { | ||||
|                 <div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center"> | ||||
|                   <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small> | ||||
|                 </div> | ||||
|               } | ||||
|               <ng-template #dateTooltip> | ||||
|                 <div class="d-flex flex-column text-light"> | ||||
|                   <span i18n>Created: {{ document.created | customDate }}</span> | ||||
|                   <span i18n>Added: {{ document.added | customDate }}</span> | ||||
|                   <span i18n>Modified: {{ document.modified | customDate }}</span> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|               <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip"> | ||||
|                 <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small> | ||||
|               </div> | ||||
|               @if (document.owner && document.owner !== settingsService.currentUser.id) { | ||||
|               @if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) { | ||||
|                 <ng-template #dateTooltip> | ||||
|                   <div class="d-flex flex-column text-light"> | ||||
|                     <span i18n>Created: {{ document.created | customDate }}</span> | ||||
|                     <span i18n>Added: {{ document.added | customDate }}</span> | ||||
|                     <span i18n>Modified: {{ document.modified | customDate }}</span> | ||||
|                   </div> | ||||
|                 </ng-template> | ||||
|                 @if (displayFields.includes(DisplayField.CREATED)) { | ||||
|                   <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip"> | ||||
|                     <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small> | ||||
|                   </div> | ||||
|                 } | ||||
|                 @if (displayFields.includes(DisplayField.ADDED)) { | ||||
|                   <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip"> | ||||
|                     <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.added | customDate:'mediumDate'}}</small> | ||||
|                   </div> | ||||
|                 } | ||||
|               } | ||||
|               @if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) { | ||||
|                 <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center"> | ||||
|                   <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small> | ||||
|                 </div> | ||||
|               } | ||||
|               @if (document.is_shared_by_requester) { | ||||
|               @if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) { | ||||
|                 <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center"> | ||||
|                   <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small> | ||||
|                 </div> | ||||
| @@ -114,6 +127,16 @@ | ||||
|                   <ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar> | ||||
|                 </div> | ||||
|               } | ||||
|               @for (field of document.custom_fields; track field.id) { | ||||
|                 @if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) { | ||||
|                   <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center"> | ||||
|                     <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="ui-radios"></i-bs> | ||||
|                     <small> | ||||
|                       <pngx-custom-field-display [document]="document" [fieldId]="field.field"></pngx-custom-field-display> | ||||
|                     </small> | ||||
|                   </div> | ||||
|                 } | ||||
|               } | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|   | ||||
| @@ -21,6 +21,7 @@ import { DocumentCardLargeComponent } from './document-card-large.component' | ||||
| import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' | ||||
| import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { CustomFieldDisplayComponent } from '../../common/custom-field-display/custom-field-display.component' | ||||
|  | ||||
| const doc = { | ||||
|   id: 10, | ||||
| @@ -53,6 +54,7 @@ describe('DocumentCardLargeComponent', () => { | ||||
|         SafeUrlPipe, | ||||
|         IsNumberPipe, | ||||
|         PreviewPopupComponent, | ||||
|         CustomFieldDisplayComponent, | ||||
|       ], | ||||
|       providers: [DatePipe], | ||||
|       imports: [ | ||||
|   | ||||
| @@ -5,7 +5,11 @@ import { | ||||
|   Output, | ||||
|   ViewChild, | ||||
| } from '@angular/core' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { | ||||
|   DEFAULT_DISPLAY_FIELDS, | ||||
|   DisplayField, | ||||
|   Document, | ||||
| } from 'src/app/data/document' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | ||||
| @@ -18,6 +22,8 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission | ||||
|   styleUrls: ['./document-card-large.component.scss'], | ||||
| }) | ||||
| export class DocumentCardLargeComponent extends ComponentWithPermissions { | ||||
|   DisplayField = DisplayField | ||||
|  | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     public settingsService: SettingsService | ||||
| @@ -28,6 +34,9 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions { | ||||
|   @Input() | ||||
|   selected = false | ||||
|  | ||||
|   @Input() | ||||
|   displayFields: string[] = DEFAULT_DISPLAY_FIELDS.map((f) => f.id) | ||||
|  | ||||
|   @Output() | ||||
|   toggleSelected = new EventEmitter() | ||||
|  | ||||
|   | ||||
| @@ -10,19 +10,21 @@ | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6"> | ||||
|         @for (t of getTagsLimited$() | async; track t) { | ||||
|           <pngx-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag> | ||||
|         } | ||||
|         @if (moreTags) { | ||||
|           <div> | ||||
|             <span class="badge text-dark">+ {{moreTags}}</span> | ||||
|           </div> | ||||
|         } | ||||
|       </div> | ||||
|       @if (displayFields?.includes(DisplayField.TAGS)) { | ||||
|         <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6"> | ||||
|           @for (t of getTagsLimited$() | async; track t) { | ||||
|             <pngx-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag> | ||||
|           } | ||||
|           @if (moreTags) { | ||||
|             <div> | ||||
|               <span class="badge text-dark">+ {{moreTags}}</span> | ||||
|             </div> | ||||
|           } | ||||
|         </div> | ||||
|       } | ||||
|     </div> | ||||
|  | ||||
|     @if (notesEnabled && document.notes.length) { | ||||
|     @if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) { | ||||
|       <a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1"> | ||||
|         <span class="badge rounded-pill bg-light border text-primary"> | ||||
|           <i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs> | ||||
| @@ -32,59 +34,86 @@ | ||||
|  | ||||
|     <div class="card-body bg-light p-2"> | ||||
|       <p class="card-text"> | ||||
|         @if (document.correspondent) { | ||||
|         @if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) { | ||||
|           <a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name ?? privateName}}</a>: | ||||
|         } | ||||
|         {{document.title | documentTitle}} | ||||
|         @if (displayFields.includes(DisplayField.TITLE)) { | ||||
|           {{document.title | documentTitle}} | ||||
|         } | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="card-footer pt-0 pb-2 px-2"> | ||||
|       <div class="list-group list-group-flush border-0 pt-1 pb-2 card-info"> | ||||
|         @if (document.document_type) { | ||||
|         @if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) { | ||||
|           <button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title | ||||
|             (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> | ||||
|             <i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs> | ||||
|             <small>{{(document.document_type$ | async)?.name ?? privateName}}</small> | ||||
|           </button> | ||||
|         } | ||||
|         @if (document.storage_path) { | ||||
|         @if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) { | ||||
|           <button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title | ||||
|             (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()"> | ||||
|             <i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs> | ||||
|             <small>{{(document.storage_path$ | async)?.name ?? privateName}}</small> | ||||
|           </button> | ||||
|         } | ||||
|         <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> | ||||
|           <ng-template #dateTooltip> | ||||
|             <div class="d-flex flex-column text-light"> | ||||
|               <span i18n>Created: {{ document.created | customDate }}</span> | ||||
|               <span i18n>Added: {{ document.added | customDate }}</span> | ||||
|               <span i18n>Modified: {{ document.modified | customDate }}</span> | ||||
|         @if (displayFields.includes(DisplayField.CREATED)) { | ||||
|           <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> | ||||
|             <ng-template #dateTooltip> | ||||
|               <div class="d-flex flex-column text-light"> | ||||
|                 <span i18n>Created: {{ document.created | customDate }}</span> | ||||
|                 <span i18n>Added: {{ document.added | customDate }}</span> | ||||
|                 <span i18n>Modified: {{ document.modified | customDate }}</span> | ||||
|               </div> | ||||
|             </ng-template> | ||||
|             <div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip"> | ||||
|               <i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs> | ||||
|               <small>{{document.created | customDate:'mediumDate'}}</small> | ||||
|             </div> | ||||
|           </ng-template> | ||||
|           <div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip"> | ||||
|             <i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs> | ||||
|             <small>{{document.created_date | customDate:'mediumDate'}}</small> | ||||
|           </div> | ||||
|         </div> | ||||
|         @if (document.archive_serial_number | isNumber) { | ||||
|         } | ||||
|         @if (displayFields.includes(DisplayField.ADDED)) { | ||||
|           <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> | ||||
|             <ng-template #dateTooltip> | ||||
|               <div class="d-flex flex-column text-light"> | ||||
|                 <span i18n>Created: {{ document.created | customDate }}</span> | ||||
|                 <span i18n>Added: {{ document.added | customDate }}</span> | ||||
|                 <span i18n>Modified: {{ document.modified | customDate }}</span> | ||||
|               </div> | ||||
|             </ng-template> | ||||
|             <div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip"> | ||||
|               <i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs> | ||||
|               <small>{{document.added | customDate:'mediumDate'}}</small> | ||||
|             </div> | ||||
|           </div> | ||||
|         } | ||||
|         @if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) { | ||||
|           <div class="ps-0 p-1"> | ||||
|             <i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs> | ||||
|             <small>#{{document.archive_serial_number}}</small> | ||||
|           </div> | ||||
|         } | ||||
|         @if (document.owner && document.owner !== settingsService.currentUser.id) { | ||||
|         @if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) { | ||||
|           <div class="ps-0 p-1"> | ||||
|             <i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs> | ||||
|             <small>{{document.owner | username}}</small> | ||||
|           </div> | ||||
|         } | ||||
|         @if (document.is_shared_by_requester) { | ||||
|         @if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) { | ||||
|           <div class="ps-0 p-1"> | ||||
|             <i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs> | ||||
|             <small i18n>Shared</small> | ||||
|           </div> | ||||
|         } | ||||
|         @for (field of document.custom_fields; track field.id) { | ||||
|           @if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) { | ||||
|             <div class="ps-0 p-1 d-flex align-items-center overflow-hidden"> | ||||
|               <i-bs width="1em" height="1em" class="me-2 text-muted" name="ui-radios"></i-bs> | ||||
|               <small><pngx-custom-field-display [document]="document" [fieldId]="field.field"></pngx-custom-field-display></small> | ||||
|             </div> | ||||
|           } | ||||
|         } | ||||
|       </div> | ||||
|       <div class="d-flex justify-content-between align-items-center"> | ||||
|         <div class="btn-group w-100"> | ||||
|   | ||||
| @@ -24,6 +24,7 @@ import { Tag } from 'src/app/data/tag' | ||||
| import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' | ||||
| import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { CustomFieldDisplayComponent } from '../../common/custom-field-display/custom-field-display.component' | ||||
|  | ||||
| const doc = { | ||||
|   id: 10, | ||||
| @@ -67,6 +68,7 @@ describe('DocumentCardSmallComponent', () => { | ||||
|         TagComponent, | ||||
|         IsNumberPipe, | ||||
|         PreviewPopupComponent, | ||||
|         CustomFieldDisplayComponent, | ||||
|       ], | ||||
|       providers: [DatePipe], | ||||
|       imports: [ | ||||
|   | ||||
| @@ -6,7 +6,11 @@ import { | ||||
|   ViewChild, | ||||
| } from '@angular/core' | ||||
| import { map } from 'rxjs/operators' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { | ||||
|   DEFAULT_DISPLAY_FIELDS, | ||||
|   DisplayField, | ||||
|   Document, | ||||
| } from 'src/app/data/document' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | ||||
| @@ -19,6 +23,8 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission | ||||
|   styleUrls: ['./document-card-small.component.scss'], | ||||
| }) | ||||
| export class DocumentCardSmallComponent extends ComponentWithPermissions { | ||||
|   DisplayField = DisplayField | ||||
|  | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     public settingsService: SettingsService | ||||
| @@ -35,6 +41,9 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions { | ||||
|   @Input() | ||||
|   document: Document | ||||
|  | ||||
|   @Input() | ||||
|   displayFields: string[] = DEFAULT_DISPLAY_FIELDS.map((f) => f.id) | ||||
|  | ||||
|   @Output() | ||||
|   dblClickDocument = new EventEmitter() | ||||
|  | ||||
|   | ||||
| @@ -11,16 +11,32 @@ | ||||
|       <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div ngbDropdown class="d-flex"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle> | ||||
|       <i-bs name="card-heading"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Show</ng-container></div> | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow"> | ||||
|       <div class="px-3"> | ||||
|         @for (field of settingsService.allDisplayFields; track field.id) { | ||||
|           <div class="form-check my-1"> | ||||
|             <input class="form-check-input mt-1" type="checkbox" id="displayField{{field.id}}" [checked]="activeDisplayFields.includes(field.id)" (change)="toggleDisplayField(field.id)"> | ||||
|             <label class="form-check-label" for="displayField{{field.id}}">{{field.name}}</label> | ||||
|           </div> | ||||
|         } | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="btn-group flex-fill" role="group"> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails" name="displayModeDetails"> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="list.displayMode" value="table" id="displayModeDetails" name="displayModeDetails"> | ||||
|     <label for="displayModeDetails" class="btn btn-outline-primary btn-sm"> | ||||
|       <i-bs name="list-ul"></i-bs> | ||||
|     </label> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall" name="displayModeSmall"> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="list.displayMode" value="smallCards" id="displayModeSmall" name="displayModeSmall"> | ||||
|     <label for="displayModeSmall" class="btn btn-outline-primary btn-sm"> | ||||
|       <i-bs name="grid"></i-bs> | ||||
|     </label> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge" name="displayModeLarge"> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="list.displayMode" value="largeCards" id="displayModeLarge" name="displayModeLarge"> | ||||
|     <label for="displayModeLarge" class="btn btn-outline-primary btn-sm"> | ||||
|       <i-bs name="hdd-stack"></i-bs> | ||||
|     </label> | ||||
| @@ -41,7 +57,7 @@ | ||||
|       </div> | ||||
|       <div> | ||||
|         @for (f of getSortFields(); track f) { | ||||
|           <button ngbDropdownItem (click)="setSortField(f.field)" | ||||
|           <button ngbDropdownItem (click)="list.sortField = f.field" | ||||
|             [class.active]="list.sortField === f.field">{{f.name}} | ||||
|           </button> | ||||
|         } | ||||
| @@ -109,7 +125,7 @@ | ||||
|         } | ||||
|       </div> | ||||
|       @if (list.collectionSize) { | ||||
|         <ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||
|         <ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||
|         [rotate]="true" aria-label="Default pagination" size="sm"></ngb-pagination> | ||||
|       } | ||||
|     </div> | ||||
| @@ -122,26 +138,38 @@ | ||||
|   @if (list.error ) { | ||||
|     <div class="alert alert-danger" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{list.error}}</div> | ||||
|   } @else { | ||||
|     @if (displayMode === 'largeCards') { | ||||
|     @if (list.displayMode === DisplayMode.LARGE_CARDS) { | ||||
|       <div> | ||||
|         @for (d of list.documents; track trackByDocumentId($index, d)) { | ||||
|           <pngx-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" (dblClickDocument)="openDocumentDetail(d)" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)"> | ||||
|           <pngx-document-card-large | ||||
|             [selected]="list.isSelected(d)" | ||||
|             (toggleSelected)="toggleSelected(d, $event)" | ||||
|             (dblClickDocument)="openDocumentDetail(d)" | ||||
|             [document]="d" | ||||
|             [displayFields]="activeDisplayFields" | ||||
|             (clickTag)="clickTag($event)" | ||||
|             (clickCorrespondent)="clickCorrespondent($event)" | ||||
|             (clickDocumentType)="clickDocumentType($event)" | ||||
|             (clickStoragePath)="clickStoragePath($event)" | ||||
|             (clickMoreLike)="clickMoreLike(d.id)"> | ||||
|           </pngx-document-card-large> | ||||
|         } | ||||
|       </div> | ||||
|     } | ||||
|     @if (displayMode === 'details') { | ||||
|     @if (list.displayMode === DisplayMode.TABLE) { | ||||
|       <table class="table table-sm align-middle border shadow-sm"> | ||||
|         <thead> | ||||
|           <th></th> | ||||
|           <th class="d-none d-lg-table-cell" | ||||
|             pngxSortable="archive_serial_number" | ||||
|             title="Sort by ASN" i18n-title | ||||
|             [currentSortField]="list.sortField" | ||||
|             [currentSortReverse]="list.sortReverse" | ||||
|             (sort)="onSort($event)" | ||||
|           i18n>ASN</th> | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|           @if (activeDisplayFields.includes(DisplayField.ASN)) { | ||||
|             <th class="d-none d-lg-table-cell" | ||||
|               pngxSortable="archive_serial_number" | ||||
|               title="Sort by ASN" i18n-title | ||||
|               [currentSortField]="list.sortField" | ||||
|               [currentSortReverse]="list.sortReverse" | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>ASN</th> | ||||
|           } | ||||
|           @if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|             <th class="d-none d-md-table-cell" | ||||
|               pngxSortable="correspondent__name" | ||||
|               title="Sort by correspondent" i18n-title | ||||
| @@ -150,22 +178,28 @@ | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>Correspondent</th> | ||||
|           } | ||||
|           <th | ||||
|             pngxSortable="title" | ||||
|             title="Sort by title" i18n-title | ||||
|             class="w-40" | ||||
|             [currentSortField]="list.sortField" | ||||
|             [currentSortReverse]="list.sortReverse" | ||||
|             (sort)="onSort($event)" | ||||
|           i18n>Title</th> | ||||
|           <th class="d-none d-xl-table-cell" | ||||
|             pngxSortable="owner" | ||||
|             title="Sort by owner" i18n-title | ||||
|             [currentSortField]="list.sortField" | ||||
|             [currentSortReverse]="list.sortReverse" | ||||
|             (sort)="onSort($event)" | ||||
|           i18n>Owner</th> | ||||
|           @if (notesEnabled) { | ||||
|           @if (activeDisplayFields.includes(DisplayField.TITLE)) { | ||||
|             <th | ||||
|               pngxSortable="title" | ||||
|               title="Sort by title" i18n-title | ||||
|               [currentSortField]="list.sortField" | ||||
|               [currentSortReverse]="list.sortReverse" | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>Title</th> | ||||
|           } | ||||
|           @if (activeDisplayFields.includes(DisplayField.TAGS) && !activeDisplayFields.includes(DisplayField.TITLE)) { | ||||
|             <th i18n>Tags</th> | ||||
|           } | ||||
|           @if (activeDisplayFields.includes(DisplayField.OWNER) && permissionService.currentUserCan(PermissionAction.View, PermissionType.User)) { | ||||
|             <th class="d-none d-xl-table-cell" | ||||
|               pngxSortable="owner" | ||||
|               title="Sort by owner" i18n-title | ||||
|               [currentSortField]="list.sortField" | ||||
|               [currentSortReverse]="list.sortReverse" | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>Owner</th> | ||||
|           } | ||||
|           @if (activeDisplayFields.includes(DisplayField.NOTES) && notesEnabled) { | ||||
|             <th class="d-none d-xl-table-cell" | ||||
|               pngxSortable="num_notes" | ||||
|               title="Sort by notes" i18n-title | ||||
| @@ -174,7 +208,7 @@ | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>Notes</th> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|           @if (activeDisplayFields.includes(DisplayField.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|             <th class="d-none d-xl-table-cell" | ||||
|               pngxSortable="document_type__name" | ||||
|               title="Sort by document type" i18n-title | ||||
| @@ -183,7 +217,7 @@ | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>Document type</th> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|           @if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|             <th class="d-none d-xl-table-cell" | ||||
|               pngxSortable="storage_path__name" | ||||
|               title="Sort by storage path" i18n-title | ||||
| @@ -192,20 +226,34 @@ | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>Storage path</th> | ||||
|           } | ||||
|           <th | ||||
|             pngxSortable="created" | ||||
|             title="Sort by created date" i18n-title | ||||
|             [currentSortField]="list.sortField" | ||||
|             [currentSortReverse]="list.sortReverse" | ||||
|             (sort)="onSort($event)" | ||||
|           i18n>Created</th> | ||||
|           <th class="d-none d-xl-table-cell" | ||||
|             pngxSortable="added" | ||||
|             title="Sort by added date" i18n-title | ||||
|             [currentSortField]="list.sortField" | ||||
|             [currentSortReverse]="list.sortReverse" | ||||
|             (sort)="onSort($event)" | ||||
|           i18n>Added</th> | ||||
|           @if (activeDisplayFields.includes(DisplayField.CREATED)) { | ||||
|             <th | ||||
|               pngxSortable="created" | ||||
|               title="Sort by created date" i18n-title | ||||
|               [currentSortField]="list.sortField" | ||||
|               [currentSortReverse]="list.sortReverse" | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>Created</th> | ||||
|           } | ||||
|           @if (activeDisplayFields.includes(DisplayField.ADDED)) { | ||||
|             <th | ||||
|               pngxSortable="added" | ||||
|               title="Sort by added date" i18n-title | ||||
|               [currentSortField]="list.sortField" | ||||
|               [currentSortReverse]="list.sortReverse" | ||||
|               (sort)="onSort($event)" | ||||
|             i18n>Added</th> | ||||
|           } | ||||
|           @if (activeDisplayFields.includes(DisplayField.SHARED)) { | ||||
|             <th i18n> | ||||
|               Shared | ||||
|             </th> | ||||
|           } | ||||
|           @for (field of activeDisplayCustomFields; track field) { | ||||
|             <th> | ||||
|               {{getDisplayCustomFieldTitle(field)}} | ||||
|             </th> | ||||
|           } | ||||
|         </thead> | ||||
|         <tbody> | ||||
|           @for (d of list.documents; track trackByDocumentId($index, d)) { | ||||
| @@ -216,26 +264,36 @@ | ||||
|                   <label class="form-check-label" for="docCheck{{d.id}}"></label> | ||||
|                 </div> | ||||
|               </td> | ||||
|               <td class="d-none d-lg-table-cell"> | ||||
|                 {{d.archive_serial_number}} | ||||
|               </td> | ||||
|               @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|                 <td class="d-none d-md-table-cell"> | ||||
|               @if (activeDisplayFields.includes(DisplayField.ASN)) { | ||||
|                 <td class="d-none d-xl-table-cell"> | ||||
|                   {{d.archive_serial_number}} | ||||
|                 </td> | ||||
|               } | ||||
|               @if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|                 <td class="d-none d-xl-table-cell"> | ||||
|                   @if (d.correspondent) { | ||||
|                     <a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a> | ||||
|                   } | ||||
|                 </td> | ||||
|               } | ||||
|               <td> | ||||
|                 <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> | ||||
|                 @for (t of d.tags$ | async; track t) { | ||||
|                   <pngx-tag [tag]="t" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></pngx-tag> | ||||
|                 } | ||||
|               </td> | ||||
|               <td> | ||||
|                 {{d.owner | username}} | ||||
|               </td> | ||||
|               @if (notesEnabled) { | ||||
|               @if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) { | ||||
|                 <td> | ||||
|                   @if (activeDisplayFields.includes(DisplayField.TITLE)) { | ||||
|                     <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> | ||||
|                   } | ||||
|                   @if (activeDisplayFields.includes(DisplayField.TAGS)) { | ||||
|                     @for (t of d.tags$ | async; track t) { | ||||
|                       <pngx-tag [tag]="t" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></pngx-tag> | ||||
|                     } | ||||
|                   } | ||||
|                 </td> | ||||
|               } | ||||
|               @if (activeDisplayFields.includes(DisplayField.OWNER) && permissionService.currentUserCan(PermissionAction.View, PermissionType.User)) { | ||||
|                 <td> | ||||
|                   {{d.owner | username}} | ||||
|                 </td> | ||||
|               } | ||||
|               @if (activeDisplayFields.includes(DisplayField.NOTES) && notesEnabled) { | ||||
|                 <td class="d-none d-xl-table-cell"> | ||||
|                   @if (d.notes.length) { | ||||
|                     <a routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0"> | ||||
| @@ -246,35 +304,59 @@ | ||||
|                   } | ||||
|                 </td> | ||||
|               } | ||||
|               @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|               @if (activeDisplayFields.includes(DisplayField.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|                 <td class="d-none d-xl-table-cell"> | ||||
|                   @if (d.document_type) { | ||||
|                     <a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a> | ||||
|                   } | ||||
|                 </td> | ||||
|               } | ||||
|               @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|               @if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|                 <td class="d-none d-xl-table-cell"> | ||||
|                   @if (d.storage_path) { | ||||
|                     <a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path" i18n-title>{{(d.storage_path$ | async)?.name}}</a> | ||||
|                   } | ||||
|                 </td> | ||||
|               } | ||||
|               <td> | ||||
|                 {{d.created_date | customDate}} | ||||
|               </td> | ||||
|               <td class="d-none d-xl-table-cell"> | ||||
|                 {{d.added | customDate}} | ||||
|               </td> | ||||
|               @if (activeDisplayFields.includes(DisplayField.CREATED)) { | ||||
|                 <td> | ||||
|                   {{d.created_date | customDate}} | ||||
|                 </td> | ||||
|               } | ||||
|               @if (activeDisplayFields.includes(DisplayField.ADDED)) { | ||||
|                 <td> | ||||
|                   {{d.added | customDate}} | ||||
|                 </td> | ||||
|               } | ||||
|               @if (activeDisplayFields.includes(DisplayField.SHARED)) { | ||||
|                 <td> | ||||
|                   @if (d.is_shared_by_requester) { <ng-container i18n>Yes</ng-container> } @else { <ng-container i18n>No</ng-container> } | ||||
|                 </td> | ||||
|               } | ||||
|               @for (field of activeDisplayCustomFields; track field) { | ||||
|                 <td class="d-none d-xl-table-cell"> | ||||
|                   <pngx-custom-field-display [document]="d" [fieldDisplayKey]="field"></pngx-custom-field-display> | ||||
|                 </td> | ||||
|               } | ||||
|             </tr> | ||||
|           } | ||||
|         </tbody> | ||||
|       </table> | ||||
|     } | ||||
|     @if (displayMode === 'smallCards') { | ||||
|     @if (list.displayMode === DisplayMode.SMALL_CARDS) { | ||||
|       <div class="row row-cols-paperless-cards"> | ||||
|         @for (d of list.documents; track trackByDocumentId($index, d)) { | ||||
|           <pngx-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" (dblClickDocument)="openDocumentDetail(d)" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></pngx-document-card-small> | ||||
|           <pngx-document-card-small class="p-0" | ||||
|             [selected]="list.isSelected(d)" | ||||
|             (toggleSelected)="toggleSelected(d, $event)" | ||||
|             (dblClickDocument)="openDocumentDetail(d)" | ||||
|             [document]="d" | ||||
|             (clickTag)="clickTag($event)" | ||||
|             [displayFields]="activeDisplayFields" | ||||
|             (clickCorrespondent)="clickCorrespondent($event)" | ||||
|             (clickStoragePath)="clickStoragePath($event)" | ||||
|             (clickDocumentType)="clickDocumentType($event)"> | ||||
|           </pngx-document-card-small> | ||||
|         } | ||||
|       </div> | ||||
|     } | ||||
|   | ||||
| @@ -10,10 +10,6 @@ th { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| th.w-40 { | ||||
|   width: 40%; | ||||
| } | ||||
|  | ||||
| .table-row-selected { | ||||
|   background-color: var(--pngx-primary-faded); | ||||
| } | ||||
| @@ -84,3 +80,7 @@ $paperless-card-breakpoints: ( | ||||
| a { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| pngx-page-header .dropdown-menu { | ||||
|   --bs-dropdown-min-width: 12em; | ||||
| } | ||||
|   | ||||
| @@ -47,12 +47,13 @@ import { DocumentCardSmallComponent } from './document-card-small/document-card- | ||||
| import { DocumentCardLargeComponent } from './document-card-large/document-card-large.component' | ||||
| import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' | ||||
| import { UsernamePipe } from 'src/app/pipes/username.pipe' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { | ||||
|   DOCUMENT_SORT_FIELDS, | ||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
|   DocumentService, | ||||
| } from 'src/app/services/rest/document.service' | ||||
|   DEFAULT_DISPLAY_FIELDS, | ||||
|   DisplayField, | ||||
|   DisplayMode, | ||||
|   Document, | ||||
| } from 'src/app/data/document' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component' | ||||
| @@ -169,17 +170,6 @@ describe('DocumentListComponent', () => { | ||||
|     component = fixture.componentInstance | ||||
|   }) | ||||
|  | ||||
|   it('should load display mode from local storage', () => { | ||||
|     window.localStorage.setItem('document-list:displayMode', 'largeCards') | ||||
|     fixture.detectChanges() | ||||
|     expect(component.displayMode).toEqual('largeCards') | ||||
|     component.displayMode = 'smallCards' | ||||
|     component.saveDisplayMode() | ||||
|     expect(window.localStorage.getItem('document-list:displayMode')).toEqual( | ||||
|       'smallCards' | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should reload on new document consumed', () => { | ||||
|     const reloadSpy = jest.spyOn(documentListService, 'reload') | ||||
|     const fileStatusSubject = new Subject<FileStatus>() | ||||
| @@ -199,7 +189,7 @@ describe('DocumentListComponent', () => { | ||||
|       }, | ||||
|     ] | ||||
|     fixture.detectChanges() | ||||
|     expect(component.getSortFields()).toEqual(DOCUMENT_SORT_FIELDS) | ||||
|     expect(component.getSortFields()).toEqual(documentListService.sortFields) | ||||
|  | ||||
|     documentListService.filterRules = [ | ||||
|       { | ||||
| @@ -208,7 +198,9 @@ describe('DocumentListComponent', () => { | ||||
|       }, | ||||
|     ] | ||||
|     fixture.detectChanges() | ||||
|     expect(component.getSortFields()).toEqual(DOCUMENT_SORT_FIELDS_FULLTEXT) | ||||
|     expect(component.getSortFields()).toEqual( | ||||
|       documentListService.sortFieldsFullText | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should determine if filtered, support reset', () => { | ||||
| @@ -297,18 +289,18 @@ describe('DocumentListComponent', () => { | ||||
|     const displayModeButtons = fixture.debugElement.queryAll( | ||||
|       By.css('input[type="radio"]') | ||||
|     ) | ||||
|     expect(component.displayMode).toEqual('smallCards') | ||||
|     expect(component.list.displayMode).toEqual('smallCards') | ||||
|  | ||||
|     displayModeButtons[0].nativeElement.checked = true | ||||
|     displayModeButtons[0].triggerEventHandler('change') | ||||
|     fixture.detectChanges() | ||||
|     expect(component.displayMode).toEqual('details') | ||||
|     expect(component.list.displayMode).toEqual('table') | ||||
|     expect(fixture.debugElement.queryAll(By.css('tr'))).toHaveLength(3) | ||||
|  | ||||
|     displayModeButtons[1].nativeElement.checked = true | ||||
|     displayModeButtons[1].triggerEventHandler('change') | ||||
|     fixture.detectChanges() | ||||
|     expect(component.displayMode).toEqual('smallCards') | ||||
|     expect(component.list.displayMode).toEqual('smallCards') | ||||
|     expect( | ||||
|       fixture.debugElement.queryAll(By.directive(DocumentCardSmallComponent)) | ||||
|     ).toHaveLength(3) | ||||
| @@ -316,7 +308,7 @@ describe('DocumentListComponent', () => { | ||||
|     displayModeButtons[2].nativeElement.checked = true | ||||
|     displayModeButtons[2].triggerEventHandler('change') | ||||
|     fixture.detectChanges() | ||||
|     expect(component.displayMode).toEqual('largeCards') | ||||
|     expect(component.list.displayMode).toEqual('largeCards') | ||||
|     expect( | ||||
|       fixture.debugElement.queryAll(By.directive(DocumentCardLargeComponent)) | ||||
|     ).toHaveLength(3) | ||||
| @@ -327,7 +319,7 @@ describe('DocumentListComponent', () => { | ||||
|     fixture.detectChanges() | ||||
|     const sortDropdown = fixture.debugElement.queryAll( | ||||
|       By.directive(NgbDropdown) | ||||
|     )[1] | ||||
|     )[2] | ||||
|     const asnSortFieldButton = sortDropdown.query(By.directive(NgbDropdownItem)) | ||||
|  | ||||
|     asnSortFieldButton.triggerEventHandler('click') | ||||
| @@ -337,6 +329,7 @@ describe('DocumentListComponent', () => { | ||||
|   }) | ||||
|  | ||||
|   it('should support setting sort field by table head', () => { | ||||
|     component.activeDisplayFields = [DisplayField.ASN] | ||||
|     jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs) | ||||
|     fixture.detectChanges() | ||||
|     expect(documentListService.sortField).toEqual('created') | ||||
| @@ -347,7 +340,7 @@ describe('DocumentListComponent', () => { | ||||
|     detailsDisplayModeButton.nativeElement.checked = true | ||||
|     detailsDisplayModeButton.triggerEventHandler('change') | ||||
|     fixture.detectChanges() | ||||
|     expect(component.displayMode).toEqual('details') | ||||
|     expect(component.list.displayMode).toEqual(DisplayMode.TABLE) | ||||
|  | ||||
|     const sortTh = fixture.debugElement.query(By.directive(SortableDirective)) | ||||
|     sortTh.triggerEventHandler('click') | ||||
| @@ -430,6 +423,8 @@ describe('DocumentListComponent', () => { | ||||
|           value: '20', | ||||
|         }, | ||||
|       ], | ||||
|       display_mode: DisplayMode.SMALL_CARDS, | ||||
|       display_fields: [DisplayField.TITLE], | ||||
|     } | ||||
|     jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view)) | ||||
|     const queryParams = { view: view.id.toString() } | ||||
| @@ -546,6 +541,42 @@ describe('DocumentListComponent', () => { | ||||
|     expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] }) | ||||
|   }) | ||||
|  | ||||
|   it('should detect saved view changes', () => { | ||||
|     const view: SavedView = { | ||||
|       id: 10, | ||||
|       name: 'Saved View 10', | ||||
|       sort_field: 'added', | ||||
|       sort_reverse: true, | ||||
|       filter_rules: [ | ||||
|         { | ||||
|           rule_type: FILTER_HAS_TAGS_ANY, | ||||
|           value: '20', | ||||
|         }, | ||||
|       ], | ||||
|       page_size: 5, | ||||
|       display_mode: DisplayMode.SMALL_CARDS, | ||||
|       display_fields: [DisplayField.TITLE], | ||||
|     } | ||||
|     jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view)) | ||||
|     const queryParams = { view: view.id.toString() } | ||||
|     jest | ||||
|       .spyOn(activatedRoute, 'queryParamMap', 'get') | ||||
|       .mockReturnValue(of(convertToParamMap(queryParams))) | ||||
|     activatedRoute.snapshot.queryParams = queryParams | ||||
|     router.routerState.snapshot.url = '/view/10/' | ||||
|     fixture.detectChanges() | ||||
|     expect(documentListService.activeSavedViewId).toEqual(10) | ||||
|  | ||||
|     component.list.displayFields = [DisplayField.ASN] | ||||
|     expect(component.savedViewIsModified).toBeTruthy() | ||||
|     component.list.displayFields = [DisplayField.TITLE] | ||||
|     expect(component.savedViewIsModified).toBeFalsy() | ||||
|     component.list.displayMode = DisplayMode.TABLE | ||||
|     expect(component.savedViewIsModified).toBeTruthy() | ||||
|     component.list.displayMode = DisplayMode.SMALL_CARDS | ||||
|     expect(component.savedViewIsModified).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should navigate to a document', () => { | ||||
|     fixture.detectChanges() | ||||
|     const routerSpy = jest.spyOn(router, 'navigate') | ||||
| @@ -558,7 +589,8 @@ describe('DocumentListComponent', () => { | ||||
|     jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs) | ||||
|     expect(documentListService.sortField).toEqual('created') | ||||
|  | ||||
|     component.displayMode = 'details' | ||||
|     component.list.displayMode = DisplayMode.TABLE | ||||
|     component.list.displayFields = DEFAULT_DISPLAY_FIELDS.map((f) => f.id) | ||||
|     fixture.detectChanges() | ||||
|  | ||||
|     expect( | ||||
| @@ -578,7 +610,7 @@ describe('DocumentListComponent', () => { | ||||
|     fixture.detectChanges() | ||||
|     expect( | ||||
|       fixture.debugElement.queryAll(By.directive(SortableDirective)) | ||||
|     ).toHaveLength(5) | ||||
|     ).toHaveLength(4) | ||||
|   }) | ||||
|  | ||||
|   it('should support toggle on document objects', () => { | ||||
| @@ -598,4 +630,28 @@ describe('DocumentListComponent', () => { | ||||
|       { rule_type: FILTER_FULLTEXT_MORELIKE, value: '99' }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should support toggling display fields', () => { | ||||
|     fixture.detectChanges() | ||||
|     component.activeDisplayFields = [DisplayField.ASN] | ||||
|     component.toggleDisplayField(DisplayField.TITLE) | ||||
|     expect(component.activeDisplayFields).toEqual([ | ||||
|       DisplayField.ASN, | ||||
|       DisplayField.TITLE, | ||||
|     ]) | ||||
|     component.toggleDisplayField(DisplayField.ASN) | ||||
|     expect(component.activeDisplayFields).toEqual([DisplayField.TITLE]) | ||||
|   }) | ||||
|  | ||||
|   it('should get custom field title', () => { | ||||
|     fixture.detectChanges() | ||||
|     jest | ||||
|       .spyOn(settingsService, 'allDisplayFields', 'get') | ||||
|       .mockReturnValue([ | ||||
|         { id: 'custom_field_1' as any, name: 'Custom Field 1' }, | ||||
|       ]) | ||||
|     expect(component.getDisplayCustomFieldTitle('custom_field_1')).toEqual( | ||||
|       'Custom Field 1' | ||||
|     ) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -15,7 +15,7 @@ import { | ||||
|   isFullTextFilterRule, | ||||
| } from 'src/app/utils/filter-rules' | ||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { DisplayField, DisplayMode, Document } from 'src/app/data/document' | ||||
| import { SavedView } from 'src/app/data/saved-view' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/ui-settings' | ||||
| import { | ||||
| @@ -25,10 +25,6 @@ import { | ||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service' | ||||
| import { | ||||
|   DOCUMENT_SORT_FIELDS, | ||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
| } from 'src/app/services/rest/document.service' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| @@ -46,6 +42,9 @@ export class DocumentListComponent | ||||
|   extends ComponentWithPermissions | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   DisplayField = DisplayField | ||||
|   DisplayMode = DisplayMode | ||||
|  | ||||
|   constructor( | ||||
|     public list: DocumentListViewService, | ||||
|     public savedViewService: SavedViewService, | ||||
| @@ -55,7 +54,7 @@ export class DocumentListComponent | ||||
|     private modalService: NgbModal, | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     public openDocumentsService: OpenDocumentsService, | ||||
|     private settingsService: SettingsService, | ||||
|     public settingsService: SettingsService, | ||||
|     public permissionService: PermissionsService | ||||
|   ) { | ||||
|     super() | ||||
| @@ -66,7 +65,25 @@ export class DocumentListComponent | ||||
|  | ||||
|   @ViewChildren(SortableDirective) headers: QueryList<SortableDirective> | ||||
|  | ||||
|   displayMode = 'smallCards' // largeCards, smallCards, details | ||||
|   get activeDisplayFields(): DisplayField[] { | ||||
|     return this.list.displayFields | ||||
|   } | ||||
|  | ||||
|   set activeDisplayFields(fields: DisplayField[]) { | ||||
|     this.list.displayFields = fields | ||||
|     this.updateDisplayCustomFields() | ||||
|   } | ||||
|   activeDisplayCustomFields: Set<string> = new Set() | ||||
|  | ||||
|   public updateDisplayCustomFields() { | ||||
|     this.activeDisplayCustomFields = new Set( | ||||
|       Array.from(this.activeDisplayFields).filter( | ||||
|         (field) => | ||||
|           typeof field === 'string' && | ||||
|           field.startsWith(DisplayField.CUSTOM_FIELD) | ||||
|       ) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   unmodifiedFilterRules: FilterRule[] = [] | ||||
|   private unmodifiedSavedView: SavedView | ||||
| @@ -79,6 +96,16 @@ export class DocumentListComponent | ||||
|       return ( | ||||
|         this.unmodifiedSavedView.sort_field !== this.list.sortField || | ||||
|         this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse || | ||||
|         (this.unmodifiedSavedView.page_size && | ||||
|           this.unmodifiedSavedView.page_size !== this.list.pageSize) || | ||||
|         (this.unmodifiedSavedView.display_mode && | ||||
|           this.unmodifiedSavedView.display_mode !== this.list.displayMode) || | ||||
|         // if the saved view has no display mode, we assume it's small cards | ||||
|         (!this.unmodifiedSavedView.display_mode && | ||||
|           this.list.displayMode !== DisplayMode.SMALL_CARDS) || | ||||
|         (this.unmodifiedSavedView.display_fields && | ||||
|           this.unmodifiedSavedView.display_fields.join(',') !== | ||||
|             this.activeDisplayFields.join(',')) || | ||||
|         filterRulesDiffer( | ||||
|           this.unmodifiedSavedView.filter_rules, | ||||
|           this.list.filterRules | ||||
| @@ -103,8 +130,8 @@ export class DocumentListComponent | ||||
|  | ||||
|   getSortFields() { | ||||
|     return isFullTextFilterRule(this.list.filterRules) | ||||
|       ? DOCUMENT_SORT_FIELDS_FULLTEXT | ||||
|       : DOCUMENT_SORT_FIELDS | ||||
|       ? this.list.sortFieldsFullText | ||||
|       : this.list.sortFields | ||||
|   } | ||||
|  | ||||
|   set listSortReverse(reverse: boolean) { | ||||
| @@ -115,10 +142,6 @@ export class DocumentListComponent | ||||
|     return this.list.sortReverse | ||||
|   } | ||||
|  | ||||
|   setSortField(field: string) { | ||||
|     this.list.sortField = field | ||||
|   } | ||||
|  | ||||
|   onSort(event: SortEvent) { | ||||
|     this.list.setSort(event.column, event.reverse) | ||||
|   } | ||||
| @@ -127,15 +150,23 @@ export class DocumentListComponent | ||||
|     return this.list.selected.size > 0 | ||||
|   } | ||||
|  | ||||
|   saveDisplayMode() { | ||||
|     localStorage.setItem('document-list:displayMode', this.displayMode) | ||||
|   toggleDisplayField(field: DisplayField) { | ||||
|     if (this.activeDisplayFields.includes(field)) { | ||||
|       this.activeDisplayFields = this.activeDisplayFields.filter( | ||||
|         (f) => f !== field | ||||
|       ) | ||||
|     } else { | ||||
|       this.activeDisplayFields = [...this.activeDisplayFields, field] | ||||
|     } | ||||
|     this.updateDisplayCustomFields() | ||||
|   } | ||||
|  | ||||
|   public getDisplayCustomFieldTitle(field: string) { | ||||
|     return this.settingsService.allDisplayFields.find((f) => f.id === field) | ||||
|       ?.name | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     if (localStorage.getItem('document-list:displayMode') != null) { | ||||
|       this.displayMode = localStorage.getItem('document-list:displayMode') | ||||
|     } | ||||
|  | ||||
|     this.consumerStatusService | ||||
|       .onDocumentConsumptionFinished() | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
| @@ -199,6 +230,8 @@ export class DocumentListComponent | ||||
|         filter_rules: this.list.filterRules, | ||||
|         sort_field: this.list.sortField, | ||||
|         sort_reverse: this.list.sortReverse, | ||||
|         display_mode: this.list.displayMode, | ||||
|         display_fields: this.activeDisplayFields, | ||||
|       } | ||||
|       this.savedViewService | ||||
|         .patch(savedView) | ||||
| @@ -238,6 +271,8 @@ export class DocumentListComponent | ||||
|         filter_rules: this.list.filterRules, | ||||
|         sort_reverse: this.list.sortReverse, | ||||
|         sort_field: this.list.sortField, | ||||
|         display_mode: this.list.displayMode, | ||||
|         display_fields: this.activeDisplayFields, | ||||
|       } | ||||
|  | ||||
|       this.savedViewService | ||||
|   | ||||
| @@ -7,6 +7,102 @@ import { ObjectWithPermissions } from './object-with-permissions' | ||||
| import { DocumentNote } from './document-note' | ||||
| import { CustomFieldInstance } from './custom-field-instance' | ||||
|  | ||||
| export enum DisplayMode { | ||||
|   TABLE = 'table', | ||||
|   SMALL_CARDS = 'smallCards', | ||||
|   LARGE_CARDS = 'largeCards', | ||||
| } | ||||
|  | ||||
| export enum DisplayField { | ||||
|   TITLE = 'title', | ||||
|   CREATED = 'created', | ||||
|   ADDED = 'added', | ||||
|   TAGS = 'tag', | ||||
|   CORRESPONDENT = 'correspondent', | ||||
|   DOCUMENT_TYPE = 'documenttype', | ||||
|   STORAGE_PATH = 'storagepath', | ||||
|   CUSTOM_FIELD = 'custom_field_', | ||||
|   NOTES = 'note', | ||||
|   OWNER = 'owner', | ||||
|   SHARED = 'shared', | ||||
|   ASN = 'asn', | ||||
| } | ||||
|  | ||||
| export const DEFAULT_DISPLAY_FIELDS = [ | ||||
|   { | ||||
|     id: DisplayField.TITLE, | ||||
|     name: $localize`Title`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.CREATED, | ||||
|     name: $localize`Created`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.ADDED, | ||||
|     name: $localize`Added`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.TAGS, | ||||
|     name: $localize`Tags`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.CORRESPONDENT, | ||||
|     name: $localize`Correspondent`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.DOCUMENT_TYPE, | ||||
|     name: $localize`Document type`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.STORAGE_PATH, | ||||
|     name: $localize`Storage path`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.NOTES, | ||||
|     name: $localize`Notes`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.OWNER, | ||||
|     name: $localize`Owner`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.SHARED, | ||||
|     name: $localize`Shared`, | ||||
|   }, | ||||
|   { | ||||
|     id: DisplayField.ASN, | ||||
|     name: $localize`ASN`, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| export const DEFAULT_DASHBOARD_VIEW_PAGE_SIZE = 10 | ||||
|  | ||||
| export const DEFAULT_DASHBOARD_DISPLAY_FIELDS = [ | ||||
|   DisplayField.CREATED, | ||||
|   DisplayField.TITLE, | ||||
|   DisplayField.TAGS, | ||||
|   DisplayField.CORRESPONDENT, | ||||
| ] | ||||
|  | ||||
| export const DOCUMENT_SORT_FIELDS = [ | ||||
|   { field: 'archive_serial_number', name: $localize`ASN` }, | ||||
|   { field: 'correspondent__name', name: $localize`Correspondent` }, | ||||
|   { field: 'title', name: $localize`Title` }, | ||||
|   { field: 'document_type__name', name: $localize`Document type` }, | ||||
|   { field: 'created', name: $localize`Created` }, | ||||
|   { field: 'added', name: $localize`Added` }, | ||||
|   { field: 'modified', name: $localize`Modified` }, | ||||
|   { field: 'num_notes', name: $localize`Notes` }, | ||||
|   { field: 'owner', name: $localize`Owner` }, | ||||
| ] | ||||
|  | ||||
| export const DOCUMENT_SORT_FIELDS_FULLTEXT = [ | ||||
|   { | ||||
|     field: 'score', | ||||
|     name: $localize`:Score is a value returned by the full text search engine and specifies how well a result matches the given query:Search score`, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| export interface SearchHit { | ||||
|   score?: number | ||||
|   rank?: number | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { DisplayMode, DisplayField } from './document' | ||||
| import { FilterRule } from './filter-rule' | ||||
| import { ObjectWithPermissions } from './object-with-permissions' | ||||
|  | ||||
| @@ -13,4 +14,10 @@ export interface SavedView extends ObjectWithPermissions { | ||||
|   sort_reverse: boolean | ||||
|  | ||||
|   filter_rules: FilterRule[] | ||||
|  | ||||
|   page_size?: number | ||||
|  | ||||
|   display_mode?: DisplayMode | ||||
|  | ||||
|   display_fields?: DisplayField[] | ||||
| } | ||||
|   | ||||
| @@ -18,6 +18,8 @@ describe('ConsumerStatusService', () => { | ||||
|   let httpTestingController: HttpTestingController | ||||
|   let consumerStatusService: ConsumerStatusService | ||||
|   let documentService: DocumentService | ||||
|   let settingsService: SettingsService | ||||
|  | ||||
|   const server = new WS( | ||||
|     `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`, | ||||
|     { jsonProtocol: true } | ||||
| @@ -25,25 +27,17 @@ describe('ConsumerStatusService', () => { | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       providers: [ | ||||
|         ConsumerStatusService, | ||||
|         DocumentService, | ||||
|         SettingsService, | ||||
|         { | ||||
|           provide: SettingsService, | ||||
|           useValue: { | ||||
|             currentUser: { | ||||
|               id: 1, | ||||
|               username: 'testuser', | ||||
|               is_superuser: false, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       ], | ||||
|       providers: [ConsumerStatusService, DocumentService, SettingsService], | ||||
|       imports: [HttpClientTestingModule], | ||||
|     }) | ||||
|  | ||||
|     httpTestingController = TestBed.inject(HttpTestingController) | ||||
|     settingsService = TestBed.inject(SettingsService) | ||||
|     settingsService.currentUser = { | ||||
|       id: 1, | ||||
|       username: 'testuser', | ||||
|       is_superuser: false, | ||||
|     } | ||||
|     consumerStatusService = TestBed.inject(ConsumerStatusService) | ||||
|     documentService = TestBed.inject(DocumentService) | ||||
|   }) | ||||
|   | ||||
| @@ -19,6 +19,11 @@ import { routes } from 'src/app/app-routing.module' | ||||
| import { PermissionsGuard } from '../guards/permissions.guard' | ||||
| import { SettingsService } from './settings.service' | ||||
| import { SETTINGS_KEYS } from '../data/ui-settings' | ||||
| import { | ||||
|   DisplayMode, | ||||
|   DisplayField, | ||||
|   DEFAULT_DISPLAY_FIELDS, | ||||
| } from '../data/document' | ||||
|  | ||||
| const documents = [ | ||||
|   { | ||||
| @@ -213,7 +218,7 @@ describe('DocumentListViewService', () => { | ||||
|     documentListViewService.loadFromQueryParams(convertToParamMap(params)) | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=${page}&page_size=${ | ||||
|         documentListViewService.currentPageSize | ||||
|         documentListViewService.pageSize | ||||
|       }&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
| @@ -231,7 +236,7 @@ describe('DocumentListViewService', () => { | ||||
|     } | ||||
|     documentListViewService.loadFromQueryParams(convertToParamMap(params)) | ||||
|     let req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` | ||||
|       `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|     expect(documentListViewService.filterRules).toEqual([ | ||||
| @@ -249,7 +254,7 @@ describe('DocumentListViewService', () => { | ||||
|   it('should use filter rules to update query params', () => { | ||||
|     documentListViewService.filterRules = filterRules | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` | ||||
|       `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|   }) | ||||
| @@ -257,7 +262,7 @@ describe('DocumentListViewService', () => { | ||||
|   it('should support quick filter', () => { | ||||
|     documentListViewService.quickFilter(filterRules) | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` | ||||
|       `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|   }) | ||||
| @@ -280,7 +285,7 @@ describe('DocumentListViewService', () => { | ||||
|       convertToParamMap(params) | ||||
|     ) | ||||
|     let req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.currentPageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` | ||||
|       `${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|     // reset the list | ||||
| @@ -305,8 +310,7 @@ describe('DocumentListViewService', () => { | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|     expect(documentListViewService.currentPage).toEqual(1) | ||||
|     documentListViewService.currentPageSize = 3 | ||||
|     documentListViewService.reload() | ||||
|     documentListViewService.pageSize = 3 | ||||
|     req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
| @@ -362,7 +366,10 @@ describe('DocumentListViewService', () => { | ||||
|       .spyOn(documentListViewService, 'documents', 'get') | ||||
|       .mockReturnValue(documents) | ||||
|     expect(documentListViewService.currentPage).toEqual(1) | ||||
|     documentListViewService.currentPageSize = 3 | ||||
|     documentListViewService.pageSize = 3 | ||||
|     httpTestingController.match( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|     jest | ||||
|       .spyOn(documentListViewService, 'getLastPage') | ||||
|       .mockReturnValue(Math.ceil(documents.length / 3)) | ||||
| @@ -410,7 +417,13 @@ describe('DocumentListViewService', () => { | ||||
|       .spyOn(documentListViewService, 'documents', 'get') | ||||
|       .mockReturnValue(documents) | ||||
|     documentListViewService.currentPage = 2 | ||||
|     documentListViewService.currentPageSize = 3 | ||||
|     httpTestingController.match( | ||||
|       `${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|     documentListViewService.pageSize = 3 | ||||
|     httpTestingController.match( | ||||
|       `${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|     const reloadSpy = jest.spyOn(documentListViewService, 'reload') | ||||
|     documentListViewService.getPrevious(1).subscribe({ | ||||
|       next: () => {}, | ||||
| @@ -426,8 +439,7 @@ describe('DocumentListViewService', () => { | ||||
|  | ||||
|   it('should update page size from settings', () => { | ||||
|     settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 10) | ||||
|     documentListViewService.updatePageSize() | ||||
|     expect(documentListViewService.currentPageSize).toEqual(10) | ||||
|     expect(documentListViewService.pageSize).toEqual(10) | ||||
|   }) | ||||
|  | ||||
|   it('should support select a document', () => { | ||||
| @@ -459,8 +471,7 @@ describe('DocumentListViewService', () => { | ||||
|   }) | ||||
|  | ||||
|   it('should support select page', () => { | ||||
|     documentListViewService.currentPageSize = 3 | ||||
|     documentListViewService.reload() | ||||
|     documentListViewService.pageSize = 3 | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
| @@ -544,4 +555,40 @@ describe('DocumentListViewService', () => { | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should update default view state when display mode changes', () => { | ||||
|     const localStorageSpy = jest.spyOn(localStorage, 'setItem') | ||||
|     expect(documentListViewService.displayMode).toEqual(DisplayMode.SMALL_CARDS) | ||||
|     documentListViewService.displayMode = DisplayMode.LARGE_CARDS | ||||
|     expect(documentListViewService.displayMode).toEqual(DisplayMode.LARGE_CARDS) | ||||
|     documentListViewService.displayMode = 'details' as any // legacy | ||||
|     expect(documentListViewService.displayMode).toEqual(DisplayMode.TABLE) | ||||
|     expect(localStorageSpy).toHaveBeenCalledTimes(2) | ||||
|   }) | ||||
|  | ||||
|   it('should update default view state when display fields change', () => { | ||||
|     const localStorageSpy = jest.spyOn(localStorage, 'setItem') | ||||
|     documentListViewService.displayFields = [ | ||||
|       DisplayField.ADDED, | ||||
|       DisplayField.TITLE, | ||||
|     ] | ||||
|     expect(documentListViewService.displayFields).toEqual([ | ||||
|       DisplayField.ADDED, | ||||
|       DisplayField.TITLE, | ||||
|     ]) | ||||
|     expect(localStorageSpy).toHaveBeenCalled() | ||||
|     // reload triggered | ||||
|     httpTestingController.match( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|     documentListViewService.displayFields = null | ||||
|     httpTestingController.match( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||
|     ) | ||||
|     expect(documentListViewService.displayFields).toEqual( | ||||
|       DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map( | ||||
|         (f) => f.id | ||||
|       ) | ||||
|     ) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -7,16 +7,17 @@ import { | ||||
|   cloneFilterRules, | ||||
|   isFullTextFilterRule, | ||||
| } from '../utils/filter-rules' | ||||
| import { Document } from '../data/document' | ||||
| import { | ||||
|   DEFAULT_DISPLAY_FIELDS, | ||||
|   DisplayField, | ||||
|   DisplayMode, | ||||
|   Document, | ||||
| } from '../data/document' | ||||
| import { SavedView } from '../data/saved-view' | ||||
| import { SETTINGS_KEYS } from '../data/ui-settings' | ||||
| import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys' | ||||
| import { paramsFromViewState, paramsToViewState } from '../utils/query-params' | ||||
| import { | ||||
|   DocumentService, | ||||
|   DOCUMENT_SORT_FIELDS, | ||||
|   SelectionData, | ||||
| } from './rest/document.service' | ||||
| import { DocumentService, SelectionData } from './rest/document.service' | ||||
| import { SettingsService } from './settings.service' | ||||
|  | ||||
| /** | ||||
| @@ -59,6 +60,21 @@ export interface ListViewState { | ||||
|    * Contains the IDs of all selected documents. | ||||
|    */ | ||||
|   selected?: Set<number> | ||||
|  | ||||
|   /** | ||||
|    * The page size of the list view. | ||||
|    */ | ||||
|   pageSize?: number | ||||
|  | ||||
|   /** | ||||
|    * Display mode of the list view. | ||||
|    */ | ||||
|   displayMode?: DisplayMode | ||||
|  | ||||
|   /** | ||||
|    * The fields to display in the document list. | ||||
|    */ | ||||
|   displayFields?: DisplayField[] | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -80,8 +96,6 @@ export class DocumentListViewService { | ||||
|  | ||||
|   selectionData?: SelectionData | ||||
|  | ||||
|   currentPageSize: number = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) | ||||
|  | ||||
|   private unsubscribeNotifier: Subject<any> = new Subject() | ||||
|  | ||||
|   private listViewStates: Map<number, ListViewState> = new Map() | ||||
| @@ -113,7 +127,7 @@ export class DocumentListViewService { | ||||
|             delete savedState[k] | ||||
|           } | ||||
|         }) | ||||
|         //only use restored state attributes instead of defaults if they are not null | ||||
|         // only use restored state attributes instead of defaults if they are not null | ||||
|         let newState = Object.assign(this.defaultListViewState(), savedState) | ||||
|         this.listViewStates.set(null, newState) | ||||
|       } catch (e) { | ||||
| @@ -176,6 +190,9 @@ export class DocumentListViewService { | ||||
|     if (this._activeSavedViewId) { | ||||
|       this.activeListViewState.title = view.name | ||||
|     } | ||||
|     this.activeListViewState.displayMode = view.display_mode | ||||
|     this.activeListViewState.pageSize = view.page_size | ||||
|     this.activeListViewState.displayFields = view.display_fields | ||||
|  | ||||
|     this.reduceSelectionToFilter() | ||||
|  | ||||
| @@ -220,7 +237,7 @@ export class DocumentListViewService { | ||||
|     this.documentService | ||||
|       .listFiltered( | ||||
|         activeListViewState.currentPage, | ||||
|         this.currentPageSize, | ||||
|         activeListViewState.pageSize ?? this.pageSize, | ||||
|         activeListViewState.sortField, | ||||
|         activeListViewState.sortReverse, | ||||
|         activeListViewState.filterRules, | ||||
| @@ -281,9 +298,8 @@ export class DocumentListViewService { | ||||
|               errorMessage = Object.keys(error.error) | ||||
|                 .map((fieldName) => { | ||||
|                   const fieldError: Array<string> = error.error[fieldName] | ||||
|                   return `${DOCUMENT_SORT_FIELDS.find( | ||||
|                     (f) => f.field == fieldName | ||||
|                   )?.name}: ${fieldError[0]}` | ||||
|                   return `${this.sortFields.find((f) => f.field == fieldName) | ||||
|                     ?.name}: ${fieldError[0]}` | ||||
|                 }) | ||||
|                 .join(', ') | ||||
|             } else { | ||||
| @@ -312,6 +328,14 @@ export class DocumentListViewService { | ||||
|     return this.activeListViewState.filterRules | ||||
|   } | ||||
|  | ||||
|   get sortFields(): any[] { | ||||
|     return this.documentService.sortFields | ||||
|   } | ||||
|  | ||||
|   get sortFieldsFullText(): any[] { | ||||
|     return this.documentService.sortFieldsFullText | ||||
|   } | ||||
|  | ||||
|   set sortField(field: string) { | ||||
|     this.activeListViewState.sortField = field | ||||
|     this.reload() | ||||
| @@ -362,6 +386,51 @@ export class DocumentListViewService { | ||||
|     this.saveDocumentListView() | ||||
|   } | ||||
|  | ||||
|   set displayMode(mode: DisplayMode) { | ||||
|     this.activeListViewState.displayMode = mode | ||||
|     this.saveDocumentListView() | ||||
|   } | ||||
|  | ||||
|   get displayMode(): DisplayMode { | ||||
|     const mode = this.activeListViewState.displayMode ?? DisplayMode.SMALL_CARDS | ||||
|     if (mode === ('details' as any)) { | ||||
|       // legacy | ||||
|       return DisplayMode.TABLE | ||||
|     } | ||||
|     return mode | ||||
|   } | ||||
|  | ||||
|   set pageSize(size: number) { | ||||
|     this.activeListViewState.pageSize = size | ||||
|     this.reload() | ||||
|     this.saveDocumentListView() | ||||
|   } | ||||
|  | ||||
|   get pageSize(): number { | ||||
|     return ( | ||||
|       this.activeListViewState.pageSize ?? | ||||
|       this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   get displayFields(): DisplayField[] { | ||||
|     let fields = | ||||
|       this.activeListViewState.displayFields ?? | ||||
|       DEFAULT_DISPLAY_FIELDS.map((f) => f.id) | ||||
|     if (!this.activeListViewState.displayFields) { | ||||
|       fields = fields.filter((f) => f !== DisplayField.ADDED) | ||||
|     } | ||||
|     return fields.filter( | ||||
|       (field) => | ||||
|         this.settings.allDisplayFields.find((f) => f.id === field) !== undefined | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   set displayFields(fields: DisplayField[]) { | ||||
|     this.activeListViewState.displayFields = fields | ||||
|     this.saveDocumentListView() | ||||
|   } | ||||
|  | ||||
|   private saveDocumentListView() { | ||||
|     if (this._activeSavedViewId == null) { | ||||
|       let savedState: ListViewState = { | ||||
| @@ -370,6 +439,8 @@ export class DocumentListViewService { | ||||
|         filterRules: this.activeListViewState.filterRules, | ||||
|         sortField: this.activeListViewState.sortField, | ||||
|         sortReverse: this.activeListViewState.sortReverse, | ||||
|         displayMode: this.activeListViewState.displayMode, | ||||
|         displayFields: this.activeListViewState.displayFields, | ||||
|       } | ||||
|       localStorage.setItem( | ||||
|         DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG, | ||||
| @@ -385,7 +456,7 @@ export class DocumentListViewService { | ||||
|   } | ||||
|  | ||||
|   getLastPage(): number { | ||||
|     return Math.ceil(this.collectionSize / this.currentPageSize) | ||||
|     return Math.ceil(this.collectionSize / this.pageSize) | ||||
|   } | ||||
|  | ||||
|   hasNext(doc: number) { | ||||
| @@ -452,13 +523,6 @@ export class DocumentListViewService { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   updatePageSize() { | ||||
|     let newPageSize = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) | ||||
|     if (newPageSize != this.currentPageSize) { | ||||
|       this.currentPageSize = newPageSize | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   selectNone() { | ||||
|     this.selected.clear() | ||||
|     this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null | ||||
|   | ||||
| @@ -1,9 +1,7 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { HttpClient, HttpParams } from '@angular/common/http' | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service' | ||||
| import { Observable } from 'rxjs' | ||||
| import { CustomField } from 'src/app/data/custom-field' | ||||
| import { CustomFieldInstance } from 'src/app/data/custom-field-instance' | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
|   | ||||
| @@ -9,11 +9,17 @@ import { DocumentService } from './document.service' | ||||
| import { FILTER_TITLE } from 'src/app/data/filter-rule-type' | ||||
| import { SettingsService } from '../settings.service' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/ui-settings' | ||||
| import { | ||||
|   DOCUMENT_SORT_FIELDS, | ||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
| } from 'src/app/data/document' | ||||
| import { PermissionsService } from '../permissions.service' | ||||
|  | ||||
| let httpTestingController: HttpTestingController | ||||
| let service: DocumentService | ||||
| let subscription: Subscription | ||||
| let settingsService: SettingsService | ||||
|  | ||||
| const endpoint = 'documents' | ||||
| const documents = [ | ||||
|   { | ||||
| @@ -275,6 +281,31 @@ describe(`DocumentService`, () => { | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| it('should construct sort fields respecting permissions', () => { | ||||
|   expect( | ||||
|     service.sortFields.find((f) => f.field === 'correspondent__name') | ||||
|   ).toBeUndefined() | ||||
|   expect( | ||||
|     service.sortFields.find((f) => f.field === 'document_type__name') | ||||
|   ).toBeUndefined() | ||||
|  | ||||
|   const permissionsService: PermissionsService = | ||||
|     TestBed.inject(PermissionsService) | ||||
|   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|   service['setupSortFields']() | ||||
|   expect(service.sortFields).toEqual(DOCUMENT_SORT_FIELDS) | ||||
|   expect(service.sortFieldsFullText).toEqual([ | ||||
|     ...DOCUMENT_SORT_FIELDS, | ||||
|     ...DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
|   ]) | ||||
|  | ||||
|   settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false) | ||||
|   service['setupSortFields']() | ||||
|   expect( | ||||
|     service.sortFields.find((f) => f.field === 'num_notes') | ||||
|   ).toBeUndefined() | ||||
| }) | ||||
|  | ||||
| afterEach(() => { | ||||
|   subscription?.unsubscribe() | ||||
|   httpTestingController.verify() | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { | ||||
|   DOCUMENT_SORT_FIELDS, | ||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
|   Document, | ||||
| } from 'src/app/data/document' | ||||
| import { DocumentMetadata } from 'src/app/data/document-metadata' | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service' | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| @@ -22,26 +26,6 @@ import { SettingsService } from '../settings.service' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/ui-settings' | ||||
| import { AuditLogEntry } from 'src/app/data/auditlog-entry' | ||||
|  | ||||
| export const DOCUMENT_SORT_FIELDS = [ | ||||
|   { field: 'archive_serial_number', name: $localize`ASN` }, | ||||
|   { field: 'correspondent__name', name: $localize`Correspondent` }, | ||||
|   { field: 'title', name: $localize`Title` }, | ||||
|   { field: 'document_type__name', name: $localize`Document type` }, | ||||
|   { field: 'created', name: $localize`Created` }, | ||||
|   { field: 'added', name: $localize`Added` }, | ||||
|   { field: 'modified', name: $localize`Modified` }, | ||||
|   { field: 'num_notes', name: $localize`Notes` }, | ||||
|   { field: 'owner', name: $localize`Owner` }, | ||||
| ] | ||||
|  | ||||
| export const DOCUMENT_SORT_FIELDS_FULLTEXT = [ | ||||
|   ...DOCUMENT_SORT_FIELDS, | ||||
|   { | ||||
|     field: 'score', | ||||
|     name: $localize`:Score is a value returned by the full text search engine and specifies how well a result matches the given query:Search score`, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| export interface SelectionDataItem { | ||||
|   id: number | ||||
|   document_count: number | ||||
| @@ -60,6 +44,16 @@ export interface SelectionData { | ||||
| export class DocumentService extends AbstractPaperlessService<Document> { | ||||
|   private _searchQuery: string | ||||
|  | ||||
|   private _sortFields | ||||
|   get sortFields() { | ||||
|     return this._sortFields | ||||
|   } | ||||
|  | ||||
|   private _sortFieldsFullText | ||||
|   get sortFieldsFullText() { | ||||
|     return this._sortFieldsFullText | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     http: HttpClient, | ||||
|     private correspondentService: CorrespondentService, | ||||
| @@ -70,6 +64,46 @@ export class DocumentService extends AbstractPaperlessService<Document> { | ||||
|     private settingsService: SettingsService | ||||
|   ) { | ||||
|     super(http, 'documents') | ||||
|     this.setupSortFields() | ||||
|   } | ||||
|  | ||||
|   private setupSortFields() { | ||||
|     this._sortFields = [...DOCUMENT_SORT_FIELDS] | ||||
|     let excludes = [] | ||||
|     if ( | ||||
|       !this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.Correspondent | ||||
|       ) | ||||
|     ) { | ||||
|       excludes.push('correspondent__name') | ||||
|     } | ||||
|     if ( | ||||
|       !this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.DocumentType | ||||
|       ) | ||||
|     ) { | ||||
|       excludes.push('document_type__name') | ||||
|     } | ||||
|     if ( | ||||
|       !this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.User | ||||
|       ) | ||||
|     ) { | ||||
|       excludes.push('owner') | ||||
|     } | ||||
|     if (!this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)) { | ||||
|       excludes.push('num_notes') | ||||
|     } | ||||
|     this._sortFields = this._sortFields.filter( | ||||
|       (field) => !excludes.includes(field.field) | ||||
|     ) | ||||
|     this._sortFieldsFullText = [ | ||||
|       ...this._sortFields, | ||||
|       ...DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
|     ] | ||||
|   } | ||||
|  | ||||
|   addObservablesToDocument(doc: Document) { | ||||
|   | ||||
| @@ -7,17 +7,38 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { RouterTestingModule } from '@angular/router/testing' | ||||
| import { NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { CookieService } from 'ngx-cookie-service' | ||||
| import { Subscription } from 'rxjs' | ||||
| import { Subscription, of } from 'rxjs' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { AppModule } from '../app.module' | ||||
| import { UiSettings, SETTINGS_KEYS } from '../data/ui-settings' | ||||
| import { SettingsService } from './settings.service' | ||||
| import { SavedView } from '../data/saved-view' | ||||
| import { CustomFieldsService } from './rest/custom-fields.service' | ||||
| import { CustomFieldDataType } from '../data/custom-field' | ||||
| import { PermissionsService } from './permissions.service' | ||||
| import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' | ||||
|  | ||||
| const customFields = [ | ||||
|   { | ||||
|     id: 1, | ||||
|     name: 'Field 1', | ||||
|     created: new Date(), | ||||
|     data_type: CustomFieldDataType.Monetary, | ||||
|   }, | ||||
|   { | ||||
|     id: 2, | ||||
|     name: 'Field 2', | ||||
|     created: new Date(), | ||||
|     data_type: CustomFieldDataType.String, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| describe('SettingsService', () => { | ||||
|   let httpTestingController: HttpTestingController | ||||
|   let settingsService: SettingsService | ||||
|   let cookieService: CookieService | ||||
|   let customFieldsService: CustomFieldsService | ||||
|   let permissionService: PermissionsService | ||||
|   let subscription: Subscription | ||||
|  | ||||
|   const ui_settings: UiSettings = { | ||||
| @@ -76,12 +97,14 @@ describe('SettingsService', () => { | ||||
|  | ||||
|     httpTestingController = TestBed.inject(HttpTestingController) | ||||
|     cookieService = TestBed.inject(CookieService) | ||||
|     customFieldsService = TestBed.inject(CustomFieldsService) | ||||
|     permissionService = TestBed.inject(PermissionsService) | ||||
|     settingsService = TestBed.inject(SettingsService) | ||||
|   }) | ||||
|  | ||||
|   afterEach(() => { | ||||
|     subscription?.unsubscribe() | ||||
|     httpTestingController.verify() | ||||
|     // httpTestingController.verify() | ||||
|   }) | ||||
|  | ||||
|   it('calls ui_settings api endpoint on initialize', () => { | ||||
| @@ -314,4 +337,51 @@ describe('SettingsService', () => { | ||||
|     // post for migrate | ||||
|     httpTestingController.expectOne(`${environment.apiBaseUrl}ui_settings/`) | ||||
|   }) | ||||
|  | ||||
|   it('should hide fields if no perms or disabled', () => { | ||||
|     jest.spyOn(permissionService, 'currentUserCan').mockReturnValue(false) | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}ui_settings/` | ||||
|     ) | ||||
|     req.flush(ui_settings) | ||||
|     settingsService.initializeDisplayFields() | ||||
|     expect( | ||||
|       settingsService.allDisplayFields.includes(DEFAULT_DISPLAY_FIELDS[0]) | ||||
|     ).toBeTruthy() // title | ||||
|     expect( | ||||
|       settingsService.allDisplayFields.includes(DEFAULT_DISPLAY_FIELDS[4]) | ||||
|     ).toBeFalsy() // correspondent | ||||
|  | ||||
|     settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false) | ||||
|     settingsService.initializeDisplayFields() | ||||
|     expect( | ||||
|       settingsService.allDisplayFields.includes(DEFAULT_DISPLAY_FIELDS[8]) | ||||
|     ).toBeFalsy() // notes | ||||
|  | ||||
|     jest.spyOn(permissionService, 'currentUserCan').mockReturnValue(true) | ||||
|     settingsService.initializeDisplayFields() | ||||
|     expect( | ||||
|       settingsService.allDisplayFields.includes(DEFAULT_DISPLAY_FIELDS[4]) | ||||
|     ).toBeTruthy() // correspondent | ||||
|   }) | ||||
|  | ||||
|   it('should dynamically create display fields options including custom fields', () => { | ||||
|     jest.spyOn(permissionService, 'currentUserCan').mockReturnValue(true) | ||||
|     jest.spyOn(customFieldsService, 'listAll').mockReturnValue( | ||||
|       of({ | ||||
|         all: customFields.map((f) => f.id), | ||||
|         count: customFields.length, | ||||
|         results: customFields.concat([]), | ||||
|       }) | ||||
|     ) | ||||
|     settingsService.initializeDisplayFields() | ||||
|     expect( | ||||
|       settingsService.allDisplayFields.includes(DEFAULT_DISPLAY_FIELDS[0]) | ||||
|     ).toBeTruthy() | ||||
|     expect( | ||||
|       settingsService.allDisplayFields.find( | ||||
|         (f) => f.id === `${DisplayField.CUSTOM_FIELD}${customFields[0].id}` | ||||
|       ).name | ||||
|     ).toEqual(customFields[0].name) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -19,9 +19,15 @@ import { | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { UiSettings, SETTINGS, SETTINGS_KEYS } from '../data/ui-settings' | ||||
| import { User } from '../data/user' | ||||
| import { PermissionsService } from './permissions.service' | ||||
| import { | ||||
|   PermissionAction, | ||||
|   PermissionType, | ||||
|   PermissionsService, | ||||
| } from './permissions.service' | ||||
| import { ToastService } from './toast.service' | ||||
| import { SavedView } from '../data/saved-view' | ||||
| import { CustomFieldsService } from './rest/custom-fields.service' | ||||
| import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document' | ||||
|  | ||||
| export interface LanguageOption { | ||||
|   code: string | ||||
| @@ -257,6 +263,12 @@ export class SettingsService { | ||||
|   public globalDropzoneActive: boolean = false | ||||
|   public organizingSidebarSavedViews: boolean = false | ||||
|  | ||||
|   private _allDisplayFields: Array<{ id: DisplayField; name: string }> = | ||||
|     DEFAULT_DISPLAY_FIELDS | ||||
|   public get allDisplayFields(): Array<{ id: DisplayField; name: string }> { | ||||
|     return this._allDisplayFields | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     rendererFactory: RendererFactory2, | ||||
|     @Inject(DOCUMENT) private document, | ||||
| @@ -265,7 +277,8 @@ export class SettingsService { | ||||
|     @Inject(LOCALE_ID) private localeId: string, | ||||
|     protected http: HttpClient, | ||||
|     private toastService: ToastService, | ||||
|     private permissionsService: PermissionsService | ||||
|     private permissionsService: PermissionsService, | ||||
|     private customFieldsService: CustomFieldsService | ||||
|   ) { | ||||
|     this._renderer = rendererFactory.createRenderer(null, null) | ||||
|   } | ||||
| @@ -288,10 +301,70 @@ export class SettingsService { | ||||
|           uisettings.permissions, | ||||
|           this.currentUser | ||||
|         ) | ||||
|  | ||||
|         this.initializeDisplayFields() | ||||
|       }) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public initializeDisplayFields() { | ||||
|     this._allDisplayFields = DEFAULT_DISPLAY_FIELDS | ||||
|  | ||||
|     this._allDisplayFields = this._allDisplayFields | ||||
|       ?.map((field) => { | ||||
|         if ( | ||||
|           field.id === DisplayField.NOTES && | ||||
|           !this.get(SETTINGS_KEYS.NOTES_ENABLED) | ||||
|         ) { | ||||
|           return null | ||||
|         } | ||||
|  | ||||
|         if ( | ||||
|           [ | ||||
|             DisplayField.TITLE, | ||||
|             DisplayField.CREATED, | ||||
|             DisplayField.ADDED, | ||||
|             DisplayField.ASN, | ||||
|             DisplayField.SHARED, | ||||
|           ].includes(field.id) | ||||
|         ) { | ||||
|           return field | ||||
|         } | ||||
|  | ||||
|         let type: PermissionType = Object.values(PermissionType).find((t) => | ||||
|           t.includes(field.id) | ||||
|         ) | ||||
|         if (field.id === DisplayField.OWNER) { | ||||
|           type = PermissionType.User | ||||
|         } | ||||
|         return this.permissionsService.currentUserCan( | ||||
|           PermissionAction.View, | ||||
|           type | ||||
|         ) | ||||
|           ? field | ||||
|           : null | ||||
|       }) | ||||
|       .filter((f) => f) | ||||
|  | ||||
|     if ( | ||||
|       this.permissionsService.currentUserCan( | ||||
|         PermissionAction.View, | ||||
|         PermissionType.CustomField | ||||
|       ) | ||||
|     ) { | ||||
|       this.customFieldsService.listAll().subscribe((r) => { | ||||
|         this._allDisplayFields = this._allDisplayFields.concat( | ||||
|           r.results.map((field) => { | ||||
|             return { | ||||
|               id: `${DisplayField.CUSTOM_FIELD}${field.id}` as any, | ||||
|               name: field.name, | ||||
|             } | ||||
|           }) | ||||
|         ) | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   get displayName(): string { | ||||
|     return ( | ||||
|       this.currentUser.first_name ?? | ||||
|   | ||||
| @@ -0,0 +1,49 @@ | ||||
| # Generated by Django 4.2.11 on 2024-04-16 18:35 | ||||
|  | ||||
| import django.core.validators | ||||
| from django.db import migrations | ||||
| from django.db import models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("documents", "1046_workflowaction_remove_all_correspondents_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="savedview", | ||||
|             name="display_mode", | ||||
|             field=models.CharField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("table", "Table"), | ||||
|                     ("smallCards", "Small Cards"), | ||||
|                     ("largeCards", "Large Cards"), | ||||
|                 ], | ||||
|                 max_length=128, | ||||
|                 null=True, | ||||
|                 verbose_name="View display mode", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="savedview", | ||||
|             name="page_size", | ||||
|             field=models.PositiveIntegerField( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 validators=[django.core.validators.MinValueValidator(1)], | ||||
|                 verbose_name="View page size", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="savedview", | ||||
|             name="display_fields", | ||||
|             field=models.JSONField( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 verbose_name="Document display fields", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -394,6 +394,25 @@ class Log(models.Model): | ||||
|  | ||||
|  | ||||
| class SavedView(ModelWithOwner): | ||||
|     class DisplayMode(models.TextChoices): | ||||
|         TABLE = ("table", _("Table")) | ||||
|         SMALL_CARDS = ("smallCards", _("Small Cards")) | ||||
|         LARGE_CARDS = ("largeCards", _("Large Cards")) | ||||
|  | ||||
|     class DisplayFields(models.TextChoices): | ||||
|         TITLE = ("title", _("Title")) | ||||
|         CREATED = ("created", _("Created")) | ||||
|         ADDED = ("added", _("Added")) | ||||
|         TAGS = ("tag"), _("Tags") | ||||
|         CORRESPONDENT = ("correspondent", _("Correspondent")) | ||||
|         DOCUMENT_TYPE = ("documenttype", _("Document Type")) | ||||
|         STORAGE_PATH = ("storagepath", _("Storage Path")) | ||||
|         NOTES = ("note", _("Note")) | ||||
|         OWNER = ("owner", _("Owner")) | ||||
|         SHARED = ("shared", _("Shared")) | ||||
|         ASN = ("asn", _("ASN")) | ||||
|         CUSTOM_FIELD = ("custom_field_%d", ("Custom Field")) | ||||
|  | ||||
|     name = models.CharField(_("name"), max_length=128) | ||||
|  | ||||
|     show_on_dashboard = models.BooleanField( | ||||
| @@ -411,6 +430,27 @@ class SavedView(ModelWithOwner): | ||||
|     ) | ||||
|     sort_reverse = models.BooleanField(_("sort reverse"), default=False) | ||||
|  | ||||
|     page_size = models.PositiveIntegerField( | ||||
|         _("View page size"), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         validators=[MinValueValidator(1)], | ||||
|     ) | ||||
|  | ||||
|     display_mode = models.CharField( | ||||
|         max_length=128, | ||||
|         verbose_name=_("View display mode"), | ||||
|         choices=DisplayMode.choices, | ||||
|         null=True, | ||||
|         blank=True, | ||||
|     ) | ||||
|  | ||||
|     display_fields = models.JSONField( | ||||
|         verbose_name=_("Document display fields"), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         ordering = ("name",) | ||||
|         verbose_name = _("saved view") | ||||
|   | ||||
| @@ -815,12 +815,33 @@ class SavedViewSerializer(OwnedObjectSerializer): | ||||
|             "sort_field", | ||||
|             "sort_reverse", | ||||
|             "filter_rules", | ||||
|             "page_size", | ||||
|             "display_mode", | ||||
|             "display_fields", | ||||
|             "owner", | ||||
|             "permissions", | ||||
|             "user_can_change", | ||||
|             "set_permissions", | ||||
|         ] | ||||
|  | ||||
|     def validate(self, attrs): | ||||
|         attrs = super().validate(attrs) | ||||
|         if "display_fields" in attrs and attrs["display_fields"] is not None: | ||||
|             for field in attrs["display_fields"]: | ||||
|                 if ( | ||||
|                     SavedView.DisplayFields.CUSTOM_FIELD[:-2] in field | ||||
|                 ):  # i.e. check for 'custom_field_' prefix | ||||
|                     field_id = int(re.search(r"\d+", field)[0]) | ||||
|                     if not CustomField.objects.filter(id=field_id).exists(): | ||||
|                         raise serializers.ValidationError( | ||||
|                             f"Invalid field: {field}", | ||||
|                         ) | ||||
|                 elif field not in SavedView.DisplayFields.values: | ||||
|                     raise serializers.ValidationError( | ||||
|                         f"Invalid field: {field}", | ||||
|                     ) | ||||
|         return attrs | ||||
|  | ||||
|     def update(self, instance, validated_data): | ||||
|         if "filter_rules" in validated_data: | ||||
|             rules_data = validated_data.pop("filter_rules") | ||||
|   | ||||
| @@ -1614,7 +1614,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): | ||||
|             status.HTTP_404_NOT_FOUND, | ||||
|         ) | ||||
|  | ||||
|     def test_create_update_patch(self): | ||||
|     def test_saved_view_create_update_patch(self): | ||||
|         User.objects.create_user("user1") | ||||
|  | ||||
|         view = { | ||||
| @@ -1661,6 +1661,155 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): | ||||
|         v1 = SavedView.objects.get(id=v1.id) | ||||
|         self.assertEqual(v1.filter_rules.count(), 0) | ||||
|  | ||||
|     def test_saved_view_display_options(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Saved view | ||||
|         WHEN: | ||||
|             - Updating display options | ||||
|         THEN: | ||||
|             - Display options are updated | ||||
|             - Display fields are validated | ||||
|         """ | ||||
|         User.objects.create_user("user1") | ||||
|  | ||||
|         view = { | ||||
|             "name": "test", | ||||
|             "show_on_dashboard": True, | ||||
|             "show_in_sidebar": True, | ||||
|             "sort_field": "created2", | ||||
|             "filter_rules": [{"rule_type": 4, "value": "test"}], | ||||
|             "page_size": 20, | ||||
|             "display_mode": SavedView.DisplayMode.SMALL_CARDS, | ||||
|             "display_fields": [ | ||||
|                 SavedView.DisplayFields.TITLE, | ||||
|                 SavedView.DisplayFields.CREATED, | ||||
|             ], | ||||
|         } | ||||
|  | ||||
|         response = self.client.post("/api/saved_views/", view, format="json") | ||||
|         self.assertEqual(response.status_code, status.HTTP_201_CREATED) | ||||
|  | ||||
|         v1 = SavedView.objects.get(name="test") | ||||
|         self.assertEqual(v1.page_size, 20) | ||||
|         self.assertEqual( | ||||
|             v1.display_mode, | ||||
|             SavedView.DisplayMode.SMALL_CARDS, | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             v1.display_fields, | ||||
|             [ | ||||
|                 SavedView.DisplayFields.TITLE, | ||||
|                 SavedView.DisplayFields.CREATED, | ||||
|             ], | ||||
|         ) | ||||
|  | ||||
|         response = self.client.patch( | ||||
|             f"/api/saved_views/{v1.id}/", | ||||
|             { | ||||
|                 "display_fields": [ | ||||
|                     SavedView.DisplayFields.TAGS, | ||||
|                     SavedView.DisplayFields.TITLE, | ||||
|                     SavedView.DisplayFields.CREATED, | ||||
|                 ], | ||||
|             }, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|  | ||||
|         v1.refresh_from_db() | ||||
|         self.assertEqual( | ||||
|             v1.display_fields, | ||||
|             [ | ||||
|                 SavedView.DisplayFields.TAGS, | ||||
|                 SavedView.DisplayFields.TITLE, | ||||
|                 SavedView.DisplayFields.CREATED, | ||||
|             ], | ||||
|         ) | ||||
|  | ||||
|         # Invalid display field | ||||
|         response = self.client.patch( | ||||
|             f"/api/saved_views/{v1.id}/", | ||||
|             { | ||||
|                 "display_fields": [ | ||||
|                     "foobar", | ||||
|                 ], | ||||
|             }, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|  | ||||
|     def test_saved_view_display_customfields(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Saved view | ||||
|         WHEN: | ||||
|             - Updating display options with custom fields | ||||
|         THEN: | ||||
|             - Display filds for custom fields are updated | ||||
|             - Display fields for custom fields are validated | ||||
|         """ | ||||
|         view = { | ||||
|             "name": "test", | ||||
|             "show_on_dashboard": True, | ||||
|             "show_in_sidebar": True, | ||||
|             "sort_field": "created2", | ||||
|             "filter_rules": [{"rule_type": 4, "value": "test"}], | ||||
|             "page_size": 20, | ||||
|             "display_mode": SavedView.DisplayMode.SMALL_CARDS, | ||||
|             "display_fields": [ | ||||
|                 SavedView.DisplayFields.TITLE, | ||||
|                 SavedView.DisplayFields.CREATED, | ||||
|             ], | ||||
|         } | ||||
|  | ||||
|         response = self.client.post("/api/saved_views/", view, format="json") | ||||
|         self.assertEqual(response.status_code, status.HTTP_201_CREATED) | ||||
|  | ||||
|         v1 = SavedView.objects.get(name="test") | ||||
|  | ||||
|         custom_field = CustomField.objects.create( | ||||
|             name="stringfield", | ||||
|             data_type=CustomField.FieldDataType.STRING, | ||||
|         ) | ||||
|  | ||||
|         response = self.client.patch( | ||||
|             f"/api/saved_views/{v1.id}/", | ||||
|             { | ||||
|                 "display_fields": [ | ||||
|                     SavedView.DisplayFields.TITLE, | ||||
|                     SavedView.DisplayFields.CREATED, | ||||
|                     SavedView.DisplayFields.CUSTOM_FIELD % custom_field.id, | ||||
|                 ], | ||||
|             }, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|  | ||||
|         v1.refresh_from_db() | ||||
|         self.assertEqual( | ||||
|             v1.display_fields, | ||||
|             [ | ||||
|                 str(SavedView.DisplayFields.TITLE), | ||||
|                 str(SavedView.DisplayFields.CREATED), | ||||
|                 SavedView.DisplayFields.CUSTOM_FIELD % custom_field.id, | ||||
|             ], | ||||
|         ) | ||||
|  | ||||
|         # Custom field not found | ||||
|         response = self.client.patch( | ||||
|             f"/api/saved_views/{v1.id}/", | ||||
|             { | ||||
|                 "display_fields": [ | ||||
|                     SavedView.DisplayFields.TITLE, | ||||
|                     SavedView.DisplayFields.CREATED, | ||||
|                     SavedView.DisplayFields.CUSTOM_FIELD % 99, | ||||
|                 ], | ||||
|             }, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|  | ||||
|     def test_get_logs(self): | ||||
|         log_data = "test\ntest2\n" | ||||
|         with open(os.path.join(settings.LOGGING_DIR, "mail.log"), "w") as f: | ||||
|   | ||||
| @@ -2,7 +2,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: paperless-ngx\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2024-04-19 01:15-0700\n" | ||||
| "POT-Creation-Date: 2024-04-24 22:54-0700\n" | ||||
| "PO-Revision-Date: 2022-02-17 04:17\n" | ||||
| "Last-Translator: \n" | ||||
| "Language-Team: English\n" | ||||
| @@ -21,31 +21,31 @@ msgstr "" | ||||
| msgid "Documents" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:36 documents/models.py:739 | ||||
| #: documents/models.py:36 documents/models.py:779 | ||||
| msgid "owner" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:53 documents/models.py:902 | ||||
| #: documents/models.py:53 documents/models.py:942 | ||||
| msgid "None" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:54 documents/models.py:903 | ||||
| #: documents/models.py:54 documents/models.py:943 | ||||
| msgid "Any word" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:55 documents/models.py:904 | ||||
| #: documents/models.py:55 documents/models.py:944 | ||||
| msgid "All words" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:56 documents/models.py:905 | ||||
| #: documents/models.py:56 documents/models.py:945 | ||||
| msgid "Exact match" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:57 documents/models.py:906 | ||||
| #: documents/models.py:57 documents/models.py:946 | ||||
| msgid "Regular expression" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:58 documents/models.py:907 | ||||
| #: documents/models.py:58 documents/models.py:947 | ||||
| msgid "Fuzzy word" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -53,20 +53,20 @@ msgstr "" | ||||
| msgid "Automatic" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:62 documents/models.py:397 documents/models.py:1223 | ||||
| #: documents/models.py:62 documents/models.py:416 documents/models.py:1263 | ||||
| #: paperless_mail/models.py:18 paperless_mail/models.py:93 | ||||
| msgid "name" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:64 documents/models.py:963 | ||||
| #: documents/models.py:64 documents/models.py:1003 | ||||
| msgid "match" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:67 documents/models.py:966 | ||||
| #: documents/models.py:67 documents/models.py:1006 | ||||
| msgid "matching algorithm" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:72 documents/models.py:971 | ||||
| #: documents/models.py:72 documents/models.py:1011 | ||||
| msgid "is insensitive" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -132,7 +132,7 @@ msgstr "" | ||||
| msgid "title" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:171 documents/models.py:653 | ||||
| #: documents/models.py:171 documents/models.py:693 | ||||
| msgid "content" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -162,8 +162,8 @@ msgstr "" | ||||
| msgid "The checksum of the archived document." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:205 documents/models.py:385 documents/models.py:659 | ||||
| #: documents/models.py:697 documents/models.py:767 documents/models.py:804 | ||||
| #: documents/models.py:205 documents/models.py:385 documents/models.py:699 | ||||
| #: documents/models.py:737 documents/models.py:807 documents/models.py:844 | ||||
| msgid "created" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -211,7 +211,7 @@ msgstr "" | ||||
| msgid "The position of this document in your physical document archive." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:279 documents/models.py:670 documents/models.py:724 | ||||
| #: documents/models.py:279 documents/models.py:710 documents/models.py:764 | ||||
| msgid "document" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -259,584 +259,652 @@ msgstr "" | ||||
| msgid "logs" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:398 | ||||
| msgid "Table" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:399 | ||||
| msgid "Small Cards" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:400 | ||||
| msgid "show on dashboard" | ||||
| msgid "Large Cards" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:403 | ||||
| msgid "show in sidebar" | ||||
| msgid "Title" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:404 | ||||
| msgid "Created" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:405 | ||||
| msgid "Added" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:406 | ||||
| msgid "Tags" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:407 | ||||
| msgid "sort field" | ||||
| msgid "Correspondent" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:408 | ||||
| msgid "Document Type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:409 | ||||
| msgid "Storage Path" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:410 | ||||
| msgid "Note" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:411 | ||||
| msgid "Owner" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:412 | ||||
| msgid "sort reverse" | ||||
| msgid "Shared" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:416 documents/models.py:469 | ||||
| msgid "saved view" | ||||
| #: documents/models.py:413 | ||||
| msgid "ASN" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:417 | ||||
| msgid "saved views" | ||||
| #: documents/models.py:419 | ||||
| msgid "show on dashboard" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:425 | ||||
| msgid "title contains" | ||||
| #: documents/models.py:422 | ||||
| msgid "show in sidebar" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:426 | ||||
| msgid "content contains" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:427 | ||||
| msgid "ASN is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:428 | ||||
| msgid "correspondent is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:429 | ||||
| msgid "document type is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:430 | ||||
| msgid "is in inbox" | ||||
| msgid "sort field" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:431 | ||||
| msgid "has tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:432 | ||||
| msgid "has any tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:433 | ||||
| msgid "created before" | ||||
| msgid "sort reverse" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:434 | ||||
| msgid "created after" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:435 | ||||
| msgid "created year is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:436 | ||||
| msgid "created month is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:437 | ||||
| msgid "created day is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:438 | ||||
| msgid "added before" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:439 | ||||
| msgid "added after" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:440 | ||||
| msgid "modified before" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:441 | ||||
| msgid "modified after" | ||||
| msgid "View page size" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:442 | ||||
| msgid "does not have tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:443 | ||||
| msgid "does not have ASN" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:444 | ||||
| msgid "title or content contains" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:445 | ||||
| msgid "fulltext query" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:446 | ||||
| msgid "more like this" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:447 | ||||
| msgid "has tags in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:448 | ||||
| msgid "ASN greater than" | ||||
| msgid "View display mode" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:449 | ||||
| msgid "ASN less than" | ||||
| msgid "Document display fields" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:450 | ||||
| msgid "storage path is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:451 | ||||
| msgid "has correspondent in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:452 | ||||
| msgid "does not have correspondent in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:453 | ||||
| msgid "has document type in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:454 | ||||
| msgid "does not have document type in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:455 | ||||
| msgid "has storage path in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:456 | ||||
| msgid "does not have storage path in" | ||||
| #: documents/models.py:456 documents/models.py:509 | ||||
| msgid "saved view" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:457 | ||||
| msgid "owner is" | ||||
| msgid "saved views" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:458 | ||||
| msgid "has owner in" | ||||
| #: documents/models.py:465 | ||||
| msgid "title contains" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:459 | ||||
| msgid "does not have owner" | ||||
| #: documents/models.py:466 | ||||
| msgid "content contains" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:460 | ||||
| msgid "does not have owner in" | ||||
| #: documents/models.py:467 | ||||
| msgid "ASN is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:461 | ||||
| msgid "has custom field value" | ||||
| #: documents/models.py:468 | ||||
| msgid "correspondent is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:462 | ||||
| msgid "is shared by me" | ||||
| #: documents/models.py:469 | ||||
| msgid "document type is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:470 | ||||
| msgid "is in inbox" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:471 | ||||
| msgid "has tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:472 | ||||
| msgid "rule type" | ||||
| msgid "has any tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:473 | ||||
| msgid "created before" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:474 | ||||
| msgid "value" | ||||
| msgid "created after" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:475 | ||||
| msgid "created year is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:476 | ||||
| msgid "created month is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:477 | ||||
| msgid "filter rule" | ||||
| msgid "created day is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:478 | ||||
| msgid "added before" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:479 | ||||
| msgid "added after" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:480 | ||||
| msgid "modified before" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:481 | ||||
| msgid "modified after" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:482 | ||||
| msgid "does not have tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:483 | ||||
| msgid "does not have ASN" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:484 | ||||
| msgid "title or content contains" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:485 | ||||
| msgid "fulltext query" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:486 | ||||
| msgid "more like this" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:487 | ||||
| msgid "has tags in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:488 | ||||
| msgid "ASN greater than" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:489 | ||||
| msgid "ASN less than" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:490 | ||||
| msgid "storage path is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:491 | ||||
| msgid "has correspondent in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:492 | ||||
| msgid "does not have correspondent in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:493 | ||||
| msgid "has document type in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:494 | ||||
| msgid "does not have document type in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:495 | ||||
| msgid "has storage path in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:496 | ||||
| msgid "does not have storage path in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:497 | ||||
| msgid "owner is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:498 | ||||
| msgid "has owner in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:499 | ||||
| msgid "does not have owner" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:500 | ||||
| msgid "does not have owner in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:501 | ||||
| msgid "has custom field value" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:502 | ||||
| msgid "is shared by me" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:512 | ||||
| msgid "rule type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:514 | ||||
| msgid "value" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:517 | ||||
| msgid "filter rule" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:518 | ||||
| msgid "filter rules" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:589 | ||||
| #: documents/models.py:629 | ||||
| msgid "Task ID" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:590 | ||||
| #: documents/models.py:630 | ||||
| msgid "Celery ID for the Task that was run" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:595 | ||||
| #: documents/models.py:635 | ||||
| msgid "Acknowledged" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:596 | ||||
| #: documents/models.py:636 | ||||
| msgid "If the task is acknowledged via the frontend or API" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:602 | ||||
| #: documents/models.py:642 | ||||
| msgid "Task Filename" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:603 | ||||
| #: documents/models.py:643 | ||||
| msgid "Name of the file which the Task was run for" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:609 | ||||
| #: documents/models.py:649 | ||||
| msgid "Task Name" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:610 | ||||
| #: documents/models.py:650 | ||||
| msgid "Name of the Task which was run" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:617 | ||||
| #: documents/models.py:657 | ||||
| msgid "Task State" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:618 | ||||
| #: documents/models.py:658 | ||||
| msgid "Current state of the task being run" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:623 | ||||
| #: documents/models.py:663 | ||||
| msgid "Created DateTime" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:624 | ||||
| #: documents/models.py:664 | ||||
| msgid "Datetime field when the task result was created in UTC" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:629 | ||||
| #: documents/models.py:669 | ||||
| msgid "Started DateTime" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:630 | ||||
| #: documents/models.py:670 | ||||
| msgid "Datetime field when the task was started in UTC" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:635 | ||||
| #: documents/models.py:675 | ||||
| msgid "Completed DateTime" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:636 | ||||
| #: documents/models.py:676 | ||||
| msgid "Datetime field when the task was completed in UTC" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:641 | ||||
| #: documents/models.py:681 | ||||
| msgid "Result Data" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:643 | ||||
| #: documents/models.py:683 | ||||
| msgid "The data returned by the task" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:655 | ||||
| #: documents/models.py:695 | ||||
| msgid "Note for the document" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:679 | ||||
| #: documents/models.py:719 | ||||
| msgid "user" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:684 | ||||
| #: documents/models.py:724 | ||||
| msgid "note" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:685 | ||||
| #: documents/models.py:725 | ||||
| msgid "notes" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:693 | ||||
| #: documents/models.py:733 | ||||
| msgid "Archive" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:694 | ||||
| #: documents/models.py:734 | ||||
| msgid "Original" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:705 | ||||
| #: documents/models.py:745 | ||||
| msgid "expiration" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:712 | ||||
| #: documents/models.py:752 | ||||
| msgid "slug" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:744 | ||||
| #: documents/models.py:784 | ||||
| msgid "share link" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:745 | ||||
| #: documents/models.py:785 | ||||
| msgid "share links" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:757 | ||||
| #: documents/models.py:797 | ||||
| msgid "String" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:758 | ||||
| #: documents/models.py:798 | ||||
| msgid "URL" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:759 | ||||
| #: documents/models.py:799 | ||||
| msgid "Date" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:760 | ||||
| #: documents/models.py:800 | ||||
| msgid "Boolean" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:761 | ||||
| #: documents/models.py:801 | ||||
| msgid "Integer" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:762 | ||||
| #: documents/models.py:802 | ||||
| msgid "Float" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:763 | ||||
| #: documents/models.py:803 | ||||
| msgid "Monetary" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:764 | ||||
| #: documents/models.py:804 | ||||
| msgid "Document Link" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:776 | ||||
| #: documents/models.py:816 | ||||
| msgid "data type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:784 | ||||
| #: documents/models.py:824 | ||||
| msgid "custom field" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:785 | ||||
| #: documents/models.py:825 | ||||
| msgid "custom fields" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:847 | ||||
| #: documents/models.py:887 | ||||
| msgid "custom field instance" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:848 | ||||
| #: documents/models.py:888 | ||||
| msgid "custom field instances" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:910 | ||||
| #: documents/models.py:950 | ||||
| msgid "Consumption Started" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:911 | ||||
| #: documents/models.py:951 | ||||
| msgid "Document Added" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:912 | ||||
| #: documents/models.py:952 | ||||
| msgid "Document Updated" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:915 | ||||
| #: documents/models.py:955 | ||||
| msgid "Consume Folder" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:916 | ||||
| #: documents/models.py:956 | ||||
| msgid "Api Upload" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:917 | ||||
| #: documents/models.py:957 | ||||
| msgid "Mail Fetch" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:920 | ||||
| #: documents/models.py:960 | ||||
| msgid "Workflow Trigger Type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:932 | ||||
| #: documents/models.py:972 | ||||
| msgid "filter path" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:937 | ||||
| #: documents/models.py:977 | ||||
| msgid "" | ||||
| "Only consume documents with a path that matches this if specified. Wildcards " | ||||
| "specified as * are allowed. Case insensitive." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:944 | ||||
| #: documents/models.py:984 | ||||
| msgid "filter filename" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:949 paperless_mail/models.py:148 | ||||
| #: documents/models.py:989 paperless_mail/models.py:148 | ||||
| msgid "" | ||||
| "Only consume documents which entirely match this filename if specified. " | ||||
| "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:960 | ||||
| #: documents/models.py:1000 | ||||
| msgid "filter documents from this mail rule" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:976 | ||||
| #: documents/models.py:1016 | ||||
| msgid "has these tag(s)" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:984 | ||||
| #: documents/models.py:1024 | ||||
| msgid "has this document type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:992 | ||||
| #: documents/models.py:1032 | ||||
| msgid "has this correspondent" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:996 | ||||
| #: documents/models.py:1036 | ||||
| msgid "workflow trigger" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:997 | ||||
| #: documents/models.py:1037 | ||||
| msgid "workflow triggers" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1007 | ||||
| #: documents/models.py:1047 | ||||
| msgid "Assignment" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1011 | ||||
| #: documents/models.py:1051 | ||||
| msgid "Removal" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1015 | ||||
| #: documents/models.py:1055 | ||||
| msgid "Workflow Action Type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1021 | ||||
| #: documents/models.py:1061 | ||||
| msgid "assign title" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1026 | ||||
| #: documents/models.py:1066 | ||||
| msgid "" | ||||
| "Assign a document title, can include some placeholders, see documentation." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1035 paperless_mail/models.py:216 | ||||
| #: documents/models.py:1075 paperless_mail/models.py:216 | ||||
| msgid "assign this tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1044 paperless_mail/models.py:224 | ||||
| #: documents/models.py:1084 paperless_mail/models.py:224 | ||||
| msgid "assign this document type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1053 paperless_mail/models.py:238 | ||||
| #: documents/models.py:1093 paperless_mail/models.py:238 | ||||
| msgid "assign this correspondent" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1062 | ||||
| #: documents/models.py:1102 | ||||
| msgid "assign this storage path" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1071 | ||||
| #: documents/models.py:1111 | ||||
| msgid "assign this owner" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1078 | ||||
| #: documents/models.py:1118 | ||||
| msgid "grant view permissions to these users" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1085 | ||||
| #: documents/models.py:1125 | ||||
| msgid "grant view permissions to these groups" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1092 | ||||
| #: documents/models.py:1132 | ||||
| msgid "grant change permissions to these users" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1099 | ||||
| #: documents/models.py:1139 | ||||
| msgid "grant change permissions to these groups" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1106 | ||||
| #: documents/models.py:1146 | ||||
| msgid "assign these custom fields" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1113 | ||||
| #: documents/models.py:1153 | ||||
| msgid "remove these tag(s)" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1118 | ||||
| #: documents/models.py:1158 | ||||
| msgid "remove all tags" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1125 | ||||
| #: documents/models.py:1165 | ||||
| msgid "remove these document type(s)" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1130 | ||||
| #: documents/models.py:1170 | ||||
| msgid "remove all document types" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1137 | ||||
| #: documents/models.py:1177 | ||||
| msgid "remove these correspondent(s)" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1142 | ||||
| #: documents/models.py:1182 | ||||
| msgid "remove all correspondents" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1149 | ||||
| #: documents/models.py:1189 | ||||
| msgid "remove these storage path(s)" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1154 | ||||
| #: documents/models.py:1194 | ||||
| msgid "remove all storage paths" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1161 | ||||
| #: documents/models.py:1201 | ||||
| msgid "remove these owner(s)" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1166 | ||||
| #: documents/models.py:1206 | ||||
| msgid "remove all owners" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1173 | ||||
| #: documents/models.py:1213 | ||||
| msgid "remove view permissions for these users" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1180 | ||||
| #: documents/models.py:1220 | ||||
| msgid "remove view permissions for these groups" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1187 | ||||
| #: documents/models.py:1227 | ||||
| msgid "remove change permissions for these users" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1194 | ||||
| #: documents/models.py:1234 | ||||
| msgid "remove change permissions for these groups" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1199 | ||||
| #: documents/models.py:1239 | ||||
| msgid "remove all permissions" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1206 | ||||
| #: documents/models.py:1246 | ||||
| msgid "remove these custom fields" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1211 | ||||
| #: documents/models.py:1251 | ||||
| msgid "remove all custom fields" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1215 | ||||
| #: documents/models.py:1255 | ||||
| msgid "workflow action" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1216 | ||||
| #: documents/models.py:1256 | ||||
| msgid "workflow actions" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1225 paperless_mail/models.py:95 | ||||
| #: documents/models.py:1265 paperless_mail/models.py:95 | ||||
| msgid "order" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1231 | ||||
| #: documents/models.py:1271 | ||||
| msgid "triggers" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1238 | ||||
| #: documents/models.py:1278 | ||||
| msgid "actions" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:1241 | ||||
| #: documents/models.py:1281 | ||||
| msgid "enabled" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -849,12 +917,12 @@ msgstr "" | ||||
| msgid "Invalid color." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1148 | ||||
| #: documents/serialisers.py:1169 | ||||
| #, python-format | ||||
| msgid "File type %(type)s not supported" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1257 | ||||
| #: documents/serialisers.py:1278 | ||||
| msgid "Invalid variable detected." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon