Enhancement: dashboard improvements, drag-n-drop reorder dashboard views (#4252)

* Updated dashboard

* Make entire screen dropzone on dashboard too

* Floating upload widget status alerts

* Visual tweaks: spacing, borders

* Better empty view widget

* Support drag + drop reorder of dashboard saved views

* Update messages.xlf

* Disable dashbaord dnd if global dnd active

* Remove ngx-file-drop dep, rebuild file-drop & upload files widget

* Revert custom file drop implementation

* Try patch-package fix

* Simplify dropzone transitions to make more reliable

* Update messages.xlf

* Update dashboard.spec.ts

* Fix coverage
This commit is contained in:
shamoon 2023-09-28 10:18:12 -07:00 committed by GitHub
parent 96176589ca
commit 6973691cce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1715 additions and 534 deletions

View File

@ -29,6 +29,7 @@ test('dashboard saved view show all', async ({ page }) => {
.locator('pngx-widget-frame')
.filter({ hasText: 'Inbox' })
.getByRole('link', { name: 'Show all' })
.first()
.click()
await expect(page).toHaveURL(/view\/7/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)

View File

@ -243,79 +243,72 @@
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="7103632680753685326" datatype="html">
<source>Drop files to begin upload</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.html</context>
<context context-type="linenumber">7</context>
</context-group>
</trans-unit>
<trans-unit id="9103526311244275943" datatype="html">
<source>Document added</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">90</context>
<context context-type="linenumber">83</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">93</context>
</context-group>
</trans-unit>
<trans-unit id="9204248378636247318" datatype="html">
<source>Document <x id="PH" equiv-text="status.filename"/> was added to paperless.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">92</context>
<context context-type="linenumber">85</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">102</context>
<context context-type="linenumber">95</context>
</context-group>
</trans-unit>
<trans-unit id="1931214133925051574" datatype="html">
<source>Open document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">93</context>
<context context-type="linenumber">86</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html</context>
<context context-type="linenumber">46</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="8582620835547864448" datatype="html">
<source>Could not add <x id="PH" equiv-text="status.filename"/>: <x id="PH_1" equiv-text="status.message"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">116</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="1710712016675379662" datatype="html">
<source>New document detected</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">131</context>
<context context-type="linenumber">124</context>
</context-group>
</trans-unit>
<trans-unit id="587031278561344416" datatype="html">
<source>Document <x id="PH" equiv-text="status.filename"/> is being processed by paperless.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">133</context>
<context context-type="linenumber">126</context>
</context-group>
</trans-unit>
<trans-unit id="2501522447884928778" datatype="html">
<source>Prev</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">138</context>
<context context-type="linenumber">131</context>
</context-group>
</trans-unit>
<trans-unit id="3885497195825665706" datatype="html">
<source>Next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">139</context>
<context context-type="linenumber">132</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@ -326,105 +319,98 @@
<source>End</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">140</context>
<context context-type="linenumber">133</context>
</context-group>
</trans-unit>
<trans-unit id="3909462337752654810" datatype="html">
<source>The dashboard can be used to show saved views, such as an &apos;Inbox&apos;. Those settings are found under Settings &gt; Saved Views once you have created some.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">146</context>
<context context-type="linenumber">139</context>
</context-group>
</trans-unit>
<trans-unit id="9075755296812854717" datatype="html">
<source>Drag-and-drop documents here to start uploading or place them in the consume folder. You can also drag-and-drop documents anywhere on all other pages of the web app. Once you do, Paperless-ngx will start training its machine learning algorithms.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">153</context>
<context context-type="linenumber">146</context>
</context-group>
</trans-unit>
<trans-unit id="7495498057594070122" datatype="html">
<source>The documents list shows all of your documents and allows for filtering as well as bulk-editing. There are three different view styles: list, small cards and large cards. A list of documents currently opened for editing is shown in the sidebar.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">158</context>
<context context-type="linenumber">151</context>
</context-group>
</trans-unit>
<trans-unit id="1334220418719920556" datatype="html">
<source>The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">165</context>
<context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="5427326625898532358" datatype="html">
<source>Any combination of filters can be saved as a &apos;view&apos; which can then be displayed on the dashboard and / or sidebar.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">171</context>
<context context-type="linenumber">164</context>
</context-group>
</trans-unit>
<trans-unit id="2804886236408698479" datatype="html">
<source>Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">176</context>
<context context-type="linenumber">169</context>
</context-group>
</trans-unit>
<trans-unit id="7851939076947092983" datatype="html">
<source>Manage e-mail accounts and rules for automatically importing documents.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">184</context>
<context context-type="linenumber">177</context>
</context-group>
</trans-unit>
<trans-unit id="1347174817382304718" datatype="html">
<source>Consumption templates give you finer control over the document ingestion process.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">192</context>
<context context-type="linenumber">185</context>
</context-group>
</trans-unit>
<trans-unit id="4680387114119209483" datatype="html">
<source>File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">200</context>
<context context-type="linenumber">193</context>
</context-group>
</trans-unit>
<trans-unit id="1453710303796913192" datatype="html">
<source>Check out the settings for various tweaks to the web app and toggle settings for saved views.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">208</context>
<context context-type="linenumber">201</context>
</context-group>
</trans-unit>
<trans-unit id="7172877665285340082" datatype="html">
<source>Thank you! 🙏</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">216</context>
<context context-type="linenumber">209</context>
</context-group>
</trans-unit>
<trans-unit id="7354947513482088740" datatype="html">
<source>There are &lt;em&gt;tons&lt;/em&gt; more features and info we didn&apos;t cover here, but this should get you started. Check out the documentation or visit the project on GitHub to learn more or to report issues.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">218</context>
<context context-type="linenumber">211</context>
</context-group>
</trans-unit>
<trans-unit id="4270528545616947218" datatype="html">
<source>Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">220</context>
</context-group>
</trans-unit>
<trans-unit id="5749300816154614125" datatype="html">
<source>Initiating upload...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">289</context>
<context context-type="linenumber">213</context>
</context-group>
</trans-unit>
<trans-unit id="4804785061014590286" datatype="html">
@ -478,11 +464,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
<context context-type="linenumber">10</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/widget-frame/widget-frame.component.html</context>
<context context-type="linenumber">7</context>
<context context-type="linenumber">21</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@ -1496,7 +1482,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">9</context>
<context context-type="linenumber">17</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@ -1720,7 +1706,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">17</context>
<context context-type="linenumber">27</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>
@ -2064,6 +2050,10 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">140</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">55</context>
</context-group>
</trans-unit>
<trans-unit id="7886570921510760899" datatype="html">
<source>Tags</source>
@ -2079,6 +2069,14 @@
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
<context context-type="linenumber">63</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">19</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context>
<context context-type="linenumber">49</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">26</context>
@ -3607,32 +3605,46 @@
<source>Hello <x id="PH" equiv-text="this.settingsService.displayName"/>, welcome to Paperless-ngx</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">23</context>
<context context-type="linenumber">48</context>
</context-group>
</trans-unit>
<trans-unit id="5334686081082652461" datatype="html">
<source>Welcome to Paperless-ngx</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">25</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="1325877348738783391" datatype="html">
<source>Dashboard updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="3214475953924351473" datatype="html">
<source>Error updating dashboard</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.ts</context>
<context context-type="linenumber">76</context>
</context-group>
</trans-unit>
<trans-unit id="2946624699882754313" datatype="html">
<source>Show all</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">3</context>
<context context-type="linenumber">12</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html</context>
<context context-type="linenumber">27</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="5701618810648052610" datatype="html">
<source>Title</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">10</context>
<context context-type="linenumber">18</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@ -3651,18 +3663,45 @@
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="2691296884221415710" datatype="html">
<source>Correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">20</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-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">38</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">142</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">35</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="8911158217491828773" datatype="html">
<source>View Preview</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">19</context>
<context context-type="linenumber">35</context>
</context-group>
</trans-unit>
<trans-unit id="3099741642167775297" datatype="html">
<source>Download</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">29</context>
<context context-type="linenumber">45</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@ -3681,6 +3720,13 @@
<context context-type="linenumber">99</context>
</context-group>
</trans-unit>
<trans-unit id="872092479747931526" datatype="html">
<source>No documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">57</context>
</context-group>
</trans-unit>
<trans-unit id="1069523139277190436" datatype="html">
<source>Statistics</source>
<context-group purpose="location">
@ -3723,11 +3769,25 @@
<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">
<context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts</context>
<context context-type="linenumber">55</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="8187573012244728580" datatype="html">
@ -3737,33 +3797,33 @@
<context context-type="linenumber">1</context>
</context-group>
</trans-unit>
<trans-unit id="1749180330008942007" datatype="html">
<source>Dismiss completed</source>
<trans-unit id="8161815301131859114" datatype="html">
<source>Drop documents anywhere or</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html</context>
<context context-type="linenumber">4</context>
</context-group>
<note priority="1" from="description">This button dismisses all status messages about processed documents on the dashboard (failed and successful)</note>
</trans-unit>
<trans-unit id="118343233500414755" datatype="html">
<source>Drop documents here or</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="8133800334834354642" datatype="html">
<source>Browse files</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html</context>
<context context-type="linenumber">5</context>
</context-group>
</trans-unit>
<trans-unit id="1749180330008942007" datatype="html">
<source>Dismiss completed</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html</context>
<context context-type="linenumber">13</context>
</context-group>
<note priority="1" from="description">This button dismisses all status messages about processed documents on the dashboard (failed and successful)</note>
</trans-unit>
<trans-unit id="2330646618997399019" datatype="html">
<source>{VAR_PLURAL, plural, =1 {One more document} other {<x id="INTERPOLATION"/> more documents}}</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html</context>
<context context-type="linenumber">25</context>
<context context-type="linenumber">27</context>
</context-group>
<note priority="1" from="description">This is shown as a summary line when there are more than 5 document in the processing pipeline.</note>
</trans-unit>
@ -3771,28 +3831,28 @@
<source>Processing: <x id="PH" equiv-text="countUploadingAndProcessing"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts</context>
<context context-type="linenumber">39</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="9182918211699394982" datatype="html">
<source>Failed: <x id="PH" equiv-text="countFailed"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts</context>
<context context-type="linenumber">42</context>
<context context-type="linenumber">47</context>
</context-group>
</trans-unit>
<trans-unit id="534116346205124059" datatype="html">
<source>Added: <x id="PH" equiv-text="countSuccess"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts</context>
<context context-type="linenumber">45</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="760986369763309193" datatype="html">
<source>, </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts</context>
<context context-type="linenumber">48</context>
<context context-type="linenumber">53</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@ -3928,29 +3988,6 @@
<context context-type="linenumber">87</context>
</context-group>
</trans-unit>
<trans-unit id="2691296884221415710" datatype="html">
<source>Correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">89</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">38</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">142</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">35</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="5066119607229701477" datatype="html">
<source>Document type</source>
<context-group purpose="location">
@ -5069,6 +5106,20 @@
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
<trans-unit id="7103632680753685326" datatype="html">
<source>Drop files to begin upload</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/file-drop/file-drop.component.html</context>
<context context-type="linenumber">6</context>
</context-group>
</trans-unit>
<trans-unit id="5749300816154614125" datatype="html">
<source>Initiating upload...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/file-drop/file-drop.component.ts</context>
<context context-type="linenumber">87</context>
</context-group>
</trans-unit>
<trans-unit id="7308826808299076537" datatype="html">
<source>Add Template</source>
<context-group purpose="location">
@ -5941,231 +5992,231 @@
<source>English (US)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">150</context>
<context context-type="linenumber">154</context>
</context-group>
</trans-unit>
<trans-unit id="7318555235181361185" datatype="html">
<source>Afrikaans</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">156</context>
<context context-type="linenumber">160</context>
</context-group>
</trans-unit>
<trans-unit id="6269202464699193298" datatype="html">
<source>Arabic</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">162</context>
<context context-type="linenumber">166</context>
</context-group>
</trans-unit>
<trans-unit id="3098941349689899577" datatype="html">
<source>Belarusian</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">168</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="1001043467371963032" datatype="html">
<source>Catalan</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">174</context>
<context context-type="linenumber">178</context>
</context-group>
</trans-unit>
<trans-unit id="2719780722934172508" datatype="html">
<source>Czech</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">180</context>
<context context-type="linenumber">184</context>
</context-group>
</trans-unit>
<trans-unit id="2924289692679201020" datatype="html">
<source>Danish</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">186</context>
<context context-type="linenumber">190</context>
</context-group>
</trans-unit>
<trans-unit id="1858110241312746425" datatype="html">
<source>German</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">192</context>
<context context-type="linenumber">196</context>
</context-group>
</trans-unit>
<trans-unit id="7067741492320440272" datatype="html">
<source>Greek</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">198</context>
<context context-type="linenumber">202</context>
</context-group>
</trans-unit>
<trans-unit id="6987083569809053351" datatype="html">
<source>English (GB)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">204</context>
<context context-type="linenumber">208</context>
</context-group>
</trans-unit>
<trans-unit id="5190825892106392539" datatype="html">
<source>Spanish</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">210</context>
<context context-type="linenumber">214</context>
</context-group>
</trans-unit>
<trans-unit id="861663369293303028" datatype="html">
<source>Finnish</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">216</context>
<context context-type="linenumber">220</context>
</context-group>
</trans-unit>
<trans-unit id="7633754075223722162" datatype="html">
<source>French</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">222</context>
<context context-type="linenumber">226</context>
</context-group>
</trans-unit>
<trans-unit id="2935232983274991580" datatype="html">
<source>Italian</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">228</context>
<context context-type="linenumber">232</context>
</context-group>
</trans-unit>
<trans-unit id="1334425850005897370" datatype="html">
<source>Luxembourgish</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">234</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="3071065188816255493" datatype="html">
<source>Dutch</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">240</context>
<context context-type="linenumber">244</context>
</context-group>
</trans-unit>
<trans-unit id="8069284467804715623" datatype="html">
<source>Norwegian</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">246</context>
<context context-type="linenumber">250</context>
</context-group>
</trans-unit>
<trans-unit id="792060551707690640" datatype="html">
<source>Polish</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">252</context>
<context context-type="linenumber">256</context>
</context-group>
</trans-unit>
<trans-unit id="9184513005098760425" datatype="html">
<source>Portuguese (Brazil)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">258</context>
<context context-type="linenumber">262</context>
</context-group>
</trans-unit>
<trans-unit id="153799456510623899" datatype="html">
<source>Portuguese</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">264</context>
<context context-type="linenumber">268</context>
</context-group>
</trans-unit>
<trans-unit id="8118856427047826368" datatype="html">
<source>Romanian</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">270</context>
<context context-type="linenumber">274</context>
</context-group>
</trans-unit>
<trans-unit id="7137419789978325708" datatype="html">
<source>Russian</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">276</context>
<context context-type="linenumber">280</context>
</context-group>
</trans-unit>
<trans-unit id="9102963095355753902" datatype="html">
<source>Slovak</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">282</context>
<context context-type="linenumber">286</context>
</context-group>
</trans-unit>
<trans-unit id="4287008301409320881" datatype="html">
<source>Slovenian</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">288</context>
<context context-type="linenumber">292</context>
</context-group>
</trans-unit>
<trans-unit id="8608389829607915090" datatype="html">
<source>Serbian</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">294</context>
<context context-type="linenumber">298</context>
</context-group>
</trans-unit>
<trans-unit id="499386805970351976" datatype="html">
<source>Swedish</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">300</context>
<context context-type="linenumber">304</context>
</context-group>
</trans-unit>
<trans-unit id="5682359291233237791" datatype="html">
<source>Turkish</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">306</context>
<context context-type="linenumber">310</context>
</context-group>
</trans-unit>
<trans-unit id="3578644052206125685" datatype="html">
<source>Ukrainian</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">312</context>
<context context-type="linenumber">316</context>
</context-group>
</trans-unit>
<trans-unit id="4689443708886954687" datatype="html">
<source>Chinese Simplified</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">318</context>
<context context-type="linenumber">322</context>
</context-group>
</trans-unit>
<trans-unit id="4912706592792948707" datatype="html">
<source>ISO 8601</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">335</context>
<context context-type="linenumber">339</context>
</context-group>
</trans-unit>
<trans-unit id="313643372755303297" datatype="html">
<source>Successfully completed one-time migratration of settings to the database!</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">454</context>
<context context-type="linenumber">458</context>
</context-group>
</trans-unit>
<trans-unit id="5558341108007064934" datatype="html">
<source>Unable to migrate settings to the database, please try saving manually.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">455</context>
<context context-type="linenumber">459</context>
</context-group>
</trans-unit>
<trans-unit id="1168781785897678748" datatype="html">
<source>You can restart the tour from the settings page.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/settings.service.ts</context>
<context context-type="linenumber">529</context>
<context context-type="linenumber">533</context>
</context-group>
</trans-unit>
<trans-unit id="5037437391296624618" datatype="html">
@ -6179,28 +6230,28 @@
<source>Connecting...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/upload-documents.service.ts</context>
<context context-type="linenumber">31</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="1245343823699368872" datatype="html">
<source>Uploading...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/upload-documents.service.ts</context>
<context context-type="linenumber">43</context>
<context context-type="linenumber">54</context>
</context-group>
</trans-unit>
<trans-unit id="7446520539098045935" datatype="html">
<source>Upload complete, waiting...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/upload-documents.service.ts</context>
<context context-type="linenumber">46</context>
<context context-type="linenumber">57</context>
</context-group>
</trans-unit>
<trans-unit id="1405142710727603568" datatype="html">
<source>HTTP error: <x id="PH" equiv-text="error.status"/> <x id="PH_1" equiv-text="error.statusText"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/upload-documents.service.ts</context>
<context context-type="linenumber">62</context>
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
</body>

256
src-ui/package-lock.json generated
View File

@ -7,6 +7,7 @@
"": {
"name": "paperless-ui",
"version": "0.0.0",
"hasInstallScript": true,
"dependencies": {
"@angular/common": "~16.2.6",
"@angular/compiler": "~16.2.6",
@ -27,6 +28,7 @@
"ngx-clipboard": "^16.0.0",
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^16.0.1",
"ngx-drag-drop": "^16.1.0",
"ngx-file-drop": "^16.0.0",
"ngx-ui-tour-ng-bootstrap": "^13.0.4",
"rxjs": "^7.8.1",
@ -55,6 +57,7 @@
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^13.1.1",
"jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0",
"ts-node": "~10.9.1",
"typescript": "^5.1.6",
"wait-on": "^7.0.1"
@ -6944,6 +6947,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
"dev": true,
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/autoprefixer": {
"version": "10.4.14",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz",
@ -9894,6 +9906,15 @@
"node": ">=8"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
@ -13062,6 +13083,18 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
"node_modules/json-stable-stringify": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.2.tgz",
"integrity": "sha512-eunSSaEnxV12z+Z73y/j5N37/In40GK4GmsSy+tEHJMxknvqnA7/djeYtAgW0GsWHUfg+847WJjKaEylk2y09g==",
"dev": true,
"dependencies": {
"jsonify": "^0.0.1"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@ -13097,6 +13130,15 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonparse": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
@ -13124,6 +13166,15 @@
"node": ">=0.10.0"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -14061,6 +14112,18 @@
"@angular/core": "^16.0.0"
}
},
"node_modules/ngx-drag-drop": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/ngx-drag-drop/-/ngx-drag-drop-16.1.0.tgz",
"integrity": "sha512-y2l9pJGD7OupsIRkCElN/JqTgzjg2V9ZxymKGQR7ZjjcdjaP1wKkiFWIgVEvLNtb8wgm10U+9tkGwLClGaHkQA==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^16.0.0",
"@angular/core": "^16.0.0"
}
},
"node_modules/ngx-file-drop": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/ngx-file-drop/-/ngx-file-drop-16.0.0.tgz",
@ -15028,6 +15091,190 @@
"node": ">= 0.8"
}
},
"node_modules/patch-package": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz",
"integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==",
"dev": true,
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^9.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"rimraf": "^2.6.3",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.0.33",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/patch-package/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/patch-package/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/patch-package/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/patch-package/node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"dev": true,
"dependencies": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/patch-package/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/patch-package/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"dev": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/patch-package/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"dev": true,
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -18299,6 +18546,15 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
},
"node_modules/yaml": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz",
"integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==",
"dev": true,
"engines": {
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",

View File

@ -6,7 +6,8 @@
"start": "ng serve",
"build": "ng build",
"test": "ng test --no-watch --coverage",
"lint": "ng lint"
"lint": "ng lint",
"postinstall": "patch-package"
},
"private": true,
"dependencies": {
@ -29,6 +30,7 @@
"ngx-clipboard": "^16.0.0",
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^16.0.1",
"ngx-drag-drop": "^16.1.0",
"ngx-file-drop": "^16.0.0",
"ngx-ui-tour-ng-bootstrap": "^13.0.4",
"rxjs": "^7.8.1",
@ -57,6 +59,7 @@
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^13.1.1",
"jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0",
"ts-node": "~10.9.1",
"typescript": "^5.1.6",
"wait-on": "^7.0.1"

File diff suppressed because one or more lines are too long

View File

@ -1,16 +1,10 @@
<pngx-toasts></pngx-toasts>
<ngx-file-drop dropZoneClassName="main-dropzone" contentClassName="main-content" [disabled]="!dragDropEnabled"
(onFileDrop)="dropped($event)" (onFileOver)="fileOver()" (onFileLeave)="fileLeave()">
<ng-template ngx-file-drop-content-tmp>
<div class="global-dropzone-overlay fade" [class.show]="fileIsOver" [class.hide]="hidden">
<h2 i18n>Drop files to begin upload</h2>
</div>
<div [class.inert]="fileIsOver">
<router-outlet></router-outlet>
</div>
</ng-template>
</ngx-file-drop>
<pngx-file-drop>
<ng-container content>
<router-outlet></router-outlet>
</ng-container>
</pngx-file-drop>
<tour-step-template>
<ng-template #tourStep let-step="step">

View File

@ -2,14 +2,11 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
tick,
} from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxFileDropModule } from 'ngx-file-drop'
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { Subject } from 'rxjs'
import { routes } from './app-routing.module'
@ -21,8 +18,9 @@ import {
} from './services/consumer-status.service'
import { PermissionsService } from './services/permissions.service'
import { ToastService, Toast } from './services/toast.service'
import { UploadDocumentsService } from './services/upload-documents.service'
import { SettingsService } from './services/settings.service'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { NgxFileDropModule } from 'ngx-file-drop'
describe('AppComponent', () => {
let component: AppComponent
@ -33,11 +31,10 @@ describe('AppComponent', () => {
let toastService: ToastService
let router: Router
let settingsService: SettingsService
let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [AppComponent, ToastsComponent],
declarations: [AppComponent, ToastsComponent, FileDropComponent],
providers: [],
imports: [
HttpClientTestingModule,
@ -53,7 +50,6 @@ describe('AppComponent', () => {
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(AppComponent)
component = fixture.componentInstance
})
@ -72,6 +68,7 @@ describe('AppComponent', () => {
}))
it('should display toast on document consumed with link if user has access', () => {
const navigateSpy = jest.spyOn(router, 'navigate')
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
let toast: Toast
toastService.getToasts().subscribe((toasts) => (toast = toasts[0]))
@ -81,9 +78,13 @@ describe('AppComponent', () => {
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
const status = new FileStatus()
status.documentId = 1
fileStatusSubject.next(status)
expect(toastSpy).toHaveBeenCalled()
expect(toast.action).not.toBeUndefined()
toast.action()
expect(navigateSpy).toHaveBeenCalledWith(['documents', status.documentId])
})
it('should display toast on document consumed without link if user does not have access', () => {
@ -138,45 +139,4 @@ describe('AppComponent', () => {
fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled()
})
it('should disable drag-drop if on dashboard', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest.spyOn(router, 'url', 'get').mockReturnValueOnce('/dashboard')
expect(component.dragDropEnabled).toBeFalsy()
jest.spyOn(router, 'url', 'get').mockReturnValueOnce('/documents')
expect(component.dragDropEnabled).toBeTruthy()
})
it('should enable drag-drop if user has permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.dragDropEnabled).toBeTruthy()
})
it('should disable drag-drop if user does not have permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
expect(component.dragDropEnabled).toBeFalsy()
})
it('should support drag drop', fakeAsync(() => {
expect(component.fileIsOver).toBeFalsy()
component.fileOver()
tick(1)
fixture.detectChanges()
expect(component.fileIsOver).toBeTruthy()
const dropzone = fixture.debugElement.query(
By.css('.global-dropzone-overlay')
)
expect(dropzone).not.toBeNull()
component.fileLeave()
tick(700)
fixture.detectChanges()
expect(dropzone.classes['hide']).toBeTruthy()
// drop
const toastSpy = jest.spyOn(toastService, 'show')
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
component.dropped([])
tick(3000)
expect(toastSpy).toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled()
}))
})

View File

@ -5,8 +5,6 @@ import { Router } from '@angular/router'
import { Subscription, first } from 'rxjs'
import { ConsumerStatusService } from './services/consumer-status.service'
import { ToastService } from './services/toast.service'
import { NgxFileDropEntry } from 'ngx-file-drop'
import { UploadDocumentsService } from './services/upload-documents.service'
import { TasksService } from './services/tasks.service'
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import {
@ -25,16 +23,11 @@ export class AppComponent implements OnInit, OnDestroy {
successSubscription: Subscription
failedSubscription: Subscription
private fileLeaveTimeoutID: any
fileIsOver: boolean = false
hidden: boolean = true
constructor(
private settings: SettingsService,
private consumerStatusService: ConsumerStatusService,
private toastService: ToastService,
private router: Router,
private uploadDocumentsService: UploadDocumentsService,
private tasksService: TasksService,
public tourService: TourService,
private renderer: Renderer2,
@ -250,42 +243,4 @@ export class AppComponent implements OnInit, OnDestroy {
})
})
}
public get dragDropEnabled(): boolean {
return (
!this.router.url.includes('dashboard') &&
this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
)
)
}
public fileOver() {
// allows transition
setTimeout(() => {
this.fileIsOver = true
}, 1)
this.hidden = false
// stop fileLeave timeout
clearTimeout(this.fileLeaveTimeoutID)
}
public fileLeave(immediate: boolean = false) {
const ms = immediate ? 0 : 500
this.fileLeaveTimeoutID = setTimeout(() => {
this.fileIsOver = false
// await transition completed
setTimeout(() => {
this.hidden = true
}, 150)
}, ms)
}
public dropped(files: NgxFileDropEntry[]) {
this.fileLeave(true)
this.uploadDocumentsService.uploadFiles(files)
this.toastService.showInfo($localize`Initiating upload...`, 3000)
}
}

View File

@ -99,6 +99,8 @@ import { ConsumptionTemplatesComponent } from './components/manage/consumption-t
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
import { DndModule } from 'ngx-drag-drop'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@ -241,6 +243,7 @@ function initializeApp(settings: SettingsService) {
ConsumptionTemplateEditDialogComponent,
MailComponent,
UsersAndGroupsComponent,
FileDropComponent,
],
imports: [
BrowserModule,
@ -254,6 +257,7 @@ function initializeApp(settings: SettingsService) {
NgSelectModule,
ColorSliderModule,
TourNgBootstrapModule,
DndModule,
],
providers: [
{

View File

@ -1,4 +1,4 @@
<nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow">
<nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow-sm">
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
(click)="isMenuCollapsed = !isMenuCollapsed">

View File

@ -99,10 +99,6 @@ main {
}
}
.col-slim {
padding-left: calc(50px + $grid-gutter-width) !important;
}
.sidebar-slim-toggler {
display: block;
position: absolute;

View File

@ -1,7 +1,9 @@
<div class="row pt-3 pb-3 pb-md-1 mb-3 border-bottom align-items-center">
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
<div class="col-md text-truncate">
<p class="h2 text-truncate" style="line-height: 1.4">{{title}}</p>
<p *ngIf="subTitle" class="h5 text-truncate" style="line-height: 1.4">{{subTitle}}</p>
<h3 class="text-truncate" style="line-height: 1.4">
{{title}}
<span *ngIf="subTitle" class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
</h3>
</div>
<div class="btn-toolbar col col-md-auto">
<ng-content></ng-content>

View File

@ -25,7 +25,7 @@ describe('PageHeaderComponent', () => {
component.title = 'Foo'
component.subTitle = 'Bar'
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('FooBar')
expect(fixture.nativeElement.textContent).toContain('Foo Bar')
})
it('should set html title', () => {

View File

@ -1,29 +1,46 @@
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title>
<pngx-logo extra_classes="d-none d-md-block"></pngx-logo>
<pngx-logo extra_classes="d-none d-md-block mt-n2 me-1" height="3.5rem"></pngx-logo>
</pngx-page-header>
<div class="row">
<div class="col-lg-8">
<div tourAnchor="tour.dashboard">
<ng-container *ngIf="savedViewService.loading">
<div class="col-auto col-lg-8 col-xl-9 mb-4">
<div class="row row-cols-1 g-4" tourAnchor="tour.dashboard"
dndDropzone
[dndDisableIf]="settingsService.globalDropzoneActive"
dndEffectAllowed="move"
(dndDrop)="onDrop($event)"
>
<div *ngIf="savedViewService.loading" class="col">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
<div *ngIf="settingsService.offerTour()" class="col">
<pngx-welcome-widget (dismiss)="completeTour()"></pngx-welcome-widget>
</div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<div *ngFor="let v of dashboardViews" class="col">
<pngx-saved-view-widget
[savedView]="v"
(dndStart)="onDragStart($event)"
(dndMoved)="onDragged(v)"
(dndEnd)="onDragEnd($event)"
>
</pngx-saved-view-widget>
</div>
</ng-container>
<pngx-welcome-widget *ngIf="settingsService.offerTour()" (dismiss)="completeTour()"></pngx-welcome-widget>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst">
<pngx-saved-view-widget [savedView]="v"></pngx-saved-view-widget>
</ng-container>
<div class="p-1" dndPlaceholderRef></div>
</div>
</div>
<div class="col-auto col-lg-4 col-xl-3">
<div class="row row-cols-1 g-4">
<div class="col">
<pngx-statistics-widget></pngx-statistics-widget>
</div>
<div class="col">
<pngx-upload-file-widget></pngx-upload-file-widget>
</div>
</div>
</div>
<div class="col-lg-4">
<pngx-statistics-widget></pngx-statistics-widget>
<pngx-upload-file-widget></pngx-upload-file-widget>
</div>
</div>

View File

@ -13,16 +13,59 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { By } from '@angular/platform-browser'
import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { NgxFileDropModule } from 'ngx-file-drop'
import { RouterTestingModule } from '@angular/router/testing'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { LogoComponent } from '../common/logo/logo.component'
import { of, throwError } from 'rxjs'
import { DndDropEvent, DndModule } from 'ngx-drag-drop'
import { ToastService } from 'src/app/services/toast.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
const saved_views = [
{
name: 'Saved View 0',
id: 0,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 1',
id: 1,
show_on_dashboard: false,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 2',
id: 2,
show_on_dashboard: true,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 3',
id: 3,
show_on_dashboard: true,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
]
describe('DashboardComponent', () => {
let component: DashboardComponent
let fixture: ComponentFixture<DashboardComponent>
let settingsService: SettingsService
let tourService: TourService
let toastService: ToastService
beforeEach(async () => {
TestBed.configureTestingModule({
@ -47,33 +90,22 @@ describe('DashboardComponent', () => {
{
provide: SavedViewService,
useValue: {
dashboardViews: [
{
id: 1,
name: 'saved view 1',
show_on_dashboard: true,
sort_field: 'added',
sort_reverse: true,
filter_rules: [],
},
{
id: 2,
name: 'saved view 2',
show_on_dashboard: true,
sort_field: 'created',
sort_reverse: true,
filter_rules: [],
},
],
listAll: () =>
of({
all: [saved_views.map((v) => v.id)],
count: saved_views.length,
results: saved_views,
}),
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
},
},
],
imports: [
NgbAlertModule,
HttpClientTestingModule,
NgxFileDropModule,
RouterTestingModule,
TourNgBootstrapModule,
DndModule,
],
}).compileComponents()
@ -82,7 +114,11 @@ describe('DashboardComponent', () => {
first_name: 'Foo',
last_name: 'Bar',
}
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [0, 2, 3]
})
tourService = TestBed.inject(TourService)
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(DashboardComponent)
component = fixture.componentInstance
@ -100,7 +136,7 @@ describe('DashboardComponent', () => {
it('should show dashboard widgets', () => {
expect(
fixture.debugElement.queryAll(By.directive(SavedViewWidgetComponent))
).toHaveLength(2)
).toHaveLength(saved_views.filter((v) => v.show_on_dashboard).length)
})
it('should end tour service if still running and welcome widget dismissed', () => {
@ -116,4 +152,44 @@ describe('DashboardComponent', () => {
component.completeTour()
expect(settingsCompleteTourSpy).toHaveBeenCalled()
})
it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
component.onDragStart(null)
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
component.onDragEnd(null)
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
})
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ index: 2, data: saved_views[0] } as DndDropEvent)
component.onDragged(saved_views[0])
expect(settingsSpy).toHaveBeenCalledWith([
saved_views[2],
saved_views[0],
saved_views[3],
])
expect(toastSpy).toHaveBeenCalled()
component.onDrop({ data: saved_views[3] } as DndDropEvent)
})
it('should update saved view sorting on drag + drop, show info2', () => {
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return []
})
fixture.destroy()
fixture = TestBed.createComponent(DashboardComponent)
component = fixture.componentInstance
fixture.detectChanges()
const toastSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('unable to save')))
component.onDrop({ index: 2, data: saved_views[0] } as DndDropEvent)
component.onDragged(saved_views[0])
expect(toastSpy).toHaveBeenCalled()
})
})

View File

@ -3,6 +3,10 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { DndDropEvent } from 'ngx-drag-drop'
import { ToastService } from 'src/app/services/toast.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({
selector: 'pngx-dashboard',
@ -10,12 +14,33 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap'
styleUrls: ['./dashboard.component.scss'],
})
export class DashboardComponent extends ComponentWithPermissions {
public dashboardViews: PaperlessSavedView[] = []
constructor(
public settingsService: SettingsService,
public savedViewService: SavedViewService,
private tourService: TourService
private tourService: TourService,
private toastService: ToastService
) {
super()
this.savedViewService.listAll().subscribe(() => {
const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER
)
this.dashboardViews =
sorted?.length > 0
? sorted
.map((id) =>
this.savedViewService.dashboardViews.find((v) => v.id === id)
)
.concat(
this.savedViewService.dashboardViews.filter(
(v) => !sorted.includes(v.id)
)
)
.filter((v) => v)
: [...this.savedViewService.dashboardViews]
})
}
get subtitle() {
@ -33,4 +58,35 @@ export class DashboardComponent extends ComponentWithPermissions {
this.settingsService.completeTour()
}
}
onDragStart(event: DragEvent) {
this.settingsService.globalDropzoneEnabled = false
}
onDragged(v: PaperlessSavedView) {
const index = this.dashboardViews.indexOf(v)
this.dashboardViews.splice(index, 1)
this.settingsService
.updateDashboardViewsSort(this.dashboardViews)
.subscribe({
next: () => {
this.toastService.showInfo($localize`Dashboard updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating dashboard`, e)
},
})
}
onDragEnd(event: DragEvent) {
this.settingsService.globalDropzoneEnabled = true
}
onDrop(event: DndDropEvent) {
if (typeof event.index === 'undefined') {
event.index = this.dashboardViews.length
}
this.dashboardViews.splice(event.index, 0, event.data)
}
}

View File

@ -1,22 +1,38 @@
<pngx-widget-frame [title]="savedView.name" [loading]="loading">
<pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name"
[loading]="loading"
[draggable]="savedView"
(dndStart)="dndStart.emit($event)"
(dndMoved)="dndMoved.emit($event)"
(dndCanceled)="dndCanceled.emit($event)"
(dndEnd)="dndEnd.emit($event)"
>
<a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" i18n>Show all</a>
<a *ngIf="documents.length" class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
<table content class="table table-sm table-hover table-borderless mb-0">
<table *ngIf="documents.length; else empty" content class="table table-hover mb-0 align-middle">
<thead>
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
</tr>
</thead>
<tbody *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<tbody>
<tr *ngFor="let doc of documents" (mouseleave)="mouseLeaveCard()">
<td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
<td class="position-relative">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="d-block text-dark text-decoration-none">{{doc.title | documentTitle}}<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag></a>
<td class="py-2 py-md-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
<td class="py-2 py-md-3">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none">{{doc.title | documentTitle}}</a>
</td>
<td class="py-2 py-md-3 d-none d-md-table-cell">
<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
</td>
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
<a *ngIf="doc.correspondent !== null" class="btn-link" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn btn-sm px-4 py-0 btn-dark border-dark-subtle"
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreview(doc)" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<svg class="buttonicon-xs" fill="currentColor">
@ -26,7 +42,7 @@
<ng-template #previewContent>
<object [data]="getPreviewUrl(doc) | safeUrl" class="preview" width="100%"></object>
</ng-template>
<a [href]="getDownloadUrl(doc)" class="btn btn-sm px-4 py-0 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<svg class="buttonicon-xs" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download"/>
</svg>
@ -37,4 +53,8 @@
</tbody>
</table>
<ng-template #empty>
<p i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
</ng-template>
</pngx-widget-frame>

View File

@ -5,9 +5,12 @@ table {
th:first-child {
width: 25%;
@media (min-width: 768px) {
width: 15%;
}
}
tbody app-tag {
tbody pngx-tag {
cursor: pointer;
}
@ -22,3 +25,8 @@ tr:hover .btn-group {
opacity: 1;
pointer-events: all;
}
td.py-3 {
padding-top: 0.75em !important;
padding-bottom: 0.75em !important;
}

View File

@ -28,6 +28,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { SavedViewWidgetComponent } from './saved-view-widget.component'
import { By } from '@angular/platform-browser'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DndModule } from 'ngx-drag-drop'
const savedView: PaperlessSavedView = {
id: 1,
@ -52,6 +53,7 @@ const documentResults = [
{
id: 3,
title: 'doc3',
correspondent: 0,
},
]
@ -89,6 +91,7 @@ describe('SavedViewWidgetComponent', () => {
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
DndModule,
],
}).compileComponents()

View File

@ -1,23 +1,29 @@
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChildren,
} from '@angular/core'
import { Router } from '@angular/router'
import { Params, Router } from '@angular/router'
import { Subject, takeUntil } from 'rxjs'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import {
FILTER_CORRESPONDENT,
FILTER_HAS_TAGS_ALL,
} from 'src/app/data/filter-rule-type'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
@Component({
selector: 'pngx-saved-view-widget',
@ -38,7 +44,8 @@ export class SavedViewWidgetComponent
private router: Router,
private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService
public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService
) {
super()
}
@ -46,6 +53,18 @@ export class SavedViewWidgetComponent
@Input()
savedView: PaperlessSavedView
@Output()
dndStart: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndMoved: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndCanceled: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndEnd: EventEmitter<DragEvent> = new EventEmitter()
documents: PaperlessDocument[] = []
unsubscribeNotifier: Subject<any> = new Subject()
@ -141,4 +160,15 @@ export class SavedViewWidgetComponent
mouseLeaveCard() {
this.popover?.close()
}
getCorrespondentQueryParams(correspondentId: number): Params {
return correspondentId !== undefined
? queryParamsFromFilterRules([
{
rule_type: FILTER_CORRESPONDENT,
value: correspondentId.toString(),
},
])
: null
}
}

View File

@ -42,5 +42,32 @@
</div>
</div>
</div>
<div class="list-group border-light mt-3">
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
<a *ngIf="statistics?.tag_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/tags/">
<ng-container i18n>Tags</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.tag_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a *ngIf="statistics?.correspondent_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/correspondents/">
<ng-container i18n>Correspondents</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.correspondent_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a *ngIf="statistics?.document_type_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documenttypes/">
<ng-container i18n>Document Types</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.document_type_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a *ngIf="statistics?.storage_path_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documenttypes/">
<ng-container i18n>Storage Paths</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.storage_path_count | number}}</span>
</a>
</ng-container>
</div>
</ng-container>
</pngx-widget-frame>

View File

@ -11,24 +11,42 @@ import { environment } from 'src/environments/environment'
import { RouterTestingModule } from '@angular/router/testing'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DndModule } from 'ngx-drag-drop'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
import { Subject } from 'rxjs'
describe('StatisticsWidgetComponent', () => {
let component: StatisticsWidgetComponent
let fixture: ComponentFixture<StatisticsWidgetComponent>
let httpTestingController: HttpTestingController
let consumerStatusService: ConsumerStatusService
const fileStatusSubject = new Subject<FileStatus>()
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [StatisticsWidgetComponent, WidgetFrameComponent],
declarations: [
StatisticsWidgetComponent,
WidgetFrameComponent,
IfPermissionsDirective,
],
providers: [PermissionsGuard],
imports: [
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
DndModule,
],
}).compileComponents()
fixture = TestBed.createComponent(StatisticsWidgetComponent)
consumerStatusService = TestBed.inject(ConsumerStatusService)
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component = fixture.componentInstance
httpTestingController = TestBed.inject(HttpTestingController)
@ -43,6 +61,12 @@ describe('StatisticsWidgetComponent', () => {
expect(req.request.method).toEqual('GET')
})
it('should reload after doc is consumed', () => {
const reloadSpy = jest.spyOn(component, 'reload')
fileStatusSubject.next(new FileStatus())
expect(reloadSpy).toHaveBeenCalled()
})
it('should display inbox link with count', () => {
const mockStats = {
documents_total: 200,
@ -107,4 +131,62 @@ describe('StatisticsWidgetComponent', () => {
'CSV(10%)'
)
})
it('should limit mime types to 5 max', () => {
const mockStats = {
documents_total: 222,
documents_inbox: 18,
inbox_tag: 10,
document_file_type_counts: [
{
mime_type: 'application/pdf',
mime_type_count: 160,
},
{
mime_type: 'text/plain',
mime_type_count: 20,
},
{
mime_type: 'text/csv',
mime_type_count: 20,
},
{
mime_type: 'application/vnd.oasis.opendocument.text',
mime_type_count: 11,
},
{
mime_type: 'application/msword',
mime_type_count: 9,
},
{
mime_type: 'image/jpeg',
mime_type_count: 2,
},
],
character_count: 162312,
}
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}statistics/`
)
req.flush(mockStats)
fixture.detectChanges()
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'PDF(72.1%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'TXT(9%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'CSV(9%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'ODT(5%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'Other(0.9%)'
)
})
})

View File

@ -6,6 +6,7 @@ import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { environment } from 'src/environments/environment'
import * as mimeTypeNames from 'mime-names'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
export interface Statistics {
documents_total?: number
@ -13,6 +14,10 @@ export interface Statistics {
inbox_tag?: number
document_file_type_counts?: DocumentFileType[]
character_count?: number
tag_count?: number
correspondent_count?: number
document_type_count?: number
storage_path_count?: number
}
interface DocumentFileType {
@ -25,14 +30,19 @@ interface DocumentFileType {
templateUrl: './statistics-widget.component.html',
styleUrls: ['./statistics-widget.component.scss'],
})
export class StatisticsWidgetComponent implements OnInit, OnDestroy {
export class StatisticsWidgetComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
loading: boolean = true
constructor(
private http: HttpClient,
private consumerStatusService: ConsumerStatusService,
private documentListViewService: DocumentListViewService
) {}
) {
super()
}
statistics: Statistics = {}
@ -87,7 +97,7 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy {
this.reload()
this.subscription = this.consumerStatusService
.onDocumentConsumptionFinished()
.subscribe((status) => {
.subscribe(() => {
this.reload()
})
}

View File

@ -1,24 +1,26 @@
<pngx-widget-frame title="Upload new documents" i18n-title *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }">
<div header-buttons>
<a *ngIf="getStatusSuccess().length > 0" (click)="dismissCompleted()" [routerLink]="[]" >
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-check2-all" viewBox="0 0 16 16">
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7zm-4.208 7l-.896-.897.707-.707.543.543 6.646-6.647a.5.5 0 0 1 .708.708l-7 7a.5.5 0 0 1-.708 0z"/>
<path d="M5.354 7.146l.896.897-.707.707-.897-.896a.5.5 0 1 1 .708-.708z"/>
</svg>
</a>
</div>
<div content tourAnchor="tour.upload-widget">
<form>
<ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card"
multiple="true" contentClassName="justify-content-center d-flex align-items-center py-5 px-2" [showBrowseBtn]=true
browseBtnClassName="btn btn-sm btn-outline-primary ms-2" i18n-dropZoneLabel i18n-browseBtnLabel>
</ngx-file-drop>
<form class="justify-content-center d-flex flex-column align-items-center py-3 px-2">
<span class="text-muted" i18n>Drop documents anywhere or</span>
<button class="btn btn-sm btn-outline-primary mt-3" (click)="fileUpload.click()" i18n>Browse files</button>
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form>
<p class="mt-3" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p>
<div *ngFor="let status of getStatus()">
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
<div class="fixed-bottom p-2 p-md-4" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
<div class="row d-flex justify-content-end">
<div class="col col-lg-4 col-xl-3 d-flex px-4 justify-content-between align-items-center">
<p class="m-0 small text-muted" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p>
<a *ngIf="getStatusCompleted().length > 0" class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-check2-all" viewBox="0 0 16 16">
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7zm-4.208 7l-.896-.897.707-.707.543.543 6.646-6.647a.5.5 0 0 1 .708.708l-7 7a.5.5 0 0 1-.708 0z"/>
<path d="M5.354 7.146l.896.897-.707.707-.897-.896a.5.5 0 1 1 .708-.708z"/>
</svg>
</a>
</div>
</div>
<div *ngFor="let status of getStatus()">
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
</div>
</div>
<div *ngIf="getStatusHidden().length" class="alerts-hidden">
<p *ngIf="!alertsExpanded" class="mt-3 mb-0 text-center">
@ -36,19 +38,23 @@
</pngx-widget-frame>
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
<p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p>
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<div *ngIf="isFinished(status)">
<button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)">
<small i18n>Open document</small>
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
</svg>
</button>
</div>
<div class="row d-flex justify-content-end">
<div class="col col-lg-4 col-xl-3">
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
<p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p>
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<div *ngIf="isFinished(status)">
<button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)">
<small i18n>Open document</small>
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
</svg>
</button>
</div>
</div>
</ngb-alert>
</div>
</ngb-alert>
</div>
</ng-template>

View File

@ -1,5 +1,10 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import {
@ -8,7 +13,6 @@ import {
NgbAlert,
NgbCollapse,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxFileDropModule } from 'ngx-file-drop'
import { routes } from 'src/app/app-routing.module'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
@ -21,6 +25,7 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { UploadFileWidgetComponent } from './upload-file-widget.component'
import { DndModule } from 'ngx-drag-drop'
describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent
@ -48,8 +53,8 @@ describe('UploadFileWidgetComponent', () => {
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
NgxFileDropModule,
NgbAlertModule,
DndModule,
],
}).compileComponents()
@ -61,13 +66,21 @@ describe('UploadFileWidgetComponent', () => {
fixture.detectChanges()
})
it('should support drop files', () => {
it('should support browse files', () => {
const fileInput = fixture.debugElement.query(By.css('input'))
const clickSpy = jest.spyOn(fileInput.nativeElement, 'click')
fixture.debugElement
.query(By.css('button'))
.nativeElement.dispatchEvent(new Event('click'))
expect(clickSpy).toHaveBeenCalled()
})
it('should upload files', () => {
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
component.dropped([])
fixture.debugElement
.query(By.css('input'))
.nativeElement.dispatchEvent(new Event('change'))
expect(uploadSpy).toHaveBeenCalled()
// coverage
component.fileLeave(null)
component.fileOver(null)
})
it('should generate stats summary', () => {
@ -114,11 +127,15 @@ describe('UploadFileWidgetComponent', () => {
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing all alerts', () => {
const dismissSpy = jest.spyOn(consumerStatusService, 'dismissCompleted')
it('should allow dismissing all alerts', fakeAsync(() => {
mockConsumerStatuses(consumerStatusService)
fixture.detectChanges()
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
component.dismissCompleted()
expect(dismissSpy).toHaveBeenCalled()
})
tick(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(6)
}))
})
function mockConsumerStatuses(consumerStatusService) {

View File

@ -1,11 +1,13 @@
import { Component } from '@angular/core'
import { NgxFileDropEntry } from 'ngx-file-drop'
import { Component, QueryList, ViewChildren } from '@angular/core'
import { NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import {
ConsumerStatusService,
FileStatus,
FileStatusPhase,
} from 'src/app/services/consumer-status.service'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
const MAX_ALERTS = 5
@ -18,9 +20,12 @@ const MAX_ALERTS = 5
export class UploadFileWidgetComponent extends ComponentWithPermissions {
alertsExpanded = false
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
constructor(
private consumerStatusService: ConsumerStatusService,
private uploadDocumentsService: UploadDocumentsService
private uploadDocumentsService: UploadDocumentsService,
public settingsService: SettingsService
) {
super()
}
@ -69,6 +74,10 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS)
}
getStatusCompleted() {
return this.consumerStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
@ -106,14 +115,16 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
dismissCompleted() {
this.consumerStatusService.dismissCompleted()
this.alerts.forEach((a) => a.close())
}
public fileOver(event) {}
public onFileSelected(event: Event) {
this.uploadDocumentsService.uploadFiles(
(event.target as HTMLInputElement).files
)
}
public fileLeave(event) {}
public dropped(files: NgxFileDropEntry[]) {
this.uploadDocumentsService.uploadFiles(files)
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
}

View File

@ -1,7 +1,21 @@
<div class="card mb-3 shadow-sm bg-light">
<div class="card shadow-sm bg-light"
[dndDraggable]="draggable"
dndEffectAllowed="move"
[dndDisableIf]="!draggable"
(dndStart)="dndStart.emit($event)"
(dndMoved)="dndMoved.emit($event)"
(dndCanceled)="dndCanceled.emit($event)"
(dndEnd)="dndEnd.emit($event)">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{{title}}</h5>
<div class="d-flex">
<div *ngIf="draggable" class="ms-n2 me-1" dndHandle>
<svg class="sidebaricon text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#grip-vertical"/>
</svg>
</div>
<h6 class="card-title mb-0">{{title}}</h6>
</div>
<ng-container *ngIf="loading">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>

View File

@ -4,6 +4,7 @@ import { By } from '@angular/platform-browser'
import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { WidgetFrameComponent } from './widget-frame.component'
import { DndModule } from 'ngx-drag-drop'
@Component({
template: `
@ -29,7 +30,7 @@ describe('WidgetFrameComponent', () => {
TestBed.configureTestingModule({
declarations: [WidgetFrameComponent, WidgetFrameComponent],
providers: [PermissionsGuard],
imports: [NgbAlertModule],
imports: [NgbAlertModule, DndModule],
}).compileComponents()
fixture = TestBed.createComponent(WidgetFrameComponent)

View File

@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core'
import { Component, EventEmitter, Input, Output } from '@angular/core'
@Component({
selector: 'pngx-widget-frame',
@ -13,4 +13,19 @@ export class WidgetFrameComponent {
@Input()
loading: boolean = false
@Input()
draggable: any
@Output()
dndStart: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndMoved: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndCanceled: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndEnd: EventEmitter<DragEvent> = new EventEmitter()
}

View File

@ -81,7 +81,7 @@
</pngx-page-header>
<div class="row sticky-top pt-3 pt-sm-4 pb-3 pb-lg-4 bg-body">
<div class="row sticky-top pb-3 bg-body">
<pngx-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-bulk-editor [hidden]="!isBulkEditing"></pngx-bulk-editor>
</div>

View File

@ -0,0 +1,14 @@
<div [class.pe-none]="fileIsOver">
<ng-content select="[content]"></ng-content>
</div>
<div class="global-dropzone-overlay position-fixed top-0 start-0 bottom-0 end-0 text-center pe-none fade" [class.show]="fileIsOver" [class.hide]="hidden">
<h2 class="pe-none position-absolute top-50 start-50 translate-middle" i18n>Drop files to begin upload</h2>
</div>
<ngx-file-drop
dropZoneClassName="visually-hidden"
contentClassName="visually-hidden"
(onFileDrop)="dropped($event)"
#ngxFileDrop>
</ngx-file-drop>

View File

@ -0,0 +1,8 @@
.global-dropzone-overlay {
background-color: hsla(var(--pngx-primary), var(--pngx-primary-lightness), .8);
z-index: 1200;
h2 {
color: var(--pngx-primary-text-contrast)
}
}

View File

@ -0,0 +1,177 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
flush,
tick,
} from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { PermissionsService } from 'src/app/services/permissions.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { ToastsComponent } from '../common/toasts/toasts.component'
import { FileDropComponent } from './file-drop.component'
import { NgxFileDropEntry, NgxFileDropModule } from 'ngx-file-drop'
describe('FileDropComponent', () => {
let component: FileDropComponent
let fixture: ComponentFixture<FileDropComponent>
let permissionsService: PermissionsService
let toastService: ToastService
let settingsService: SettingsService
let uploadDocumentsService: UploadDocumentsService
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [FileDropComponent, ToastsComponent],
providers: [],
imports: [HttpClientTestingModule, NgxFileDropModule],
}).compileComponents()
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(FileDropComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should enable drag-drop if user has permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.dragDropEnabled).toBeTruthy()
})
it('should disable drag-drop if user does not have permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
expect(component.dragDropEnabled).toBeFalsy()
})
it('should disable drag-drop if disabled in settings', fakeAsync(() => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
settingsService.globalDropzoneEnabled = false
expect(component.dragDropEnabled).toBeFalsy()
component.onDragOver(new Event('dragover') as DragEvent)
tick(1)
fixture.detectChanges()
expect(component.fileIsOver).toBeFalsy()
const dropzone = fixture.debugElement.query(
By.css('.global-dropzone-overlay')
)
expect(dropzone.classes['hide']).toBeTruthy()
component.onDragLeave(new Event('dragleave') as DragEvent)
tick(700)
fixture.detectChanges()
// drop
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
const dragEvent = new Event('drop')
dragEvent['dataTransfer'] = {
files: {
item: () => {},
length: 0,
},
}
component.onDrop(dragEvent as DragEvent)
tick(3000)
expect(uploadSpy).not.toHaveBeenCalled()
}))
it('should support drag drop, initiate upload', fakeAsync(() => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.fileIsOver).toBeFalsy()
component.onDragOver(new Event('dragover') as DragEvent)
tick(1)
fixture.detectChanges()
expect(component.fileIsOver).toBeTruthy()
const dropzone = fixture.debugElement.query(
By.css('.global-dropzone-overlay')
)
component.onDragLeave(new Event('dragleave') as DragEvent)
tick(700)
fixture.detectChanges()
expect(dropzone.classes['hide']).toBeTruthy()
// drop
const toastSpy = jest.spyOn(toastService, 'show')
const uploadSpy = jest.spyOn(
UploadDocumentsService.prototype as any,
'uploadFile'
)
const dragEvent = new Event('drop')
dragEvent['dataTransfer'] = {
files: {
item: () => {
return new File(
[new Blob(['testing'], { type: 'application/pdf' })],
'file.pdf'
)
},
length: 1,
} as unknown as FileList,
}
component.onDrop(dragEvent as DragEvent)
component.dropped([
{
fileEntry: {
isFile: true,
file: (callback) => {
callback(
new File(
[new Blob(['testing'], { type: 'application/pdf' })],
'file.pdf'
)
)
},
},
} as unknown as NgxFileDropEntry,
])
tick(3000)
expect(toastSpy).toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled()
discardPeriodicTasks()
}))
it('should ignore events if disabled', fakeAsync(() => {
settingsService.globalDropzoneEnabled = false
expect(settingsService.globalDropzoneActive).toBeFalsy()
component.onDragOver(new Event('dragover') as DragEvent)
expect(settingsService.globalDropzoneActive).toBeFalsy()
settingsService.globalDropzoneActive = true
component.onDragLeave(new Event('dragleave') as DragEvent)
expect(settingsService.globalDropzoneActive).toBeTruthy()
component.onDrop(new Event('drop') as DragEvent)
expect(settingsService.globalDropzoneActive).toBeTruthy()
}))
it('should hide if app loses focus', fakeAsync(() => {
const leaveSpy = jest.spyOn(component, 'onDragLeave')
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
settingsService.globalDropzoneEnabled = true
component.onDragOver(new Event('dragover') as DragEvent)
tick(1)
expect(component.hidden).toBeFalsy()
expect(component.fileIsOver).toBeTruthy()
jest.spyOn(document, 'hidden', 'get').mockReturnValue(true)
component.onVisibilityChange()
expect(leaveSpy).toHaveBeenCalled()
flush()
}))
it('should hide on window blur', fakeAsync(() => {
const leaveSpy = jest.spyOn(component, 'onDragLeave')
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
settingsService.globalDropzoneEnabled = true
component.onDragOver(new Event('dragover') as DragEvent)
tick(1)
expect(component.hidden).toBeFalsy()
expect(component.fileIsOver).toBeTruthy()
jest.spyOn(document, 'hidden', 'get').mockReturnValue(true)
component.onWindowBlur()
expect(leaveSpy).toHaveBeenCalled()
flush()
}))
})

View File

@ -0,0 +1,98 @@
import { Component, HostListener, ViewChild } from '@angular/core'
import { NgxFileDropComponent, NgxFileDropEntry } from 'ngx-file-drop'
import {
PermissionsService,
PermissionAction,
PermissionType,
} from 'src/app/services/permissions.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
@Component({
selector: 'pngx-file-drop',
templateUrl: './file-drop.component.html',
styleUrls: ['./file-drop.component.scss'],
})
export class FileDropComponent {
private fileLeaveTimeoutID: any
fileIsOver: boolean = false
hidden: boolean = true
constructor(
private settings: SettingsService,
private toastService: ToastService,
private uploadDocumentsService: UploadDocumentsService,
private permissionsService: PermissionsService
) {}
public get dragDropEnabled(): boolean {
return (
this.settings.globalDropzoneEnabled &&
this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
)
)
}
@ViewChild('ngxFileDrop') ngxFileDrop: NgxFileDropComponent
@HostListener('dragover', ['$event ']) onDragOver(event: DragEvent) {
if (!this.dragDropEnabled) return
event.preventDefault()
event.stopImmediatePropagation()
this.settings.globalDropzoneActive = true
// allows transition
setTimeout(() => {
this.fileIsOver = true
}, 1)
this.hidden = false
// stop fileLeave timeout
clearTimeout(this.fileLeaveTimeoutID)
}
@HostListener('dragleave', ['$event']) public onDragLeave(
event: DragEvent,
immediate: boolean = false
) {
if (!this.dragDropEnabled) return
event?.preventDefault()
event?.stopImmediatePropagation()
this.settings.globalDropzoneActive = false
const ms = immediate ? 0 : 500
this.fileLeaveTimeoutID = setTimeout(() => {
this.fileIsOver = false
// await transition completed
setTimeout(() => {
this.hidden = true
}, 150)
}, ms)
}
@HostListener('drop', ['$event']) public onDrop(event: DragEvent) {
if (!this.dragDropEnabled) return
event.preventDefault()
event.stopImmediatePropagation()
// pass event onto ngx-file-drop to handle files
this.ngxFileDrop.dropFiles(event)
this.onDragLeave(event, true)
}
public dropped(files: NgxFileDropEntry[]) {
this.uploadDocumentsService.onNgxFileDrop(files)
if (files.length > 0)
this.toastService.showInfo($localize`Initiating upload...`, 3000)
}
@HostListener('window:blur', ['$event']) public onWindowBlur() {
if (this.fileIsOver) this.onDragLeave(null)
}
@HostListener('document:visibilitychange', ['$event'])
public onVisibilityChange() {
if (document.hidden && this.fileIsOver) this.onDragLeave(null)
}
}

View File

@ -41,6 +41,8 @@ export const SETTINGS_KEYS = {
'general-settings:update-checking:backend-setting',
SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE:
'general-settings:saved-views:warn-on-unsaved-change',
DASHBOARD_VIEWS_SORT_ORDER:
'general-settings:saved-views:dashboard-views-sort-order',
TOUR_COMPLETE: 'general-settings:tour-complete',
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
@ -180,4 +182,9 @@ export const SETTINGS: PaperlessUiSetting[] = [
type: 'array',
default: [],
},
{
key: SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
type: 'array',
default: [],
},
]

View File

@ -230,7 +230,10 @@ export class ConsumerStatusService {
dismissCompleted() {
this.consumerStatus = this.consumerStatus.filter(
(status) => status.phase != FileStatusPhase.SUCCESS
(status) =>
![FileStatusPhase.SUCCESS, FileStatusPhase.FAILED].includes(
status.phase
)
)
}

View File

@ -15,6 +15,7 @@ import {
SETTINGS_KEYS,
} from '../data/paperless-uisettings'
import { SettingsService } from './settings.service'
import { PaperlessSavedView } from '../data/paperless-saved-view'
describe('SettingsService', () => {
let httpTestingController: HttpTestingController
@ -277,4 +278,22 @@ describe('SettingsService', () => {
)[0]
expect(req.request.method).toEqual('POST')
})
it('should update saved view sorting', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
const setSpy = jest.spyOn(settingsService, 'set')
settingsService.updateDashboardViewsSort([
{ id: 1 } as PaperlessSavedView,
{ id: 4 } as PaperlessSavedView,
])
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
[1, 4]
)
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
})
})

View File

@ -26,6 +26,7 @@ import { PaperlessUser } from '../data/paperless-user'
import { PermissionsService } from './permissions.service'
import { SavedViewService } from './rest/saved-view.service'
import { ToastService } from './toast.service'
import { PaperlessSavedView } from '../data/paperless-saved-view'
export interface LanguageOption {
code: string
@ -54,6 +55,9 @@ export class SettingsService {
return this._renderer
}
public globalDropzoneEnabled: boolean = true
public globalDropzoneActive: boolean = false
constructor(
rendererFactory: RendererFactory2,
@Inject(DOCUMENT) private document,
@ -531,4 +535,13 @@ export class SettingsService {
})
}
}
updateDashboardViewsSort(
dashboardViews: PaperlessSavedView[]
): Observable<any> {
this.set(SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER, [
...new Set(dashboardViews.map((v) => v.id)),
])
return this.storeSettings()
}
}

View File

@ -5,12 +5,39 @@ import {
HttpTestingController,
} from '@angular/common/http/testing'
import { environment } from 'src/environments/environment'
import { HttpEventType, HttpResponse } from '@angular/common/http'
import { HttpEventType } from '@angular/common/http'
import {
ConsumerStatusService,
FileStatusPhase,
} from './consumer-status.service'
const files = [
{
lastModified: 1693349892540,
lastModifiedDate: new Date(),
name: 'file1.pdf',
size: 386,
type: 'application/pdf',
},
{
lastModified: 1695618533892,
lastModifiedDate: new Date(),
name: 'file2.pdf',
size: 358265,
type: 'application/pdf',
},
]
const fileList = {
item: (x) => {
return new File(
[new Blob(['testing'], { type: files[x].type })],
files[x].name
)
},
length: files.length,
} as unknown as FileList
describe('UploadDocumentsService', () => {
let httpTestingController: HttpTestingController
let uploadDocumentsService: UploadDocumentsService
@ -32,66 +59,30 @@ describe('UploadDocumentsService', () => {
})
it('calls post_document api endpoint on upload', () => {
const fileEntry = {
name: 'file.pdf',
isDirectory: false,
isFile: true,
file: (callback) => {
return callback(
new File(
[new Blob(['testing'], { type: 'application/pdf' })],
'file.pdf'
)
)
},
}
uploadDocumentsService.uploadFiles([
{
relativePath: 'path/to/file.pdf',
fileEntry,
},
])
const req = httpTestingController.expectOne(
uploadDocumentsService.uploadFiles(fileList)
const req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
expect(req.request.method).toEqual('POST')
expect(req[0].request.method).toEqual('POST')
req.flush('123-456')
req[0].flush('123-456')
})
it('updates progress during upload and failure', () => {
const fileEntry = {
name: 'file.pdf',
isDirectory: false,
isFile: true,
file: (callback) => {
return callback(
new File(
[new Blob(['testing'], { type: 'application/pdf' })],
'file.pdf'
)
)
},
}
uploadDocumentsService.uploadFiles([
{
relativePath: 'path/to/file.pdf',
fileEntry,
},
])
uploadDocumentsService.uploadFiles(fileList)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
2
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toHaveLength(0)
const req = httpTestingController.expectOne(
const req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
req.event({
req[0].event({
type: HttpEventType.UploadProgress,
loaded: 100,
total: 300,
@ -103,6 +94,52 @@ describe('UploadDocumentsService', () => {
})
it('updates progress on failure', () => {
uploadDocumentsService.uploadFiles(fileList)
let req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(0)
req[0].flush(
{},
{
status: 400,
statusText: 'failed',
}
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(1)
uploadDocumentsService.uploadFiles(fileList)
req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
req[0].flush(
{},
{
status: 500,
statusText: 'failed',
}
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(2)
})
it('accepts files via drag and drop', () => {
const uploadSpy = jest.spyOn(
UploadDocumentsService.prototype as any,
'uploadFile'
)
const fileEntry = {
name: 'file.pdf',
isDirectory: false,
@ -116,54 +153,16 @@ describe('UploadDocumentsService', () => {
)
},
}
uploadDocumentsService.uploadFiles([
uploadDocumentsService.onNgxFileDrop([
{
relativePath: 'path/to/file.pdf',
fileEntry,
},
])
expect(uploadSpy).toHaveBeenCalled()
let req = httpTestingController.expectOne(
let req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(0)
req.flush(
{},
{
status: 400,
statusText: 'failed',
}
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(1)
uploadDocumentsService.uploadFiles([
{
relativePath: 'path/to/file.pdf',
fileEntry,
},
])
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/post_document/`
)
req.flush(
{},
{
status: 500,
statusText: 'failed',
}
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(2)
})
})

View File

@ -19,56 +19,61 @@ export class UploadDocumentsService {
private consumerStatusService: ConsumerStatusService
) {}
uploadFiles(files: NgxFileDropEntry[]) {
onNgxFileDrop(files: NgxFileDropEntry[]) {
for (const droppedFile of files) {
if (droppedFile.fileEntry.isFile) {
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry
fileEntry.file((file: File) => {
let formData = new FormData()
formData.append('document', file, file.name)
let status = this.consumerStatusService.newFileUpload(file.name)
status.message = $localize`Connecting...`
this.uploadSubscriptions[file.name] = this.documentService
.uploadDocument(formData)
.subscribe({
next: (event) => {
if (event.type == HttpEventType.UploadProgress) {
status.updateProgress(
FileStatusPhase.UPLOADING,
event.loaded,
event.total
)
status.message = $localize`Uploading...`
} else if (event.type == HttpEventType.Response) {
status.taskId = event.body['task_id']
status.message = $localize`Upload complete, waiting...`
this.uploadSubscriptions[file.name]?.complete()
}
},
error: (error) => {
switch (error.status) {
case 400: {
this.consumerStatusService.fail(
status,
error.error.document
)
break
}
default: {
this.consumerStatusService.fail(
status,
$localize`HTTP error: ${error.status} ${error.statusText}`
)
break
}
}
this.uploadSubscriptions[file.name]?.complete()
},
})
})
fileEntry.file((file: File) => this.uploadFile(file))
}
}
}
uploadFiles(files: FileList) {
for (let index = 0; index < files.length; index++) {
this.uploadFile(files.item(index))
}
}
private uploadFile(file: File) {
let formData = new FormData()
formData.append('document', file, file.name)
let status = this.consumerStatusService.newFileUpload(file.name)
status.message = $localize`Connecting...`
this.uploadSubscriptions[file.name] = this.documentService
.uploadDocument(formData)
.subscribe({
next: (event) => {
if (event.type == HttpEventType.UploadProgress) {
status.updateProgress(
FileStatusPhase.UPLOADING,
event.loaded,
event.total
)
status.message = $localize`Uploading...`
} else if (event.type == HttpEventType.Response) {
status.taskId = event.body['task_id']
status.message = $localize`Upload complete, waiting...`
this.uploadSubscriptions[file.name]?.complete()
}
},
error: (error) => {
switch (error.status) {
case 400: {
this.consumerStatusService.fail(status, error.error.document)
break
}
default: {
this.consumerStatusService.fail(
status,
$localize`HTTP error: ${error.status} ${error.statusText}`
)
break
}
}
this.uploadSubscriptions[file.name]?.complete()
},
})
}
}

View File

@ -16,6 +16,12 @@ body {
transition: background-color 0.3s ease, border-color 0.3s ease;
}
@media(min-width: 768px) {
.col-slim {
padding-left: calc(50px + $grid-gutter-width) !important;
}
}
svg.logo {
.leaf {
fill: var(--bs-primary) !important;
@ -478,50 +484,6 @@ table.table {
color: var(--bs-body-color);
}
.main-dropzone {
height: 100%;
width: 100%;
&.ngx-file-drop__drop-zone--over {
background-color: transparent !important;
}
}
.global-dropzone-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: hsla(var(--pngx-primary), var(--pngx-primary-lightness), .8);
z-index: 1055; // $zindex-modal
pointer-events: none !important;
user-select: none !important;
text-align: center;
padding-top: 25%;
h2 {
color: var(--pngx-primary-text-contrast)
}
&.show {
opacity: 1 !important;
}
&.hide {
display: none;
}
}
.ngx-file-drop__drop-zone--over .global-dropzone-overlay {
opacity: 0;
}
.inert {
pointer-events: none !important;
user-select: none !important;
}
.alert-primary {
--bs-alert-color: var(--bs-primary);
--bs-alert-bg: var(--pngx-primary-faded);

View File

@ -214,6 +214,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
}
}
.card .table {
border-color: var(--bs-gray-800);
}
.alert-secondary {
background-color: var(--bs-light);
border-color: var(--pngx-bg-darker);

View File

@ -1571,6 +1571,13 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
)
tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True)
Tag.objects.create(name="t2")
Tag.objects.create(name="t3")
Correspondent.objects.create(name="c1")
Correspondent.objects.create(name="c2")
DocumentType.objects.create(name="dt1")
StoragePath.objects.create(name="sp1")
StoragePath.objects.create(name="sp2")
doc1.tags.add(tag_inbox)
@ -1588,6 +1595,10 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
1,
)
self.assertEqual(response.data["character_count"], 11)
self.assertEqual(response.data["tag_count"], 3)
self.assertEqual(response.data["correspondent_count"], 2)
self.assertEqual(response.data["document_type_count"], 1)
self.assertEqual(response.data["storage_path_count"], 2)
def test_statistics_no_inbox_tag(self):
Document.objects.create(title="none1", checksum="A")

View File

@ -907,6 +907,39 @@ class StatisticsView(APIView):
if user is None
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
)
correspondent_count = (
Correspondent.objects.count()
if user is None
else len(
get_objects_for_user_owner_aware(
user,
"documents.view_correspondent",
Correspondent,
),
)
)
document_type_count = (
DocumentType.objects.count()
if user is None
else len(
get_objects_for_user_owner_aware(
user,
"documents.view_documenttype",
DocumentType,
),
)
)
storage_path_count = (
StoragePath.objects.count()
if user is None
else len(
get_objects_for_user_owner_aware(
user,
"documents.view_storagepath",
StoragePath,
),
)
)
documents_total = documents.count()
@ -941,6 +974,10 @@ class StatisticsView(APIView):
"inbox_tag": inbox_tag.first().pk if inbox_tag.exists() else None,
"document_file_type_counts": document_file_type_counts,
"character_count": character_count,
"tag_count": len(tags),
"correspondent_count": correspondent_count,
"document_type_count": document_type_count,
"storage_path_count": storage_path_count,
},
)