]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: customizable fields display for documents, saved views & dashboard widgets...
authorshamoon <4887959+shamoon@users.noreply.github.com>
Fri, 26 Apr 2024 13:41:12 +0000 (06:41 -0700)
committerGitHub <noreply@github.com>
Fri, 26 Apr 2024 13:41:12 +0000 (06:41 -0700)
50 files changed:
src-ui/e2e/dashboard/requests/api-dashboard1.har
src-ui/e2e/dashboard/requests/api-dashboard2.har
src-ui/e2e/dashboard/requests/api-dashboard3.har
src-ui/e2e/dashboard/requests/api-dashboard4.har
src-ui/e2e/document-list/document-list.spec.ts
src-ui/messages.xlf
src-ui/src/app/app.module.ts
src-ui/src/app/components/admin/settings/settings.component.html
src-ui/src/app/components/admin/settings/settings.component.spec.ts
src-ui/src/app/components/admin/settings/settings.component.ts
src-ui/src/app/components/app-frame/app-frame.component.html
src-ui/src/app/components/common/custom-field-display/custom-field-display.component.html [new file with mode: 0644]
src-ui/src/app/components/common/custom-field-display/custom-field-display.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.html [new file with mode: 0644]
src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.ts [new file with mode: 0644]
src-ui/src/app/components/dashboard/dashboard.component.html
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.scss
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts
src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.html
src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html
src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts
src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts
src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html
src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts
src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts
src-ui/src/app/components/document-list/document-list.component.html
src-ui/src/app/components/document-list/document-list.component.scss
src-ui/src/app/components/document-list/document-list.component.spec.ts
src-ui/src/app/components/document-list/document-list.component.ts
src-ui/src/app/data/document.ts
src-ui/src/app/data/saved-view.ts
src-ui/src/app/services/consumer-status.service.spec.ts
src-ui/src/app/services/document-list-view.service.spec.ts
src-ui/src/app/services/document-list-view.service.ts
src-ui/src/app/services/rest/custom-fields.service.ts
src-ui/src/app/services/rest/document.service.spec.ts
src-ui/src/app/services/rest/document.service.ts
src-ui/src/app/services/settings.service.spec.ts
src-ui/src/app/services/settings.service.ts
src/documents/migrations/1047_savedview_display_mode_and_more.py [new file with mode: 0644]
src/documents/models.py
src/documents/serialisers.py
src/documents/tests/test_api_documents.py
src/locale/en_US/LC_MESSAGES/django.po

index 3e0829c2f3a97c3fc5417b89a8f3061df5d09237..9758236d14f6d18facec68b63e678f089cb84137 100644 (file)
           "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,
index 2436a627247009dba9edb7b6e646420c35e311bd..952387f5648ec7cc15d282806e392878388bd7f3 100644 (file)
           "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,
index 328c9db6ea647bcfbabd896b2161093d4400ba50..4fc9d62d67e3fd84b93b5264fd117aaf99d2f4d4 100644 (file)
           "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,
index ca0101d5916be66415ced5c5cfc9350c0c617d81..ecd539fdd515c4f3b624e2249125dd6ed013ff3f 100644 (file)
           "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,
index e748972574e0181b6aba1252ab2357c3cd27d496..e974d36a1dc1e905700b4d9441993defacc7ef93 100644 (file)
@@ -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()
 })
 
index 13490548553578464f074ffb124c75fcc08c1056..3478d4d40eda02b3cede59a9717a699ea7e14f76 100644 (file)
           <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
           <context context-type="linenumber">14,15</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/drag-drop-select/drag-drop-select.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
           <context context-type="linenumber">4</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">376</context>
+          <context context-type="linenumber">395</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">364</context>
+          <context context-type="linenumber">383</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">93</context>
+          <context context-type="linenumber">109</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">175</context>
+          <context context-type="linenumber">209</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">33</context>
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">62</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">95</context>
         </context-group>
       </trans-unit>
       <trans-unit id="293524471897878391" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">54</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="8953033926734869941" datatype="html">
-        <source>Name</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">328</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
-          <context context-type="linenumber">36</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
-          <context context-type="linenumber">21</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
-          <context context-type="linenumber">58</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
-          <context context-type="linenumber">12</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
-          <context context-type="linenumber">11</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">12</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
-          <context context-type="linenumber">11</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
-          <context context-type="linenumber">8</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
-          <context context-type="linenumber">17</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
-          <context context-type="linenumber">20</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
-          <context context-type="linenumber">64</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">20</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">20</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">20</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">20</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">37</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">37</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">37</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">37</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
-          <context context-type="linenumber">17</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="9187755754633397589" datatype="html">
-        <source> <x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;visually-hidden&quot;&gt;"/>Appears on<x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">332,333</context>
+          <context context-type="linenumber">70</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4104807402967139762" datatype="html">
         <source>Delete</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">346</context>
+          <context context-type="linenumber">345</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
           <context context-type="linenumber">38</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="6338800642797811873" datatype="html">
+        <source>Documents page size</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">356</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4729108320774167624" datatype="html">
+        <source>Display as</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">359</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1358239534403218079" datatype="html">
+        <source>Table</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">361</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4236040382842528005" datatype="html">
+        <source>Small Cards</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">362</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8569593958139569111" datatype="html">
+        <source>Large Cards</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">363</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8461842260159597706" datatype="html">
+        <source>Show</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">367</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
+          <context context-type="linenumber">17</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5607669932062416162" datatype="html">
+        <source>Default</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">367</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">113</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="7877440816920439876" datatype="html">
         <source>No saved views defined.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">358</context>
+          <context context-type="linenumber">376</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2159130950882492111" datatype="html">
+        <source>Cancel</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">396</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
+          <context context-type="linenumber">44</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
+          <context context-type="linenumber">27</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
+          <context context-type="linenumber">18</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
+          <context context-type="linenumber">29</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
+          <context context-type="linenumber">19</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
+          <context context-type="linenumber">39</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
+          <context context-type="linenumber">50</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">28</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
+          <context context-type="linenumber">30</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
+          <context context-type="linenumber">42</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">113</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
+          <context context-type="linenumber">25</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">98</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
+          <context context-type="linenumber">4</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
+          <context context-type="linenumber">20</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6839066544204061364" datatype="html">
         <source>Use system language</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">61</context>
+          <context context-type="linenumber">62</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7729897675462249787" datatype="html">
         <source>Use date format of display language</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">64</context>
+          <context context-type="linenumber">65</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1235706724900303689" datatype="html">
         <source>Error retrieving users</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">183</context>
+          <context context-type="linenumber">188</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
         <source>Error retrieving groups</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">202</context>
+          <context context-type="linenumber">207</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
         <source>Saved view &quot;<x id="PH" equiv-text="savedView.name"/>&quot; deleted.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">410</context>
+          <context context-type="linenumber">421</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7217000812750597833" datatype="html">
         <source>Settings were saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">536</context>
+          <context context-type="linenumber">547</context>
         </context-group>
       </trans-unit>
       <trans-unit id="525012668859298131" datatype="html">
         <source>Settings were saved successfully. Reload is required to apply some changes.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">540</context>
+          <context context-type="linenumber">551</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8491974984518503778" datatype="html">
         <source>Reload now</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">541</context>
+          <context context-type="linenumber">552</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3011185103048412841" datatype="html">
         <source>An error occurred while saving settings.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">551</context>
+          <context context-type="linenumber">562</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
         <source>Error while storing settings on server.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">585</context>
+          <context context-type="linenumber">596</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2991443309752293110" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">3</context>
+          <context context-type="linenumber">3</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">3</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8953033926734869941" datatype="html">
+        <source>Name</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
+          <context context-type="linenumber">36</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
+          <context context-type="linenumber">21</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
+          <context context-type="linenumber">58</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+          <context context-type="linenumber">17</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
+          <context context-type="linenumber">64</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">37</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">37</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">37</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
+          <context context-type="linenumber">37</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
-          <context context-type="linenumber">3</context>
+          <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
+          <context context-type="linenumber">17</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4207916966377787111" datatype="html">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
           <context context-type="linenumber">37</context>
         </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">16</context>
-        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">201</context>
+          <context context-type="linenumber">236</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
           <context context-type="linenumber">76</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">30</context>
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">38</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">92</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5968132631442328843" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">33</context>
+          <context context-type="linenumber">43</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">53</context>
+          <context context-type="linenumber">57</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">91</context>
+          <context context-type="linenumber">120</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">99</context>
+          <context context-type="linenumber">126</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
           <context context-type="linenumber">63</context>
         </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">19</context>
-        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
           <context context-type="linenumber">58</context>
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
           <context context-type="linenumber">21</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
+          <context context-type="linenumber">191</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
           <context context-type="linenumber">33</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">46</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="4369111787961525769" datatype="html">
         <source>Document Types</source>
           <context context-type="linenumber">483</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="2159130950882492111" datatype="html">
-        <source>Cancel</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
-          <context context-type="linenumber">44</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
-          <context context-type="linenumber">27</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
-          <context context-type="linenumber">18</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
-          <context context-type="linenumber">29</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
-          <context context-type="linenumber">19</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
-          <context context-type="linenumber">39</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
-          <context context-type="linenumber">50</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">28</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
-          <context context-type="linenumber">30</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
-          <context context-type="linenumber">42</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
-          <context context-type="linenumber">113</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
-          <context context-type="linenumber">25</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">98</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context>
-          <context context-type="linenumber">12</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">4</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
-          <context context-type="linenumber">20</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="994016933065248559" datatype="html">
         <source>Documents:</source>
         <context-group purpose="location">
           <context context-type="linenumber">28</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="2509141182388535183" datatype="html">
+        <source>View</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/custom-field-display/custom-field-display.component.html</context>
+          <context context-type="linenumber">15</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
+          <context context-type="linenumber">34</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
+          <context context-type="linenumber">10</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
+          <context context-type="linenumber">62</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="3972154626835212608" datatype="html">
         <source>Create New Field</source>
         <context-group purpose="location">
           <context context-type="linenumber">44</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="8627133593113147800" datatype="html">
+        <source>Selected items</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/drag-drop-select/drag-drop-select.component.ts</context>
+          <context context-type="linenumber">23</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="528456107179161277" datatype="html">
+        <source>No items selected</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/drag-drop-select/drag-drop-select.component.ts</context>
+          <context context-type="linenumber">29</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6932865105766151309" datatype="html">
         <source>Upload</source>
         <context-group purpose="location">
           <context context-type="linenumber">26</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="2509141182388535183" datatype="html">
-        <source>View</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
-          <context context-type="linenumber">34</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
-          <context context-type="linenumber">10</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">58</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="2722549756198502062" datatype="html">
         <source>Add item</source>
         <context-group purpose="location">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.ts</context>
-          <context context-type="linenumber">77</context>
+          <context context-type="linenumber">86</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2504502765849142619" datatype="html">
           <context context-type="linenumber">39</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="5701618810648052610" datatype="html">
-        <source>Title</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">17</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">104</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">160</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">115</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">28</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="2691296884221415710" datatype="html">
-        <source>Correspondent</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">22</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">108</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">36</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">151</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
-          <context context-type="linenumber">44</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">27</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="8911158217491828773" datatype="html">
         <source>View Preview</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">47</context>
+          <context context-type="linenumber">71</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3099741642167775297" datatype="html">
         <source>Download</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">55</context>
+          <context context-type="linenumber">79</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">64</context>
+          <context context-type="linenumber">68</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">102</context>
+          <context context-type="linenumber">131</context>
         </context-group>
       </trans-unit>
       <trans-unit id="872092479747931526" datatype="html">
         <source>No documents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
-          <context context-type="linenumber">65</context>
+          <context context-type="linenumber">121</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1069523139277190436" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">50</context>
+          <context context-type="linenumber">54</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2434944824726929798" datatype="html">
           <context context-type="linenumber">101</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="5701618810648052610" datatype="html">
+        <source>Title</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">104</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
+          <context context-type="linenumber">188</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
+          <context context-type="linenumber">115</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">34</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">90</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="1379170675585571971" datatype="html">
         <source>Archive serial number</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
           <context context-type="linenumber">105</context>
         </context-group>
-      </trans-unit>
-      <trans-unit id="5114742157723900905" datatype="html">
-        <source>Date created</source>
+      </trans-unit>
+      <trans-unit id="5114742157723900905" datatype="html">
+        <source>Date created</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">106</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2691296884221415710" datatype="html">
+        <source>Correspondent</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">108</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
+          <context context-type="linenumber">36</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
+          <context context-type="linenumber">179</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
+          <context context-type="linenumber">44</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">50</context>
+        </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">106</context>
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">89</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5066119607229701477" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">184</context>
+          <context context-type="linenumber">218</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
           <context context-type="linenumber">54</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">29</context>
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">54</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">91</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2091353339965748767" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">193</context>
+          <context context-type="linenumber">227</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
           <context context-type="linenumber">64</context>
         </context-group>
-      </trans-unit>
-      <trans-unit id="5607669932062416162" datatype="html">
-        <source>Default</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">113</context>
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">58</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6205355627445317276" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">225</context>
+          <context context-type="linenumber">275</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2784168796433474565" datatype="html">
         <source>Filter by tag</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">28</context>
+          <context context-type="linenumber">31</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">232</context>
+          <context context-type="linenumber">286</context>
         </context-group>
       </trans-unit>
       <trans-unit id="106713086593101376" datatype="html">
         <source>View notes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">70</context>
+          <context context-type="linenumber">74</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8778002102373462277" datatype="html">
         <source><x id="INTERPOLATION" equiv-text="ocument.notes.length}}"/> Notes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">71</context>
+          <context context-type="linenumber">75</context>
         </context-group>
       </trans-unit>
       <trans-unit id="78870852467682010" datatype="html">
         <source>Filter by document type</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">75</context>
+          <context context-type="linenumber">79</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">252</context>
+          <context context-type="linenumber">310</context>
         </context-group>
       </trans-unit>
       <trans-unit id="157572966557284263" datatype="html">
         <source>Filter by storage path</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">81</context>
+          <context context-type="linenumber">85</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">259</context>
+          <context context-type="linenumber">317</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3727324658595204357" datatype="html">
         <source>Created: <x id="INTERPOLATION" equiv-text="{{ document.created | customDate }}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">93,94</context>
+          <context context-type="linenumber">98,99</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
+          <context context-type="linenumber">65,66</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">60,61</context>
+          <context context-type="linenumber">80,81</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2030261243264601523" datatype="html">
         <source>Added: <x id="INTERPOLATION" equiv-text="{{ document.added | customDate }}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">94,95</context>
+          <context context-type="linenumber">99,100</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
+          <context context-type="linenumber">66,67</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">61,62</context>
+          <context context-type="linenumber">81,82</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4235671847487610290" datatype="html">
         <source>Modified: <x id="INTERPOLATION" equiv-text="{{ document.modified | customDate }}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">95,96</context>
+          <context context-type="linenumber">100,101</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
+          <context context-type="linenumber">67,68</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">62,63</context>
+          <context context-type="linenumber">82,83</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5739581984228459958" datatype="html">
         <source>Shared</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">108</context>
+          <context context-type="linenumber">121</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">85</context>
+          <context context-type="linenumber">106</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">70</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/pipes/username.pipe.ts</context>
         <source>Score:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
-          <context context-type="linenumber">113</context>
+          <context context-type="linenumber">126</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3661756380991326939" datatype="html">
         <source>Toggle tag filter</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">15</context>
+          <context context-type="linenumber">16</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4648526799630820486" datatype="html">
         <source>Toggle correspondent filter</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">36</context>
+          <context context-type="linenumber">38</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5319701482646590642" datatype="html">
         <source>Toggle document type filter</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">44</context>
+          <context context-type="linenumber">48</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8950368321707344185" datatype="html">
         <source>Toggle storage path filter</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">51</context>
+          <context context-type="linenumber">55</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5145213156408463657" datatype="html">
         <source>Sort</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">30</context>
+          <context context-type="linenumber">46</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1233494216161906927" datatype="html">
         <source>Save &quot;<x id="INTERPOLATION" equiv-text="{{list.activeSavedViewTitle}}"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">73</context>
+          <context context-type="linenumber">89</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2276119452079372898" datatype="html">
         <source>Save as...</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">76</context>
+          <context context-type="linenumber">92</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8786996283897742947" datatype="html">
         <source>{VAR_PLURAL, plural, =1 {Selected <x id="INTERPOLATION"/> of one document} other {Selected <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> documents}}</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">96</context>
+          <context context-type="linenumber">112</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6600548268163632449" datatype="html">
         <source>{VAR_PLURAL, plural, =1 {One document} other {<x id="INTERPOLATION"/> documents}}</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">100</context>
+          <context context-type="linenumber">116</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2243770355958919528" datatype="html">
         <source>(filtered)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">102</context>
+          <context context-type="linenumber">118</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6849725902312323996" datatype="html">
         <source>Reset filters</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">107</context>
+          <context context-type="linenumber">123</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
         <source>Error while loading documents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">123</context>
+          <context context-type="linenumber">139</context>
         </context-group>
       </trans-unit>
       <trans-unit id="494022736054110363" datatype="html">
         <source>Sort by ASN</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">139</context>
+          <context context-type="linenumber">166</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7517688192215738656" datatype="html">
         <source>ASN</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">143</context>
+          <context context-type="linenumber">170</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
           <context context-type="linenumber">120</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">26</context>
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">74</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">88</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6954625430271090777" datatype="html">
         <source>Sort by correspondent</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">147</context>
+          <context context-type="linenumber">175</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2066713941761361709" datatype="html">
         <source>Sort by title</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">155</context>
+          <context context-type="linenumber">184</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6232673011753681091" datatype="html">
         <source>Sort by owner</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">163</context>
+          <context context-type="linenumber">196</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3715596725146409911" datatype="html">
         <source>Owner</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">167</context>
+          <context context-type="linenumber">200</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">34</context>
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">66</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">96</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3557446856808034218" datatype="html">
         <source>Sort by notes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">171</context>
+          <context context-type="linenumber">205</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5499001829734502606" datatype="html">
         <source>Sort by document type</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">180</context>
+          <context context-type="linenumber">214</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6213829731736042759" datatype="html">
         <source>Sort by storage path</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">189</context>
+          <context context-type="linenumber">223</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3406167410329973166" datatype="html">
         <source>Sort by created date</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">197</context>
+          <context context-type="linenumber">232</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3769035778779263084" datatype="html">
         <source>Sort by added date</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">241</context>
         </context-group>
       </trans-unit>
       <trans-unit id="231679111972850796" datatype="html">
         <source>Added</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">208</context>
+          <context context-type="linenumber">245</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
           <context context-type="linenumber">82</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">31</context>
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">42</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">93</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="329406837759048287" datatype="html">
+        <source> Shared </source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
+          <context context-type="linenumber">248,250</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2179847500064178686" datatype="html">
         <source>Edit document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
-          <context context-type="linenumber">230</context>
+          <context context-type="linenumber">282</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2807800733729323332" datatype="html">
+        <source>Yes</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
+          <context context-type="linenumber">333</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3542042671420335679" datatype="html">
+        <source>No</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
+          <context context-type="linenumber">333</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context>
+          <context context-type="linenumber">8</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2155249406916744630" datatype="html">
         <source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">209</context>
+          <context context-type="linenumber">242</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6837554170707123455" datatype="html">
         <source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">250</context>
+          <context context-type="linenumber">285</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3100631071441658964" datatype="html">
           <context context-type="linenumber">45</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="3553216189604488439" datatype="html">
+        <source>Modified</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">94</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4460262093225954455" datatype="html">
+        <source>Search score</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/document.ts</context>
+          <context context-type="linenumber">102</context>
+        </context-group>
+        <note priority="1" from="description">Score is a value returned by the full text search engine and specifies how well a result matches the given query</note>
+      </trans-unit>
       <trans-unit id="2167862279705099846" datatype="html">
         <source>Auto: Learn matching automatically</source>
         <context-group purpose="location">
           <context context-type="linenumber">11</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="2807800733729323332" datatype="html">
-        <source>Yes</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context>
-          <context context-type="linenumber">8</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="3542042671420335679" datatype="html">
-        <source>No</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context>
-          <context context-type="linenumber">8</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="2119857572761283468" datatype="html">
         <source>Document already exists.</source>
         <context-group purpose="location">
           <context context-type="linenumber">135</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="3553216189604488439" datatype="html">
-        <source>Modified</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">32</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="4460262093225954455" datatype="html">
-        <source>Search score</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
-          <context context-type="linenumber">41</context>
-        </context-group>
-        <note priority="1" from="description">Score is a value returned by the full text search engine and specifies how well a result matches the given query</note>
-      </trans-unit>
       <trans-unit id="1206520795340730278" datatype="html">
         <source>English (US)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">40</context>
+          <context context-type="linenumber">46</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7318555235181361185" datatype="html">
         <source>Afrikaans</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">46</context>
+          <context context-type="linenumber">52</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6269202464699193298" datatype="html">
         <source>Arabic</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">52</context>
+          <context context-type="linenumber">58</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3098941349689899577" datatype="html">
         <source>Belarusian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">58</context>
+          <context context-type="linenumber">64</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6821856961727142928" datatype="html">
         <source>Bulgarian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">64</context>
+          <context context-type="linenumber">70</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1001043467371963032" datatype="html">
         <source>Catalan</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">70</context>
+          <context context-type="linenumber">76</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2719780722934172508" datatype="html">
         <source>Czech</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">76</context>
+          <context context-type="linenumber">82</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2924289692679201020" datatype="html">
         <source>Danish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">82</context>
+          <context context-type="linenumber">88</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1858110241312746425" datatype="html">
         <source>German</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">88</context>
+          <context context-type="linenumber">94</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7067741492320440272" datatype="html">
         <source>Greek</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">94</context>
+          <context context-type="linenumber">100</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6987083569809053351" datatype="html">
         <source>English (GB)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">100</context>
+          <context context-type="linenumber">106</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5190825892106392539" datatype="html">
         <source>Spanish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">106</context>
+          <context context-type="linenumber">112</context>
         </context-group>
       </trans-unit>
       <trans-unit id="861663369293303028" datatype="html">
         <source>Finnish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">112</context>
+          <context context-type="linenumber">118</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7633754075223722162" datatype="html">
         <source>French</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">118</context>
+          <context context-type="linenumber">124</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7891809788881004730" datatype="html">
         <source>Hungarian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">124</context>
+          <context context-type="linenumber">130</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2935232983274991580" datatype="html">
         <source>Italian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">130</context>
+          <context context-type="linenumber">136</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6924606686202701860" datatype="html">
         <source>Japanese</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">136</context>
+          <context context-type="linenumber">142</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1334425850005897370" datatype="html">
         <source>Luxembourgish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">142</context>
+          <context context-type="linenumber">148</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3071065188816255493" datatype="html">
         <source>Dutch</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">148</context>
+          <context context-type="linenumber">154</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8069284467804715623" datatype="html">
         <source>Norwegian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">154</context>
+          <context context-type="linenumber">160</context>
         </context-group>
       </trans-unit>
       <trans-unit id="792060551707690640" datatype="html">
         <source>Polish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">160</context>
+          <context context-type="linenumber">166</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9184513005098760425" datatype="html">
         <source>Portuguese (Brazil)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">166</context>
+          <context context-type="linenumber">172</context>
         </context-group>
       </trans-unit>
       <trans-unit id="153799456510623899" datatype="html">
         <source>Portuguese</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">172</context>
+          <context context-type="linenumber">178</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8118856427047826368" datatype="html">
         <source>Romanian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">178</context>
+          <context context-type="linenumber">184</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7137419789978325708" datatype="html">
         <source>Russian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">184</context>
+          <context context-type="linenumber">190</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9102963095355753902" datatype="html">
         <source>Slovak</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">190</context>
+          <context context-type="linenumber">196</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4287008301409320881" datatype="html">
         <source>Slovenian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">196</context>
+          <context context-type="linenumber">202</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8608389829607915090" datatype="html">
         <source>Serbian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">202</context>
+          <context context-type="linenumber">208</context>
         </context-group>
       </trans-unit>
       <trans-unit id="499386805970351976" datatype="html">
         <source>Swedish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">208</context>
+          <context context-type="linenumber">214</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5682359291233237791" datatype="html">
         <source>Turkish</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">214</context>
+          <context context-type="linenumber">220</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3578644052206125685" datatype="html">
         <source>Ukrainian</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">220</context>
+          <context context-type="linenumber">226</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4689443708886954687" datatype="html">
         <source>Chinese Simplified</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">226</context>
+          <context context-type="linenumber">232</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4912706592792948707" datatype="html">
         <source>ISO 8601</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">234</context>
+          <context context-type="linenumber">240</context>
         </context-group>
       </trans-unit>
       <trans-unit id="313643372755303297" datatype="html">
         <source>Successfully completed one-time migratration of settings to the database!</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">477</context>
+          <context context-type="linenumber">550</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5558341108007064934" datatype="html">
         <source>Unable to migrate settings to the database, please try saving manually.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">478</context>
+          <context context-type="linenumber">551</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1168781785897678748" datatype="html">
         <source>You can restart the tour from the settings page.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/settings.service.ts</context>
-          <context context-type="linenumber">548</context>
+          <context context-type="linenumber">621</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3852289441366561594" datatype="html">
index d7263de82f59867b3dd6d75be61bfd1eb56fc719..ab33840a2c647ca5a62a9f9cbf0ccd554d681422 100644 (file)
@@ -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,
index 0fc744edb544e7a9df5f22b3a478b16883a6a998..b5c6ca6b44da213e3f91e233db9da8e4d302bcf6 100644 (file)
         </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>&nbsp;<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>
   <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>
index d53f57b6985544e33ab94aeb89d139ccdf7db7cb..7b23edc2184a3ed07430fbed60e196ce774e3ede 100644 (file)
@@ -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('')
+  })
 })
index 33f6949a149cfca030d8cd4202adab5f0aba6a38..7df90e3de62b3830ca74cae935f42be238072f44 100644 (file)
@@ -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('')
   }
index bdc8d08f2596334d83df099404ab0bb01288330c..1e4080c48cd995a71259873638d23973d5754018 100644 (file)
             </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)">
diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.html b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.html
new file mode 100644 (file)
index 0000000..61946db
--- /dev/null
@@ -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>&nbsp;<span>{{ getDocumentTitle(docId) }}</span>
+                    </a>
+                }
+            </div>
+        }
+        @default {
+          <span>{{value}}</span>
+        }
+    }
+}
diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.scss b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts
new file mode 100644 (file)
index 0000000..d2b8d9f
--- /dev/null
@@ -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')
+  })
+})
diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts
new file mode 100644 (file)
index 0000000..f53c7c8
--- /dev/null
@@ -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()
+  }
+}
diff --git a/src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.html b/src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.html
new file mode 100644 (file)
index 0000000..dd7d7b3
--- /dev/null
@@ -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>
diff --git a/src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.scss b/src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.scss
new file mode 100644 (file)
index 0000000..d30c984
--- /dev/null
@@ -0,0 +1,7 @@
+.badge {
+    cursor: move;
+}
+
+.d-flex {
+    overflow-x: scroll;
+}
diff --git a/src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.spec.ts b/src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.spec.ts
new file mode 100644 (file)
index 0000000..b5b5bb4
--- /dev/null
@@ -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' },
+    ])
+  })
+})
diff --git a/src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.ts b/src-ui/src/app/components/common/input/drag-drop-select/drag-drop-select.component.ts
new file mode 100644 (file)
index 0000000..3cf0264
--- /dev/null
@@ -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))
+  }
+}
index ac7bb9eb1aca21cd14b3a7b991502b06c1b9720b..cc796f6379d75a57d1ef80599b2c9e027d363b47 100644 (file)
@@ -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"
index de46991d286d3bc6be9259f2da393e0bf20f3f9b..4ea6020987cb27a9170683b00591a645b5bced8d 100644 (file)
     <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>
   }
index bf1894b48bdb1a2939e403c3062a7229d4c7b182..8c445f18e23c01781386eda62c314bbde119f5dc 100644 (file)
@@ -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
+}
index 545f5696b6967e8f7450134c01dbf3c11ad97247..432c686c3e22947026a75e8d6617135739a78a4d 100644 (file)
@@ -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')
   })
 })
index c81ea54846ee33f238058c25757595bf29540e0a..4765319471cc9ea3e4b7a0e0fdfc4f56dec9821e 100644 (file)
@@ -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: tagID.toString() },
+    ])
+  }
+
+  clickCorrespondent(correspondentId: number, event: MouseEvent = null) {
+    event?.preventDefault()
+    event?.stopImmediatePropagation()
 
     this.list.quickFilter([
-      { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() },
+      { 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
   }
 }
index 49af71b08809f5311677663345129633736e615d..b64d5e5674400d16332dd4b70019e04d6a4e740f 100644 (file)
         <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>
index 81489a40adabd24865553c00e115020a235f3ad1..3981ea7e5ec34545126b806c511aeed8027b4fb7 100644 (file)
@@ -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 {
               }
               :
             }
-            {{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>
             </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>
                   <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>
index c74bc0dc1ad1127d1dd8d52cc0cdb5fce6d2fac4..20da1cfada5c9994f87071c45340b2d7530e1c1c 100644 (file)
@@ -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: [
index 442114767d961954cc8abfbb7bc6dc229bb3b664..a3d57d950eca363962c534e44a448561f7c1f946 100644 (file)
@@ -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()
 
index ea9ba99141fc41893836aa549dec536ef3ddec09..a3e6b2847ce8f1ccf078c810046b36e3b638ba59 100644 (file)
         </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>
 
     <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">
index 28c50fbc7927c7e8b021887256c1ec704aca68a6..2e4b927c3581fe2cee08a24bafda5f9418c7aeef 100644 (file)
@@ -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: [
index 2ca1a340830ed9257d2211e5888f9d7532f2d620..5cd583fb0a72d07ef420c33b4f7c2cb059edc1d7 100644 (file)
@@ -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()
 
index 3cce1496bf2d04101840b73f7dad87ba879619b0..25e28a7a228386b18c4e68975592a47512ef5923 100644 (file)
       <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">&nbsp;<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>
         }
         }
       </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>
   @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
               (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
               (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
               (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
               (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)) {
                   <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">
                   }
                 </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>
     }
index eb81519e8bf931dbb8b92dd8a869dc6343064b1f..4959d52ef92759625ecf218fc9e576a6b6fa5f4b 100644 (file)
@@ -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;
+}
index 77dc03f84a5c4f049efcfe2fea7aca0f08aab16d..237520f33dd04531d283469a19aecb438eb04377 100644 (file)
@@ -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'
+    )
+  })
 })
index 7d27f4e3e0e8ec4e2d2f520c5935ba36edfac645..d56f9c508ec1be6b226dfdebe00337ea93eddde8 100644 (file)
@@ -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()
   }
 
-  ngOnInit(): void {
-    if (localStorage.getItem('document-list:displayMode') != null) {
-      this.displayMode = localStorage.getItem('document-list:displayMode')
-    }
+  public getDisplayCustomFieldTitle(field: string) {
+    return this.settingsService.allDisplayFields.find((f) => f.id === field)
+      ?.name
+  }
 
+  ngOnInit(): void {
     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
index 910666f108a9a518ba9d0be0c27a45c8200ded3b..7b7c6f7869f2cc0e770b2f613bf47b9c0ad560a3 100644 (file)
@@ -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
index b2941bb05baf39f511a542f957f5a05f409a347f..1dc35ed32dab79e39a973a03cd63e6a85d3821ab 100644 (file)
@@ -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[]
 }
index d3867e889beee47c9015e7e01f1a3e0c510670be..b9066bbc0be8b2b99f49a57d9b3aa2c6c614a993 100644 (file)
@@ -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)
   })
index afbe831757ef44e973d3ace42ccdcc65c33bdf67..2319d91aaae80d9c96f873f68518c785a9e9b468 100644 (file)
@@ -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
+      )
+    )
+  })
 })
index e1881c2f2fa7a0d52b98fba3791de31f4ed37cf1..715095266c4d2d0b252fea3e5b0852e9aecac601 100644 (file)
@@ -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
index e951e68b05763117da03ea0d37c3c4e5e0321adc..e4be128aa262eb879f3003ab39c17ce15e932ec6 100644 (file)
@@ -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',
index c379ba0102a7e6ee45abd4492f7bc100b109db0c..b08051a7919e580fd9a050f42df167f319b6112a 100644 (file)
@@ -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()
index f078a8de59eab1c2043a5086b873b4638fcdd0ae..9780b958675f02e0c3c10ab5cd4541ec203e5084 100644 (file)
@@ -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) {
index 71568dc4bf3c8e1df9c8c90c2e240d57794f7d24..ae89767267ca53290cc81ba7ffae6ce39beddc5a 100644 (file)
@@ -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)
+  })
 })
index 67804fa12ea8e0c88280278eac88096a849f6213..4a411bafc22d2a4971a11fe99239e5c0c73ca684 100644 (file)
@@ -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 ??
diff --git a/src/documents/migrations/1047_savedview_display_mode_and_more.py b/src/documents/migrations/1047_savedview_display_mode_and_more.py
new file mode 100644 (file)
index 0000000..3bfd638
--- /dev/null
@@ -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",
+            ),
+        ),
+    ]
index 5cb35a8f7db4a99a8b057715fed535ac7900063d..6d8a49350c8409a75ad7393387143511fac050d8 100644 (file)
@@ -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")
index c7e86a7bf63b2a925b39b89bdef315dd8a4835ba..2512723aac0ea3a211cbb38930718d6c5db974dc 100644 (file)
@@ -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")
index 9ae0b8bc3fcdd88de87c955d8fe7934e7908e288..9667a8bb275814a53fdf979fefdb7e32bef0bac0 100644 (file)
@@ -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:
index 6496b56b36e1cdb194aa23d113ced6f8505a6755..2cb0a9cc5dbc79f623fa8cd3caedcd4799af5a63 100644 (file)
@@ -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 "Shared"
+msgstr ""
+
+#: documents/models.py:413
+msgid "ASN"
+msgstr ""
+
+#: documents/models.py:419
+msgid "show on dashboard"
+msgstr ""
+
+#: documents/models.py:422
+msgid "show in sidebar"
+msgstr ""
+
+#: documents/models.py:426
+msgid "sort field"
+msgstr ""
+
+#: documents/models.py:431
 msgid "sort reverse"
 msgstr ""
 
-#: documents/models.py:416 documents/models.py:469
+#: documents/models.py:434
+msgid "View page size"
+msgstr ""
+
+#: documents/models.py:442
+msgid "View display mode"
+msgstr ""
+
+#: documents/models.py:449
+msgid "Document display fields"
+msgstr ""
+
+#: documents/models.py:456 documents/models.py:509
 msgid "saved view"
 msgstr ""
 
-#: documents/models.py:417
+#: documents/models.py:457
 msgid "saved views"
 msgstr ""
 
-#: documents/models.py:425
+#: documents/models.py:465
 msgid "title contains"
 msgstr ""
 
-#: documents/models.py:426
+#: documents/models.py:466
 msgid "content contains"
 msgstr ""
 
-#: documents/models.py:427
+#: documents/models.py:467
 msgid "ASN is"
 msgstr ""
 
-#: documents/models.py:428
+#: documents/models.py:468
 msgid "correspondent is"
 msgstr ""
 
-#: documents/models.py:429
+#: documents/models.py:469
 msgid "document type is"
 msgstr ""
 
-#: documents/models.py:430
+#: documents/models.py:470
 msgid "is in inbox"
 msgstr ""
 
-#: documents/models.py:431
+#: documents/models.py:471
 msgid "has tag"
 msgstr ""
 
-#: documents/models.py:432
+#: documents/models.py:472
 msgid "has any tag"
 msgstr ""
 
-#: documents/models.py:433
+#: documents/models.py:473
 msgid "created before"
 msgstr ""
 
-#: documents/models.py:434
+#: documents/models.py:474
 msgid "created after"
 msgstr ""
 
-#: documents/models.py:435
+#: documents/models.py:475
 msgid "created year is"
 msgstr ""
 
-#: documents/models.py:436
+#: documents/models.py:476
 msgid "created month is"
 msgstr ""
 
-#: documents/models.py:437
+#: documents/models.py:477
 msgid "created day is"
 msgstr ""
 
-#: documents/models.py:438
+#: documents/models.py:478
 msgid "added before"
 msgstr ""
 
-#: documents/models.py:439
+#: documents/models.py:479
 msgid "added after"
 msgstr ""
 
-#: documents/models.py:440
+#: documents/models.py:480
 msgid "modified before"
 msgstr ""
 
-#: documents/models.py:441
+#: documents/models.py:481
 msgid "modified after"
 msgstr ""
 
-#: documents/models.py:442
+#: documents/models.py:482
 msgid "does not have tag"
 msgstr ""
 
-#: documents/models.py:443
+#: documents/models.py:483
 msgid "does not have ASN"
 msgstr ""
 
-#: documents/models.py:444
+#: documents/models.py:484
 msgid "title or content contains"
 msgstr ""
 
-#: documents/models.py:445
+#: documents/models.py:485
 msgid "fulltext query"
 msgstr ""
 
-#: documents/models.py:446
+#: documents/models.py:486
 msgid "more like this"
 msgstr ""
 
-#: documents/models.py:447
+#: documents/models.py:487
 msgid "has tags in"
 msgstr ""
 
-#: documents/models.py:448
+#: documents/models.py:488
 msgid "ASN greater than"
 msgstr ""
 
-#: documents/models.py:449
+#: documents/models.py:489
 msgid "ASN less than"
 msgstr ""
 
-#: documents/models.py:450
+#: documents/models.py:490
 msgid "storage path is"
 msgstr ""
 
-#: documents/models.py:451
+#: documents/models.py:491
 msgid "has correspondent in"
 msgstr ""
 
-#: documents/models.py:452
+#: documents/models.py:492
 msgid "does not have correspondent in"
 msgstr ""
 
-#: documents/models.py:453
+#: documents/models.py:493
 msgid "has document type in"
 msgstr ""
 
-#: documents/models.py:454
+#: documents/models.py:494
 msgid "does not have document type in"
 msgstr ""
 
-#: documents/models.py:455
+#: documents/models.py:495
 msgid "has storage path in"
 msgstr ""
 
-#: documents/models.py:456
+#: documents/models.py:496
 msgid "does not have storage path in"
 msgstr ""
 
-#: documents/models.py:457
+#: documents/models.py:497
 msgid "owner is"
 msgstr ""
 
-#: documents/models.py:458
+#: documents/models.py:498
 msgid "has owner in"
 msgstr ""
 
-#: documents/models.py:459
+#: documents/models.py:499
 msgid "does not have owner"
 msgstr ""
 
-#: documents/models.py:460
+#: documents/models.py:500
 msgid "does not have owner in"
 msgstr ""
 
-#: documents/models.py:461
+#: documents/models.py:501
 msgid "has custom field value"
 msgstr ""
 
-#: documents/models.py:462
+#: documents/models.py:502
 msgid "is shared by me"
 msgstr ""
 
-#: documents/models.py:472
+#: documents/models.py:512
 msgid "rule type"
 msgstr ""
 
-#: documents/models.py:474
+#: documents/models.py:514
 msgid "value"
 msgstr ""
 
-#: documents/models.py:477
+#: documents/models.py:517
 msgid "filter rule"
 msgstr ""
 
-#: documents/models.py:478
+#: 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 ""