]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: Implement custom fields for documents (#4502)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 6 Nov 2023 01:26:51 +0000 (17:26 -0800)
committershamoon <4887959+shamoon@users.noreply.github.com>
Mon, 6 Nov 2023 01:27:23 +0000 (17:27 -0800)
Adds custom fields of certain data types, attachable to documents and searchable

Co-Authored-By: Trenton H <797416+stumpylog@users.noreply.github.com>
67 files changed:
Pipfile
Pipfile.lock
docker/docker-prepare.sh
docs/api.md
docs/usage.md
src-ui/e2e/document-detail/requests/api-document-detail.har
src-ui/e2e/document-detail/requests/api-document-detail2.har
src-ui/messages.xlf
src-ui/src/app/app-routing.module.ts
src-ui/src/app/app.module.ts
src-ui/src/app/components/app-frame/app-frame.component.html
src-ui/src/app/components/app-frame/app-frame.component.spec.ts
src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html [new file with mode: 0644]
src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/abstract-input.ts
src-ui/src/app/components/common/input/check/check.component.html
src-ui/src/app/components/common/input/date/date.component.html
src-ui/src/app/components/common/input/number/number.component.html
src-ui/src/app/components/common/input/number/number.component.spec.ts
src-ui/src/app/components/common/input/number/number.component.ts
src-ui/src/app/components/common/input/select/select.component.html
src-ui/src/app/components/common/input/select/select.component.ts
src-ui/src/app/components/common/input/tags/tags.component.html
src-ui/src/app/components/common/input/tags/tags.component.ts
src-ui/src/app/components/common/input/text/text.component.html
src-ui/src/app/components/common/input/url/url.component.html [new file with mode: 0644]
src-ui/src/app/components/common/input/url/url.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/input/url/url.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/input/url/url.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/page-header/page-header.component.scss
src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html
src-ui/src/app/components/document-detail/document-detail.component.html
src-ui/src/app/components/document-detail/document-detail.component.spec.ts
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
src-ui/src/app/components/manage/custom-fields/custom-fields.component.html [new file with mode: 0644]
src-ui/src/app/components/manage/custom-fields/custom-fields.component.scss [new file with mode: 0644]
src-ui/src/app/components/manage/custom-fields/custom-fields.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/custom-fields/custom-fields.component.ts [new file with mode: 0644]
src-ui/src/app/data/filter-rule-type.ts
src-ui/src/app/data/paperless-custom-field-instance.ts [new file with mode: 0644]
src-ui/src/app/data/paperless-custom-field.ts [new file with mode: 0644]
src-ui/src/app/data/paperless-document.ts
src-ui/src/app/services/permissions.service.spec.ts
src-ui/src/app/services/permissions.service.ts
src-ui/src/app/services/rest/custom-fields.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/custom-fields.service.ts [new file with mode: 0644]
src-ui/src/styles.scss
src/documents/admin.py
src/documents/filters.py
src/documents/index.py
src/documents/migrations/1040_customfield_customfieldinstance_and_more.py [new file with mode: 0644]
src/documents/models.py
src/documents/serialisers.py
src/documents/tests/test_api.py
src/documents/tests/test_api_custom_fields.py [new file with mode: 0644]
src/documents/tests/test_management_exporter.py
src/documents/views.py
src/locale/en_US/LC_MESSAGES/django.po
src/paperless/urls.py

diff --git a/Pipfile b/Pipfile
index 1b8d3a94aadd9fdad62bede43d74fe165f58a550..876ff481c469e8b878bb12d5288564de6b80ad18 100644 (file)
--- a/Pipfile
+++ b/Pipfile
@@ -16,6 +16,7 @@ django-extensions = "*"
 django-filter = "~=23.3"
 djangorestframework = "~=3.14"
 djangorestframework-guardian = "*"
+drf-writable-nested = "*"
 filelock = "*"
 gunicorn = "*"
 imap-tools = "*"
index 920c3b0f0656f8ff4f53c0372af5691c655e5fc7..93cd8fa24d73f1d871280f36738d65e54f44bd28 100644 (file)
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "7b4272de2042a346f3252ae20e7bbeee60c375381f59526caa35511a706d4977"
+            "sha256": "3c380d590439f008ec85f1d5821ed96b4ebd56fcee3f287e6e0a6f5923262229"
         },
         "pipfile-spec": 6,
         "requires": {},
             "index": "pypi",
             "version": "==0.3.0"
         },
+        "drf-writable-nested": {
+            "hashes": [
+                "sha256:154c0381e8a3a477e0fd539d5e1caf8ff4c1097a9c0c0fe741d4858b11b0455b"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.7'",
+            "version": "==0.7.0"
+        },
         "exceptiongroup": {
             "hashes": [
                 "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9",
index 6e5f6889aa6e8c907c1be0e4a8bd5541c7ae873e..2e66a1a3e658d5312fbe03e6dce27ed2b9f33afd 100755 (executable)
@@ -80,7 +80,7 @@ django_checks() {
 
 search_index() {
 
-       local -r index_version=6
+       local -r index_version=7
        local -r index_version_file=${DATA_DIR}/.index_version
 
        if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
index 749c9e0620e15f398a84fd554cda1987c2544b73..27fcc7f4fbfa6ce9f19a3915c01675f327bf02e0 100644 (file)
@@ -20,6 +20,7 @@ The API provides the following main endpoints:
 - `/api/users/`: Full CRUD support.
 - `/api/groups/`: Full CRUD support.
 - `/api/share_links/`: Full CRUD support.
+- `/api/custom_fields/`: Full CRUD support.
 
 All of these endpoints except for the logging endpoint allow you to
 fetch (and edit and delete where appropriate) individual objects by
@@ -51,6 +52,8 @@ fields:
 - `notes`: Array of notes associated with the document.
 - `set_permissions`: Allows setting document permissions. Optional,
   write-only. See [below](#permissions).
+- `custom_fields`: Array of custom fields & values, specified as
+  { field: CUSTOM_FIELD_ID, value: VALUE }
 
 ## Downloading documents
 
index aad4976f8bc091c6fda6574789afb21ec15573ef..6cceeed22ff500ecffffac929091e52356fbd2f7 100644 (file)
@@ -322,6 +322,38 @@ applied. You can use the following placeholders:
 - `{added_month_name_short}`: added month short name
 - `{added_day}`: added day
 
+## Custom Fields {#custom-fields}
+
+Paperless-ngx supports the use of custom fields for documents as of v2.0, allowing a user
+to optionally attach data to documents which does not fit in the existing set of fields
+Paperless-ngx provides.
+
+1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
+2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field and click "Add". Once the field is visible in the form you can enter the appropriate
+   data which will be validated according to the custom field "data type".
+3. Fields can be removed by hovering over the field name revealing a "Remove" button.
+
+!!! important
+
+    Added / removed fields, as well as any data is not saved to the document until you
+    actually hit the "Save" button, similar to other changes on the document details page.
+
+!!! note
+
+    Once the data type for a field is set, it cannot be changed.
+
+Multiple fields may be attached to a document but the same field name cannot be assigned multiple times to the a single document.
+
+The following custom field types are supported:
+
+- `Text`: any text
+- `Boolean`: true / false (check / unchecked) field
+- `Date`: date
+- `URL`: a valid url
+- `Integer`: integer number e.g. 12
+- `Number`: float number e.g. 12.3456
+- `Monetary`: float number with exactly two decimals, e.g. 12.30
+
 ## Best practices {#basic-searching}
 
 Paperless offers a couple tools that help you organize your document
index 2383b2f70578fe66220a43c7ce9392ae7d5c003d..c3892a121af2dfc2da17a52cdc735df46d79ace0 100644 (file)
         "cache": {},
         "timings": { "send": -1, "wait": -1, "receive": 0.951 }
       },
+      {
+        "startedDateTime": "2023-05-14T07:03:41.086Z",
+        "time": 0.951,
+        "request": {
+          "method": "GET",
+          "url": "http://localhost:8000/api/custom_fields/?page=1&page_size=100000",
+          "httpVersion": "HTTP/1.1",
+          "cookies": [],
+          "headers": [
+            { "name": "Accept", "value": "application/json; version=3" },
+            { "name": "Accept-Encoding", "value": "gzip, deflate, br" },
+            { "name": "Accept-Language", "value": "en-US" },
+            { "name": "Connection", "value": "keep-alive" },
+            { "name": "Host", "value": "localhost:8000" },
+            { "name": "Origin", "value": "http://localhost:4200" },
+            { "name": "Referer", "value": "http://localhost:4200/" },
+            { "name": "Sec-Fetch-Dest", "value": "empty" },
+            { "name": "Sec-Fetch-Mode", "value": "cors" },
+            { "name": "Sec-Fetch-Site", "value": "same-site" },
+            { "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
+          ],
+          "queryString": [
+            {
+              "name": "page",
+              "value": "1"
+            },
+            {
+              "name": "page_size",
+              "value": "100000"
+            }
+          ],
+          "headersSize": -1,
+          "bodySize": -1
+        },
+        "response": {
+          "status": 200,
+          "statusText": "OK",
+          "httpVersion": "HTTP/1.1",
+          "cookies": [],
+          "headers": [
+            { "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
+            { "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
+            { "name": "Content-Encoding", "value": "br" },
+            { "name": "Content-Language", "value": "en-us" },
+            { "name": "Content-Length", "value": "851" },
+            { "name": "Content-Type", "value": "application/json" },
+            { "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
+            { "name": "Referrer-Policy", "value": "same-origin" },
+            { "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
+            { "name": "X-Api-Version", "value": "3" },
+            { "name": "X-Content-Type-Options", "value": "nosniff" },
+            { "name": "X-Frame-Options", "value": "ANY" },
+            { "name": "X-Version", "value": "1.14.4" }
+          ],
+          "content": {
+            "size": -1,
+            "mimeType": "application/json",
+            "text": "{\"count\":0,\"next\":null,\"previous\":null,\"all\":[],\"results\":[]}"
+          },
+          "headersSize": -1,
+          "bodySize": -1,
+          "redirectURL": ""
+        },
+        "cache": {},
+        "timings": { "send": -1, "wait": -1, "receive": 0.951 }
+      },
       {
         "startedDateTime": "2023-05-14T07:03:41.087Z",
         "time": 0.54,
           "content": {
             "size": -1,
             "mimeType": "application/json",
-            "text": "{\"id\":175,\"correspondent\":null,\"document_type\":null,\"storage_path\":8,\"title\":\"file-sample_150kBs\",\"content\":\"Lorem ipsum\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit. Nunc ac faucibus odio.\\n\\nVestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut\\nvarius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum\\ncondimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus\\nconvallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.\\n\\n• Maecenas non lorem quis tellus placerat varius.\\n\\n• Nulla facilisi.\\n\\n• Aenean congue fringilla justo ut aliquam.\\n\\n• Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante\\nsagittis.\\n\\n• Morbi viverra semper lorem nec molestie.\\n\\n• Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.\\n\\n\\n12\\n\\n10\\n\\n8\\nColumn 1\\n6\\nColumn 2\\n4 Column 3\\n\\n2\\n\\n0\\nRow 1 Row 2 Row 3 Row 4\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\nIn eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque\\nvulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas\\nvelit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci\\nmaximus ultricies.\\n\\n\\n\\n\\nCras fringilla ipsum magna, in fringilla dui commodo\\na.\\n\\nLorem ipsum Lorem ipsum Lorem ipsum\\n\\n1 In eleifend velit vitae libero sollicitudin euismod. Lorem\\n\\n2 Cras fringilla ipsum magna, in fringilla dui commodo Ipsum\\na.\\n\\n3 Aliquam erat volutpat. Lorem\\n\\n4 Fusce vitae vestibulum velit. Lorem\\n\\n5 Etiam vehicula luctus fermentum. Ipsum\\n\\n\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit.\\n\\nNunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue\\nmolestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor\\nvitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum\\ncursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit\\ndictum tellus.\\nMaecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo\\nut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum\\nante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur\\nligula euismod, sit amet ornare est vulputate.\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\n\\nIn eleifend velit vitae libero sollicitudin euismod.\\nFusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo.\\nAliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae\\nfringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum\\nmagna, in fringilla dui commodo a.\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nSOOORGsARO Or\\n008soee\\naS\",\"tags\":[4],\"created\":\"2021-02-15T00:00:00Z\",\"created_date\":\"2021-02-15\",\"modified\":\"2023-02-17T22:25:47.449036Z\",\"added\":\"2021-01-26T22:46:32.447764Z\",\"archive_serial_number\":null,\"original_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"archived_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"owner\":null,\"permissions\":{\"view\":{\"users\":[],\"groups\":[]},\"change\":{\"users\":[],\"groups\":[]}},\"notes\":[{\"id\":70,\"note\":\"This is a second note\",\"created\":\"2023-05-12T17:04:50.873017Z\",\"document\":175,\"user\":2},{\"id\":71,\"note\":\"And a third\",\"created\":\"2023-05-12T17:04:54.027561Z\",\"document\":175,\"user\":2},{\"id\":72,\"note\":\"One more\",\"created\":\"2023-05-12T17:04:57.581521Z\",\"document\":175,\"user\":2},{\"id\":73,\"note\":\"This is a new note\",\"created\":\"2023-05-14T06:05:17.715744Z\",\"document\":175,\"user\":2}]}"
+            "text": "{\"id\":175,\"correspondent\":null,\"document_type\":null,\"storage_path\":8,\"title\":\"file-sample_150kBs\",\"content\":\"Lorem ipsum\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit. Nunc ac faucibus odio.\\n\\nVestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut\\nvarius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum\\ncondimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus\\nconvallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.\\n\\n• Maecenas non lorem quis tellus placerat varius.\\n\\n• Nulla facilisi.\\n\\n• Aenean congue fringilla justo ut aliquam.\\n\\n• Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante\\nsagittis.\\n\\n• Morbi viverra semper lorem nec molestie.\\n\\n• Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.\\n\\n\\n12\\n\\n10\\n\\n8\\nColumn 1\\n6\\nColumn 2\\n4 Column 3\\n\\n2\\n\\n0\\nRow 1 Row 2 Row 3 Row 4\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\nIn eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque\\nvulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas\\nvelit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci\\nmaximus ultricies.\\n\\n\\n\\n\\nCras fringilla ipsum magna, in fringilla dui commodo\\na.\\n\\nLorem ipsum Lorem ipsum Lorem ipsum\\n\\n1 In eleifend velit vitae libero sollicitudin euismod. Lorem\\n\\n2 Cras fringilla ipsum magna, in fringilla dui commodo Ipsum\\na.\\n\\n3 Aliquam erat volutpat. Lorem\\n\\n4 Fusce vitae vestibulum velit. Lorem\\n\\n5 Etiam vehicula luctus fermentum. Ipsum\\n\\n\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit.\\n\\nNunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue\\nmolestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor\\nvitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum\\ncursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit\\ndictum tellus.\\nMaecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo\\nut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum\\nante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur\\nligula euismod, sit amet ornare est vulputate.\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\n\\nIn eleifend velit vitae libero sollicitudin euismod.\\nFusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo.\\nAliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae\\nfringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum\\nmagna, in fringilla dui commodo a.\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nSOOORGsARO Or\\n008soee\\naS\",\"tags\":[4],\"created\":\"2021-02-15T00:00:00Z\",\"created_date\":\"2021-02-15\",\"modified\":\"2023-02-17T22:25:47.449036Z\",\"added\":\"2021-01-26T22:46:32.447764Z\",\"archive_serial_number\":null,\"original_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"archived_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"owner\":null,\"permissions\":{\"view\":{\"users\":[],\"groups\":[]},\"change\":{\"users\":[],\"groups\":[]}},\"notes\":[{\"id\":70,\"note\":\"This is a second note\",\"created\":\"2023-05-12T17:04:50.873017Z\",\"document\":175,\"user\":2},{\"id\":71,\"note\":\"And a third\",\"created\":\"2023-05-12T17:04:54.027561Z\",\"document\":175,\"user\":2},{\"id\":72,\"note\":\"One more\",\"created\":\"2023-05-12T17:04:57.581521Z\",\"document\":175,\"user\":2},{\"id\":73,\"note\":\"This is a new note\",\"created\":\"2023-05-14T06:05:17.715744Z\",\"document\":175,\"user\":2}],\"custom_fields\":[]}"
           },
           "headersSize": -1,
           "bodySize": -1,
index 2325e03a269d73fa94519fb54089ccbcde4b8479..ee31662116a7384a3f97c9cd39113ff944ec7dc9 100644 (file)
         "cache": {},
         "timings": { "send": -1, "wait": -1, "receive": 1.02 }
       },
+      {
+        "startedDateTime": "2023-05-14T07:03:41.086Z",
+        "time": 0.951,
+        "request": {
+          "method": "GET",
+          "url": "http://localhost:8000/api/custom_fields/?page=1&page_size=100000",
+          "httpVersion": "HTTP/1.1",
+          "cookies": [],
+          "headers": [
+            { "name": "Accept", "value": "application/json; version=3" },
+            { "name": "Accept-Encoding", "value": "gzip, deflate, br" },
+            { "name": "Accept-Language", "value": "en-US" },
+            { "name": "Connection", "value": "keep-alive" },
+            { "name": "Host", "value": "localhost:8000" },
+            { "name": "Origin", "value": "http://localhost:4200" },
+            { "name": "Referer", "value": "http://localhost:4200/" },
+            { "name": "Sec-Fetch-Dest", "value": "empty" },
+            { "name": "Sec-Fetch-Mode", "value": "cors" },
+            { "name": "Sec-Fetch-Site", "value": "same-site" },
+            { "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
+          ],
+          "queryString": [
+            {
+              "name": "page",
+              "value": "1"
+            },
+            {
+              "name": "page_size",
+              "value": "100000"
+            }
+          ],
+          "headersSize": -1,
+          "bodySize": -1
+        },
+        "response": {
+          "status": 200,
+          "statusText": "OK",
+          "httpVersion": "HTTP/1.1",
+          "cookies": [],
+          "headers": [
+            { "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
+            { "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
+            { "name": "Content-Encoding", "value": "br" },
+            { "name": "Content-Language", "value": "en-us" },
+            { "name": "Content-Length", "value": "851" },
+            { "name": "Content-Type", "value": "application/json" },
+            { "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
+            { "name": "Referrer-Policy", "value": "same-origin" },
+            { "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
+            { "name": "X-Api-Version", "value": "3" },
+            { "name": "X-Content-Type-Options", "value": "nosniff" },
+            { "name": "X-Frame-Options", "value": "ANY" },
+            { "name": "X-Version", "value": "1.14.4" }
+          ],
+          "content": {
+            "size": -1,
+            "mimeType": "application/json",
+            "text": "{\"count\":0,\"next\":null,\"previous\":null,\"all\":[],\"results\":[]}"
+          },
+          "headersSize": -1,
+          "bodySize": -1,
+          "redirectURL": ""
+        },
+        "cache": {},
+        "timings": { "send": -1, "wait": -1, "receive": 0.951 }
+      },
       {
         "startedDateTime": "2023-05-28T06:08:41.662Z",
         "time": 1.012,
           "content": {
             "size": -1,
             "mimeType": "application/json",
-            "text": "{\"id\":175,\"correspondent\":null,\"document_type\":null,\"storage_path\":8,\"title\":\"file-sample_150kBs\",\"content\":\"Lorem ipsum\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit. Nunc ac faucibus odio.\\n\\nVestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut\\nvarius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum\\ncondimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus\\nconvallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.\\n\\n• Maecenas non lorem quis tellus placerat varius.\\n\\n• Nulla facilisi.\\n\\n• Aenean congue fringilla justo ut aliquam.\\n\\n• Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante\\nsagittis.\\n\\n• Morbi viverra semper lorem nec molestie.\\n\\n• Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.\\n\\n\\n12\\n\\n10\\n\\n8\\nColumn 1\\n6\\nColumn 2\\n4 Column 3\\n\\n2\\n\\n0\\nRow 1 Row 2 Row 3 Row 4\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\nIn eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque\\nvulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas\\nvelit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci\\nmaximus ultricies.\\n\\n\\n\\n\\nCras fringilla ipsum magna, in fringilla dui commodo\\na.\\n\\nLorem ipsum Lorem ipsum Lorem ipsum\\n\\n1 In eleifend velit vitae libero sollicitudin euismod. Lorem\\n\\n2 Cras fringilla ipsum magna, in fringilla dui commodo Ipsum\\na.\\n\\n3 Aliquam erat volutpat. Lorem\\n\\n4 Fusce vitae vestibulum velit. Lorem\\n\\n5 Etiam vehicula luctus fermentum. Ipsum\\n\\n\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit.\\n\\nNunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue\\nmolestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor\\nvitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum\\ncursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit\\ndictum tellus.\\nMaecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo\\nut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum\\nante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur\\nligula euismod, sit amet ornare est vulputate.\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\n\\nIn eleifend velit vitae libero sollicitudin euismod.\\nFusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo.\\nAliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae\\nfringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum\\nmagna, in fringilla dui commodo a.\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nSOOORGsARO Or\\n008soee\\naS\",\"tags\":[4],\"created\":\"2021-02-15T00:00:00Z\",\"created_date\":\"2021-02-15\",\"modified\":\"2023-02-17T22:25:47.449036Z\",\"added\":\"2021-01-26T22:46:32.447764Z\",\"archive_serial_number\":null,\"original_file_name\":null,\"archived_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"owner\":null,\"permissions\":{\"view\":{\"users\":[],\"groups\":[]},\"change\":{\"users\":[],\"groups\":[]}},\"notes\":[{\"id\":70,\"note\":\"This is a second note\",\"created\":\"2023-05-12T17:04:50.873017Z\",\"document\":175,\"user\":2},{\"id\":71,\"note\":\"And a third\",\"created\":\"2023-05-12T17:04:54.027561Z\",\"document\":175,\"user\":2},{\"id\":72,\"note\":\"One more\",\"created\":\"2023-05-12T17:04:57.581521Z\",\"document\":175,\"user\":2},{\"id\":73,\"note\":\"This is a new note\",\"created\":\"2023-05-14T06:05:17.715744Z\",\"document\":175,\"user\":2},{\"id\":74,\"note\":\"This is a new note\",\"created\":\"2023-05-28T05:49:25.763951Z\",\"document\":175,\"user\":2}]}"
+            "text": "{\"id\":175,\"correspondent\":null,\"document_type\":null,\"storage_path\":8,\"title\":\"file-sample_150kBs\",\"content\":\"Lorem ipsum\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit. Nunc ac faucibus odio.\\n\\nVestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut\\nvarius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum\\ncondimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus\\nconvallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.\\n\\n• Maecenas non lorem quis tellus placerat varius.\\n\\n• Nulla facilisi.\\n\\n• Aenean congue fringilla justo ut aliquam.\\n\\n• Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante\\nsagittis.\\n\\n• Morbi viverra semper lorem nec molestie.\\n\\n• Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.\\n\\n\\n12\\n\\n10\\n\\n8\\nColumn 1\\n6\\nColumn 2\\n4 Column 3\\n\\n2\\n\\n0\\nRow 1 Row 2 Row 3 Row 4\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\nIn eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque\\nvulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas\\nvelit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci\\nmaximus ultricies.\\n\\n\\n\\n\\nCras fringilla ipsum magna, in fringilla dui commodo\\na.\\n\\nLorem ipsum Lorem ipsum Lorem ipsum\\n\\n1 In eleifend velit vitae libero sollicitudin euismod. Lorem\\n\\n2 Cras fringilla ipsum magna, in fringilla dui commodo Ipsum\\na.\\n\\n3 Aliquam erat volutpat. Lorem\\n\\n4 Fusce vitae vestibulum velit. Lorem\\n\\n5 Etiam vehicula luctus fermentum. Ipsum\\n\\n\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nLorem ipsum dolor sit amet, consectetur adipiscing\\nelit.\\n\\nNunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue\\nmolestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor\\nvitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum\\ncursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis,\\nvulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus\\nnisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum,\\nac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet\\ntortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet\\nmauris tempus fringilla.\\n\\n\\nMaecenas mauris lectus, lobortis et purus mattis, blandit\\ndictum tellus.\\nMaecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo\\nut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum\\nante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur\\nligula euismod, sit amet ornare est vulputate.\\n\\nIn non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam\\nest ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat\\net. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis\\ntristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque\\nscelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam\\nlobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel,\\nultricies ut purus. Ut facilisis et lacus eu cursus.\\n\\n\\nIn eleifend velit vitae libero sollicitudin euismod.\\nFusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo.\\nAliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae\\nfringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum\\nmagna, in fringilla dui commodo a.\\n\\nEtiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui.\\nMaecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam,\\npellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti\\nsociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper\\njusto sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo\\nposuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut\\net pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo\\nimperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem\\nsed turpis imperdiet eleifend sit amet id sapien.\\n\\nSOOORGsARO Or\\n008soee\\naS\",\"tags\":[4],\"created\":\"2021-02-15T00:00:00Z\",\"created_date\":\"2021-02-15\",\"modified\":\"2023-02-17T22:25:47.449036Z\",\"added\":\"2021-01-26T22:46:32.447764Z\",\"archive_serial_number\":null,\"original_file_name\":null,\"archived_file_name\":\"2021-02-15 file-sample_150kBs.pdf\",\"owner\":null,\"permissions\":{\"view\":{\"users\":[],\"groups\":[]},\"change\":{\"users\":[],\"groups\":[]}},\"notes\":[{\"id\":70,\"note\":\"This is a second note\",\"created\":\"2023-05-12T17:04:50.873017Z\",\"document\":175,\"user\":2},{\"id\":71,\"note\":\"And a third\",\"created\":\"2023-05-12T17:04:54.027561Z\",\"document\":175,\"user\":2},{\"id\":72,\"note\":\"One more\",\"created\":\"2023-05-12T17:04:57.581521Z\",\"document\":175,\"user\":2},{\"id\":73,\"note\":\"This is a new note\",\"created\":\"2023-05-14T06:05:17.715744Z\",\"document\":175,\"user\":2},{\"id\":74,\"note\":\"This is a new note\",\"created\":\"2023-05-28T05:49:25.763951Z\",\"document\":175,\"user\":2}],\"custom_fields\":[]}"
           },
           "headersSize": -1,
           "bodySize": -1,
index 1a637fbb1e19774b8d403a2fbf3960de6dda65fb..a132bb7ec79f951351c6464a6c49e894d5d03c57 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">65</context>
+          <context context-type="linenumber">81</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1241348629231510663" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">218</context>
+          <context context-type="linenumber">225</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">221</context>
+          <context context-type="linenumber">228</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3894950702316166331" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">196</context>
+          <context context-type="linenumber">203</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">199</context>
+          <context context-type="linenumber">206</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1685061484835793745" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">192</context>
+          <context context-type="linenumber">221</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="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
           <context context-type="linenumber">9</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">8</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">10</context>
           <context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
           <context context-type="linenumber">14</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">14</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
           <context context-type="linenumber">17</context>
           <context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
           <context context-type="linenumber">17</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">16</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
           <context context-type="linenumber">19</context>
           <context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
           <context context-type="linenumber">36</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">34</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
           <context context-type="linenumber">42</context>
           <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
           <context context-type="linenumber">21</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">14</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">23</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">208</context>
+          <context context-type="linenumber">93</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-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">210</context>
+          <context context-type="linenumber">217</context>
         </context-group>
       </trans-unit>
       <trans-unit id="103921551219467537" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
-          <context context-type="linenumber">2</context>
+          <context context-type="linenumber">4</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">203</context>
+          <context context-type="linenumber">210</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">206</context>
+          <context context-type="linenumber">213</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4555457172864212828" datatype="html">
           <context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.html</context>
           <context context-type="linenumber">31</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">29</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
           <context context-type="linenumber">32</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">656</context>
+          <context context-type="linenumber">677</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
           <context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.ts</context>
           <context context-type="linenumber">91</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+          <context context-type="linenumber">76</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
           <context context-type="linenumber">114</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">658</context>
+          <context context-type="linenumber">679</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
           <context context-type="sourcefile">src/app/components/manage/consumption-templates/consumption-templates.component.ts</context>
           <context context-type="linenumber">93</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+          <context context-type="linenumber">78</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
           <context context-type="linenumber">116</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">225</context>
+          <context context-type="linenumber">232</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">228</context>
+          <context context-type="linenumber">235</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6570363013146073520" datatype="html">
           <context context-type="linenumber">26</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="3079652255369035" datatype="html">
-        <source>Document types</source>
+      <trans-unit id="4369111787961525769" datatype="html">
+        <source>Document Types</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
           <context context-type="linenumber">162</context>
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
           <context context-type="linenumber">165</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">61</context>
+        </context-group>
       </trans-unit>
-      <trans-unit id="8835528846812581148" datatype="html">
-        <source>Storage paths</source>
+      <trans-unit id="5421255270838137624" datatype="html">
+        <source>Storage Paths</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
           <context context-type="linenumber">169</context>
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
           <context context-type="linenumber">172</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">67</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3188389494264426470" datatype="html">
+        <source>Custom Fields</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">176</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">179</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
+          <context context-type="linenumber">6</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="4462691404891390153" datatype="html">
         <source>Consumption templates</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">176</context>
+          <context context-type="linenumber">183</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5433675495457939071" datatype="html">
         <source>Templates</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">179</context>
+          <context context-type="linenumber">186</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1292737233370901804" datatype="html">
         <source>Mail</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">183</context>
+          <context context-type="linenumber">190</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">186</context>
+          <context context-type="linenumber">193</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7844706011418789951" datatype="html">
         <source>Administration</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">192</context>
+          <context context-type="linenumber">199</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5537285341303594392" datatype="html">
         <source>File Tasks<x id="START_TAG_SPAN_1" ctype="x-span_1" equiv-text="&lt;span *ngIf=&quot;tasksService.failedFileTasks.length &gt; 0&quot;&gt;"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-danger ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><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/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">214</context>
+          <context context-type="linenumber">221</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1534029177398918729" datatype="html">
         <source>GitHub</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">234</context>
+          <context context-type="linenumber">241</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4112664765954374539" datatype="html">
         <source>is available.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">240</context>
+          <context context-type="linenumber">247</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1175891574282637937" datatype="html">
         <source>Click to view.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">240</context>
+          <context context-type="linenumber">247</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9811291095862612" datatype="html">
         <source>Paperless-ngx can automatically check for updates</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">244</context>
+          <context context-type="linenumber">251</context>
         </context-group>
       </trans-unit>
       <trans-unit id="894819944961861800" datatype="html">
         <source> How does this work? </source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">251,253</context>
+          <context context-type="linenumber">258,260</context>
         </context-group>
       </trans-unit>
       <trans-unit id="509090351011426949" datatype="html">
         <source>Update available</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">262</context>
+          <context context-type="linenumber">269</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1542489069631984294" datatype="html">
           <context context-type="linenumber">439</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="3972154626835212608" datatype="html">
+        <source>Create New Field</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
+          <context context-type="linenumber">25</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3249513483374643425" datatype="html">
+        <source>Add</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context>
+          <context context-type="linenumber">30</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">7</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7827616268749044116" datatype="html">
+        <source>Choose field</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts</context>
+          <context context-type="linenumber">52</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7284517513296281043" datatype="html">
+        <source>No unused fields found</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts</context>
+          <context context-type="linenumber">56</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6973528734330066202" datatype="html">
+        <source>Saved field &quot;<x id="PH" equiv-text="newField.name"/>&quot;.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts</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.ts</context>
+          <context context-type="linenumber">59</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1841172489943868696" datatype="html">
+        <source>Error saving field.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts</context>
+          <context context-type="linenumber">128</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+          <context context-type="linenumber">66</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6052766076365105714" datatype="html">
         <source>now</source>
         <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">20</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">13</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">22</context>
           <context context-type="linenumber">31</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="4894226280476434291" datatype="html">
+        <source>Data type</source>
+        <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">9</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5933665691581884232" datatype="html">
+        <source>Data type cannot be changed after a field is created</source>
+        <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">10</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="528950215505228201" datatype="html">
+        <source>Create new custom field</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts</context>
+          <context context-type="linenumber">39</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8751213029607178010" datatype="html">
+        <source>Edit custom field</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts</context>
+          <context context-type="linenumber">43</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6672809941092516947" datatype="html">
         <source>Create new document type</source>
         <context-group purpose="location">
         </context-group>
         <note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
       </trans-unit>
+      <trans-unit id="4814285799071780083" datatype="html">
+        <source>Remove</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/check/check.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/number/number.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/text/text.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="445302259125375799" datatype="html">
         <source>Invalid date.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
-          <context context-type="linenumber">18</context>
+          <context context-type="linenumber">27</context>
         </context-group>
       </trans-unit>
       <trans-unit id="524422427194414813" datatype="html">
         <source>Suggestions:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
-          <context context-type="linenumber">21</context>
+          <context context-type="linenumber">30</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
-          <context context-type="linenumber">37</context>
+          <context context-type="linenumber">47</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">43</context>
+          <context context-type="linenumber">46</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6344437738844463465" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context>
-          <context context-type="linenumber">149</context>
+          <context context-type="linenumber">155</context>
         </context-group>
       </trans-unit>
       <trans-unit id="594042705136125260" datatype="html">
         <source>Add item</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
-          <context context-type="linenumber">12</context>
+          <context context-type="linenumber">21</context>
         </context-group>
         <note priority="1" from="description">Used for both types, correspondents, storage paths</note>
       </trans-unit>
           <context context-type="linenumber">80</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="2504502765849142619" datatype="html">
+        <source>No items found</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context>
+          <context context-type="linenumber">92</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6560126119609945418" datatype="html">
         <source>Add tag</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
-          <context context-type="linenumber">12</context>
+          <context context-type="linenumber">15</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2561408369057364131" datatype="html">
         <source>Filter documents with these Tags</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
-          <context context-type="linenumber">35</context>
+          <context context-type="linenumber">38</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1388712764439031120" datatype="html">
+        <source>Open link</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
+          <context context-type="linenumber">14</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7062872617520618723" datatype="html">
           <context context-type="linenumber">5</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="3249513483374643425" datatype="html">
-        <source>Add</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
-          <context context-type="linenumber">7</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="1230154438678955604" datatype="html">
         <source>Change</source>
         <context-group purpose="location">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">85</context>
+          <context context-type="linenumber">103</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</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">201</context>
+          <context context-type="linenumber">203</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</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">89</context>
+          <context context-type="linenumber">107</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">13</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="4369111787961525769" datatype="html">
-        <source>Document Types</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
-          <context context-type="linenumber">61</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="5421255270838137624" datatype="html">
-        <source>Storage Paths</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
-          <context context-type="linenumber">67</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="8693603235657020323" datatype="html">
         <source>Other</source>
         <context-group purpose="location">
         <source>Close</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">53</context>
+          <context context-type="linenumber">71</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
         <source>Previous</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">60</context>
+          <context context-type="linenumber">76</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3823219296477075982" datatype="html">
+        <source>Discard</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">89</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5129524307369213584" datatype="html">
+        <source>Save &amp; next</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">91</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4910102545766233758" datatype="html">
+        <source>Save &amp; close</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
+          <context context-type="linenumber">92</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5028777105388019087" datatype="html">
         <source>Details</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">82</context>
+          <context context-type="linenumber">100</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">86</context>
+          <context context-type="linenumber">104</context>
         </context-group>
       </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">87</context>
+          <context context-type="linenumber">105</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5066119607229701477" datatype="html">
         <source>Document type</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">91</context>
+          <context context-type="linenumber">109</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
         <source>Storage path</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">93</context>
+          <context context-type="linenumber">111</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
         <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">94</context>
+          <context context-type="linenumber">112</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6205355627445317276" datatype="html">
         <source>Content</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">101</context>
+          <context context-type="linenumber">130</context>
         </context-group>
       </trans-unit>
       <trans-unit id="218403386307979629" datatype="html">
         <source>Metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">110</context>
+          <context context-type="linenumber">139</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
         <source>Date modified</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">116</context>
+          <context context-type="linenumber">145</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6392918669949841614" datatype="html">
         <source>Date added</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">120</context>
+          <context context-type="linenumber">149</context>
         </context-group>
       </trans-unit>
       <trans-unit id="146828917013192897" datatype="html">
         <source>Media filename</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">124</context>
+          <context context-type="linenumber">153</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4500855521601039868" datatype="html">
         <source>Original filename</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">128</context>
+          <context context-type="linenumber">157</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7985558498848210210" datatype="html">
         <source>Original MD5 checksum</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">132</context>
+          <context context-type="linenumber">161</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5888243105821763422" datatype="html">
         <source>Original file size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">136</context>
+          <context context-type="linenumber">165</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2696647325713149563" datatype="html">
         <source>Original mime type</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">140</context>
+          <context context-type="linenumber">169</context>
         </context-group>
       </trans-unit>
       <trans-unit id="342875990758166588" datatype="html">
         <source>Archive MD5 checksum</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">144</context>
+          <context context-type="linenumber">173</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6033581412811562084" datatype="html">
         <source>Archive file size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">148</context>
+          <context context-type="linenumber">177</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6992781481378431874" datatype="html">
         <source>Original document metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">154</context>
+          <context context-type="linenumber">183</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2846565152091361585" datatype="html">
         <source>Archived document metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">155</context>
+          <context context-type="linenumber">184</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1295614462098694869" datatype="html">
         <source>Preview</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">161</context>
+          <context context-type="linenumber">190</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8191371354890763172" datatype="html">
         <source>Enter Password</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">177</context>
+          <context context-type="linenumber">206</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">228</context>
+          <context context-type="linenumber">249</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8460995830263484763" datatype="html">
         <source>Notes <x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span *ngIf=&quot;document?.notes.length&quot; class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/a&gt;"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">185,186</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="3823219296477075982" datatype="html">
-        <source>Discard</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">204</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="5129524307369213584" datatype="html">
-        <source>Save &amp; next</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">206</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="4910102545766233758" datatype="html">
-        <source>Save &amp; close</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
-          <context context-type="linenumber">207</context>
+          <context context-type="linenumber">214,215</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2218903673684131427" datatype="html">
         <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">253,255</context>
+          <context context-type="linenumber">265,267</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5758784066858623886" datatype="html">
         <source>Error retrieving metadata</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">398</context>
+          <context context-type="linenumber">413</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3456881259945295697" datatype="html">
         <source>Error retrieving suggestions.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">419</context>
+          <context context-type="linenumber">434</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8348337312757497317" datatype="html">
         <source>Document saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">532</context>
+          <context context-type="linenumber">552</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">540</context>
+          <context context-type="linenumber">561</context>
         </context-group>
       </trans-unit>
       <trans-unit id="448882439049417053" datatype="html">
         <source>Error saving document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">544</context>
+          <context context-type="linenumber">565</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">585</context>
+          <context context-type="linenumber">606</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9021887951960049161" datatype="html">
         <source>Confirm delete</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">611</context>
+          <context context-type="linenumber">632</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
         <source>Do you really want to delete document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">612</context>
+          <context context-type="linenumber">633</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6691075929777935948" datatype="html">
         <source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">613</context>
+          <context context-type="linenumber">634</context>
         </context-group>
       </trans-unit>
       <trans-unit id="719892092227206532" datatype="html">
         <source>Delete document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">615</context>
+          <context context-type="linenumber">636</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7295637485862454066" datatype="html">
         <source>Error deleting document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">634</context>
+          <context context-type="linenumber">655</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7362691899087997122" datatype="html">
         <source>Redo OCR confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">654</context>
+          <context context-type="linenumber">675</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
         <source>This operation will permanently redo OCR for this document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">655</context>
+          <context context-type="linenumber">676</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5729001209753056399" datatype="html">
         <source>Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">666</context>
+          <context context-type="linenumber">687</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4409560272830824468" datatype="html">
         <source>Error executing operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
-          <context context-type="linenumber">677</context>
+          <context context-type="linenumber">698</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6857598786757174736" datatype="html">
         </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">206</context>
+          <context context-type="linenumber">208</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
             )?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">118,120</context>
+          <context context-type="linenumber">120,122</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8170755470576301659" datatype="html">
         <source>Without correspondent</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">122</context>
+          <context context-type="linenumber">124</context>
         </context-group>
       </trans-unit>
       <trans-unit id="317796810569008208" datatype="html">
             )?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">128,130</context>
+          <context context-type="linenumber">130,132</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4362173610367509215" datatype="html">
         <source>Without document type</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">132</context>
+          <context context-type="linenumber">134</context>
         </context-group>
       </trans-unit>
       <trans-unit id="232202047340644471" datatype="html">
             )?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">138,140</context>
+          <context context-type="linenumber">140,142</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1562820715074533164" datatype="html">
         <source>Without storage path</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">142</context>
+          <context context-type="linenumber">144</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8180755793012580465" datatype="html">
             ?.name"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">146,147</context>
+          <context context-type="linenumber">148,149</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6494566478302448576" datatype="html">
         <source>Without any tag</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">151</context>
+          <context context-type="linenumber">153</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6523384805359286307" datatype="html">
         <source>Title: <x id="PH" equiv-text="rule.value"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">155</context>
+          <context context-type="linenumber">157</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1872523635812236432" datatype="html">
         <source>ASN: <x id="PH" equiv-text="rule.value"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">158</context>
+          <context context-type="linenumber">160</context>
         </context-group>
       </trans-unit>
       <trans-unit id="102674688969746976" datatype="html">
         <source>Owner: <x id="PH" equiv-text="rule.value"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">161</context>
+          <context context-type="linenumber">163</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3550877650686009106" datatype="html">
         <source>Owner not in: <x id="PH" equiv-text="rule.value"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">164</context>
+          <context context-type="linenumber">166</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1082034558646673343" datatype="html">
         <source>Without an owner</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">167</context>
+          <context context-type="linenumber">169</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3100631071441658964" datatype="html">
         <source>Title &amp; content</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">206</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="9149498548977462220" datatype="html">
+        <source>Custom fields</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
+          <context context-type="linenumber">211</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1010505078885609376" datatype="html">
         <source>Advanced search</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">209</context>
+          <context context-type="linenumber">215</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2649431021108393503" datatype="html">
         <source>More like</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">215</context>
+          <context context-type="linenumber">221</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3697582909018473071" datatype="html">
         <source>equals</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">234</context>
+          <context context-type="linenumber">240</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5325481293405718739" datatype="html">
         <source>is empty</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">238</context>
+          <context context-type="linenumber">244</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6166785695326182482" datatype="html">
         <source>is not empty</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">242</context>
+          <context context-type="linenumber">248</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4686622206659266699" datatype="html">
         <source>greater than</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">246</context>
+          <context context-type="linenumber">252</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8014012170270529279" datatype="html">
         <source>less than</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
-          <context context-type="linenumber">250</context>
+          <context context-type="linenumber">256</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7210076240260527720" datatype="html">
           <context context-type="linenumber">67</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="8019331026479399960" datatype="html">
+        <source>Add Field</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+          <context context-type="linenumber">6</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6122252173137142810" datatype="html">
+        <source>Data Type</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+          <context context-type="linenumber">15</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="651372623796033489" datatype="html">
+        <source>No fields defined.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
+          <context context-type="linenumber">40</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3032792139967609806" datatype="html">
+        <source>Confirm delete field</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+          <context context-type="linenumber">74</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2939457975223185057" datatype="html">
+        <source>This operation will permanently delete this field.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+          <context context-type="linenumber">75</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5137089475515834162" datatype="html">
+        <source>Deleted field</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+          <context context-type="linenumber">84</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6352403551920829405" datatype="html">
+        <source>Error deleting field.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
+          <context context-type="linenumber">89</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="8084492669582894778" datatype="html">
         <source>document type</source>
         <context-group purpose="location">
           <context context-type="linenumber">46</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="969459137986754249" datatype="html">
+        <source>Boolean</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+          <context context-type="linenumber">16</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3973931101896534797" datatype="html">
+        <source>Date</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="362956598863566327" datatype="html">
+        <source>Integer</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+          <context context-type="linenumber">24</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6370642728789544052" datatype="html">
+        <source>Number</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+          <context context-type="linenumber">28</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6430409302408843009" datatype="html">
+        <source>Monetary</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+          <context context-type="linenumber">32</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6162693758764653365" datatype="html">
+        <source>Text</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+          <context context-type="linenumber">36</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8308045076391224954" datatype="html">
+        <source>Url</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/data/paperless-custom-field.ts</context>
+          <context context-type="linenumber">40</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="5948496158474272829" datatype="html">
         <source>Warning: You have unsaved changes to your document(s).</source>
         <context-group purpose="location">
index f2888b596068d8d17c201029deae350de79754f8..b3952634c38457c17793d96c37e5bdf566d5f3b1 100644 (file)
@@ -24,6 +24,7 @@ import {
 import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
 import { MailComponent } from './components/manage/mail/mail.component'
 import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
+import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
 
 export const routes: Routes = [
   { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
@@ -189,6 +190,17 @@ export const routes: Routes = [
           },
         },
       },
+      {
+        path: 'customfields',
+        component: CustomFieldsComponent,
+        canActivate: [PermissionsGuard],
+        data: {
+          requiredPermission: {
+            action: PermissionAction.View,
+            type: PermissionType.CustomField,
+          },
+        },
+      },
       {
         path: 'templates',
         component: ConsumptionTemplatesComponent,
index 0634ffd34e2e11c6d73ea52399796d999049eb0e..1967a3f78da29845b1b50948b8e5626eb2d793dc 100644 (file)
@@ -39,6 +39,7 @@ import { NgxFileDropModule } from 'ngx-file-drop'
 import { TextComponent } from './components/common/input/text/text.component'
 import { SelectComponent } from './components/common/input/select/select.component'
 import { CheckComponent } from './components/common/input/check/check.component'
+import { UrlComponent } from './components/common/input/url/url.component'
 import { PasswordComponent } from './components/common/input/password/password.component'
 import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'
 import { TagsComponent } from './components/common/input/tags/tags.component'
@@ -101,6 +102,9 @@ import { MailComponent } from './components/manage/mail/mail.component'
 import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
 import { DragDropModule } from '@angular/cdk/drag-drop'
 import { FileDropComponent } from './components/file-drop/file-drop.component'
+import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
+import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
 
 import localeAf from '@angular/common/locales/af'
 import localeAr from '@angular/common/locales/ar'
@@ -200,6 +204,7 @@ function initializeApp(settings: SettingsService) {
     TextComponent,
     SelectComponent,
     CheckComponent,
+    UrlComponent,
     PasswordComponent,
     SaveViewConfigDialogComponent,
     TagsComponent,
@@ -246,6 +251,9 @@ function initializeApp(settings: SettingsService) {
     MailComponent,
     UsersAndGroupsComponent,
     FileDropComponent,
+    CustomFieldsComponent,
+    CustomFieldEditDialogComponent,
+    CustomFieldsDropdownComponent,
   ],
   imports: [
     BrowserModule,
index c496ee4e78789eb2f8560663897ce28135eb0657..49220d0ab4a09404b3e96ee566df1275120ce034 100644 (file)
             </a>
           </li>
           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
-            <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
+            <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
               <svg class="sidebaricon" fill="currentColor">
                 <use xlink:href="assets/bootstrap-icons.svg#hash"/>
-              </svg><span>&nbsp;<ng-container i18n>Document types</ng-container></span>
+              </svg><span>&nbsp;<ng-container i18n>Document Types</ng-container></span>
             </a>
           </li>
           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
-            <a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
+            <a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
               <svg class="sidebaricon" fill="currentColor">
                 <use xlink:href="assets/bootstrap-icons.svg#folder"/>
-              </svg><span>&nbsp;<ng-container i18n>Storage paths</ng-container></span>
+              </svg><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
+            </a>
+          </li>
+          <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
+            <a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
+              <svg class="sidebaricon" fill="currentColor">
+                <use xlink:href="assets/bootstrap-icons.svg#ui-radios"/>
+              </svg><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
             </a>
           </li>
           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }" tourAnchor="tour.consumption-templates">
index 8de5ffef34ef2091017b3b1db20afb273a08dae4..7b8bf4bcea9ebce215d532d06f414973ea000afc 100644 (file)
@@ -30,7 +30,7 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
 import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
 import { routes } from 'src/app/app-routing.module'
 import { PermissionsGuard } from 'src/app/guards/permissions.guard'
-import { CdkDragDrop } from '@angular/cdk/drag-drop'
+import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
 import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
 
 const saved_views = [
@@ -97,6 +97,7 @@ describe('AppFrameComponent', () => {
         NgbModule,
         FormsModule,
         ReactiveFormsModule,
+        DragDropModule,
       ],
       providers: [
         SettingsService,
diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html
new file mode 100644 (file)
index 0000000..e0d26bf
--- /dev/null
@@ -0,0 +1,36 @@
+<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose()">
+    <button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
+      <svg class="toolbaricon" fill="currentColor">
+        <use xlink:href="assets/bootstrap-icons.svg#ui-radios" />
+      </svg>
+      <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div>
+    </button>
+    <div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
+        <ul class="list-group list-group-flush">
+            <li class="list-group-item">
+                <pngx-input-select class="mb-3"
+                    [items]="unusedFields"
+                    bindLabel="name"
+                    [(ngModel)]="field"
+                    [placeholder]="placeholderText"
+                    [notFoundText]="notFoundText"
+                    [disableCreateNew]="!canCreateFields"
+                    (createNew)="createField($event)"
+                    bindValue="id">
+                </pngx-input-select>
+                <div class="btn-toolbar" role="toolbar">
+                    <button class="btn btn-sm btn-outline-secondary me-auto" type="button" (click)="createField()" [disabled]="!canCreateFields">
+                        <svg fill="currentColor" class="buttonicon-sm me-1 mb-1">
+                        <use xlink:href="assets/bootstrap-icons.svg#asterisk"/>
+                        </svg><ng-container i18n>Create New Field</ng-container>
+                    </button>
+                    <button class="btn btn-sm btn-outline-primary me-1" type="button" (click)="addField(); fieldDropdown.close()" [disabled]="field === undefined">
+                        <svg fill="currentColor" class="buttonicon me-1">
+                          <use xlink:href="assets/bootstrap-icons.svg#plus-circle"/>
+                        </svg><ng-container i18n>Add</ng-container>
+                    </button>
+                </div>
+            </li>
+        </ul>
+    </div>
+</div>
diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.scss b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.scss
new file mode 100644 (file)
index 0000000..496fc8a
--- /dev/null
@@ -0,0 +1,24 @@
+.custom-fields-dropdown {
+    min-width: 350px;
+
+    // correct position on mobile
+    @media (max-width: 575.98px) {
+        &.show {
+            margin-left: -175px !important;
+        }
+    }
+}
+
+::ng-deep .ng-select .ng-select-container .ng-value-container .ng-placeholder,
+::ng-deep .ng-select .ng-option,
+::ng-deep .ng-select .ng-select-container .ng-value-container .ng-value {
+    font-size: 0.875rem;
+}
+
+::ng-deep .paperless-input-select .ng-select {
+    min-height: calc(1em + 0.75rem + 5px);
+}
+
+::ng-deep .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
+    top: 4px;
+}
diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts
new file mode 100644 (file)
index 0000000..e1892ac
--- /dev/null
@@ -0,0 +1,137 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
+import {
+  HttpClientTestingModule,
+  HttpTestingController,
+} from '@angular/common/http/testing'
+import { ToastService } from 'src/app/services/toast.service'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { of } from 'rxjs'
+import {
+  PaperlessCustomField,
+  PaperlessCustomFieldDataType,
+} from 'src/app/data/paperless-custom-field'
+import { SelectComponent } from '../input/select/select.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import {
+  NgbDropdownModule,
+  NgbModal,
+  NgbModalModule,
+  NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { By } from '@angular/platform-browser'
+
+const fields: PaperlessCustomField[] = [
+  {
+    id: 0,
+    name: 'Field 1',
+    data_type: PaperlessCustomFieldDataType.Integer,
+  },
+  {
+    id: 1,
+    name: 'Field 2',
+    data_type: PaperlessCustomFieldDataType.String,
+  },
+]
+
+describe('CustomFieldsDropdownComponent', () => {
+  let component: CustomFieldsDropdownComponent
+  let fixture: ComponentFixture<CustomFieldsDropdownComponent>
+  let customFieldService: CustomFieldsService
+  let toastService: ToastService
+  let modalService: NgbModal
+  let httpController: HttpTestingController
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [CustomFieldsDropdownComponent, SelectComponent],
+      imports: [
+        HttpClientTestingModule,
+        NgSelectModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbModalModule,
+        NgbDropdownModule,
+      ],
+    })
+    customFieldService = TestBed.inject(CustomFieldsService)
+    httpController = TestBed.inject(HttpTestingController)
+    toastService = TestBed.inject(ToastService)
+    modalService = TestBed.inject(NgbModal)
+    jest.spyOn(customFieldService, 'listAll').mockReturnValue(
+      of({
+        all: fields.map((f) => f.id),
+        count: fields.length,
+        results: fields.concat([]),
+      })
+    )
+    fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should support add field', () => {
+    let addedField
+    component.added.subscribe((f) => (addedField = f))
+    component.documentId = 11
+    component.field = fields[0].id
+    component.addField()
+    expect(addedField).not.toBeUndefined()
+  })
+
+  it('should clear field on open / close, updated unused fields', () => {
+    component.field = fields[1].id
+    component.onOpenClose()
+    expect(component.field).toBeUndefined()
+
+    expect(component.unusedFields).toEqual(fields)
+    const updateSpy = jest.spyOn(
+      CustomFieldsDropdownComponent.prototype as any,
+      'updateUnusedFields'
+    )
+    component.existingFields = [{ field: fields[1].id } as any]
+    component.onOpenClose()
+    expect(updateSpy).toHaveBeenCalled()
+    expect(component.unusedFields).toEqual([fields[0]])
+  })
+
+  it('should support creating field, show error if necessary', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const getFieldsSpy = jest.spyOn(
+      CustomFieldsDropdownComponent.prototype as any,
+      'getFields'
+    )
+
+    const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
+    createButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+
+    // fail first
+    editDialog.failed.emit({ error: 'error creating field' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(getFieldsSpy).not.toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit(fields[0])
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(getFieldsSpy).toHaveBeenCalled()
+  })
+
+  it('should support creating field with name', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    component.createField('Foo bar')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+    expect(editDialog.object.name).toEqual('Foo bar')
+  })
+})
diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts
new file mode 100644 (file)
index 0000000..8a23728
--- /dev/null
@@ -0,0 +1,131 @@
+import {
+  Component,
+  EventEmitter,
+  Input,
+  OnDestroy,
+  Output,
+} from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { Subject, first, takeUntil } from 'rxjs'
+import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
+import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import {
+  PermissionAction,
+  PermissionType,
+  PermissionsService,
+} from 'src/app/services/permissions.service'
+
+@Component({
+  selector: 'pngx-custom-fields-dropdown',
+  templateUrl: './custom-fields-dropdown.component.html',
+  styleUrls: ['./custom-fields-dropdown.component.scss'],
+})
+export class CustomFieldsDropdownComponent implements OnDestroy {
+  @Input()
+  documentId: number
+
+  @Input()
+  disabled: boolean = false
+
+  @Input()
+  existingFields: PaperlessCustomFieldInstance[] = []
+
+  @Output()
+  added: EventEmitter<PaperlessCustomField> = new EventEmitter()
+
+  @Output()
+  created: EventEmitter<PaperlessCustomField> = new EventEmitter()
+
+  private customFields: PaperlessCustomField[] = []
+  public unusedFields: PaperlessCustomField[]
+
+  public name: string
+
+  public field: number
+
+  private unsubscribeNotifier: Subject<any> = new Subject()
+
+  get placeholderText(): string {
+    return $localize`Choose field`
+  }
+
+  get notFoundText(): string {
+    return $localize`No unused fields found`
+  }
+
+  get canCreateFields(): boolean {
+    return this.permissionsService.currentUserCan(
+      PermissionAction.Add,
+      PermissionType.CustomField
+    )
+  }
+
+  constructor(
+    private customFieldsService: CustomFieldsService,
+    private modalService: NgbModal,
+    private toastService: ToastService,
+    private permissionsService: PermissionsService
+  ) {
+    this.getFields()
+  }
+
+  ngOnDestroy(): void {
+    this.unsubscribeNotifier.next(this)
+    this.unsubscribeNotifier.complete()
+  }
+
+  private getFields() {
+    this.customFieldsService
+      .listAll()
+      .pipe(first(), takeUntil(this.unsubscribeNotifier))
+      .subscribe((result) => {
+        this.customFields = result.results
+        this.updateUnusedFields()
+      })
+  }
+
+  public getCustomFieldFromInstance(
+    instance: PaperlessCustomFieldInstance
+  ): PaperlessCustomField {
+    return this.customFields.find((f) => f.id === instance.field)
+  }
+
+  private updateUnusedFields() {
+    this.unusedFields = this.customFields.filter(
+      (f) =>
+        !this.existingFields?.find(
+          (e) => this.getCustomFieldFromInstance(e)?.id === f.id
+        )
+    )
+  }
+
+  onOpenClose() {
+    this.field = undefined
+    this.updateUnusedFields()
+  }
+
+  addField() {
+    this.added.emit(this.customFields.find((f) => f.id === this.field))
+  }
+
+  createField(newName: string = null) {
+    const modal = this.modalService.open(CustomFieldEditDialogComponent)
+    if (newName) modal.componentInstance.object = { name: newName }
+    modal.componentInstance.succeeded
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((newField) => {
+        this.toastService.showInfo($localize`Saved field "${newField.name}".`)
+        this.customFieldsService.clearCache()
+        this.getFields()
+        this.created.emit(newField)
+      })
+    modal.componentInstance.failed
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((e) => {
+        this.toastService.showError($localize`Error saving field.`, e)
+      })
+  }
+}
diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html
new file mode 100644 (file)
index 0000000..94dc329
--- /dev/null
@@ -0,0 +1,16 @@
+<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
+    <div class="modal-header">
+      <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
+      <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
+      </button>
+    </div>
+    <div class="modal-body">
+      <pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
+      <pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
+      <small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
+    </div>
+    <div class="modal-footer">
+      <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
+      <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
+    </div>
+  </form>
diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.scss b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..9570cfc
--- /dev/null
@@ -0,0 +1,67 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { CustomFieldEditDialogComponent } from './custom-field-edit-dialog.component'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { SettingsService } from 'src/app/services/settings.service'
+import { SelectComponent } from '../../input/select/select.component'
+import { TextComponent } from '../../input/text/text.component'
+import { EditDialogMode } from '../edit-dialog.component'
+
+describe('CustomFieldEditDialogComponent', () => {
+  let component: CustomFieldEditDialogComponent
+  let settingsService: SettingsService
+  let fixture: ComponentFixture<CustomFieldEditDialogComponent>
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [
+        CustomFieldEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        SafeHtmlPipe,
+      ],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
+    settingsService = TestBed.inject(SettingsService)
+    settingsService.currentUser = { id: 99, username: 'user99' }
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+
+  it('should disable data type select on edit', () => {
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    component.ngOnInit()
+    expect(component.objectForm.get('data_type').disabled).toBeTruthy()
+  })
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts
new file mode 100644 (file)
index 0000000..51a3530
--- /dev/null
@@ -0,0 +1,60 @@
+import { Component, OnInit } from '@angular/core'
+import { FormGroup, FormControl } from '@angular/forms'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import {
+  DATA_TYPE_LABELS,
+  PaperlessCustomField,
+} from 'src/app/data/paperless-custom-field'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { UserService } from 'src/app/services/rest/user.service'
+import { SettingsService } from 'src/app/services/settings.service'
+import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
+
+@Component({
+  selector: 'pngx-custom-field-edit-dialog',
+  templateUrl: './custom-field-edit-dialog.component.html',
+  styleUrls: ['./custom-field-edit-dialog.component.scss'],
+})
+export class CustomFieldEditDialogComponent
+  extends EditDialogComponent<PaperlessCustomField>
+  implements OnInit
+{
+  constructor(
+    service: CustomFieldsService,
+    activeModal: NgbActiveModal,
+    userService: UserService,
+    settingsService: SettingsService
+  ) {
+    super(service, activeModal, userService, settingsService)
+  }
+
+  ngOnInit(): void {
+    super.ngOnInit()
+    if (this.typeFieldDisabled) {
+      this.objectForm.get('data_type').disable()
+    }
+  }
+
+  getCreateTitle() {
+    return $localize`Create new custom field`
+  }
+
+  getEditTitle() {
+    return $localize`Edit custom field`
+  }
+
+  getForm(): FormGroup {
+    return new FormGroup({
+      name: new FormControl(null),
+      data_type: new FormControl(null),
+    })
+  }
+
+  getDataTypes() {
+    return DATA_TYPE_LABELS
+  }
+
+  get typeFieldDisabled(): boolean {
+    return this.dialogMode === EditDialogMode.EDIT
+  }
+}
index 113945749835c944d424d648e52ba86684579242..5e95de0bfbc0d0d1d420bdef8fd4a6a853e75665 100644 (file)
@@ -1,4 +1,12 @@
-import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core'
+import {
+  Directive,
+  ElementRef,
+  EventEmitter,
+  Input,
+  OnInit,
+  Output,
+  ViewChild,
+} from '@angular/core'
 import { ControlValueAccessor } from '@angular/forms'
 import { v4 as uuidv4 } from 'uuid'
 
@@ -41,6 +49,18 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
   @Input()
   error: string
 
+  @Input()
+  hint: string
+
+  @Input()
+  horizontal: boolean = false
+
+  @Input()
+  removable: boolean = false
+
+  @Output()
+  removed: EventEmitter<AbstractInputComponent<any>> = new EventEmitter()
+
   value: T
 
   ngOnInit(): void {
@@ -48,7 +68,4 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
   }
 
   inputId: string
-
-  @Input()
-  hint: string
 }
index 51d5250b43db7322f1d4e5cd7bd85f88e74f498e..9d75088b407db16714b1077c71721c9607fa3f8b 100644 (file)
@@ -1,5 +1,19 @@
-<div class="mb-3 form-check">
-  <input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
-  <label class="form-check-label" [for]="inputId">{{title}}</label>
-  <div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
+<div class="mb-3">
+  <div class="row">
+    <div *ngIf="horizontal" class="d-flex align-items-center position-relative hidden-button-container col-md-3">
+      <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+      <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+        <svg class="sidebaricon" fill="currentColor">
+          <use xlink:href="assets/bootstrap-icons.svg#x"/>
+        </svg>&nbsp;<ng-container i18n>Remove</ng-container>
+      </button>
+    </div>
+    <div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
+      <div class="form-check">
+        <input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
+        <label *ngIf="!horizontal" class="form-check-label" [for]="inputId">{{title}}</label>
+        <div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
+      </div>
+    </div>
+  </div>
 </div>
index 947e832ae5756cabceb2eb24263f19cf0f8ea2bf..9becf6cb210ee85e9771f541b3c4fe3fc716b369 100644 (file)
@@ -1,26 +1,37 @@
-<div class="mb-3">
-  <label class="form-label" [for]="inputId">{{title}}</label>
-  <div class="input-group" [class.is-invalid]="error">
-    <input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
-          (dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
-          name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
-    <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
-      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
-        <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
-      </svg>
-    </button>
-    <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
-      <svg class="buttonicon" fill="currentColor">
-        <use xlink:href="assets/bootstrap-icons.svg#filter" />
-      </svg>
-    </button>
+<div class="mb-3" [class.pb-3]="error">
+  <div class="row">
+    <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+      <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+      <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+        <svg class="sidebaricon" fill="currentColor">
+          <use xlink:href="assets/bootstrap-icons.svg#x"/>
+        </svg>&nbsp;<ng-container i18n>Remove</ng-container>
+      </button>
+    </div>
+    <div class="position-relative" [class.col-md-9]="horizontal">
+      <div class="input-group" [class.is-invalid]="error">
+        <input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
+              (dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
+              name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
+        <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
+          <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="buttonicon">
+            <use _ngcontent-ng-c3750736003="" xlink:href="assets/bootstrap-icons.svg#calendar"></use>
+          </svg>
+        </button>
+        <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
+          <svg class="buttonicon" fill="currentColor">
+            <use xlink:href="assets/bootstrap-icons.svg#filter" />
+          </svg>
+        </button>
+      </div>
+      <div class="invalid-feedback position-absolute top-100" i18n>Invalid date.</div>
+      <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
+      <small *ngIf="getSuggestions().length > 0">
+        <span i18n>Suggestions:</span>&nbsp;
+        <ng-container *ngFor="let s of getSuggestions()">
+          <a (click)="onSuggestionClick(s)" [routerLink]="[]">{{s}}</a>&nbsp;
+        </ng-container>
+      </small>
+    </div>
   </div>
-  <div class="invalid-feedback" i18n>Invalid date.</div>
-  <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
-  <small *ngIf="getSuggestions().length > 0">
-    <span i18n>Suggestions:</span>&nbsp;
-    <ng-container *ngFor="let s of getSuggestions()">
-      <a (click)="onSuggestionClick(s)" [routerLink]="[]">{{s}}</a>&nbsp;
-    </ng-container>
-  </small>
 </div>
index 1e1f1237ca661547b64c5158c114f77c3bb2dc42..d934993c0c58b7e77b2c97a07a1e7c06c55c0c85 100644 (file)
@@ -1,12 +1,22 @@
-<div class="mb-3">
-  <label class="form-label" [for]="inputId">{{title}}</label>
-  <div class="input-group" [class.is-invalid]="error">
-    <input #inputField type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
-    <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
+<div class="mb-3" [class.pb-3]="error">
+  <div class="row">
+    <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+      <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+      <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+        <svg class="sidebaricon" fill="currentColor">
+          <use xlink:href="assets/bootstrap-icons.svg#x"/>
+        </svg>&nbsp;<ng-container i18n>Remove</ng-container>
+      </button>
+    </div>
+    <div class="position-relative" [class.col-md-9]="horizontal">
+      <div class="input-group" [class.is-invalid]="error">
+        <input #inputField type="number" class="form-control" [step]="step" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
+        <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
+      </div>
+      <div class="invalid-feedback position-absolute top-100">
+        {{error}}
+      </div>
+      <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
+    </div>
   </div>
-  <div class="invalid-feedback">
-    {{error}}
-  </div>
-  <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
-
 </div>
index dfe1673db0e830cd47f22e08a8abd450ec37c36f..b6a281e6fb40d1c3867cc2ff564fbb1cafddc2fe 100644 (file)
@@ -46,4 +46,18 @@ describe('NumberComponent', () => {
     component.nextAsn()
     expect(component.value).toEqual(1002)
   })
+
+  it('should support float & monetary values', () => {
+    component.writeValue(11.13)
+    expect(component.value).toEqual(11)
+    component.step = 0.01
+    component.writeValue(11.1)
+    expect(component.value).toEqual('11.10')
+    component.step = 0.1
+    component.writeValue(12.3456)
+    expect(component.value).toEqual(12.3456)
+    // float (step = .1) doesnt force 2 decimals
+    component.writeValue(11.1)
+    expect(component.value).toEqual(11.1)
+  })
 })
index 682cd8036d3cbe9d01fb491f2165d2df9f660985..0b113a4de847bd42dc482c2ef214fff6c8fa35dd 100644 (file)
@@ -19,6 +19,9 @@ export class NumberComponent extends AbstractInputComponent<number> {
   @Input()
   showAdd: boolean = true
 
+  @Input()
+  step: number = 1
+
   constructor(private documentService: DocumentService) {
     super()
   }
@@ -32,4 +35,10 @@ export class NumberComponent extends AbstractInputComponent<number> {
       this.onChange(this.value)
     })
   }
+
+  writeValue(newValue: any): void {
+    if (this.step === 1) newValue = parseInt(newValue, 10)
+    if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
+    super.writeValue(newValue)
+  }
 }
index 1f6f2b3386425472e5601e4afb8ebe553ece90d9..56e256bb4faf4042c1e5b4984ae8f8e7b579e09f 100644 (file)
@@ -1,44 +1,54 @@
 <div class="mb-3 paperless-input-select" [class.disabled]="disabled">
-  <label *ngIf="title" class="form-label" [for]="inputId">{{title}}</label>
-    <div [class.input-group]="allowCreateNew || showFilter">
-      <ng-select name="inputId" [(ngModel)]="value"
-        [disabled]="disabled"
-        [style.color]="textColor"
-        [style.background]="backgroundColor"
-        [class.private]="isPrivate"
-        [clearable]="allowNull"
-        [items]="items"
-        [addTag]="allowCreateNew && addItemRef"
-        addTagText="Add item"
-        i18n-addTagText="Used for both types, correspondents, storage paths"
-        [placeholder]="placeholder"
-        [multiple]="multiple"
-        [bindLabel]="bindLabel"
-        bindValue="id"
-        (change)="onChange(value)"
-        (search)="onSearch($event)"
-        (focus)="clearLastSearchTerm()"
-        (clear)="clearLastSearchTerm()"
-        (blur)="onBlur()">
-      </ng-select>
-      <button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
-        <svg class="buttonicon" fill="currentColor">
-          <use xlink:href="assets/bootstrap-icons.svg#plus" />
-        </svg>
-      </button>
-      <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
-        <svg class="buttonicon" fill="currentColor">
-          <use xlink:href="assets/bootstrap-icons.svg#filter" />
-        </svg>
+  <div class="row">
+    <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+      <label *ngIf="title" class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+      <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+        <svg class="sidebaricon" fill="currentColor">
+          <use xlink:href="assets/bootstrap-icons.svg#x"/>
+        </svg>&nbsp;<ng-container i18n>Remove</ng-container>
       </button>
     </div>
-  <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
-  <small *ngIf="getSuggestions().length > 0">
-    <span i18n>Suggestions:</span>&nbsp;
-    <ng-container *ngFor="let s of getSuggestions()">
-      <a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>&nbsp;
-    </ng-container>
-
-
-  </small>
+    <div [class.col-md-9]="horizontal">
+      <div [class.input-group]="allowCreateNew || showFilter">
+        <ng-select name="inputId" [(ngModel)]="value"
+          [disabled]="disabled"
+          [style.color]="textColor"
+          [style.background]="backgroundColor"
+          [class.private]="isPrivate"
+          [clearable]="allowNull"
+          [items]="items"
+          [addTag]="allowCreateNew && addItemRef"
+          addTagText="Add item"
+          i18n-addTagText="Used for both types, correspondents, storage paths"
+          [placeholder]="placeholder"
+          [notFoundText]="notFoundText"
+          [multiple]="multiple"
+          [bindLabel]="bindLabel"
+          bindValue="id"
+          (change)="onChange(value)"
+          (search)="onSearch($event)"
+          (focus)="clearLastSearchTerm()"
+          (clear)="clearLastSearchTerm()"
+          (blur)="onBlur()">
+        </ng-select>
+        <button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
+          <svg class="buttonicon" fill="currentColor">
+            <use xlink:href="assets/bootstrap-icons.svg#plus" />
+          </svg>
+        </button>
+        <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
+          <svg class="buttonicon" fill="currentColor">
+            <use xlink:href="assets/bootstrap-icons.svg#filter" />
+          </svg>
+        </button>
+      </div>
+      <small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
+      <small *ngIf="getSuggestions().length > 0">
+        <span i18n>Suggestions:</span>&nbsp;
+        <ng-container *ngFor="let s of getSuggestions()">
+          <a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>&nbsp;
+        </ng-container>
+      </small>
+    </div>
+  </div>
 </div>
index 437fc60d2cffd8063d58fa92ce53535aa983e07b..76acf31dc8a11379165770d34ce684a6c3967051 100644 (file)
@@ -88,6 +88,12 @@ export class SelectComponent extends AbstractInputComponent<number> {
   @Input()
   showFilter: boolean = false
 
+  @Input()
+  notFoundText: string = $localize`No items found`
+
+  @Input()
+  disableCreateNew: boolean = false
+
   @Output()
   createNew = new EventEmitter<string>()
 
@@ -99,7 +105,7 @@ export class SelectComponent extends AbstractInputComponent<number> {
   private _lastSearchTerm: string
 
   get allowCreateNew(): boolean {
-    return this.createNew.observers.length > 0
+    return !this.disableCreateNew && this.createNew.observers.length > 0
   }
 
   get isPrivate(): boolean {
index 7d96a1026f29d357c393c94142cdc4fcb00f0ecc..975119f69cfa423b9eb6eb24d75fca7ff2567a50 100644 (file)
@@ -1,51 +1,53 @@
-<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled">
-  <label class="form-label" for="tags" i18n>{{title}}</label>
+<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="suggestions">
+  <div class="row">
+    <div class="d-flex align-items-center" [class.col-md-3]="horizontal">
+      <label class="form-label mb-md-0" for="tags" i18n>{{title}}</label>
+    </div>
+    <div class="position-relative" [class.col-md-9]="horizontal">
+      <div class="input-group flex-nowrap">
+        <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
+          [disabled]="disabled"
+          [multiple]="true"
+          [closeOnSelect]="false"
+          [clearSearchOnAdd]="true"
+          [hideSelected]="tags.length > 0"
+          [addTag]="allowCreate ? createTagRef : false"
+          addTagText="Add tag"
+          i18n-addTagText
+          (change)="onChange(value)">
 
-  <div class="input-group flex-nowrap">
-    <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
-      [disabled]="disabled"
-      [multiple]="true"
-      [closeOnSelect]="false"
-      [clearSearchOnAdd]="true"
-      [hideSelected]="tags.length > 0"
-      [addTag]="allowCreate ? createTagRef : false"
-      addTagText="Add tag"
-      i18n-addTagText
-      (change)="onChange(value)">
-
-      <ng-template ng-label-tmp let-item="item">
-        <span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">
-          <svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
-            <use xlink:href="assets/bootstrap-icons.svg#x"/>
+          <ng-template ng-label-tmp let-item="item">
+            <span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">
+              <svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+                <use xlink:href="assets/bootstrap-icons.svg#x"/>
+              </svg>
+              <pngx-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
+            </span>
+          </ng-template>
+          <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
+            <div class="tag-wrap">
+              <pngx-tag *ngIf="item.id && tags" class="me-2" [tag]="getTag(item.id)"></pngx-tag>
+            </div>
+          </ng-template>
+        </ng-select>
+        <button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
+          <svg class="buttonicon" fill="currentColor">
+            <use xlink:href="assets/bootstrap-icons.svg#plus" />
+          </svg>
+        </button>
+        <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="hasPrivate || this.value === null" i18n-title title="Filter documents with these Tags">
+          <svg class="buttonicon" fill="currentColor">
+            <use xlink:href="assets/bootstrap-icons.svg#filter" />
           </svg>
-          <pngx-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
-        </span>
-      </ng-template>
-      <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
-        <div class="tag-wrap">
-          <pngx-tag *ngIf="item.id && tags" class="me-2" [tag]="getTag(item.id)"></pngx-tag>
-        </div>
-      </ng-template>
-    </ng-select>
-    <button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
-      <svg class="buttonicon" fill="currentColor">
-        <use xlink:href="assets/bootstrap-icons.svg#plus" />
-      </svg>
-    </button>
-    <button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="hasPrivate || this.value === null" i18n-title title="Filter documents with these Tags">
-      <svg class="buttonicon" fill="currentColor">
-        <use xlink:href="assets/bootstrap-icons.svg#filter" />
-      </svg>
-    </button>
+        </button>
+      </div>
+      <small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
+      <small *ngIf="getSuggestions().length > 0" class="position-absolute top-100">
+        <span i18n>Suggestions:</span>&nbsp;
+        <ng-container *ngFor="let tag of getSuggestions()">
+          <a (click)="addTag(tag.id)" [routerLink]="[]">{{tag?.name}}</a>&nbsp;
+        </ng-container>
+      </small>
+    </div>
   </div>
-  <small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
-  <small *ngIf="getSuggestions().length > 0">
-    <span i18n>Suggestions:</span>&nbsp;
-    <ng-container *ngFor="let tag of getSuggestions()">
-      <a (click)="addTag(tag.id)" [routerLink]="[]">{{tag.name}}</a>&nbsp;
-    </ng-container>
-
-
-  </small>
-
 </div>
index 64d6eddc337c73eae5224d2524a75ebc92ae9d80..c1bde5e8fe115bdb5842768c43dd5d9370452115 100644 (file)
@@ -77,6 +77,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
   @Input()
   showFilter: boolean = false
 
+  @Input()
+  horizontal: boolean = false
+
   @Output()
   filterDocuments = new EventEmitter<PaperlessTag[]>()
 
index 92925efc342250f87877e226138b71287bd3cd6b..431a170802bff1cd0ac6729c3af126dad68639c7 100644 (file)
@@ -1,8 +1,19 @@
-<div class="mb-3">
-  <label class="form-label" [for]="inputId">{{title}}</label>
-  <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
-  <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
-  <div class="invalid-feedback">
-    {{error}}
+<div class="mb-3" [class.pb-3]="error">
+  <div class="row">
+    <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+      <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+      <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+        <svg class="sidebaricon" fill="currentColor">
+          <use xlink:href="assets/bootstrap-icons.svg#x"/>
+        </svg>&nbsp;<ng-container i18n>Remove</ng-container>
+      </button>
+    </div>
+    <div class="position-relative" [class.col-md-9]="horizontal">
+      <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
+      <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
+      <div class="invalid-feedback position-absolute top-100">
+        {{error}}
+      </div>
+    </div>
   </div>
 </div>
diff --git a/src-ui/src/app/components/common/input/url/url.component.html b/src-ui/src/app/components/common/input/url/url.component.html
new file mode 100644 (file)
index 0000000..451bb8a
--- /dev/null
@@ -0,0 +1,26 @@
+<div class="mb-3" [class.pb-3]="error">
+    <div class="row">
+      <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
+        <label class="form-label mb-md-0" [for]="inputId">{{title}}</label>
+        <button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
+          <svg class="sidebaricon" fill="currentColor">
+            <use xlink:href="assets/bootstrap-icons.svg#x"/>
+          </svg>&nbsp;<ng-container i18n>Remove</ng-container>
+        </button>
+      </div>
+      <div [class.col-md-9]="horizontal">
+        <div class="input-group" [class.is-invalid]="error">
+          <input #inputField type="url" class="form-control" [class.is-invalid]="error" placeholder="https://" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
+          <a class="btn btn-outline-secondary rounded-end" title="Open link" i18n-title [href]="value" target="_blank">
+            <svg class="buttonicon mb-1" fill="currentColor">
+              <use xlink:href="assets/bootstrap-icons.svg#box-arrow-up-right" />
+            </svg>
+          </a>
+          <div class="invalid-feedback position-absolute top-100">
+            {{error}}
+          </div>
+        </div>
+        <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
+      </div>
+    </div>
+  </div>
diff --git a/src-ui/src/app/components/common/input/url/url.component.scss b/src-ui/src/app/components/common/input/url/url.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/common/input/url/url.component.spec.ts b/src-ui/src/app/components/common/input/url/url.component.spec.ts
new file mode 100644 (file)
index 0000000..33eb96e
--- /dev/null
@@ -0,0 +1,36 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+  FormsModule,
+  ReactiveFormsModule,
+  NG_VALUE_ACCESSOR,
+} from '@angular/forms'
+import { UrlComponent } from './url.component'
+
+describe('TextComponent', () => {
+  let component: UrlComponent
+  let fixture: ComponentFixture<UrlComponent>
+  let input: HTMLInputElement
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [UrlComponent],
+      providers: [],
+      imports: [FormsModule, ReactiveFormsModule],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(UrlComponent)
+    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+    input = component.inputField.nativeElement
+  })
+
+  it('should support use of input field', () => {
+    expect(component.value).toBeUndefined()
+    // TODO: why doesnt this work?
+    // input.value = 'foo'
+    // input.dispatchEvent(new Event('change'))
+    // fixture.detectChanges()
+    // expect(component.value).toEqual('foo')
+  })
+})
diff --git a/src-ui/src/app/components/common/input/url/url.component.ts b/src-ui/src/app/components/common/input/url/url.component.ts
new file mode 100644 (file)
index 0000000..c0c48ea
--- /dev/null
@@ -0,0 +1,21 @@
+import { Component, forwardRef } from '@angular/core'
+import { NG_VALUE_ACCESSOR } from '@angular/forms'
+import { AbstractInputComponent } from '../abstract-input'
+
+@Component({
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => UrlComponent),
+      multi: true,
+    },
+  ],
+  selector: 'pngx-input-url',
+  templateUrl: './url.component.html',
+  styleUrls: ['./url.component.scss'],
+})
+export class UrlComponent extends AbstractInputComponent<string> {
+  constructor() {
+    super()
+  }
+}
index d4f2165dd113f431311e958529c9f42a32295e88..ee373a8e276e1e3ad1fedd7c9f92fa8f595346a9 100644 (file)
@@ -1,9 +1,9 @@
-.h2 {
+h3 {
     min-height: calc(1.325rem + 0.9vw);
 }
 
 @media (min-width: 1200px) {
-    .h2 {
+    h3 {
         min-height: 2.8rem;
     }
 }
index 15b3ce64cee3708b21ef24f825027f8e608373db..559f11d05de65086da2b3f2da65c0355eb97b5df 100644 (file)
@@ -1,5 +1,5 @@
 <div ngbDropdown>
-    <button class="btn btn-sm btn-outline-primary me-2" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
+    <button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
       <svg class="toolbaricon" fill="currentColor">
         <use xlink:href="assets/bootstrap-icons.svg#link" />
       </svg>
index f1031484aebaba7437c682960615b911eec757a2..facd5ed4f6339a37a9e3b0cc57c9afde363420aa 100644 (file)
@@ -26,7 +26,7 @@
         </div>
     </div>
 
-    <div ngbDropdown>
+    <div class="ms-auto" ngbDropdown>
         <button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
           <svg class="toolbaricon" fill="currentColor">
             <use xlink:href="assets/bootstrap-icons.svg#three-dots" />
         </div>
     </div>
 
-    <pngx-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
-
-    <button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()">
-        <svg class="buttonicon" fill="currentColor">
-            <use xlink:href="assets/bootstrap-icons.svg#x" />
-        </svg>
-    </button>
-
-    <div class="button-group">
-        <button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
-            <svg class="buttonicon" fill="currentColor">
-                <use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
-            </svg>
-        </button>
-        <button type="button" class="btn btn-sm btn-outline-primary"  i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
-            <svg class="buttonicon" fill="currentColor">
-                <use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
-            </svg>
-        </button>
-    </div>
+    <pngx-custom-fields-dropdown
+        *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
+        class="me-2"
+        [documentId]="documentId"
+        [disabled]="!userIsOwner"
+        [existingFields]="document?.custom_fields"
+        (created)="refreshCustomFields()"
+        (added)="addField($event)">
+    </pngx-custom-fields-dropdown>
 
+    <pngx-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
 </pngx-page-header>
 
-
 <div class="row">
     <div class="col-md-6 col-xl-4 mb-4">
 
         <form [formGroup]='documentForm' (ngSubmit)="save()">
 
-            <ul ngbNav #nav="ngbNav" class="nav-tabs" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
+            <div class="btn-toolbar mb-1 pb-3 border-bottom">
+                <div class="btn-group">
+                    <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
+                        <svg class="buttonicon" fill="currentColor">
+                            <use xlink:href="assets/bootstrap-icons.svg#x" />
+                        </svg>
+                    </button>
+                    <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
+                        <svg class="buttonicon" fill="currentColor">
+                            <use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
+                        </svg>
+                    </button>
+                    <button type="button" class="btn btn-sm btn-outline-secondary"  i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
+                        <svg class="buttonicon" fill="currentColor">
+                            <use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
+                        </svg>
+                    </button>
+                </div>
+
+                <div class="btn-group ms-auto">
+                    <button type="button" class="btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
+                    <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
+                        <button *ngIf="hasNext()" type="button" class="btn btn-sm btn-outline-primary" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; next</button>
+                        <button *ngIf="!hasNext()" type="button" class="btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; close</button>
+                        <button type="submit" class="btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
+                    </ng-container>
+                </div>
+            </div>
+
+            <ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
                 <li [ngbNavItem]="DocumentDetailNavIDs.Details">
                     <a ngbNavLink i18n>Details</a>
                     <ng-template ngbNavContent>
-
-                        <pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
-                        <pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></pngx-input-number>
-                        <pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
-                            [error]="error?.created_date"></pngx-input-date>
-                        <pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
-                            (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
-                        <pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
-                            (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
-                        <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
-                            (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
-                        <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
-
+                        <div>
+                            <pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
+                            <pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
+                            <pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
+                                [error]="error?.created_date"></pngx-input-date>
+                            <pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
+                                (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
+                            <pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
+                                (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
+                            <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
+                                (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
+                            <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
+                            <ng-container *ngFor="let fieldInstance of document?.custom_fields; let i = index">
+                                <div [formGroup]="customFieldFormFields.controls[i]">
+                                    <pngx-input-text formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.String" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-text>
+                                    <pngx-input-date formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Date" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-date>
+                                    <pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Integer" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [error]="getCustomFieldError(i)"></pngx-input-number>
+                                    <pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Float" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".1" [error]="getCustomFieldError(i)"></pngx-input-number>
+                                    <pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Monetary" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
+                                    <pngx-input-check formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Boolean" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
+                                    <pngx-input-url formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Url" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
+                                </div>
+                            </ng-container>
+                        </div>
                     </ng-template>
                 </li>
 
                 <li [ngbNavItem]="DocumentDetailNavIDs.Content">
                     <a ngbNavLink i18n>Content</a>
                     <ng-template ngbNavContent>
-                        <div class="mb-3">
+                        <div>
                             <textarea class="form-control" id="content" rows="20" formControlName='content' [class.rtl]="isRTL"></textarea>
                         </div>
                     </ng-template>
                 </li>
             </ul>
 
-            <div [ngbNavOutlet]="nav" class="mt-2"></div>
+            <div [ngbNavOutlet]="nav" class="mt-3"></div>
 
-            <ng-container>
-                <button type="button" class="btn btn-outline-secondary me-2" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
-                <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
-                    <button *ngIf="hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; next</button>
-                    <button *ngIf="!hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; close</button>
-                    <button type="submit" class="btn btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
-                </ng-container>
-            </ng-container>
         </form>
     </div>
 
index 3d3e34069784658bb23f070d55a3bfa03af07374..9f49e737bec8c57edb6a73dff169093d16514252 100644 (file)
@@ -67,6 +67,9 @@ import { PageHeaderComponent } from '../common/page-header/page-header.component
 import { DocumentNotesComponent } from '../document-notes/document-notes.component'
 import { DocumentDetailComponent } from './document-detail.component'
 import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
+import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
+import { PaperlessCustomFieldDataType } from 'src/app/data/paperless-custom-field'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 
 const doc: PaperlessDocument = {
   id: 3,
@@ -94,8 +97,31 @@ const doc: PaperlessDocument = {
       user: 2,
     },
   ],
+  custom_fields: [
+    {
+      field: 0,
+      document: 3,
+      created: new Date(),
+      value: 'custom foo bar',
+    },
+  ],
 }
 
+const customFields = [
+  {
+    id: 0,
+    name: 'Field 1',
+    data_type: PaperlessCustomFieldDataType.String,
+    created: new Date(),
+  },
+  {
+    id: 1,
+    name: 'Custom Field 2',
+    data_type: PaperlessCustomFieldDataType.Integer,
+    created: new Date(),
+  },
+]
+
 describe('DocumentDetailComponent', () => {
   let component: DocumentDetailComponent
   let fixture: ComponentFixture<DocumentDetailComponent>
@@ -107,6 +133,7 @@ describe('DocumentDetailComponent', () => {
   let toastService: ToastService
   let documentListViewService: DocumentListViewService
   let settingsService: SettingsService
+  let customFieldsService: CustomFieldsService
 
   let currentUserCan = true
   let currentUserHasObjectPermissions = true
@@ -136,6 +163,7 @@ describe('DocumentDetailComponent', () => {
         PdfViewerComponent,
         SafeUrlPipe,
         ShareLinksDropdownComponent,
+        CustomFieldsDropdownComponent,
       ],
       providers: [
         DocumentTitlePipe,
@@ -199,6 +227,7 @@ describe('DocumentDetailComponent', () => {
               }),
           },
         },
+        CustomFieldsService,
         {
           provide: PermissionsService,
           useValue: {
@@ -234,6 +263,7 @@ describe('DocumentDetailComponent', () => {
     toastService = TestBed.inject(ToastService)
     documentListViewService = TestBed.inject(DocumentListViewService)
     settingsService = TestBed.inject(SettingsService)
+    customFieldsService = TestBed.inject(CustomFieldsService)
     fixture = TestBed.createComponent(DocumentDetailComponent)
     component = fixture.componentInstance
   })
@@ -290,6 +320,13 @@ describe('DocumentDetailComponent', () => {
   it('should load already-opened document via param', () => {
     jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
     jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(doc)
+    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
+      of({
+        count: customFields.length,
+        all: customFields.map((f) => f.id),
+        results: customFields,
+      })
+    )
     fixture.detectChanges() // calls ngOnInit
     expect(component.document).toEqual(doc)
   })
@@ -797,12 +834,92 @@ describe('DocumentDetailComponent', () => {
     expect(toastSpy).toHaveBeenCalledWith('Error retrieving metadata', error)
   })
 
+  it('should display custom fields', () => {
+    initNormally()
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      customFields[0].name
+    )
+  })
+
+  it('should support add custom field, correctly send via post', () => {
+    initNormally()
+    const initialLength = doc.custom_fields.length
+    expect(component.customFieldFormFields).toHaveLength(initialLength)
+    component.addField(customFields[1])
+    fixture.detectChanges()
+    expect(component.document.custom_fields).toHaveLength(initialLength + 1)
+    expect(component.customFieldFormFields).toHaveLength(initialLength + 1)
+    expect(fixture.debugElement.nativeElement.textContent).toContain(
+      customFields[1].name
+    )
+    const updateSpy = jest.spyOn(documentService, 'update')
+    component.save(true)
+    expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(2)
+    expect(updateSpy.mock.lastCall[0].custom_fields[1]).toEqual({
+      field: customFields[1].id,
+      value: null,
+    })
+  })
+
+  it('should support remove custom field, correctly send via post', () => {
+    initNormally()
+    const initialLength = doc.custom_fields.length
+    expect(component.customFieldFormFields).toHaveLength(initialLength)
+    component.removeField(doc.custom_fields[0])
+    fixture.detectChanges()
+    expect(component.document.custom_fields).toHaveLength(initialLength - 1)
+    expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
+    expect(fixture.debugElement.nativeElement.textContent).not.toContain(
+      'Field 1'
+    )
+    const updateSpy = jest.spyOn(documentService, 'update')
+    component.save(true)
+    expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(
+      initialLength - 1
+    )
+  })
+
+  it('should show custom field errors', () => {
+    initNormally()
+    component.error = {
+      custom_fields: [
+        {},
+        {},
+        { value: ['This field may not be null.'] },
+        {},
+        { non_field_errors: ['Enter a valid URL.'] },
+      ],
+    }
+    expect(component.getCustomFieldError(2)).toEqual([
+      'This field may not be null.',
+    ])
+    expect(component.getCustomFieldError(4)).toEqual(['Enter a valid URL.'])
+  })
+
+  it('should refresh custom fields when created', () => {
+    initNormally()
+    const refreshSpy = jest.spyOn(component, 'refreshCustomFields')
+    fixture.debugElement
+      .query(By.directive(CustomFieldsDropdownComponent))
+      .triggerEventHandler('created')
+    expect(refreshSpy).toHaveBeenCalled()
+  })
+
   function initNormally() {
-    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
+    jest
+      .spyOn(documentService, 'get')
+      .mockReturnValueOnce(of(Object.assign({}, doc)))
     jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
     jest
       .spyOn(openDocumentsService, 'openDocument')
       .mockReturnValueOnce(of(true))
+    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
+      of({
+        count: customFields.length,
+        all: customFields.map((f) => f.id),
+        results: customFields,
+      })
+    )
     fixture.detectChanges()
   }
 })
index d992b0191ede98eec75650f037206783379204f0..368f1e632fdb0407a029231dd865bf30ca43dae3 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'
-import { FormControl, FormGroup } from '@angular/forms'
+import { FormArray, FormControl, FormGroup } from '@angular/forms'
 import { ActivatedRoute, Router } from '@angular/router'
 import {
   NgbDateStruct,
@@ -63,7 +63,12 @@ import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
 import { ObjectWithId } from 'src/app/data/object-with-id'
 import { FilterRule } from 'src/app/data/filter-rule'
 import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
-import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
+import {
+  PaperlessCustomField,
+  PaperlessCustomFieldDataType,
+} from 'src/app/data/paperless-custom-field'
+import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 
 enum DocumentDetailNavIDs {
   Details = 1,
@@ -120,6 +125,7 @@ export class DocumentDetailComponent
     archive_serial_number: new FormControl(),
     tags: new FormControl([]),
     permissions_form: new FormControl(null),
+    custom_fields: new FormArray([]),
   })
 
   previewCurrentPage: number = 1
@@ -135,6 +141,9 @@ export class DocumentDetailComponent
 
   ogDate: Date
 
+  customFields: PaperlessCustomField[]
+  public readonly PaperlessCustomFieldDataType = PaperlessCustomFieldDataType
+
   @ViewChild('nav') nav: NgbNav
   @ViewChild('pdfPreview') set pdfPreview(element) {
     // this gets called when compontent added or removed from DOM
@@ -166,6 +175,7 @@ export class DocumentDetailComponent
     private storagePathService: StoragePathService,
     private permissionsService: PermissionsService,
     private userService: UserService,
+    private customFieldsService: CustomFieldsService,
     private http: HttpClient
   ) {
     super()
@@ -232,6 +242,8 @@ export class DocumentDetailComponent
       .pipe(first(), takeUntil(this.unsubscribeNotifier))
       .subscribe((result) => (this.users = result.results))
 
+    this.getCustomFields()
+
     this.route.paramMap
       .pipe(
         takeUntil(this.unsubscribeNotifier),
@@ -324,6 +336,7 @@ export class DocumentDetailComponent
               owner: doc.owner,
               set_permissions: doc.permissions,
             },
+            custom_fields: doc.custom_fields,
           })
 
           this.isDirty$ = dirtyCheck(
@@ -385,6 +398,8 @@ export class DocumentDetailComponent
   updateComponent(doc: PaperlessDocument) {
     this.document = doc
     this.requiresPassword = false
+    // this.customFields = doc.custom_fields.concat([])
+    this.updateFormForCustomFields()
     this.documentsService
       .getMetadata(doc.id)
       .pipe(first())
@@ -433,6 +448,10 @@ export class DocumentDetailComponent
     if (!this.userCanEdit) this.documentForm.disable()
   }
 
+  get customFieldFormFields(): FormArray {
+    return this.documentForm.get('custom_fields') as FormArray
+  }
+
   createDocumentType(newName: string) {
     var modal = this.modalService.open(DocumentTypeEditDialogComponent, {
       backdrop: 'static',
@@ -510,6 +529,7 @@ export class DocumentDetailComponent
             set_permissions: doc.permissions,
           }
           this.title = doc.title
+          this.updateFormForCustomFields()
           this.documentForm.patchValue(doc)
           this.openDocumentService.setDirty(doc, false)
         },
@@ -533,6 +553,7 @@ export class DocumentDetailComponent
           close && this.close()
           this.networkActive = false
           this.error = null
+          this.openDocumentService.refreshDocument(this.documentId)
         },
         error: (error) => {
           this.networkActive = false
@@ -819,4 +840,61 @@ export class DocumentDetailComponent
 
     this.documentListViewService.quickFilter(filterRules)
   }
+
+  private getCustomFields() {
+    this.customFieldsService
+      .listAll()
+      .pipe(first(), takeUntil(this.unsubscribeNotifier))
+      .subscribe((result) => (this.customFields = result.results))
+  }
+
+  public refreshCustomFields() {
+    this.customFieldsService.clearCache()
+    this.getCustomFields()
+  }
+
+  public getCustomFieldFromInstance(
+    instance: PaperlessCustomFieldInstance
+  ): PaperlessCustomField {
+    return this.customFields?.find((f) => f.id === instance.field)
+  }
+
+  public getCustomFieldError(index: number) {
+    const fieldError = this.error?.custom_fields?.[index]
+    return fieldError?.['non_field_errors'] ?? fieldError?.['value']
+  }
+
+  private updateFormForCustomFields(emitEvent: boolean = false) {
+    this.customFieldFormFields.clear({ emitEvent: false })
+    this.document.custom_fields?.forEach((fieldInstance) => {
+      this.customFieldFormFields.push(
+        new FormGroup({
+          field: new FormControl(
+            this.getCustomFieldFromInstance(fieldInstance)?.id
+          ),
+          value: new FormControl(fieldInstance.value),
+        }),
+        { emitEvent }
+      )
+    })
+  }
+
+  public addField(field: PaperlessCustomField) {
+    this.document.custom_fields.push({
+      field: field.id,
+      value: null,
+      document: this.documentId,
+      created: new Date(),
+    })
+    this.updateFormForCustomFields(true)
+  }
+
+  public removeField(fieldInstance: PaperlessCustomFieldInstance) {
+    this.document.custom_fields.splice(
+      this.document.custom_fields.indexOf(fieldInstance),
+      1
+    )
+    this.updateFormForCustomFields(true)
+    this.documentForm.updateValueAndValidity()
+  }
 }
index e499a36f505c056564a18008165650bab5f808ba..30bee4d92eb1943c2a23eb62972f5350a3225364 100644 (file)
@@ -46,6 +46,7 @@ import {
   FILTER_OWNER_ANY,
   FILTER_OWNER_DOES_NOT_INCLUDE,
   FILTER_OWNER_ISNULL,
+  FILTER_CUSTOM_FIELDS,
 } from 'src/app/data/filter-rule-type'
 import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
 import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
@@ -240,6 +241,18 @@ describe('FilterEditorComponent', () => {
     expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
   }))
 
+  it('should ingest text filter rules for custom fields', fakeAsync(() => {
+    expect(component.textFilter).toEqual(null)
+    component.filterRules = [
+      {
+        rule_type: FILTER_CUSTOM_FIELDS,
+        value: 'foo',
+      },
+    ]
+    expect(component.textFilter).toEqual('foo')
+    expect(component.textFilterTarget).toEqual('custom-fields') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
+  }))
+
   it('should ingest text filter rules for doc asn is null', fakeAsync(() => {
     expect(component.textFilterTarget).toEqual('title-content')
     expect(component.textFilterModifier).toEqual('equals') // TEXT_FILTER_MODIFIER_EQUALS
@@ -956,12 +969,30 @@ describe('FilterEditorComponent', () => {
     ])
   }))
 
-  it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
+  it('should convert user input to correct filter rules on custom fields query', fakeAsync(() => {
     component.textFilterInput.nativeElement.value = 'foo'
     component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
     const textFieldTargetDropdown = fixture.debugElement.queryAll(
       By.directive(NgbDropdownItem)
     )[3]
+    textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
+    fixture.detectChanges()
+    tick(400)
+    expect(component.textFilterTarget).toEqual('custom-fields')
+    expect(component.filterRules).toEqual([
+      {
+        rule_type: FILTER_CUSTOM_FIELDS,
+        value: 'foo',
+      },
+    ])
+  }))
+
+  it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
+    component.textFilterInput.nativeElement.value = 'foo'
+    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+    const textFieldTargetDropdown = fixture.debugElement.queryAll(
+      By.directive(NgbDropdownItem)
+    )[4]
     textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
     fixture.detectChanges()
     tick(400)
index 78b1bdc8304e422a2eec0e396a7ee598bef3428d..030f4ec0750f8cf132defcee5b4453fe31044406 100644 (file)
@@ -48,6 +48,7 @@ import {
   FILTER_OWNER_DOES_NOT_INCLUDE,
   FILTER_OWNER_ISNULL,
   FILTER_OWNER_ANY,
+  FILTER_CUSTOM_FIELDS,
 } from 'src/app/data/filter-rule-type'
 import {
   FilterableDropdownSelectionModel,
@@ -74,6 +75,7 @@ const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
 const TEXT_FILTER_TARGET_ASN = 'asn'
 const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
 const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
+const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
 
 const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
 const TEXT_FILTER_MODIFIER_NULL = 'is null'
@@ -204,6 +206,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
         name: $localize`Title & content`,
       },
       { id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
+      {
+        id: TEXT_FILTER_TARGET_CUSTOM_FIELDS,
+        name: $localize`Custom fields`,
+      },
       {
         id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
         name: $localize`Advanced search`,
@@ -321,6 +327,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
           this._textFilter = rule.value
           this.textFilterTarget = TEXT_FILTER_TARGET_ASN
           break
+        case FILTER_CUSTOM_FIELDS:
+          this._textFilter = rule.value
+          this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
+          break
         case FILTER_FULLTEXT_QUERY:
           let allQueryArgs = rule.value.split(',')
           let textQueryArgs = []
@@ -552,6 +562,15 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
         })
       }
     }
+    if (
+      this._textFilter &&
+      this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
+    ) {
+      filterRules.push({
+        rule_type: FILTER_CUSTOM_FIELDS,
+        value: this._textFilter,
+      })
+    }
     if (
       this._textFilter &&
       this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY
diff --git a/src-ui/src/app/components/manage/custom-fields/custom-fields.component.html b/src-ui/src/app/components/manage/custom-fields/custom-fields.component.html
new file mode 100644 (file)
index 0000000..9542553
--- /dev/null
@@ -0,0 +1,41 @@
+<pngx-page-header title="Custom Fields">
+    <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
+      <svg class="sidebaricon me-1" fill="currentColor">
+        <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
+      </svg>
+      <ng-container i18n>Add Field</ng-container>
+    </button>
+</pngx-page-header>
+
+<ul class="list-group">
+
+    <li class="list-group-item">
+        <div class="row">
+            <div class="col" i18n>Name</div>
+            <div class="col" i18n>Data Type</div>
+            <div class="col" i18n>Actions</div>
+        </div>
+    </li>
+
+    <li *ngFor="let field of fields" class="list-group-item">
+        <div class="row">
+            <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
+            <div class="col d-flex align-items-center">{{getDataType(field)}}</div>
+            <div class="col">
+                <div class="btn-group">
+                    <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
+                    <svg class="buttonicon-sm" fill="currentColor">
+                        <use xlink:href="assets/bootstrap-icons.svg#pencil" />
+                      </svg>&nbsp;<ng-container i18n>Edit</ng-container>
+                    </button>
+                    <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
+                        <svg class="buttonicon-sm" fill="currentColor">
+                            <use xlink:href="assets/bootstrap-icons.svg#trash" />
+                        </svg>&nbsp;<ng-container i18n>Delete</ng-container>
+                    </button>
+                </div>
+            </div>
+        </div>
+    </li>
+    <li *ngIf="fields.length === 0" class="list-group-item" i18n>No fields defined.</li>
+</ul>
diff --git a/src-ui/src/app/components/manage/custom-fields/custom-fields.component.scss b/src-ui/src/app/components/manage/custom-fields/custom-fields.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/manage/custom-fields/custom-fields.component.spec.ts b/src-ui/src/app/components/manage/custom-fields/custom-fields.component.spec.ts
new file mode 100644 (file)
index 0000000..ed46834
--- /dev/null
@@ -0,0 +1,162 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { CustomFieldsComponent } from './custom-fields.component'
+import {
+  PaperlessCustomField,
+  PaperlessCustomFieldDataType,
+} from 'src/app/data/paperless-custom-field'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { By } from '@angular/platform-browser'
+import {
+  NgbModal,
+  NgbPaginationModule,
+  NgbModalModule,
+  NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+import { of, throwError } from 'rxjs'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+
+const fields: PaperlessCustomField[] = [
+  {
+    id: 0,
+    name: 'Field 1',
+    data_type: PaperlessCustomFieldDataType.String,
+  },
+  {
+    id: 1,
+    name: 'Field 2',
+    data_type: PaperlessCustomFieldDataType.Integer,
+  },
+]
+
+describe('CustomFieldsComponent', () => {
+  let component: CustomFieldsComponent
+  let fixture: ComponentFixture<CustomFieldsComponent>
+  let customFieldsService: CustomFieldsService
+  let modalService: NgbModal
+  let toastService: ToastService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [
+        CustomFieldsComponent,
+        IfPermissionsDirective,
+        PageHeaderComponent,
+        ConfirmDialogComponent,
+      ],
+      providers: [
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+            currentUserHasObjectPermissions: () => true,
+            currentUserOwnsObject: () => true,
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        NgbPaginationModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbModalModule,
+      ],
+    })
+
+    customFieldsService = TestBed.inject(CustomFieldsService)
+    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
+      of({
+        count: fields.length,
+        all: fields.map((o) => o.id),
+        results: fields,
+      })
+    )
+    modalService = TestBed.inject(NgbModal)
+    toastService = TestBed.inject(ToastService)
+
+    fixture = TestBed.createComponent(CustomFieldsComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should support create, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const reloadSpy = jest.spyOn(component, 'reload')
+
+    const createButton = fixture.debugElement.queryAll(By.css('button'))[0]
+    createButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+
+    // fail first
+    editDialog.failed.emit({ error: 'error creating item' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit(fields[0])
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should support edit, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const reloadSpy = jest.spyOn(component, 'reload')
+
+    const editButton = fixture.debugElement.queryAll(By.css('button'))[1]
+    editButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+    expect(editDialog.object).toEqual(fields[0])
+
+    // fail first
+    editDialog.failed.emit({ error: 'error editing item' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit(fields[0])
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should support delete, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const deleteSpy = jest.spyOn(customFieldsService, 'delete')
+    const reloadSpy = jest.spyOn(component, 'reload')
+
+    const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
+    deleteButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog = modal.componentInstance as ConfirmDialogComponent
+
+    // fail first
+    deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
+    editDialog.confirmClicked.emit()
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    deleteSpy.mockReturnValueOnce(of(true))
+    editDialog.confirmClicked.emit()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/manage/custom-fields/custom-fields.component.ts b/src-ui/src/app/components/manage/custom-fields/custom-fields.component.ts
new file mode 100644 (file)
index 0000000..9349d96
--- /dev/null
@@ -0,0 +1,98 @@
+import { Component, OnInit } from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { Subject, takeUntil } from 'rxjs'
+import {
+  DATA_TYPE_LABELS,
+  PaperlessCustomField,
+} from 'src/app/data/paperless-custom-field'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
+import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+
+@Component({
+  selector: 'pngx-custom-fields',
+  templateUrl: './custom-fields.component.html',
+  styleUrls: ['./custom-fields.component.scss'],
+})
+export class CustomFieldsComponent
+  extends ComponentWithPermissions
+  implements OnInit
+{
+  public fields: PaperlessCustomField[] = []
+
+  private unsubscribeNotifier: Subject<any> = new Subject()
+  constructor(
+    private customFieldsService: CustomFieldsService,
+    public permissionsService: PermissionsService,
+    private modalService: NgbModal,
+    private toastService: ToastService
+  ) {
+    super()
+  }
+
+  ngOnInit() {
+    this.reload()
+  }
+
+  reload() {
+    this.customFieldsService
+      .listAll()
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((r) => {
+        this.fields = r.results
+      })
+  }
+
+  editField(field: PaperlessCustomField) {
+    const modal = this.modalService.open(CustomFieldEditDialogComponent)
+    modal.componentInstance.dialogMode = field
+      ? EditDialogMode.EDIT
+      : EditDialogMode.CREATE
+    modal.componentInstance.object = field
+    modal.componentInstance.succeeded
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((newField) => {
+        this.toastService.showInfo($localize`Saved field "${newField.name}".`)
+        this.customFieldsService.clearCache()
+        this.reload()
+      })
+    modal.componentInstance.failed
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((e) => {
+        this.toastService.showError($localize`Error saving field.`, e)
+      })
+  }
+
+  deleteField(field: PaperlessCustomField) {
+    const modal = this.modalService.open(ConfirmDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.title = $localize`Confirm delete field`
+    modal.componentInstance.messageBold = $localize`This operation will permanently delete this field.`
+    modal.componentInstance.message = $localize`This operation cannot be undone.`
+    modal.componentInstance.btnClass = 'btn-danger'
+    modal.componentInstance.btnCaption = $localize`Proceed`
+    modal.componentInstance.confirmClicked.subscribe(() => {
+      modal.componentInstance.buttonsEnabled = false
+      this.customFieldsService.delete(field).subscribe({
+        next: () => {
+          modal.close()
+          this.toastService.showInfo($localize`Deleted field`)
+          this.customFieldsService.clearCache()
+          this.reload()
+        },
+        error: (e) => {
+          this.toastService.showError($localize`Error deleting field.`, e)
+        },
+      })
+    })
+  }
+
+  getDataType(field: PaperlessCustomField): string {
+    return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
+  }
+}
index f65f52fd2f900a3eea096ac137f39bfffcd9f36b..a6c73fe297425d66f175e983e595b6070675bad5 100644 (file)
@@ -46,6 +46,8 @@ export const FILTER_OWNER_ANY = 33
 export const FILTER_OWNER_ISNULL = 34
 export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
 
+export const FILTER_CUSTOM_FIELDS = 36
+
 export const FILTER_RULE_TYPES: FilterRuleType[] = [
   {
     id: FILTER_TITLE,
@@ -271,6 +273,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
     datatype: 'number',
     multi: true,
   },
+  {
+    id: FILTER_CUSTOM_FIELDS,
+    filtervar: 'custom_fields__icontains',
+    datatype: 'string',
+    multi: false,
+  },
 ]
 
 export interface FilterRuleType {
diff --git a/src-ui/src/app/data/paperless-custom-field-instance.ts b/src-ui/src/app/data/paperless-custom-field-instance.ts
new file mode 100644 (file)
index 0000000..600e523
--- /dev/null
@@ -0,0 +1,9 @@
+import { ObjectWithId } from './object-with-id'
+import { PaperlessCustomField } from './paperless-custom-field'
+
+export interface PaperlessCustomFieldInstance extends ObjectWithId {
+  document: number // PaperlessDocument
+  field: number // PaperlessCustomField
+  created: Date
+  value?: any
+}
diff --git a/src-ui/src/app/data/paperless-custom-field.ts b/src-ui/src/app/data/paperless-custom-field.ts
new file mode 100644 (file)
index 0000000..663e150
--- /dev/null
@@ -0,0 +1,48 @@
+import { ObjectWithId } from './object-with-id'
+
+export enum PaperlessCustomFieldDataType {
+  String = 'string',
+  Url = 'url',
+  Date = 'date',
+  Boolean = 'boolean',
+  Integer = 'integer',
+  Float = 'float',
+  Monetary = 'monetary',
+}
+
+export const DATA_TYPE_LABELS = [
+  {
+    id: PaperlessCustomFieldDataType.Boolean,
+    name: $localize`Boolean`,
+  },
+  {
+    id: PaperlessCustomFieldDataType.Date,
+    name: $localize`Date`,
+  },
+  {
+    id: PaperlessCustomFieldDataType.Integer,
+    name: $localize`Integer`,
+  },
+  {
+    id: PaperlessCustomFieldDataType.Float,
+    name: $localize`Number`,
+  },
+  {
+    id: PaperlessCustomFieldDataType.Monetary,
+    name: $localize`Monetary`,
+  },
+  {
+    id: PaperlessCustomFieldDataType.String,
+    name: $localize`Text`,
+  },
+  {
+    id: PaperlessCustomFieldDataType.Url,
+    name: $localize`Url`,
+  },
+]
+
+export interface PaperlessCustomField extends ObjectWithId {
+  data_type: PaperlessCustomFieldDataType
+  name: string
+  created?: Date
+}
index 755d44f6aea4f780242a317e83e239eef5f06a62..b00c478fcfa881c12b6eb462be8f177257350415 100644 (file)
@@ -5,6 +5,7 @@ import { Observable } from 'rxjs'
 import { PaperlessStoragePath } from './paperless-storage-path'
 import { ObjectWithPermissions } from './object-with-permissions'
 import { PaperlessDocumentNote } from './paperless-document-note'
+import { PaperlessCustomFieldInstance } from './paperless-custom-field-instance'
 
 export interface SearchHit {
   score?: number
@@ -58,4 +59,6 @@ export interface PaperlessDocument extends ObjectWithPermissions {
   notes?: PaperlessDocumentNote[]
 
   __search_hit__?: SearchHit
+
+  custom_fields?: PaperlessCustomFieldInstance[]
 }
index 820b9ac1f119ce5fe4f08e6ee894925e9eb74707..635c9beb914c04ef0a94699d0b393a01f4507c94 100644 (file)
@@ -256,6 +256,10 @@ describe('PermissionsService', () => {
         'view_consumptiontemplate',
         'change_consumptiontemplate',
         'delete_consumptiontemplate',
+        'add_customfield',
+        'view_customfield',
+        'change_customfield',
+        'delete_customfield',
       ],
       {
         username: 'testuser',
index b22e5617725c0ba2befd1eb1326bc2467bda7aea..b484e0bd3158afb33fc0bd5be422c24238b8fb78 100644 (file)
@@ -26,6 +26,7 @@ export enum PermissionType {
   Admin = '%s_logentry',
   ShareLink = '%s_sharelink',
   ConsumptionTemplate = '%s_consumptiontemplate',
+  CustomField = '%s_customfield',
 }
 
 @Injectable({
diff --git a/src-ui/src/app/services/rest/custom-fields.service.spec.ts b/src-ui/src/app/services/rest/custom-fields.service.spec.ts
new file mode 100644 (file)
index 0000000..a002a28
--- /dev/null
@@ -0,0 +1,14 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { Subscription } from 'rxjs'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
+import { CustomFieldsService } from './custom-fields.service'
+
+let httpTestingController: HttpTestingController
+let service: CustomFieldsService
+let subscription: Subscription
+const endpoint = 'custom_fields'
+
+// run common tests
+commonAbstractPaperlessServiceTests(endpoint, CustomFieldsService)
diff --git a/src-ui/src/app/services/rest/custom-fields.service.ts b/src-ui/src/app/services/rest/custom-fields.service.ts
new file mode 100644 (file)
index 0000000..352b949
--- /dev/null
@@ -0,0 +1,15 @@
+import { Injectable } from '@angular/core'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { AbstractPaperlessService } from './abstract-paperless-service'
+import { Observable } from 'rxjs'
+import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
+import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
+
+@Injectable({
+  providedIn: 'root',
+})
+export class CustomFieldsService extends AbstractPaperlessService<PaperlessCustomField> {
+  constructor(http: HttpClient) {
+    super(http, 'custom_fields')
+  }
+}
index fde841ffbf6e56b9c60d63fdb4f3ab7ac57af0f8..504dd302894f8aa1edaf4485e6206cb1e14b362d 100644 (file)
@@ -384,6 +384,14 @@ ul.pagination {
   }
 }
 
+.nav-underline {
+  .nav-link {
+    &.active, &:hover, &:focus {
+      color: var(--bs-primary);
+    }
+  }
+}
+
 .ng-select-container,
 .ng-select.ng-select-opened > .ng-select-container,
 .ng-dropdown-panel,
@@ -661,3 +669,17 @@ code {
 .cdk-drag-animating {
   transition: transform 300ms cubic-bezier(0, 0, 0.2, 1);
 }
+
+.hidden-button-container {
+  button {
+    opacity: 0;
+    pointer-events: none;
+    transition: opacity .2s ease;
+  }
+  &:hover {
+    button {
+      opacity: 1;
+      pointer-events: initial;
+    }
+  }
+}
index 97871e4f8e345754a36a0e8e2e09fa6979584576..d648d5829658152aa74e2fce0742417a84509704 100644 (file)
@@ -3,6 +3,8 @@ from django.contrib import admin
 from guardian.admin import GuardedModelAdmin
 
 from documents.models import Correspondent
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import Note
@@ -144,6 +146,20 @@ class ShareLinksAdmin(GuardedModelAdmin):
     list_display_links = ("created",)
 
 
+class CustomFieldsAdmin(GuardedModelAdmin):
+    fields = ("name", "created", "data_type")
+    readonly_fields = ("created", "data_type")
+    list_display = ("name", "created", "data_type")
+    list_filter = ("created", "data_type")
+
+
+class CustomFieldInstancesAdmin(GuardedModelAdmin):
+    fields = ("field", "document", "created", "value")
+    readonly_fields = ("field", "document", "created", "value")
+    list_display = ("field", "document", "value", "created")
+    list_filter = ("document", "created")
+
+
 admin.site.register(Correspondent, CorrespondentAdmin)
 admin.site.register(Tag, TagAdmin)
 admin.site.register(DocumentType, DocumentTypeAdmin)
@@ -153,6 +169,8 @@ admin.site.register(StoragePath, StoragePathAdmin)
 admin.site.register(PaperlessTask, TaskAdmin)
 admin.site.register(Note, NotesAdmin)
 admin.site.register(ShareLink, ShareLinksAdmin)
+admin.site.register(CustomField, CustomFieldsAdmin)
+admin.site.register(CustomFieldInstance, CustomFieldInstancesAdmin)
 
 if settings.AUDIT_LOG_ENABLED:
 
index 2d4cdf8251b81018abf07b54b6f2712ea0c45de6..21d120047273e1f3b0279617aca66ae0cbfa6d30 100644 (file)
@@ -82,6 +82,21 @@ class TitleContentFilter(Filter):
             return qs
 
 
+class CustomFieldsFilter(Filter):
+    def filter(self, qs, value):
+        if value:
+            return (
+                qs.filter(custom_fields__field__name__icontains=value)
+                | qs.filter(custom_fields__value_text__icontains=value)
+                | qs.filter(custom_fields__value_bool__icontains=value)
+                | qs.filter(custom_fields__value_int__icontains=value)
+                | qs.filter(custom_fields__value_date__icontains=value)
+                | qs.filter(custom_fields__value_url__icontains=value)
+            )
+        else:
+            return qs
+
+
 class DocumentFilterSet(FilterSet):
     is_tagged = BooleanFilter(
         label="Is tagged",
@@ -108,6 +123,8 @@ class DocumentFilterSet(FilterSet):
 
     owner__id__none = ObjectFilter(field_name="owner", exclude=True)
 
+    custom_fields__icontains = CustomFieldsFilter()
+
     class Meta:
         model = Document
         fields = {
@@ -132,6 +149,7 @@ class DocumentFilterSet(FilterSet):
             "storage_path__name": CHAR_KWARGS,
             "owner": ["isnull"],
             "owner__id": ID_KWARGS,
+            "custom_fields": ["icontains"],
         }
 
 
index 34e0fd14ba4937d980b2d32d0af6397bbb56490f..2e25850712113b09894209a9014e51ea4cb96417 100644 (file)
@@ -30,6 +30,8 @@ from whoosh.searching import ResultsPage
 from whoosh.searching import Searcher
 from whoosh.writing import AsyncWriter
 
+# from documents.models import CustomMetadata
+from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import Note
 from documents.models import User
@@ -60,6 +62,8 @@ def get_schema():
         has_path=BOOLEAN(),
         notes=TEXT(),
         num_notes=NUMERIC(sortable=True, signed=False),
+        custom_fields=TEXT(),
+        custom_field_count=NUMERIC(sortable=True, signed=False),
         owner=TEXT(),
         owner_id=NUMERIC(),
         has_owner=BOOLEAN(),
@@ -69,7 +73,7 @@ def get_schema():
     )
 
 
-def open_index(recreate=False):
+def open_index(recreate=False) -> FileIndex:
     try:
         if exists_in(settings.INDEX_DIR) and not recreate:
             return open_dir(settings.INDEX_DIR, schema=get_schema())
@@ -82,7 +86,7 @@ def open_index(recreate=False):
 
 
 @contextmanager
-def open_index_writer(optimize=False):
+def open_index_writer(optimize=False) -> AsyncWriter:
     writer = AsyncWriter(open_index())
 
     try:
@@ -95,7 +99,7 @@ def open_index_writer(optimize=False):
 
 
 @contextmanager
-def open_index_searcher():
+def open_index_searcher() -> Searcher:
     searcher = open_index().searcher()
 
     try:
@@ -108,6 +112,9 @@ def update_document(writer: AsyncWriter, doc: Document):
     tags = ",".join([t.name for t in doc.tags.all()])
     tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
     notes = ",".join([str(c.note) for c in Note.objects.filter(document=doc)])
+    custom_fields = ",".join(
+        [str(c) for c in CustomFieldInstance.objects.filter(document=doc)],
+    )
     asn = doc.archive_serial_number
     if asn is not None and (
         asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
@@ -147,6 +154,8 @@ def update_document(writer: AsyncWriter, doc: Document):
         has_path=doc.storage_path is not None,
         notes=notes,
         num_notes=len(notes),
+        custom_fields=custom_fields,
+        custom_field_count=len(doc.custom_fields.all()),
         owner=doc.owner.username if doc.owner else None,
         owner_id=doc.owner.id if doc.owner else None,
         has_owner=doc.owner is not None,
@@ -156,20 +165,20 @@ def update_document(writer: AsyncWriter, doc: Document):
     )
 
 
-def remove_document(writer, doc):
+def remove_document(writer: AsyncWriter, doc: Document):
     remove_document_by_id(writer, doc.pk)
 
 
-def remove_document_by_id(writer, doc_id):
+def remove_document_by_id(writer: AsyncWriter, doc_id):
     writer.delete_by_term("id", doc_id)
 
 
-def add_or_update_document(document):
+def add_or_update_document(document: Document):
     with open_index_writer() as writer:
         update_document(writer, document)
 
 
-def remove_document_from_index(document):
+def remove_document_from_index(document: Document):
     with open_index_writer() as writer:
         remove_document(writer, document)
 
@@ -185,6 +194,7 @@ class DelayedQuery:
         "created": ("created", ["date__lt", "date__gt"]),
         "checksum": ("checksum", ["icontains", "istartswith"]),
         "original_filename": ("original_filename", ["icontains", "istartswith"]),
+        "custom_fields": ("custom_fields", ["icontains", "istartswith"]),
     }
 
     def _get_query(self):
@@ -350,7 +360,15 @@ class DelayedFullTextQuery(DelayedQuery):
     def _get_query(self):
         q_str = self.query_params["query"]
         qp = MultifieldParser(
-            ["content", "title", "correspondent", "tag", "type", "notes"],
+            [
+                "content",
+                "title",
+                "correspondent",
+                "tag",
+                "type",
+                "notes",
+                "custom_fields",
+            ],
             self.searcher.ixreader.schema,
         )
         qp.add_plugin(DateParserPlugin(basedate=timezone.now()))
diff --git a/src/documents/migrations/1040_customfield_customfieldinstance_and_more.py b/src/documents/migrations/1040_customfield_customfieldinstance_and_more.py
new file mode 100644 (file)
index 0000000..eb644b5
--- /dev/null
@@ -0,0 +1,131 @@
+# Generated by Django 4.2.6 on 2023-11-02 17:38
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("documents", "1039_consumptiontemplate"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="CustomField",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "created",
+                    models.DateTimeField(
+                        db_index=True,
+                        default=django.utils.timezone.now,
+                        editable=False,
+                        verbose_name="created",
+                    ),
+                ),
+                ("name", models.CharField(max_length=128)),
+                (
+                    "data_type",
+                    models.CharField(
+                        choices=[
+                            ("string", "String"),
+                            ("url", "URL"),
+                            ("date", "Date"),
+                            ("boolean", "Boolean"),
+                            ("integer", "Integer"),
+                            ("float", "Float"),
+                            ("monetary", "Monetary"),
+                        ],
+                        editable=False,
+                        max_length=50,
+                        verbose_name="data type",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "custom field",
+                "verbose_name_plural": "custom fields",
+                "ordering": ("created",),
+            },
+        ),
+        migrations.CreateModel(
+            name="CustomFieldInstance",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "created",
+                    models.DateTimeField(
+                        db_index=True,
+                        default=django.utils.timezone.now,
+                        editable=False,
+                        verbose_name="created",
+                    ),
+                ),
+                ("value_text", models.CharField(max_length=128, null=True)),
+                ("value_bool", models.BooleanField(null=True)),
+                ("value_url", models.URLField(null=True)),
+                ("value_date", models.DateField(null=True)),
+                ("value_int", models.IntegerField(null=True)),
+                ("value_float", models.FloatField(null=True)),
+                (
+                    "value_monetary",
+                    models.DecimalField(decimal_places=2, max_digits=12, null=True),
+                ),
+                (
+                    "document",
+                    models.ForeignKey(
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="custom_fields",
+                        to="documents.document",
+                    ),
+                ),
+                (
+                    "field",
+                    models.ForeignKey(
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="fields",
+                        to="documents.customfield",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "custom field instance",
+                "verbose_name_plural": "custom field instances",
+                "ordering": ("created",),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name="customfield",
+            constraint=models.UniqueConstraint(
+                fields=("name",),
+                name="documents_customfield_unique_name",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="customfieldinstance",
+            constraint=models.UniqueConstraint(
+                fields=("document", "field"),
+                name="documents_customfieldinstance_unique_document_field",
+            ),
+        ),
+    ]
index b0d347f200728895b7c70056b1ad1c18648c41ce..04a3230e6ce25e3267311f6463e1b11e1923851a 100644 (file)
@@ -877,9 +877,139 @@ class ConsumptionTemplate(models.Model):
         return f"{self.name}"
 
 
+class CustomField(models.Model):
+    """
+    Defines the name and type of a custom field
+    """
+
+    class FieldDataType(models.TextChoices):
+        STRING = ("string", _("String"))
+        URL = ("url", _("URL"))
+        DATE = ("date", _("Date"))
+        BOOL = ("boolean"), _("Boolean")
+        INT = ("integer", _("Integer"))
+        FLOAT = ("float", _("Float"))
+        MONETARY = ("monetary", _("Monetary"))
+
+    created = models.DateTimeField(
+        _("created"),
+        default=timezone.now,
+        db_index=True,
+        editable=False,
+    )
+
+    name = models.CharField(max_length=128)
+
+    data_type = models.CharField(
+        _("data type"),
+        max_length=50,
+        choices=FieldDataType.choices,
+        editable=False,
+    )
+
+    class Meta:
+        ordering = ("created",)
+        verbose_name = _("custom field")
+        verbose_name_plural = _("custom fields")
+        constraints = [
+            models.UniqueConstraint(
+                fields=["name"],
+                name="%(app_label)s_%(class)s_unique_name",
+            ),
+        ]
+
+    def __str__(self) -> str:
+        return f"{self.name} : {self.data_type}"
+
+
+class CustomFieldInstance(models.Model):
+    """
+    A single instance of a field, attached to a CustomField for the name and type
+    and attached to a single Document to be metadata for it
+    """
+
+    created = models.DateTimeField(
+        _("created"),
+        default=timezone.now,
+        db_index=True,
+        editable=False,
+    )
+
+    document = models.ForeignKey(
+        Document,
+        blank=False,
+        null=False,
+        on_delete=models.CASCADE,
+        related_name="custom_fields",
+        editable=False,
+    )
+
+    field = models.ForeignKey(
+        CustomField,
+        blank=False,
+        null=False,
+        on_delete=models.CASCADE,
+        related_name="fields",
+        editable=False,
+    )
+
+    # Actual data storage
+    value_text = models.CharField(max_length=128, null=True)
+
+    value_bool = models.BooleanField(null=True)
+
+    value_url = models.URLField(null=True)
+
+    value_date = models.DateField(null=True)
+
+    value_int = models.IntegerField(null=True)
+
+    value_float = models.FloatField(null=True)
+
+    value_monetary = models.DecimalField(null=True, decimal_places=2, max_digits=12)
+
+    class Meta:
+        ordering = ("created",)
+        verbose_name = _("custom field instance")
+        verbose_name_plural = _("custom field instances")
+        constraints = [
+            models.UniqueConstraint(
+                fields=["document", "field"],
+                name="%(app_label)s_%(class)s_unique_document_field",
+            ),
+        ]
+
+    def __str__(self) -> str:
+        return str(self.field.name) + f" : {self.value}"
+
+    @property
+    def value(self):
+        """
+        Based on the data type, access the actual value the instance stores
+        A little shorthand/quick way to get what is actually here
+        """
+        if self.field.data_type == CustomField.FieldDataType.STRING:
+            return self.value_text
+        elif self.field.data_type == CustomField.FieldDataType.URL:
+            return self.value_url
+        elif self.field.data_type == CustomField.FieldDataType.DATE:
+            return self.value_date
+        elif self.field.data_type == CustomField.FieldDataType.BOOL:
+            return self.value_bool
+        elif self.field.data_type == CustomField.FieldDataType.INT:
+            return self.value_int
+        elif self.field.data_type == CustomField.FieldDataType.FLOAT:
+            return self.value_float
+        elif self.field.data_type == CustomField.FieldDataType.MONETARY:
+            return self.value_monetary
+        raise NotImplementedError(self.field.data_type)
+
+
 if settings.AUDIT_LOG_ENABLED:
     auditlog.register(Document, m2m_fields={"tags"})
     auditlog.register(Correspondent)
     auditlog.register(Tag)
     auditlog.register(DocumentType)
     auditlog.register(Note)
+    auditlog.register(CustomField)
+    auditlog.register(CustomFieldInstance)
index 6f20a4cf06d4d86758323a075cd06b6ec97b0101..3cbcea5a991ff954a33edc0d3741fbe1dc8d72a9 100644 (file)
@@ -8,9 +8,11 @@ from celery import states
 from django.conf import settings
 from django.contrib.auth.models import Group
 from django.contrib.auth.models import User
+from django.core.validators import URLValidator
 from django.utils.crypto import get_random_string
 from django.utils.text import slugify
 from django.utils.translation import gettext as _
+from drf_writable_nested.serializers import NestedUpdateMixin
 from guardian.core import ObjectPermissionChecker
 from guardian.shortcuts import get_users_with_perms
 from rest_framework import fields
@@ -21,6 +23,8 @@ from documents import bulk_edit
 from documents.data_models import DocumentSource
 from documents.models import ConsumptionTemplate
 from documents.models import Correspondent
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import MatchingModel
@@ -394,7 +398,92 @@ class StoragePathField(serializers.PrimaryKeyRelatedField):
         return StoragePath.objects.all()
 
 
-class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
+class CustomFieldSerializer(serializers.ModelSerializer):
+    data_type = serializers.ChoiceField(
+        choices=CustomField.FieldDataType,
+        read_only=False,
+    )
+
+    class Meta:
+        model = CustomField
+        fields = [
+            "id",
+            "name",
+            "data_type",
+        ]
+
+
+class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
+    """
+    Based on https://stackoverflow.com/a/62579804
+    """
+
+    def __init__(self, method_name=None, *args, **kwargs):
+        self.method_name = method_name
+        kwargs["source"] = "*"
+        super(serializers.SerializerMethodField, self).__init__(*args, **kwargs)
+
+    def to_internal_value(self, data):
+        return {self.field_name: data}
+
+
+class CustomFieldInstanceSerializer(serializers.ModelSerializer):
+    field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
+    value = ReadWriteSerializerMethodField()
+
+    def create(self, validated_data):
+        type_to_data_store_name_map = {
+            CustomField.FieldDataType.STRING: "value_text",
+            CustomField.FieldDataType.URL: "value_url",
+            CustomField.FieldDataType.DATE: "value_date",
+            CustomField.FieldDataType.BOOL: "value_bool",
+            CustomField.FieldDataType.INT: "value_int",
+            CustomField.FieldDataType.FLOAT: "value_float",
+            CustomField.FieldDataType.MONETARY: "value_monetary",
+        }
+        # An instance is attached to a document
+        document: Document = validated_data["document"]
+        # And to a CustomField
+        custom_field: CustomField = validated_data["field"]
+        # This key must exist, as it is validated
+        data_store_name = type_to_data_store_name_map[custom_field.data_type]
+
+        # Actually update or create the instance, providing the value
+        # to fill in the correct attribute based on the type
+        instance, _ = CustomFieldInstance.objects.update_or_create(
+            document=document,
+            field=custom_field,
+            defaults={data_store_name: validated_data["value"]},
+        )
+        return instance
+
+    def get_value(self, obj: CustomFieldInstance):
+        return obj.value
+
+    def validate(self, data):
+        """
+        For some reason, URLField validation is not run against the value
+        automatically.  Force it to run against the value
+        """
+        data = super().validate(data)
+        field: CustomField = data["field"]
+        if field.data_type == CustomField.FieldDataType.URL:
+            URLValidator()(data["value"])
+        return data
+
+    class Meta:
+        model = CustomFieldInstance
+        fields = [
+            "value",
+            "field",
+        ]
+
+
+class DocumentSerializer(
+    OwnedObjectSerializer,
+    NestedUpdateMixin,
+    DynamicFieldsModelSerializer,
+):
     correspondent = CorrespondentField(allow_null=True)
     tags = TagsField(many=True)
     document_type = DocumentTypeField(allow_null=True)
@@ -404,6 +493,8 @@ class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
     archived_file_name = SerializerMethodField()
     created_date = serializers.DateField(required=False)
 
+    custom_fields = CustomFieldInstanceSerializer(many=True, allow_null=True)
+
     owner = serializers.PrimaryKeyRelatedField(
         queryset=User.objects.all(),
         required=False,
@@ -425,7 +516,7 @@ class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
             doc["content"] = doc.get("content")[0:550]
         return doc
 
-    def update(self, instance, validated_data):
+    def update(self, instance: Document, validated_data):
         if "created_date" in validated_data and "created" not in validated_data:
             new_datetime = datetime.datetime.combine(
                 validated_data.get("created_date"),
@@ -466,6 +557,7 @@ class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
             "user_can_change",
             "set_permissions",
             "notes",
+            "custom_fields",
         )
 
 
index 84d74346916f0ed1f062597cafe4c18d3ea7fd19..d9359ef3cd3b844b5256618758183a2da55c6b51 100644 (file)
@@ -35,6 +35,8 @@ from documents import index
 from documents.data_models import DocumentSource
 from documents.models import ConsumptionTemplate
 from documents.models import Correspondent
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import MatchingModel
@@ -347,11 +349,36 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
         tag_2 = Tag.objects.create(name="t2")
         tag_3 = Tag.objects.create(name="t3")
 
+        cf1 = CustomField.objects.create(
+            name="stringfield",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        cf2 = CustomField.objects.create(
+            name="numberfield",
+            data_type=CustomField.FieldDataType.INT,
+        )
+
         doc1.tags.add(tag_inbox)
         doc2.tags.add(tag_2)
         doc3.tags.add(tag_2)
         doc3.tags.add(tag_3)
 
+        cf1_d1 = CustomFieldInstance.objects.create(
+            document=doc1,
+            field=cf1,
+            value_text="foobard1",
+        )
+        CustomFieldInstance.objects.create(
+            document=doc1,
+            field=cf2,
+            value_int=999,
+        )
+        cf1_d3 = CustomFieldInstance.objects.create(
+            document=doc3,
+            field=cf1,
+            value_text="foobard3",
+        )
+
         response = self.client.get("/api/documents/?is_in_inbox=true")
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         results = response.data["results"]
@@ -423,6 +450,31 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
         results = response.data["results"]
         self.assertEqual(len(results), 0)
 
+        # custom field name
+        response = self.client.get(
+            f"/api/documents/?custom_fields__icontains={cf1.name}",
+        )
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        results = response.data["results"]
+        self.assertEqual(len(results), 2)
+
+        # custom field value
+        response = self.client.get(
+            f"/api/documents/?custom_fields__icontains={cf1_d1.value}",
+        )
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        results = response.data["results"]
+        self.assertEqual(len(results), 1)
+        self.assertEqual(results[0]["id"], doc1.id)
+
+        response = self.client.get(
+            f"/api/documents/?custom_fields__icontains={cf1_d3.value}",
+        )
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        results = response.data["results"]
+        self.assertEqual(len(results), 1)
+        self.assertEqual(results[0]["id"], doc3.id)
+
     def test_document_checksum_filter(self):
         Document.objects.create(
             title="none1",
@@ -1146,6 +1198,14 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
         dt2 = DocumentType.objects.create(name="type2")
         sp = StoragePath.objects.create(name="path")
         sp2 = StoragePath.objects.create(name="path2")
+        cf1 = CustomField.objects.create(
+            name="string field",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        cf2 = CustomField.objects.create(
+            name="number field",
+            data_type=CustomField.FieldDataType.INT,
+        )
 
         d1 = Document.objects.create(checksum="1", correspondent=c, content="test")
         d2 = Document.objects.create(checksum="2", document_type=dt, content="test")
@@ -1176,6 +1236,22 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
             content="test",
         )
 
+        cf1_d1 = CustomFieldInstance.objects.create(
+            document=d1,
+            field=cf1,
+            value_text="foobard1",
+        )
+        cf2_d1 = CustomFieldInstance.objects.create(
+            document=d1,
+            field=cf2,
+            value_int=999,
+        )
+        cf1_d4 = CustomFieldInstance.objects.create(
+            document=d4,
+            field=cf1,
+            value_text="foobard4",
+        )
+
         with AsyncWriter(index.open_index()) as writer:
             for doc in Document.objects.all():
                 index.update_document(writer, doc)
@@ -1304,6 +1380,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
                 + datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d"),
             ),
         )
+
         self.assertIn(
             d5.id,
             search_query(
@@ -1322,6 +1399,27 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
             [d4.id, d5.id],
         )
 
+        self.assertIn(
+            d1.id,
+            search_query(
+                "&custom_fields__icontains=" + cf1_d1.value,
+            ),
+        )
+
+        self.assertIn(
+            d1.id,
+            search_query(
+                "&custom_fields__icontains=" + str(cf2_d1.value),
+            ),
+        )
+
+        self.assertIn(
+            d4.id,
+            search_query(
+                "&custom_fields__icontains=" + cf1_d4.value,
+            ),
+        )
+
     def test_search_filtering_respect_owner(self):
         """
         GIVEN:
@@ -2421,7 +2519,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
             f"/api/documents/{doc.pk}/notes/",
             format="json",
         )
-        self.assertEqual(resp.content, b"Insufficient permissions to view")
+        self.assertEqual(resp.content, b"Insufficient permissions to view notes")
         self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
 
         assign_perm("view_document", user1, doc)
@@ -2430,7 +2528,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
             f"/api/documents/{doc.pk}/notes/",
             data={"note": "this is a posted note"},
         )
-        self.assertEqual(resp.content, b"Insufficient permissions to create")
+        self.assertEqual(resp.content, b"Insufficient permissions to create notes")
         self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
 
         note = Note.objects.create(
@@ -2444,7 +2542,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
             format="json",
         )
 
-        self.assertEqual(response.content, b"Insufficient permissions to delete")
+        self.assertEqual(response.content, b"Insufficient permissions to delete notes")
         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
     def test_delete_note(self):
@@ -2694,7 +2792,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
             f"/api/documents/{doc.pk}/share_links/",
             format="json",
         )
-        self.assertEqual(resp.content, b"Insufficient permissions")
+        self.assertEqual(resp.content, b"Insufficient permissions to add share link")
         self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
 
         assign_perm("change_document", user1, doc)
diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py
new file mode 100644 (file)
index 0000000..725bd92
--- /dev/null
@@ -0,0 +1,384 @@
+from datetime import date
+
+from django.contrib.auth.models import User
+from rest_framework import status
+from rest_framework.test import APITestCase
+
+from documents.models import CustomField
+from documents.models import CustomFieldInstance
+from documents.models import Document
+from documents.tests.utils import DirectoriesMixin
+
+
+class TestCustomField(DirectoriesMixin, APITestCase):
+    ENDPOINT = "/api/custom_fields/"
+
+    def setUp(self):
+        self.user = User.objects.create_superuser(username="temp_admin")
+        self.client.force_authenticate(user=self.user)
+        return super().setUp()
+
+    def test_create_custom_field(self):
+        """
+        GIVEN:
+            - Each of the supported data types is created
+        WHEN:
+            - API request to create custom metadata is made
+        THEN:
+            - the field is created
+            - the field returns the correct fields
+        """
+        for field_type, name in [
+            ("string", "Custom Text"),
+            ("url", "Wikipedia Link"),
+            ("date", "Invoiced Date"),
+            ("integer", "Invoice #"),
+            ("boolean", "Is Active"),
+            ("float", "Total Paid"),
+        ]:
+            resp = self.client.post(
+                self.ENDPOINT,
+                data={
+                    "data_type": field_type,
+                    "name": name,
+                },
+            )
+            self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
+
+            data = resp.json()
+
+            self.assertEqual(len(data), 3)
+            self.assertEqual(data["name"], name)
+            self.assertEqual(data["data_type"], field_type)
+
+    def test_create_custom_field_instance(self):
+        """
+        GIVEN:
+            - Field of each data type is created
+        WHEN:
+            - API request to create custom metadata instance with each data type
+        THEN:
+            - the field instance is created
+            - the field returns the correct fields and values
+            - the field is attached to the given document
+        """
+        doc = Document.objects.create(
+            title="WOW",
+            content="the content",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        custom_field_string = CustomField.objects.create(
+            name="Test Custom Field String",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        custom_field_date = CustomField.objects.create(
+            name="Test Custom Field Date",
+            data_type=CustomField.FieldDataType.DATE,
+        )
+        custom_field_int = CustomField.objects.create(
+            name="Test Custom Field Int",
+            data_type=CustomField.FieldDataType.INT,
+        )
+        custom_field_boolean = CustomField.objects.create(
+            name="Test Custom Field Boolean",
+            data_type=CustomField.FieldDataType.BOOL,
+        )
+        custom_field_url = CustomField.objects.create(
+            name="Test Custom Field Url",
+            data_type=CustomField.FieldDataType.URL,
+        )
+        custom_field_float = CustomField.objects.create(
+            name="Test Custom Field Float",
+            data_type=CustomField.FieldDataType.FLOAT,
+        )
+        custom_field_monetary = CustomField.objects.create(
+            name="Test Custom Field Monetary",
+            data_type=CustomField.FieldDataType.MONETARY,
+        )
+
+        date_value = date.today()
+
+        resp = self.client.patch(
+            f"/api/documents/{doc.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_string.id,
+                        "value": "test value",
+                    },
+                    {
+                        "field": custom_field_date.id,
+                        "value": date_value.isoformat(),
+                    },
+                    {
+                        "field": custom_field_int.id,
+                        "value": 3,
+                    },
+                    {
+                        "field": custom_field_boolean.id,
+                        "value": True,
+                    },
+                    {
+                        "field": custom_field_url.id,
+                        "value": "https://example.com",
+                    },
+                    {
+                        "field": custom_field_float.id,
+                        "value": 12.3456,
+                    },
+                    {
+                        "field": custom_field_monetary.id,
+                        "value": 11.10,
+                    },
+                ],
+            },
+            format="json",
+        )
+
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+
+        resp_data = resp.json()["custom_fields"]
+
+        self.assertCountEqual(
+            resp_data,
+            [
+                {"field": custom_field_string.id, "value": "test value"},
+                {"field": custom_field_date.id, "value": date_value.isoformat()},
+                {"field": custom_field_int.id, "value": 3},
+                {"field": custom_field_boolean.id, "value": True},
+                {"field": custom_field_url.id, "value": "https://example.com"},
+                {"field": custom_field_float.id, "value": 12.3456},
+                {"field": custom_field_monetary.id, "value": 11.10},
+            ],
+        )
+
+        doc.refresh_from_db()
+        self.assertEqual(len(doc.custom_fields.all()), 7)
+
+    def test_change_custom_field_instance_value(self):
+        """
+        GIVEN:
+            - Custom field instance is created and attached to document
+        WHEN:
+            - API request to create change the value of the custom field
+        THEN:
+            - the field instance is updated
+            - the field returns the correct fields and values
+        """
+        doc = Document.objects.create(
+            title="WOW",
+            content="the content",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        custom_field_string = CustomField.objects.create(
+            name="Test Custom Field String",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+
+        self.assertEqual(CustomFieldInstance.objects.count(), 0)
+
+        # Create
+        resp = self.client.patch(
+            f"/api/documents/{doc.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_string.id,
+                        "value": "test value",
+                    },
+                ],
+            },
+            format="json",
+        )
+
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        self.assertEqual(CustomFieldInstance.objects.count(), 1)
+        self.assertEqual(doc.custom_fields.first().value, "test value")
+
+        # Update
+        resp = self.client.patch(
+            f"/api/documents/{doc.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_string.id,
+                        "value": "a new test value",
+                    },
+                ],
+            },
+            format="json",
+        )
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        self.assertEqual(CustomFieldInstance.objects.count(), 1)
+        self.assertEqual(doc.custom_fields.first().value, "a new test value")
+
+    def test_delete_custom_field_instance(self):
+        """
+        GIVEN:
+            - Multiple custom field instances are created and attached to document
+        WHEN:
+            - API request to remove a field
+        THEN:
+            - the field instance is removed
+            - the other field remains unchanged
+            - the field returns the correct fields and values
+        """
+        doc = Document.objects.create(
+            title="WOW",
+            content="the content",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        custom_field_string = CustomField.objects.create(
+            name="Test Custom Field String",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        custom_field_date = CustomField.objects.create(
+            name="Test Custom Field Date",
+            data_type=CustomField.FieldDataType.DATE,
+        )
+
+        date_value = date.today()
+
+        resp = self.client.patch(
+            f"/api/documents/{doc.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_string.id,
+                        "value": "a new test value",
+                    },
+                    {
+                        "field": custom_field_date.id,
+                        "value": date_value.isoformat(),
+                    },
+                ],
+            },
+            format="json",
+        )
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        self.assertEqual(CustomFieldInstance.objects.count(), 2)
+        self.assertEqual(len(doc.custom_fields.all()), 2)
+
+        resp = self.client.patch(
+            f"/api/documents/{doc.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_date.id,
+                        "value": date_value.isoformat(),
+                    },
+                ],
+            },
+            format="json",
+        )
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        self.assertEqual(CustomFieldInstance.objects.count(), 1)
+        self.assertEqual(Document.objects.count(), 1)
+        self.assertEqual(len(doc.custom_fields.all()), 1)
+        self.assertEqual(doc.custom_fields.first().value, date_value)
+
+    def test_custom_field_validation(self):
+        """
+        GIVEN:
+            - Document exists with no fields
+        WHEN:
+            - API request to remove a field
+            - API request is not valid
+        THEN:
+            - HTTP 400 is returned
+            - No field created
+            - No field attached to the document
+        """
+        doc = Document.objects.create(
+            title="WOW",
+            content="the content",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        custom_field_string = CustomField.objects.create(
+            name="Test Custom Field String",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+
+        resp = self.client.patch(
+            f"/api/documents/{doc.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_string.id,
+                        # Whoops, spelling
+                        "valeu": "a new test value",
+                    },
+                ],
+            },
+            format="json",
+        )
+        from pprint import pprint
+
+        pprint(resp.json())
+        self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(CustomFieldInstance.objects.count(), 0)
+        self.assertEqual(len(doc.custom_fields.all()), 0)
+
+    def test_custom_field_value_validation(self):
+        """
+        GIVEN:
+            - Document & custom field exist
+        WHEN:
+            - API request to set a field value
+        THEN:
+            - HTTP 400 is returned
+            - No field instance is created or attached to the document
+        """
+        doc = Document.objects.create(
+            title="WOW",
+            content="the content",
+            checksum="123",
+            mime_type="application/pdf",
+        )
+        custom_field_url = CustomField.objects.create(
+            name="Test Custom Field URL",
+            data_type=CustomField.FieldDataType.URL,
+        )
+        custom_field_int = CustomField.objects.create(
+            name="Test Custom Field INT",
+            data_type=CustomField.FieldDataType.INT,
+        )
+
+        resp = self.client.patch(
+            f"/api/documents/{doc.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_url.id,
+                        "value": "not a url",
+                    },
+                ],
+            },
+            format="json",
+        )
+
+        self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(CustomFieldInstance.objects.count(), 0)
+        self.assertEqual(len(doc.custom_fields.all()), 0)
+
+        self.assertRaises(
+            Exception,
+            self.client.patch,
+            f"/api/documents/{doc.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_int.id,
+                        "value": "not an int",
+                    },
+                ],
+            },
+            format="json",
+        )
+
+        self.assertEqual(CustomFieldInstance.objects.count(), 0)
+        self.assertEqual(len(doc.custom_fields.all()), 0)
index b86fb2ef02ded272bb02d8f07da4a7dbd23d5dd6..10a27299197d8a9f7ce1060e4420714e0f346975 100644 (file)
@@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
 
         manifest = self._do_export(use_filename_format=use_filename_format)
 
-        self.assertEqual(len(manifest), 159)
+        self.assertEqual(len(manifest), 169)
 
         # dont include consumer or AnonymousUser users
         self.assertEqual(
@@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
             self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
             self.assertEqual(GroupObjectPermission.objects.count(), 1)
             self.assertEqual(UserObjectPermission.objects.count(), 1)
-            self.assertEqual(Permission.objects.count(), 116)
+            self.assertEqual(Permission.objects.count(), 124)
             messages = check_sanity()
             # everything is alright after the test
             self.assertEqual(len(messages), 0)
@@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
             os.path.join(self.dirs.media_dir, "documents"),
         )
 
-        self.assertEqual(ContentType.objects.count(), 29)
-        self.assertEqual(Permission.objects.count(), 116)
+        self.assertEqual(ContentType.objects.count(), 31)
+        self.assertEqual(Permission.objects.count(), 124)
 
         manifest = self._do_export()
 
         with paperless_environment():
             self.assertEqual(
                 len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
-                116,
+                124,
             )
             # add 1 more to db to show objects are not re-created by import
             Permission.objects.create(
@@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
                 codename="test_perm",
                 content_type_id=1,
             )
-            self.assertEqual(Permission.objects.count(), 117)
+            self.assertEqual(Permission.objects.count(), 125)
 
             # will cause an import error
             self.user.delete()
@@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
             with self.assertRaises(IntegrityError):
                 call_command("document_importer", "--no-progress-bar", self.target)
 
-            self.assertEqual(ContentType.objects.count(), 29)
-            self.assertEqual(Permission.objects.count(), 117)
+            self.assertEqual(ContentType.objects.count(), 31)
+            self.assertEqual(Permission.objects.count(), 125)
index 49682e3ea0187d18d8ff13db1b03129062edf943..6331761eef8ec2cc1dcc215a4e41cb126e0353d2 100644 (file)
@@ -78,6 +78,7 @@ from documents.matching import match_storage_paths
 from documents.matching import match_tags
 from documents.models import ConsumptionTemplate
 from documents.models import Correspondent
+from documents.models import CustomField
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import Note
@@ -99,6 +100,7 @@ from documents.serialisers import BulkEditObjectPermissionsSerializer
 from documents.serialisers import BulkEditSerializer
 from documents.serialisers import ConsumptionTemplateSerializer
 from documents.serialisers import CorrespondentSerializer
+from documents.serialisers import CustomFieldSerializer
 from documents.serialisers import DocumentListSerializer
 from documents.serialisers import DocumentSerializer
 from documents.serialisers import DocumentTypeSerializer
@@ -497,7 +499,7 @@ class DocumentViewSet(
                 "view_document",
                 doc,
             ):
-                return HttpResponseForbidden("Insufficient permissions to view")
+                return HttpResponseForbidden("Insufficient permissions to view notes")
         except Document.DoesNotExist:
             raise Http404
 
@@ -507,7 +509,7 @@ class DocumentViewSet(
             except Exception as e:
                 logger.warning(f"An error occurred retrieving notes: {e!s}")
                 return Response(
-                    {"error": "Error retreiving notes, check logs for more detail."},
+                    {"error": "Error retrieving notes, check logs for more detail."},
                 )
         elif request.method == "POST":
             try:
@@ -516,7 +518,9 @@ class DocumentViewSet(
                     "change_document",
                     doc,
                 ):
-                    return HttpResponseForbidden("Insufficient permissions to create")
+                    return HttpResponseForbidden(
+                        "Insufficient permissions to create notes",
+                    )
 
                 c = Note.objects.create(
                     document=doc,
@@ -558,7 +562,7 @@ class DocumentViewSet(
                 "change_document",
                 doc,
             ):
-                return HttpResponseForbidden("Insufficient permissions to delete")
+                return HttpResponseForbidden("Insufficient permissions to delete notes")
 
             note = Note.objects.get(id=int(request.GET.get("id")))
             if settings.AUDIT_LOG_ENABLED:
@@ -599,7 +603,9 @@ class DocumentViewSet(
                 "change_document",
                 doc,
             ):
-                return HttpResponseForbidden("Insufficient permissions")
+                return HttpResponseForbidden(
+                    "Insufficient permissions to add share link",
+                )
         except Document.DoesNotExist:
             raise Http404
 
@@ -1071,47 +1077,6 @@ class BulkDownloadView(GenericAPIView):
             return response
 
 
-class RemoteVersionView(GenericAPIView):
-    def get(self, request, format=None):
-        remote_version = "0.0.0"
-        is_greater_than_current = False
-        current_version = packaging_version.parse(version.__full_version_str__)
-        try:
-            req = urllib.request.Request(
-                "https://api.github.com/repos/paperless-ngx/"
-                "paperless-ngx/releases/latest",
-            )
-            # Ensure a JSON response
-            req.add_header("Accept", "application/json")
-
-            with urllib.request.urlopen(req) as response:
-                remote = response.read().decode("utf-8")
-            try:
-                remote_json = json.loads(remote)
-                remote_version = remote_json["tag_name"]
-                # Basically PEP 616 but that only went in 3.9
-                if remote_version.startswith("ngx-"):
-                    remote_version = remote_version[len("ngx-") :]
-            except ValueError:
-                logger.debug("An error occurred parsing remote version json")
-        except urllib.error.URLError:
-            logger.debug("An error occurred checking for available updates")
-
-        is_greater_than_current = (
-            packaging_version.parse(
-                remote_version,
-            )
-            > current_version
-        )
-
-        return Response(
-            {
-                "version": remote_version,
-                "update_available": is_greater_than_current,
-            },
-        )
-
-
 class StoragePathViewSet(ModelViewSet, PassUserMixin):
     model = StoragePath
 
@@ -1186,6 +1151,47 @@ class UiSettingsView(GenericAPIView):
         )
 
 
+class RemoteVersionView(GenericAPIView):
+    def get(self, request, format=None):
+        remote_version = "0.0.0"
+        is_greater_than_current = False
+        current_version = packaging_version.parse(version.__full_version_str__)
+        try:
+            req = urllib.request.Request(
+                "https://api.github.com/repos/paperlessngx/"
+                "paperlessngx/releases/latest",
+            )
+            # Ensure a JSON response
+            req.add_header("Accept", "application/json")
+
+            with urllib.request.urlopen(req) as response:
+                remote = response.read().decode("utf8")
+            try:
+                remote_json = json.loads(remote)
+                remote_version = remote_json["tag_name"]
+                # Basically PEP 616 but that only went in 3.9
+                if remote_version.startswith("ngx-"):
+                    remote_version = remote_version[len("ngx-") :]
+            except ValueError:
+                logger.debug("An error occurred parsing remote version json")
+        except urllib.error.URLError:
+            logger.debug("An error occurred checking for available updates")
+
+        is_greater_than_current = (
+            packaging_version.parse(
+                remote_version,
+            )
+            > current_version
+        )
+
+        return Response(
+            {
+                "version": remote_version,
+                "update_available": is_greater_than_current,
+            },
+        )
+
+
 class TasksViewSet(ReadOnlyModelViewSet):
     permission_classes = (IsAuthenticated,)
     serializer_class = TasksViewSerializer
@@ -1341,4 +1347,15 @@ class ConsumptionTemplateViewSet(ModelViewSet):
 
     model = ConsumptionTemplate
 
-    queryset = ConsumptionTemplate.objects.all().order_by("order")
+    queryset = ConsumptionTemplate.objects.all().order_by("name")
+
+
+class CustomFieldViewSet(ModelViewSet):
+    permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
+
+    serializer_class = CustomFieldSerializer
+    pagination_class = StandardPagination
+
+    model = CustomField
+
+    queryset = CustomField.objects.all().order_by("-created")
index ab0f1ab00fd1fb4dcb204f948df5f80241337543..df680665c0fec6af20f27c4343e3294c25718311 100644 (file)
@@ -2,7 +2,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: paperless-ngx\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-10-31 15:23-0700\n"
+"POT-Creation-Date: 2023-11-04 20:12-0700\n"
 "PO-Revision-Date: 2022-02-17 04:17\n"
 "Last-Translator: \n"
 "Language-Team: English\n"
@@ -163,7 +163,7 @@ msgid "The checksum of the archived document."
 msgstr ""
 
 #: documents/models.py:205 documents/models.py:385 documents/models.py:654
-#: documents/models.py:692
+#: documents/models.py:692 documents/models.py:895 documents/models.py:932
 msgid "created"
 msgstr ""
 
@@ -648,21 +648,69 @@ msgstr ""
 msgid "consumption templates"
 msgstr ""
 
-#: documents/serialisers.py:98
+#: documents/models.py:886
+msgid "String"
+msgstr ""
+
+#: documents/models.py:887
+msgid "URL"
+msgstr ""
+
+#: documents/models.py:888
+msgid "Date"
+msgstr ""
+
+#: documents/models.py:889
+msgid "Boolean"
+msgstr ""
+
+#: documents/models.py:890
+msgid "Integer"
+msgstr ""
+
+#: documents/models.py:891
+msgid "Float"
+msgstr ""
+
+#: documents/models.py:892
+msgid "Monetary"
+msgstr ""
+
+#: documents/models.py:904
+msgid "data type"
+msgstr ""
+
+#: documents/models.py:912
+msgid "custom field"
+msgstr ""
+
+#: documents/models.py:913
+msgid "custom fields"
+msgstr ""
+
+#: documents/models.py:973
+msgid "custom field instance"
+msgstr ""
+
+#: documents/models.py:974
+msgid "custom field instances"
+msgstr ""
+
+#: documents/serialisers.py:102
 #, python-format
 msgid "Invalid regular expression: %(error)s"
 msgstr ""
 
-#: documents/serialisers.py:373
+#: documents/serialisers.py:377
 msgid "Invalid color."
 msgstr ""
 
-#: documents/serialisers.py:749
+#: documents/serialisers.py:841
 #, python-format
 msgid "File type %(type)s not supported"
 msgstr ""
 
-#: documents/serialisers.py:846
+#: documents/serialisers.py:938
 msgid "Invalid variable detected."
 msgstr ""
 
@@ -929,7 +977,7 @@ msgstr ""
 msgid "Chinese Simplified"
 msgstr ""
 
-#: paperless/urls.py:184
+#: paperless/urls.py:186
 msgid "Paperless-ngx administration"
 msgstr ""
 
index 415efc4de7bac3d1b52067e541bd948a0be2c7f5..2f0c562672cefbb2777f9b5baec36e3378d4dd8c 100644 (file)
@@ -16,6 +16,7 @@ from documents.views import BulkEditObjectPermissionsView
 from documents.views import BulkEditView
 from documents.views import ConsumptionTemplateViewSet
 from documents.views import CorrespondentViewSet
+from documents.views import CustomFieldViewSet
 from documents.views import DocumentTypeViewSet
 from documents.views import IndexView
 from documents.views import LogViewSet
@@ -55,6 +56,7 @@ api_router.register(r"mail_accounts", MailAccountViewSet)
 api_router.register(r"mail_rules", MailRuleViewSet)
 api_router.register(r"share_links", ShareLinkViewSet)
 api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
+api_router.register(r"custom_fields", CustomFieldViewSet)
 
 
 urlpatterns = [