]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: bulk edit custom field values (#8428)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 9 Dec 2024 17:35:49 +0000 (09:35 -0800)
committerGitHub <noreply@github.com>
Mon, 9 Dec 2024 17:35:49 +0000 (17:35 +0000)
18 files changed:
docs/api.md
src-ui/messages.xlf
src-ui/src/app/app.module.ts
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
src-ui/src/app/components/dashboard/dashboard.component.html
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html [new file with mode: 0644]
src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.scss [new file with mode: 0644]
src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts [new file with mode: 0644]
src/documents/bulk_edit.py
src/documents/serialisers.py
src/documents/tests/test_api_bulk_edit.py
src/documents/tests/test_bulk_edit.py

index c5f20edd179e782c2394f8dfece7ba7e75a8c905..8b755b11d87c44e853d4520a2066cfbfff8450a3 100644 (file)
@@ -473,6 +473,11 @@ The following methods are supported:
     -   Requires `parameters`:
         -   `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
     -   The delete_pages operation only accepts a single document.
+-   `modify_custom_fields`
+    -   Requires `parameters`:
+        -   `"add_custom_fields": { CUSTOM_FIELD_ID: VALUE }`: JSON object consisting of custom field id:value pairs to add to the document, can also be a list of custom field IDs
+            to add with empty values.
+        -   `"remove_custom_fields": [CUSTOM_FIELD_ID]`: custom field ids to remove from the document.
 
 ### Objects
 
index be1b9d7f2862882ae52908ad800d604c58ef7ba7..e19a67b286356fb0b07337827becc2ced6c42a12 100644 (file)
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
           <context context-type="linenumber">341</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
+          <context context-type="linenumber">79</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
           <context context-type="linenumber">21</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">101</context>
+          <context context-type="linenumber">104</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
           <context context-type="linenumber">4</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
+          <context context-type="linenumber">77</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
           <context context-type="linenumber">20</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">107</context>
+          <context context-type="linenumber">110</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">161</context>
+          <context context-type="linenumber">164</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">758</context>
+          <context context-type="linenumber">759</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="linenumber">791</context>
+          <context context-type="linenumber">792</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="linenumber">810</context>
+          <context context-type="linenumber">811</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.ts</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">143</context>
+          <context context-type="linenumber">147</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6329940072345709724" datatype="html">
         </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">133</context>
+          <context context-type="linenumber">136</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">149</context>
+          <context context-type="linenumber">153</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.noResults" datatype="html">
         </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="linenumber">401</context>
+          <context context-type="linenumber">402</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="linenumber">441</context>
+          <context context-type="linenumber">442</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="linenumber">479</context>
+          <context context-type="linenumber">480</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="linenumber">517</context>
+          <context context-type="linenumber">518</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="linenumber">579</context>
+          <context context-type="linenumber">580</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="linenumber">712</context>
+          <context context-type="linenumber">713</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1407560924967345762" datatype="html">
         <source>Click again to exclude items.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
-          <context context-type="linenumber">71</context>
+          <context context-type="linenumber">77</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7593728289020204896" datatype="html">
         <source>Open <x id="PH" equiv-text="this.title"/> filter</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
-          <context context-type="linenumber">488</context>
+          <context context-type="linenumber">494</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7005745151564974365" datatype="html">
         </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="linenumber">346</context>
+          <context context-type="linenumber">347</context>
         </context-group>
         <note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
       </trans-unit>
         </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">111</context>
+          <context context-type="linenumber">114</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1418444397960583910" datatype="html">
         </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">114</context>
+          <context context-type="linenumber">117</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4399672576012609374" datatype="html">
         </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="linenumber">714</context>
+          <context context-type="linenumber">715</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2048798344356757326" datatype="html">
         </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="linenumber">716</context>
+          <context context-type="linenumber">717</context>
         </context-group>
       </trans-unit>
       <trans-unit id="619486176823357521" datatype="html">
         </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="linenumber">754</context>
+          <context context-type="linenumber">755</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2951161989614003846" datatype="html">
         </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="linenumber">788</context>
+          <context context-type="linenumber">789</context>
         </context-group>
       </trans-unit>
       <trans-unit id="857641176955257111" datatype="html">
           <context context-type="linenumber">83</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="5139192806922838657" datatype="html">
+        <source>Set values</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
+          <context context-type="linenumber">93</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="3206542606001340679" datatype="html">
         <source>Merge</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">117</context>
+          <context context-type="linenumber">120</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1015374532025907183" datatype="html">
         <source>Include:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">139</context>
+          <context context-type="linenumber">142</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1537670659786159738" datatype="html">
         <source>Archived files</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">143</context>
+          <context context-type="linenumber">146</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2520291319362448498" datatype="html">
         <source>Original files</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">147</context>
+          <context context-type="linenumber">150</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8009862506882713059" datatype="html">
         <source>Use formatted filename</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
-          <context context-type="linenumber">152</context>
+          <context context-type="linenumber">155</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1215215387232313677" datatype="html">
         <source>Error executing bulk operation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">250</context>
+          <context context-type="linenumber">251</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="linenumber">859</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7894972847287473517" datatype="html">
         <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">338</context>
+          <context context-type="linenumber">339</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="linenumber">344</context>
+          <context context-type="linenumber">345</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8639884465898458690" datatype="html">
         <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">340</context>
+          <context context-type="linenumber">341</context>
         </context-group>
         <note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
       </trans-unit>
         <source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">348,350</context>
+          <context context-type="linenumber">349,351</context>
         </context-group>
         <note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note>
       </trans-unit>
         <source>Confirm tags assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">365</context>
+          <context context-type="linenumber">366</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6619516195038467207" datatype="html">
         <source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">371</context>
+          <context context-type="linenumber">372</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1894412783609570695" datatype="html">
         )"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">376,378</context>
+          <context context-type="linenumber">377,379</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7181166515756808573" datatype="html">
         <source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">384</context>
+          <context context-type="linenumber">385</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3819792277998068944" datatype="html">
         )"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">389,391</context>
+          <context context-type="linenumber">390,392</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2739066218579571288" datatype="html">
         )"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">393,397</context>
+          <context context-type="linenumber">394,398</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2996713129519325161" datatype="html">
         <source>Confirm correspondent assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">434</context>
+          <context context-type="linenumber">435</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6900893559485781849" datatype="html">
         <source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">436</context>
+          <context context-type="linenumber">437</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1257522660364398440" datatype="html">
         <source>This operation will remove the correspondent from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">438</context>
+          <context context-type="linenumber">439</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5393409374423140648" datatype="html">
         <source>Confirm document type assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">472</context>
+          <context context-type="linenumber">473</context>
         </context-group>
       </trans-unit>
       <trans-unit id="332180123895325027" datatype="html">
         <source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">474</context>
+          <context context-type="linenumber">475</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2236642492594872779" datatype="html">
         <source>This operation will remove the document type from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">476</context>
+          <context context-type="linenumber">477</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6386555513013840736" datatype="html">
         <source>Confirm storage path assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">510</context>
+          <context context-type="linenumber">511</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8750527458618415924" datatype="html">
         <source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">512</context>
+          <context context-type="linenumber">513</context>
         </context-group>
       </trans-unit>
       <trans-unit id="60728365335056946" datatype="html">
         <source>This operation will remove the storage path from <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">514</context>
+          <context context-type="linenumber">515</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4187352575310415704" datatype="html">
         <source>Confirm custom field assignment</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">543</context>
+          <context context-type="linenumber">544</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7966494636326273856" datatype="html">
         <source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">549</context>
+          <context context-type="linenumber">550</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5789455969634598553" datatype="html">
         )"/> to <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">554,556</context>
+          <context context-type="linenumber">555,557</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5648572354333199245" datatype="html">
         <source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">562</context>
+          <context context-type="linenumber">563</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6666899594015948817" datatype="html">
         )"/> from <x id="PH_1" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">567,569</context>
+          <context context-type="linenumber">568,570</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8050047262594964176" datatype="html">
         )"/> on <x id="PH_2" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">571,575</context>
+          <context context-type="linenumber">572,576</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8615059324209654051" datatype="html">
         <source>Move <x id="PH" equiv-text="this.list.selected.size"/> selected document(s) to the trash?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">713</context>
+          <context context-type="linenumber">714</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8585195717323764335" datatype="html">
         <source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.list.selected.size"/> selected document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">755</context>
+          <context context-type="linenumber">756</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7366623494074776040" datatype="html">
         <source>The archive files will be re-generated with the current settings.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">756</context>
+          <context context-type="linenumber">757</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6390006284731990222" datatype="html">
         <source>This operation will permanently rotate the original version of <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">789</context>
+          <context context-type="linenumber">790</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7910756456450124185" datatype="html">
         <source>Merge confirm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">808</context>
+          <context context-type="linenumber">809</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7643543647233874431" datatype="html">
         <source>This operation will merge <x id="PH" equiv-text="this.list.selected.size"/> selected documents into a new document.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">809</context>
+          <context context-type="linenumber">810</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7869008840945899895" datatype="html">
         <source>Merged document will be queued for consumption.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
-          <context context-type="linenumber">825</context>
+          <context context-type="linenumber">826</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8362457261872200374" datatype="html">
+        <source>Bulk operation executed successfully</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
+          <context context-type="linenumber">849</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6307402210351946694" datatype="html">
+        <source>{VAR_PLURAL, plural, =1 {Set custom fields for 1 document} other {Set custom fields for <x id="INTERPOLATION"/> documents}}</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
+          <context context-type="linenumber">3,7</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8100177157764133131" datatype="html">
+        <source>Select custom fields</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8244572554104037643" datatype="html">
+        <source>{VAR_PLURAL, plural, =1 {This operation will also remove 1 custom field from the selected documents.} other {This operation will also
+          remove <x id="INTERPOLATION"/> custom fields from the selected documents.}}</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context>
+          <context context-type="linenumber">69,74</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2784168796433474565" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">76,77</context>
+          <context context-type="linenumber">80,81</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">91,92</context>
+          <context context-type="linenumber">95,96</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2030261243264601523" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">77,78</context>
+          <context context-type="linenumber">81,82</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">92,93</context>
+          <context context-type="linenumber">96,97</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4235671847487610290" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">78,79</context>
+          <context context-type="linenumber">82,83</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">93,94</context>
+          <context context-type="linenumber">97,98</context>
         </context-group>
       </trans-unit>
       <trans-unit id="197162226430950645" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">106</context>
+          <context context-type="linenumber">110</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5739581984228459958" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">125</context>
+          <context context-type="linenumber">129</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
         <source>Toggle document type filter</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">59</context>
+          <context context-type="linenumber">63</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8950368321707344185" datatype="html">
         <source>Toggle storage path filter</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
-          <context context-type="linenumber">66</context>
+          <context context-type="linenumber">70</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5145213156408463657" datatype="html">
index 68124d5411518da1f8d662e36e38c6add231569e..79880c75ba55eee704663d23d8327d3b74a66c5e 100644 (file)
@@ -133,6 +133,7 @@ import { DeletePagesConfirmDialogComponent } from './components/common/confirm-d
 import { TrashComponent } from './components/admin/trash/trash.component'
 import { EntriesComponent } from './components/common/input/entries/entries.component'
 import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
+import { CustomFieldsBulkEditDialogComponent } from './components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
 import {
   airplane,
   archive,
@@ -528,6 +529,7 @@ function initializeApp(settings: SettingsService) {
     TrashComponent,
     EntriesComponent,
     SavedViewsComponent,
+    CustomFieldsBulkEditDialogComponent,
   ],
   bootstrap: [AppComponent],
   imports: [
index 28ce03ad65bb7c57a254b2fd8928f7f8f2369295..f6888488dac99a5846ebe155435d6f2b9beb76fc 100644 (file)
           </button>
         }
         @if ((selectionModel.items | filter: filterText:'name').length > 0) {
-          <button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
+          <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
             <small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
             <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
           </button>
         }
       }
+      @if (extraButtonTitle) {
+        <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
+          <small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
+          <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
+        </button>
+      }
       @if (!editing && manyToOne) {
         <div class="list-group-item list-group-item-note pt-1 pb-2">
           <small i18n>Click again to exclude items.</small>
index 78af75607f777507269a761f535e1ad5a47f7318..2a4cce8d645aadd32a33fed9947064e738f54cb3 100644 (file)
@@ -616,4 +616,24 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
     document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }))
     expect(openSpy).toHaveBeenCalled()
   })
+
+  it('should support an extra button and not apply changes when clicked', () => {
+    component.items = items
+    component.icon = 'tag-fill'
+    component.extraButtonTitle = 'Extra'
+    component.selectionModel = selectionModel
+    component.applyOnClose = true
+    let extraButtonClicked,
+      applied = false
+    component.extraButton.subscribe(() => (extraButtonClicked = true))
+    component.apply.subscribe(() => (applied = true))
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    expect(fixture.debugElement.nativeElement.textContent).toContain('Extra')
+    component.extraButtonClicked()
+    expect(extraButtonClicked).toBeTruthy()
+    expect(applied).toBeFalsy()
+  })
 })
index 925e4f3191ac61909a1e019d442759cdad21f593..df225c7d98920ef6deef1c106cbbd3fe2e3e56c6 100644 (file)
@@ -437,6 +437,19 @@ export class FilterableDropdownComponent
   @Input()
   createRef: (name) => void
 
+  @Input()
+  set documentCounts(counts: SelectionDataItem[]) {
+    if (counts) {
+      this.selectionModel.documentCounts = counts
+    }
+  }
+
+  @Input()
+  shortcutKey: string
+
+  @Input()
+  extraButtonTitle: string
+
   creating: boolean = false
 
   @Output()
@@ -445,6 +458,9 @@ export class FilterableDropdownComponent
   @Output()
   opened = new EventEmitter()
 
+  @Output()
+  extraButton = new EventEmitter<ChangedItems>()
+
   get modifierToggleEnabled(): boolean {
     return this.manyToOne
       ? this.selectionModel.selectionSize() > 1 &&
@@ -452,16 +468,6 @@ export class FilterableDropdownComponent
       : !this.selectionModel.isNoneSelected()
   }
 
-  @Input()
-  set documentCounts(counts: SelectionDataItem[]) {
-    if (counts) {
-      this.selectionModel.documentCounts = counts
-    }
-  }
-
-  @Input()
-  shortcutKey: string
-
   get name(): string {
     return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
   }
@@ -641,4 +647,13 @@ export class FilterableDropdownComponent
       this.selectionModel.get(item.id) !== ToggleableItemState.Selected
     )
   }
+
+  extraButtonClicked() {
+    // don't apply changes when clicking the extra button
+    const applyOnClose = this.applyOnClose
+    this.applyOnClose = false
+    this.dropdown.close()
+    this.extraButton.emit(this.selectionModel.diff())
+    this.applyOnClose = applyOnClose
+  }
 }
index 27f19475c56e2c4663c11a3cf1d91726706d7635..5e0db4b2f6e678bce5abe2df561a5f78344d8ee8 100644 (file)
@@ -18,7 +18,7 @@
                   <div class="ms-n2 me-1">
                     <i-bs name="grip-vertical"></i-bs>
                   </div>
-                  <h6 class="card-title mb-0" i18n></h6>
+                  <h6 class="card-title mb-0"></h6>
                 </div>
               </div>
             </div>
index 2b9a20f7e3e4fada26f413ae67ff814b37e61ec5..242e8abab8062ee18cec32f03143913f8882fc8b 100644 (file)
@@ -90,6 +90,9 @@
               (opened)="openCustomFieldsDropdown()"
               [(selectionModel)]="customFieldsSelectionModel"
               [documentCounts]="customFieldDocumentCounts"
+              extraButtonTitle="Set values"
+              i18n-extraButtonTitle
+              (extraButton)="setCustomFieldValues($event)"
               (apply)="setCustomFields($event)">
             </pngx-filterable-dropdown>
           }
index 0dd056cfd9ea4e8d5dc2f4d21621b3770f37afdd..c0db41512ac4ba0a527d1712376a85189d7d07ff 100644 (file)
@@ -1416,4 +1416,55 @@ describe('BulkEditorComponent', () => {
     )
     expect(component.customFields).toEqual(customFields.results)
   })
+
+  it('should open the bulk edit custom field values dialog with correct parameters', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[0]))
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(documentListViewService, 'documents', 'get')
+      .mockReturnValue([{ id: 3 }, { id: 4 }])
+    jest.spyOn(documentService, 'getFew').mockReturnValue(
+      of({
+        all: [3, 4],
+        count: 2,
+        results: [{ id: 3 }, { id: 4 }],
+      })
+    )
+    jest
+      .spyOn(documentListViewService, 'selected', 'get')
+      .mockReturnValue(new Set([3, 4]))
+    fixture.detectChanges()
+    const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
+    const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
+
+    component.customFields = [
+      { id: 1, name: 'Custom Field 1', data_type: CustomFieldDataType.String },
+      { id: 2, name: 'Custom Field 2', data_type: CustomFieldDataType.String },
+    ]
+
+    component.setCustomFieldValues({
+      itemsToAdd: [{ id: 1 }, { id: 2 }],
+      itemsToRemove: [1],
+    } as any)
+
+    expect(modal.componentInstance.customFields).toEqual(component.customFields)
+    expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
+    expect(modal.componentInstance.documents).toEqual([3, 4])
+
+    modal.componentInstance.failed.emit()
+    expect(toastServiceShowErrorSpy).toHaveBeenCalled()
+    expect(listReloadSpy).not.toHaveBeenCalled()
+
+    modal.componentInstance.succeeded.emit()
+    expect(toastServiceShowInfoSpy).toHaveBeenCalled()
+    expect(listReloadSpy).toHaveBeenCalled()
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
+    ) // list reload
+    httpTestingController.match(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
+    ) // listAllFilteredIds
+  })
 })
index 6892cc823d8abbe3a6cd8eaaf1d70c167eb6e6a3..499f52f032a0f237d8c19b6e64df94285d323066 100644 (file)
@@ -44,6 +44,7 @@ import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-c
 import { CustomField } from 'src/app/data/custom-field'
 import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
 
 @Component({
   selector: 'pngx-bulk-editor',
@@ -826,4 +827,38 @@ export class BulkEditorComponent
         )
       })
   }
+
+  public setCustomFieldValues(changedCustomFields: ChangedItems) {
+    const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
+      backdrop: 'static',
+      size: 'lg',
+    })
+    const dialog =
+      modal.componentInstance as CustomFieldsBulkEditDialogComponent
+    dialog.customFields = this.customFields
+    dialog.fieldsToAddIds = changedCustomFields.itemsToAdd.map(
+      (item) => item.id
+    )
+    dialog.fieldsToRemoveIds = changedCustomFields.itemsToRemove.map(
+      (item) => item.id
+    )
+
+    dialog.documents = Array.from(this.list.selected)
+    dialog.succeeded.subscribe((result) => {
+      this.toastService.showInfo(
+        $localize`Bulk operation executed successfully`
+      )
+      this.list.reload()
+      this.list.reduceSelectionToFilter()
+      this.list.selected.forEach((id) => {
+        this.openDocumentService.refreshDocument(id)
+      })
+    })
+    dialog.failed.subscribe((error) => {
+      this.toastService.showError(
+        $localize`Error executing bulk operation`,
+        error
+      )
+    })
+  }
 }
diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html
new file mode 100644 (file)
index 0000000..637832a
--- /dev/null
@@ -0,0 +1,81 @@
+<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
+  <div class="modal-header">
+    <h4 class="modal-title" id="modal-basic-title" i8n>{
+      documents.length,
+      plural,
+      =1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
+      }</h4>
+    <button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
+    </button>
+  </div>
+  <div class="modal-body">
+    <pngx-input-select i18n-title title="" multiple="true" [items]="customFields" [(ngModel)]="fieldsToAddIds"
+      placeholder="Select custom fields" i18n-placeholder [ngModelOptions]="{standalone: true}">
+    </pngx-input-select>
+    <div class="d-flex flex-column gap-2">
+      @for (field of fieldsToAdd; track field.id) {
+        <div class="d-flex gap-2">
+          @switch (field.data_type) {
+            @case (CustomFieldDataType.String) {
+              <pngx-input-text formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
+              </pngx-input-text>
+            }
+            @case (CustomFieldDataType.Date) {
+              <pngx-input-date formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
+              </pngx-input-date>
+            }
+            @case (CustomFieldDataType.Integer) {
+              <pngx-input-number formControlName="{{field.id}}" class="w-100" [title]="field.name" [showAdd]="false"
+                [horizontal]="true">
+              </pngx-input-number>
+            }
+            @case (CustomFieldDataType.Float) {
+              <pngx-input-number formControlName="{{field.id}}" class="w-100" [title]="field.name" [showAdd]="false"
+                [step]=".1" [horizontal]="true">
+              </pngx-input-number>
+            }
+            @case (CustomFieldDataType.Monetary) {
+              <pngx-input-monetary formControlName="{{field.id}}" class="w-100" [title]="field.name"
+                [defaultCurrency]="field.extra_data?.default_currency" [horizontal]="true">
+              </pngx-input-monetary>
+            }
+            @case (CustomFieldDataType.Boolean) {
+              <pngx-input-check formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
+              </pngx-input-check>
+            }
+            @case (CustomFieldDataType.Url) {
+              <pngx-input-url formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
+              </pngx-input-url>
+            }
+            @case (CustomFieldDataType.DocumentLink) {
+              <pngx-input-document-link formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
+              </pngx-input-document-link>
+            }
+            @case (CustomFieldDataType.Select) {
+              <pngx-input-select formControlName="{{field.id}}" class="w-100" [title]="field.name"
+                [items]="field.extra_data.select_options" bindLabel="label" [allowNull]="true" [horizontal]="true">
+              </pngx-input-select>
+            }
+          }
+          <button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)">
+            <i-bs name="x"></i-bs>
+          </button>
+        </div>
+      }
+    </div>
+  </div>
+  <div class="modal-footer">
+    @if (fieldsToRemoveIds.length) {
+      <p class="mb-0 small"><em i18n>{
+          fieldsToRemoveIds.length,
+          plural,
+          =1 {This operation will also remove 1 custom field from the selected documents.} other {This operation will also
+          remove {{fieldsToRemoveIds.length}} custom fields from the selected documents.}
+          }</em></p>
+    }
+    <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 || fieldsToRemoveIds.length + fieldsToAddIds.length === 0">Save</button>
+  </div>
+</form>
diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.scss b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..a5c76d5
--- /dev/null
@@ -0,0 +1,89 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog.component'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { of, throwError } from 'rxjs'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { SelectComponent } from 'src/app/components/common/input/select/select.component'
+import { CustomFieldDataType } from 'src/app/data/custom-field'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
+import { provideHttpClient } from '@angular/common/http'
+
+describe('CustomFieldsBulkEditDialogComponent', () => {
+  let component: CustomFieldsBulkEditDialogComponent
+  let fixture: ComponentFixture<CustomFieldsBulkEditDialogComponent>
+  let documentService: DocumentService
+  let activeModal: NgbActiveModal
+
+  beforeEach(async () => {
+    TestBed.configureTestingModule({
+      declarations: [CustomFieldsBulkEditDialogComponent, SelectComponent],
+      imports: [FormsModule, ReactiveFormsModule, NgbModule, NgSelectModule],
+      providers: [
+        NgbActiveModal,
+        provideHttpClient(),
+        provideHttpClientTesting(),
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(CustomFieldsBulkEditDialogComponent)
+    component = fixture.componentInstance
+    documentService = TestBed.inject(DocumentService)
+    activeModal = TestBed.inject(NgbActiveModal)
+    fixture.detectChanges()
+  })
+
+  it('should initialize form controls based on selected field ids', () => {
+    component.customFields = [
+      { id: 1, name: 'Field 1', data_type: CustomFieldDataType.String },
+      { id: 2, name: 'Field 2', data_type: CustomFieldDataType.Integer },
+    ]
+    component.fieldsToAddIds = [1, 2]
+    expect(component.form.contains('1')).toBeTruthy()
+    expect(component.form.contains('2')).toBeTruthy()
+  })
+
+  it('should emit succeeded event and close modal on successful save', () => {
+    const editSpy = jest
+      .spyOn(documentService, 'bulkEdit')
+      .mockReturnValue(of('Success'))
+    const successSpy = jest.spyOn(component.succeeded, 'emit')
+
+    component.documents = [1, 2]
+    component.fieldsToAddIds = [1]
+    component.form.controls['1'].setValue('Value 1')
+    component.save()
+
+    expect(editSpy).toHaveBeenCalled()
+    expect(successSpy).toHaveBeenCalled()
+  })
+
+  it('should emit failed event on save error', () => {
+    const editSpy = jest
+      .spyOn(documentService, 'bulkEdit')
+      .mockReturnValue(throwError(new Error('Error')))
+    const failSpy = jest.spyOn(component.failed, 'emit')
+
+    component.documents = [1, 2]
+    component.fieldsToAddIds = [1]
+    component.form.controls['1'].setValue('Value 1')
+    component.save()
+
+    expect(editSpy).toHaveBeenCalled()
+    expect(failSpy).toHaveBeenCalled()
+  })
+
+  it('should close modal on cancel', () => {
+    const activeModalSpy = jest.spyOn(activeModal, 'close')
+    component.cancel()
+    expect(activeModalSpy).toHaveBeenCalled()
+  })
+
+  it('should remove field from selected fields', () => {
+    component.fieldsToAddIds = [1, 2]
+    component.removeField(1)
+    expect(component.fieldsToAddIds).toEqual([2])
+  })
+})
diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts
new file mode 100644 (file)
index 0000000..9a253f4
--- /dev/null
@@ -0,0 +1,90 @@
+import { Component, EventEmitter, Output } from '@angular/core'
+import { FormControl, FormGroup } from '@angular/forms'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { first } from 'rxjs'
+import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
+import { DocumentService } from 'src/app/services/rest/document.service'
+
+@Component({
+  selector: 'pngx-custom-fields-bulk-edit-dialog',
+  templateUrl: './custom-fields-bulk-edit-dialog.component.html',
+  styleUrl: './custom-fields-bulk-edit-dialog.component.scss',
+})
+export class CustomFieldsBulkEditDialogComponent {
+  CustomFieldDataType = CustomFieldDataType
+
+  @Output()
+  succeeded = new EventEmitter()
+
+  @Output()
+  failed = new EventEmitter()
+
+  public networkActive = false
+
+  public customFields: CustomField[] = []
+
+  private _fieldsToAdd: CustomField[] = [] // static object for change detection
+  public get fieldsToAdd() {
+    return this._fieldsToAdd
+  }
+
+  private _fieldsToAddIds: number[] = []
+  public get fieldsToAddIds() {
+    return this._fieldsToAddIds
+  }
+  public set fieldsToAddIds(ids: number[]) {
+    this._fieldsToAddIds = ids
+    this._fieldsToAdd = this.customFields.filter((field) =>
+      this._fieldsToAddIds.includes(field.id)
+    )
+    this.initForm()
+  }
+
+  public fieldsToRemoveIds: number[] = []
+
+  public form: FormGroup = new FormGroup({})
+
+  public documents: number[] = []
+
+  constructor(
+    private activeModal: NgbActiveModal,
+    private documentService: DocumentService
+  ) {}
+
+  initForm() {
+    Object.keys(this.form.controls).forEach((key) => {
+      if (!this._fieldsToAddIds.includes(parseInt(key))) {
+        this.form.removeControl(key)
+      }
+    })
+    this._fieldsToAddIds.forEach((field_id) => {
+      this.form.addControl(field_id.toString(), new FormControl(null))
+    })
+  }
+
+  public save() {
+    this.documentService
+      .bulkEdit(this.documents, 'modify_custom_fields', {
+        add_custom_fields: this.form.value,
+        remove_custom_fields: this.fieldsToRemoveIds,
+      })
+      .pipe(first())
+      .subscribe({
+        next: () => {
+          this.activeModal.close()
+          this.succeeded.emit()
+        },
+        error: (error) => {
+          this.failed.emit(error)
+        },
+      })
+  }
+
+  public cancel() {
+    this.activeModal.close()
+  }
+
+  public removeField(fieldId: number) {
+    this.fieldsToAddIds = this._fieldsToAddIds.filter((id) => id !== fieldId)
+  }
+}
index 83be5eea9626eb36bee27fe74874e0dbd78ef11e..9698f65cf83ebf735ab58c02d9362877a4393a63 100644 (file)
@@ -17,6 +17,7 @@ from documents.data_models import ConsumableDocument
 from documents.data_models import DocumentMetadataOverrides
 from documents.data_models import DocumentSource
 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
@@ -147,17 +148,34 @@ def modify_tags(
 
 def modify_custom_fields(
     doc_ids: list[int],
-    add_custom_fields,
-    remove_custom_fields,
+    add_custom_fields: list[int] | dict,
+    remove_custom_fields: list[int],
 ) -> Literal["OK"]:
     qs = Document.objects.filter(id__in=doc_ids).only("pk")
     affected_docs = list(qs.values_list("pk", flat=True))
+    # Ensure add_custom_fields is a list of tuples, supports old API
+    add_custom_fields = (
+        add_custom_fields.items()
+        if isinstance(add_custom_fields, dict)
+        else [(field, None) for field in add_custom_fields]
+    )
 
-    for field in add_custom_fields:
+    custom_fields = CustomField.objects.filter(
+        id__in=[int(field) for field, _ in add_custom_fields],
+    ).distinct()
+    for field_id, value in add_custom_fields:
         for doc_id in affected_docs:
+            defaults = {}
+            custom_field = custom_fields.get(id=field_id)
+            if custom_field:
+                value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
+                    custom_field.data_type
+                ]
+                defaults[value_field] = value
             CustomFieldInstance.objects.update_or_create(
                 document_id=doc_id,
-                field_id=field,
+                field_id=field_id,
+                defaults=defaults,
             )
     CustomFieldInstance.objects.filter(
         document_id__in=affected_docs,
index 91e291c219535f0c09a1661a01122fbfd731c841..31871a3ad97d21e04f26d91cf036f9f0ebbdcdcd 100644 (file)
@@ -638,7 +638,10 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
                 uri_validator(data["value"])
             elif field.data_type == CustomField.FieldDataType.INT:
                 integer_validator(data["value"])
-            elif field.data_type == CustomField.FieldDataType.MONETARY:
+            elif (
+                field.data_type == CustomField.FieldDataType.MONETARY
+                and data["value"] != ""
+            ):
                 try:
                     # First try to validate as a number from legacy format
                     DecimalValidator(max_digits=12, decimal_places=2)(
@@ -1140,13 +1143,28 @@ class BulkEditSerializer(
                 f"Some tags in {name} don't exist or were specified twice.",
             )
 
-    def _validate_custom_field_id_list(self, custom_fields, name="custom_fields"):
-        if not isinstance(custom_fields, list):
-            raise serializers.ValidationError(f"{name} must be a list")
-        if not all(isinstance(i, int) for i in custom_fields):
-            raise serializers.ValidationError(f"{name} must be a list of integers")
-        count = CustomField.objects.filter(id__in=custom_fields).count()
-        if not count == len(custom_fields):
+    def _validate_custom_field_id_list_or_dict(
+        self,
+        custom_fields,
+        name="custom_fields",
+    ):
+        ids = custom_fields
+        if isinstance(custom_fields, dict):
+            try:
+                ids = [int(i[0]) for i in custom_fields.items()]
+            except Exception as e:
+                logger.exception(f"Error validating custom fields: {e}")
+                raise serializers.ValidationError(
+                    f"{name} must be a list of integers or a dict of id:value pairs, see the log for details",
+                )
+        elif not isinstance(custom_fields, list) or not all(
+            isinstance(i, int) for i in ids
+        ):
+            raise serializers.ValidationError(
+                f"{name} must be a list of integers or a dict of id:value pairs",
+            )
+        count = CustomField.objects.filter(id__in=ids).count()
+        if not count == len(ids):
             raise serializers.ValidationError(
                 f"Some custom fields in {name} don't exist or were specified twice.",
             )
@@ -1245,7 +1263,7 @@ class BulkEditSerializer(
 
     def _validate_parameters_modify_custom_fields(self, parameters):
         if "add_custom_fields" in parameters:
-            self._validate_custom_field_id_list(
+            self._validate_custom_field_id_list_or_dict(
                 parameters["add_custom_fields"],
                 "add_custom_fields",
             )
@@ -1253,7 +1271,7 @@ class BulkEditSerializer(
             raise serializers.ValidationError("add_custom_fields not specified")
 
         if "remove_custom_fields" in parameters:
-            self._validate_custom_field_id_list(
+            self._validate_custom_field_id_list_or_dict(
                 parameters["remove_custom_fields"],
                 "remove_custom_fields",
             )
index 075bbfd6ae3f8e36954e31b0df638e41fc70dfb2..ff0b367d1ef39accaa58d8640e8bd9f1d59092ed 100644 (file)
@@ -244,7 +244,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
                     "documents": [self.doc1.id, self.doc3.id],
                     "method": "modify_custom_fields",
                     "parameters": {
-                        "add_custom_fields": [self.cf1.id],
+                        "add_custom_fields": [
+                            self.cf1.id,
+                        ],  # old format accepts list of IDs
                         "remove_custom_fields": [self.cf2.id],
                     },
                 },
@@ -258,6 +260,30 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
         self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
         self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
 
+    @mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
+    def test_api_modify_custom_fields_with_values(self, m):
+        self.setup_mock(m, "modify_custom_fields")
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc1.id, self.doc3.id],
+                    "method": "modify_custom_fields",
+                    "parameters": {
+                        "add_custom_fields": {self.cf1.id: "foo"},
+                        "remove_custom_fields": [self.cf2.id],
+                    },
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        m.assert_called_once()
+        args, kwargs = m.call_args
+        self.assertListEqual(args[0], [self.doc1.id, self.doc3.id])
+        self.assertEqual(kwargs["add_custom_fields"], {str(self.cf1.id): "foo"})
+        self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
+
     @mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
     def test_api_modify_custom_fields_invalid_params(self, m):
         """
@@ -322,7 +348,23 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         m.assert_not_called()
 
-        # Not a list of integers
+        # Invalid dict
+        response = self.client.post(
+            "/api/documents/bulk_edit/",
+            json.dumps(
+                {
+                    "documents": [self.doc1.id, self.doc3.id],
+                    "method": "modify_custom_fields",
+                    "parameters": {
+                        "add_custom_fields": {"foo": 99},
+                        "remove_custom_fields": [self.cf2.id],
+                    },
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        m.assert_not_called()
 
         # Missing remove_custom_fields
         response = self.client.post(
index bb5ebf04d27ded0c58d6b60a6ded4a0cdf18eeac..03c177343cacd507181dd8b366e815aa35064ea9 100644 (file)
@@ -189,6 +189,15 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
         self.assertCountEqual(kwargs["document_ids"], [self.doc2.id, self.doc3.id])
 
     def test_modify_custom_fields(self):
+        """
+        GIVEN:
+            - 2 documents with custom fields
+            - 3 custom fields
+        WHEN:
+            - Custom fields are modified using old format (list of ids)
+        THEN:
+            - Custom fields are modified for the documents
+        """
         cf = CustomField.objects.create(
             name="cf1",
             data_type=CustomField.FieldDataType.STRING,
@@ -235,6 +244,78 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
         args, kwargs = self.async_task.call_args
         self.assertCountEqual(kwargs["document_ids"], [self.doc1.id, self.doc2.id])
 
+    def test_modify_custom_fields_with_values(self):
+        """
+        GIVEN:
+            - 2 documents with custom fields
+            - 3 custom fields
+        WHEN:
+            - Custom fields are modified using new format (dict)
+        THEN:
+            - Custom fields are modified for the documents
+        """
+        cf = CustomField.objects.create(
+            name="cf",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        cf1 = CustomField.objects.create(
+            name="cf1",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        cf2 = CustomField.objects.create(
+            name="cf2",
+            data_type=CustomField.FieldDataType.MONETARY,
+        )
+        cf3 = CustomField.objects.create(
+            name="cf3",
+            data_type=CustomField.FieldDataType.STRING,
+        )
+        CustomFieldInstance.objects.create(
+            document=self.doc2,
+            field=cf,
+        )
+        CustomFieldInstance.objects.create(
+            document=self.doc2,
+            field=cf1,
+        )
+        CustomFieldInstance.objects.create(
+            document=self.doc2,
+            field=cf3,
+        )
+        bulk_edit.modify_custom_fields(
+            [self.doc1.id, self.doc2.id],
+            add_custom_fields={cf2.id: None, cf3.id: "value"},
+            remove_custom_fields=[cf.id],
+        )
+
+        self.doc1.refresh_from_db()
+        self.doc2.refresh_from_db()
+
+        self.assertEqual(
+            self.doc1.custom_fields.count(),
+            2,
+        )
+        self.assertEqual(
+            self.doc1.custom_fields.get(field=cf2).value,
+            None,
+        )
+        self.assertEqual(
+            self.doc1.custom_fields.get(field=cf3).value,
+            "value",
+        )
+        self.assertEqual(
+            self.doc2.custom_fields.count(),
+            3,
+        )
+        self.assertEqual(
+            self.doc2.custom_fields.get(field=cf3).value,
+            "value",
+        )
+
+        self.async_task.assert_called_once()
+        args, kwargs = self.async_task.call_args
+        self.assertCountEqual(kwargs["document_ids"], [self.doc1.id, self.doc2.id])
+
     def test_delete(self):
         self.assertEqual(Document.objects.count(), 5)
         bulk_edit.delete([self.doc1.id, self.doc2.id])