mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: custom fields queries (#7761)
This commit is contained in:
@@ -698,7 +698,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
|
||||
<context context-type="linenumber">38</context>
|
||||
<context context-type="linenumber">51</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
|
||||
@@ -1031,7 +1031,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">143</context>
|
||||
<context context-type="linenumber">152</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8104421162933956065" datatype="html">
|
||||
@@ -1088,7 +1088,7 @@
|
||||
</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">110</context>
|
||||
<context context-type="linenumber">105</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||
@@ -3300,6 +3300,102 @@
|
||||
<context context-type="linenumber">63</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4465085913683915434" datatype="html">
|
||||
<source>True</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">40</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">73</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">79</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3800326155195149498" datatype="html">
|
||||
<source>False</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">41</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">74</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">80</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7551700625201096185" datatype="html">
|
||||
<source>Search docs...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">96</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3184700926171002527" datatype="html">
|
||||
<source>Any</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">126</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
|
||||
<context context-type="linenumber">17</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1616102757855967475" datatype="html">
|
||||
<source>All</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">128</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
|
||||
<context context-type="linenumber">15</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
|
||||
<context context-type="linenumber">16</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||
<context context-type="linenumber">16</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||
<context context-type="linenumber">27</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">14</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1496549861742963591" datatype="html">
|
||||
<source>Not</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">131</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6548676277933116532" datatype="html">
|
||||
<source>Add query</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">150</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5599577087865387184" datatype="html">
|
||||
<source>Add expression</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||
<context context-type="linenumber">153</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6052766076365105714" datatype="html">
|
||||
<source>now</source>
|
||||
<context-group purpose="location">
|
||||
@@ -4549,36 +4645,6 @@
|
||||
<context context-type="linenumber">146</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1616102757855967475" datatype="html">
|
||||
<source>All</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
|
||||
<context context-type="linenumber">15</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
|
||||
<context context-type="linenumber">16</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||
<context context-type="linenumber">16</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||
<context context-type="linenumber">27</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">14</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3184700926171002527" datatype="html">
|
||||
<source>Any</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
|
||||
<context context-type="linenumber">17</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6381578200008167206" datatype="html">
|
||||
<source>Include</source>
|
||||
<context-group purpose="location">
|
||||
@@ -4668,7 +4734,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
|
||||
<context context-type="linenumber">9</context>
|
||||
<context context-type="linenumber">12</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
|
||||
@@ -4740,14 +4806,14 @@
|
||||
<source>Remove link</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
|
||||
<context context-type="linenumber">30</context>
|
||||
<context context-type="linenumber">43</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1388712764439031120" datatype="html">
|
||||
<source>Open link</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
|
||||
<context context-type="linenumber">31</context>
|
||||
<context context-type="linenumber">44</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/url/url.component.html</context>
|
||||
@@ -4761,6 +4827,13 @@
|
||||
<context context-type="linenumber">44</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5676637575587497817" datatype="html">
|
||||
<source>Search for documents</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
|
||||
<context context-type="linenumber">53</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8627133593113147800" datatype="html">
|
||||
<source>Selected items</source>
|
||||
<context-group purpose="location">
|
||||
@@ -5834,7 +5907,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">131</context>
|
||||
<context context-type="linenumber">140</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -6416,7 +6489,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">139</context>
|
||||
<context context-type="linenumber">148</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6475890479659129881" datatype="html">
|
||||
@@ -6425,10 +6498,6 @@
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
<context context-type="linenumber">83</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">90</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3206542606001340679" datatype="html">
|
||||
<source>Merge</source>
|
||||
@@ -6925,7 +6994,7 @@
|
||||
</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">116</context>
|
||||
<context context-type="linenumber">111</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1559883523769732271" datatype="html">
|
||||
@@ -6950,7 +7019,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">136</context>
|
||||
<context context-type="linenumber">145</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/document.ts</context>
|
||||
@@ -7126,161 +7195,154 @@
|
||||
<source>Dates</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||
<context context-type="linenumber">100</context>
|
||||
<context context-type="linenumber">95</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3100631071441658964" datatype="html">
|
||||
<source>Title & content</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">134</context>
|
||||
<context context-type="linenumber">143</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2649431021108393503" datatype="html">
|
||||
<source>More like</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">149</context>
|
||||
<context context-type="linenumber">158</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3697582909018473071" datatype="html">
|
||||
<source>equals</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">155</context>
|
||||
<context context-type="linenumber">164</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5325481293405718739" datatype="html">
|
||||
<source>is empty</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">159</context>
|
||||
<context context-type="linenumber">168</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6166785695326182482" datatype="html">
|
||||
<source>is not empty</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">163</context>
|
||||
<context context-type="linenumber">172</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4686622206659266699" datatype="html">
|
||||
<source>greater than</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">167</context>
|
||||
<context context-type="linenumber">176</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8014012170270529279" datatype="html">
|
||||
<source>less than</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">171</context>
|
||||
<context context-type="linenumber">180</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5195932016807797291" datatype="html">
|
||||
<source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) => c.id == +rule.value)?.name"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">191,193</context>
|
||||
<context context-type="linenumber">200,202</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8170755470576301659" datatype="html">
|
||||
<source>Without correspondent</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">195</context>
|
||||
<context context-type="linenumber">204</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="317796810569008208" datatype="html">
|
||||
<source>Document type: <x id="PH" equiv-text="this.documentTypes.find((dt) => dt.id == +rule.value)?.name"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">201,203</context>
|
||||
<context context-type="linenumber">210,212</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4362173610367509215" datatype="html">
|
||||
<source>Without document type</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">205</context>
|
||||
<context context-type="linenumber">214</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="232202047340644471" datatype="html">
|
||||
<source>Storage path: <x id="PH" equiv-text="this.storagePaths.find((sp) => sp.id == +rule.value)?.name"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">211,213</context>
|
||||
<context context-type="linenumber">220,222</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1562820715074533164" datatype="html">
|
||||
<source>Without storage path</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">215</context>
|
||||
<context context-type="linenumber">224</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8180755793012580465" datatype="html">
|
||||
<source>Tag: <x id="PH" equiv-text="this.tags.find((t) => t.id == +rule.value)?.name"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">219,221</context>
|
||||
<context context-type="linenumber">228,230</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6494566478302448576" datatype="html">
|
||||
<source>Without any tag</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">225</context>
|
||||
<context context-type="linenumber">234</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6370692707013694620" datatype="html">
|
||||
<source>Custom fields: <x id="PH" equiv-text="this.customFields.find((f) => f.id == +rule.value)?.name"/></source>
|
||||
<trans-unit id="8644099678903817943" datatype="html">
|
||||
<source>Custom fields query</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">229,231</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5297600960590041873" datatype="html">
|
||||
<source>Without any custom field</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">235</context>
|
||||
<context context-type="linenumber">238</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6523384805359286307" datatype="html">
|
||||
<source>Title: <x id="PH" equiv-text="rule.value"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">239</context>
|
||||
<context context-type="linenumber">241</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1872523635812236432" datatype="html">
|
||||
<source>ASN: <x id="PH" equiv-text="rule.value"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">242</context>
|
||||
<context context-type="linenumber">244</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="102674688969746976" datatype="html">
|
||||
<source>Owner: <x id="PH" equiv-text="rule.value"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">245</context>
|
||||
<context context-type="linenumber">247</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3550877650686009106" datatype="html">
|
||||
<source>Owner not in: <x id="PH" equiv-text="rule.value"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">248</context>
|
||||
<context context-type="linenumber">250</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1082034558646673343" datatype="html">
|
||||
<source>Without an owner</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
|
||||
<context context-type="linenumber">251</context>
|
||||
<context context-type="linenumber">253</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7210076240260527720" datatype="html">
|
||||
@@ -8007,6 +8069,83 @@
|
||||
<context context-type="linenumber">9</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7088714514100361567" datatype="html">
|
||||
<source>Equal to</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">24</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2841739558138901231" datatype="html">
|
||||
<source>In</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">25</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6504828068656625171" datatype="html">
|
||||
<source>Is null</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">26</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4112599358351148632" datatype="html">
|
||||
<source>Exists</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">27</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6238291467288576076" datatype="html">
|
||||
<source>Contains</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">28</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="870133374397538941" datatype="html">
|
||||
<source>Contains (case-insensitive)</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">29</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7732309408488818531" datatype="html">
|
||||
<source>Greater than</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">30</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9087788064443057357" datatype="html">
|
||||
<source>Greater than or equal to</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">31</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5995604223909447366" datatype="html">
|
||||
<source>Less than</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6989379963430864867" datatype="html">
|
||||
<source>Less than or equal to</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">33</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2348971518300945764" datatype="html">
|
||||
<source>Range</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/data/custom-field-query.ts</context>
|
||||
<context context-type="linenumber">34</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="969459137986754249" datatype="html">
|
||||
<source>Boolean</source>
|
||||
<context-group purpose="location">
|
||||
|
@@ -108,6 +108,7 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { CustomFieldsQueryDropdownComponent } from './components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
||||
@@ -141,6 +142,7 @@ import {
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
asterisk,
|
||||
braces,
|
||||
bodyText,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
@@ -198,6 +200,7 @@ import {
|
||||
link,
|
||||
listTask,
|
||||
listUl,
|
||||
nodePlus,
|
||||
pencil,
|
||||
people,
|
||||
peopleFill,
|
||||
@@ -227,6 +230,7 @@ import {
|
||||
uiRadios,
|
||||
upcScan,
|
||||
x,
|
||||
xCircle,
|
||||
xLg,
|
||||
} from 'ngx-bootstrap-icons'
|
||||
|
||||
@@ -242,6 +246,7 @@ const icons = {
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
asterisk,
|
||||
braces,
|
||||
bodyText,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
@@ -299,6 +304,7 @@ const icons = {
|
||||
link,
|
||||
listTask,
|
||||
listUl,
|
||||
nodePlus,
|
||||
pencil,
|
||||
people,
|
||||
peopleFill,
|
||||
@@ -328,6 +334,7 @@ const icons = {
|
||||
uiRadios,
|
||||
upcScan,
|
||||
x,
|
||||
xCircle,
|
||||
xLg,
|
||||
}
|
||||
|
||||
@@ -485,6 +492,7 @@ function initializeApp(settings: SettingsService) {
|
||||
CustomFieldsComponent,
|
||||
CustomFieldEditDialogComponent,
|
||||
CustomFieldsDropdownComponent,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
ProfileEditDialogComponent,
|
||||
DocumentLinkComponent,
|
||||
PreviewPopupComponent,
|
||||
|
@@ -0,0 +1,163 @@
|
||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
||||
<i-bs name="{{icon}}"></i-bs>
|
||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||
@if (isActive) {
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||
}
|
||||
</button>
|
||||
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
|
||||
<div class="list-group list-group-flush">
|
||||
@for (element of selectionModel.queries; track element.id; let i = $index) {
|
||||
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||
@switch (element.type) {
|
||||
@case (CustomFieldQueryComponentType.Atom) {
|
||||
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryComponentType.Expression) {
|
||||
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #comparisonValueTemplate let-atom="atom">
|
||||
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
||||
<input class="form-control" placeholder="yyyy-mm-dd"
|
||||
[(ngModel)]="atom.value"
|
||||
ngbDatepicker
|
||||
#d="ngbDatepicker" />
|
||||
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
|
||||
<i-bs name="calendar-event"></i-bs>
|
||||
</button>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
|
||||
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
|
||||
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||
<option value="true" i18n>True</option>
|
||||
<option value="false" i18n>False</option>
|
||||
</select>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Select) {
|
||||
<ng-select
|
||||
class="paperless-input-select rounded-end"
|
||||
[items]="getSelectOptionsForField(atom.field)"
|
||||
[(ngModel)]="atom.value"
|
||||
[disabled]="disabled"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
} @else {
|
||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #queryAtom let-atom="atom">
|
||||
<div class="input-group input-group-sm">
|
||||
<ng-select
|
||||
class="paperless-input-select"
|
||||
[items]="customFields"
|
||||
[(ngModel)]="atom.field"
|
||||
[disabled]="disabled"
|
||||
bindLabel="name"
|
||||
bindValue="id"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
|
||||
<option *ngFor="let operator of getOperatorsForField(atom.field)" [ngValue]="operator.value">{{operator.label}}</option>
|
||||
</select>
|
||||
@switch (atom.operator) {
|
||||
@case (CustomFieldQueryOperator.Exists) {
|
||||
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||
<option value="true" i18n>True</option>
|
||||
<option value="false" i18n>False</option>
|
||||
</select>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.IsNull) {
|
||||
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||
<option value="true" i18n>True</option>
|
||||
<option value="false" i18n>False</option>
|
||||
</select>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.GreaterThanOrEqual) {
|
||||
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.LessThanOrEqual) {
|
||||
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.GreaterThan) {
|
||||
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.LessThan) {
|
||||
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.Contains) {
|
||||
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.In) {
|
||||
<ng-select
|
||||
class="paperless-input-select rounded-end"
|
||||
[items]="getSelectOptionsForField(atom.field)"
|
||||
[(ngModel)]="atom.value"
|
||||
[disabled]="disabled"
|
||||
[multiple]="true"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.Exact) {
|
||||
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||
}
|
||||
@default {
|
||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||
}
|
||||
}
|
||||
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled">
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #queryExpression let-expression="expression">
|
||||
<div class="d-flex w-100">
|
||||
<div class="d-flex flex-grow-1 flex-column">
|
||||
<div class="btn-group btn-group-xs" role="group">
|
||||
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorOr_{{expression.id}}" name="logicalOperatorOr_{{expression.id}}" value="OR" [disabled]="expression.depth > 0 && expression.value.length < 2">
|
||||
<label class="btn btn-outline-primary" for="logicalOperatorOr_{{expression.id}}" i18n>Any</label>
|
||||
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorAnd_{{expression.id}}" name="logicalOperatorAnd_{{expression.id}}" value="AND" [disabled]="expression.depth > 0 && expression.value.length < 2">
|
||||
<label class="btn btn-outline-primary" for="logicalOperatorAnd_{{expression.id}}" i18n>All</label>
|
||||
@if (expression.negatable) {
|
||||
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorNot_{{expression.id}}" name="logicalOperatorNot_{{expression.id}}" value="NOT">
|
||||
<label class="btn btn-outline-secondary" for="logicalOperatorNot_{{expression.id}}" i18n>Not</label>
|
||||
}
|
||||
</div>
|
||||
<div class="list-group list-group-flush mb-n2">
|
||||
@for (element of expression.value; track element.id; let i = $index) {
|
||||
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||
@switch (element.type) {
|
||||
@case (CustomFieldQueryComponentType.Atom) {
|
||||
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryComponentType.Expression) {
|
||||
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group-vertical ms-2 ps-2 border-start" role="group" aria-label="Vertical button group">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add query" i18n-title (click)="addAtom(expression)" [disabled]="disabled || expression.value.length === CUSTOM_FIELD_QUERY_MAX_ATOMS">
|
||||
<i-bs name="node-plus"></i-bs>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add expression" i18n-title (click)="addExpression(expression)" [disabled]="disabled || expression.depth === CUSTOM_FIELD_QUERY_MAX_DEPTH">
|
||||
<i-bs name="braces"></i-bs>
|
||||
</button>
|
||||
@if (expression.depth > 0) {
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled">
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
@@ -0,0 +1,43 @@
|
||||
.dropdown-menu {
|
||||
width: 370px;
|
||||
@media(min-width: 768px) {
|
||||
width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .ng-select-container {
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
::ng-deep .rounded-end .ng-select-container {
|
||||
border-top-right-radius: var(--bs-border-radius) !important;
|
||||
border-bottom-right-radius: var(--bs-border-radius) !important;
|
||||
border-top-left-radius: 0 !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
}
|
||||
|
||||
::ng-deep .ng-select {
|
||||
max-width: 100px;
|
||||
min-width: 35%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
::ng-deep .doc-link-select {
|
||||
padding-top: 0 !important;
|
||||
border-top-right-radius: var(--bs-border-radius) !important;
|
||||
border-bottom-right-radius: var(--bs-border-radius) !important;
|
||||
background-image: none !important;
|
||||
|
||||
.ng-select-container,
|
||||
.ng-select.ng-select-opened > .ng-select-container {
|
||||
border: none !important;
|
||||
min-height: 34px !important;
|
||||
background: none !important;
|
||||
}
|
||||
.ng-select {
|
||||
max-width: 200px;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
@@ -0,0 +1,320 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
CustomFieldQueriesModel,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
} from './custom-fields-query-dropdown.component'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { of } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperatorGroups,
|
||||
} from 'src/app/data/custom-field-query'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
CustomFieldQueryExpression,
|
||||
CustomFieldQueryAtom,
|
||||
CustomFieldQueryElement,
|
||||
} from 'src/app/utils/custom-field-query-element'
|
||||
|
||||
const customFields = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Field',
|
||||
data_type: CustomFieldDataType.String,
|
||||
extra_data: {},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test Select Field',
|
||||
data_type: CustomFieldDataType.Select,
|
||||
extra_data: { select_options: ['Option 1', 'Option 2'] },
|
||||
},
|
||||
]
|
||||
|
||||
describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
let component: CustomFieldsQueryDropdownComponent
|
||||
let fixture: ComponentFixture<CustomFieldsQueryDropdownComponent>
|
||||
let customFieldsService: CustomFieldsService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [CustomFieldsQueryDropdownComponent],
|
||||
imports: [NgbDropdownModule, NgxBootstrapIconsModule.pick(allIcons)],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: customFields.length,
|
||||
all: customFields.map((f) => f.id),
|
||||
results: customFields,
|
||||
})
|
||||
)
|
||||
fixture = TestBed.createComponent(CustomFieldsQueryDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
component.icon = 'ui-radios'
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should initialize custom fields on creation', () => {
|
||||
expect(component.customFields).toEqual(customFields)
|
||||
})
|
||||
|
||||
it('should add an expression when opened if queries are empty', () => {
|
||||
component.selectionModel.clear()
|
||||
component.onOpenChange(true)
|
||||
expect(component.selectionModel.queries.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should support reset the selection model', () => {
|
||||
component.selectionModel.addExpression()
|
||||
component.reset()
|
||||
expect(component.selectionModel.isEmpty()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should get operators for a field', () => {
|
||||
const field: CustomField = {
|
||||
id: 1,
|
||||
name: 'Test Field',
|
||||
data_type: CustomFieldDataType.String,
|
||||
extra_data: {},
|
||||
}
|
||||
component.customFields = [field]
|
||||
const operators = component.getOperatorsForField(1)
|
||||
expect(operators.length).toEqual(
|
||||
[
|
||||
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
|
||||
CustomFieldQueryOperatorGroups.Basic
|
||||
],
|
||||
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
|
||||
CustomFieldQueryOperatorGroups.String
|
||||
],
|
||||
].length
|
||||
)
|
||||
|
||||
// Fallback to basic operators if field is not found
|
||||
const operators2 = component.getOperatorsForField(2)
|
||||
expect(operators2.length).toEqual(
|
||||
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
|
||||
CustomFieldQueryOperatorGroups.Basic
|
||||
].length
|
||||
)
|
||||
})
|
||||
|
||||
it('should get select options for a field', () => {
|
||||
const field: CustomField = {
|
||||
id: 1,
|
||||
name: 'Test Field',
|
||||
data_type: CustomFieldDataType.Select,
|
||||
extra_data: { select_options: ['Option 1', 'Option 2'] },
|
||||
}
|
||||
component.customFields = [field]
|
||||
const options = component.getSelectOptionsForField(1)
|
||||
expect(options).toEqual(['Option 1', 'Option 2'])
|
||||
|
||||
// Fallback to empty array if field is not found
|
||||
const options2 = component.getSelectOptionsForField(2)
|
||||
expect(options2).toEqual([])
|
||||
})
|
||||
|
||||
it('should remove an element from the selection model', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
;(expression.value as CustomFieldQueryElement[]).push(atom)
|
||||
component.selectionModel.addExpression(expression)
|
||||
component.removeElement(atom)
|
||||
expect(component.selectionModel.isEmpty()).toBeTruthy()
|
||||
const expression2 = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[
|
||||
[1, 'icontains', 'test'],
|
||||
[2, 'icontains', 'test'],
|
||||
],
|
||||
])
|
||||
component.selectionModel.addExpression(expression2)
|
||||
component.removeElement(expression2)
|
||||
expect(component.selectionModel.isEmpty()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should emit selectionModelChange when model changes', () => {
|
||||
const nextSpy = jest.spyOn(component.selectionModelChange, 'next')
|
||||
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||
component.selectionModel.addAtom(atom)
|
||||
atom.changed.next(atom)
|
||||
expect(nextSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should complete selection model subscription when new selection model is set', () => {
|
||||
const completeSpy = jest.spyOn(component.selectionModel.changed, 'complete')
|
||||
const selectionModel = new CustomFieldQueriesModel()
|
||||
component.selectionModel = selectionModel
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support adding an atom', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
component.addAtom(expression)
|
||||
expect(expression.value.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should support adding an expression', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
component.addExpression(expression)
|
||||
expect(expression.value.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should support getting a custom field by ID', () => {
|
||||
expect(component.getCustomFieldByID(1)).toEqual(customFields[0])
|
||||
})
|
||||
|
||||
it('should sanitize name from title', () => {
|
||||
component.title = 'Test Title'
|
||||
expect(component.name).toBe('test_title')
|
||||
})
|
||||
|
||||
describe('CustomFieldQueriesModel', () => {
|
||||
let model: CustomFieldQueriesModel
|
||||
|
||||
beforeEach(() => {
|
||||
model = new CustomFieldQueriesModel()
|
||||
})
|
||||
|
||||
it('should initialize with empty queries', () => {
|
||||
expect(model.queries).toEqual([])
|
||||
})
|
||||
|
||||
it('should clear queries and fire event', () => {
|
||||
const nextSpy = jest.spyOn(model.changed, 'next')
|
||||
model.addExpression()
|
||||
model.clear()
|
||||
expect(model.queries).toEqual([])
|
||||
expect(nextSpy).toHaveBeenCalledWith(model)
|
||||
})
|
||||
|
||||
it('should clear queries without firing event', () => {
|
||||
const nextSpy = jest.spyOn(model.changed, 'next')
|
||||
model.addExpression()
|
||||
model.clear(false)
|
||||
expect(model.queries).toEqual([])
|
||||
expect(nextSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate an empty model as invalid', () => {
|
||||
expect(model.isValid()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should validate a model with valid expression as valid', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||
const atom2 = new CustomFieldQueryAtom([2, 'icontains', 'test'])
|
||||
const expression2 = new CustomFieldQueryExpression()
|
||||
expression2.addAtom(atom)
|
||||
expression2.addAtom(atom2)
|
||||
expression.addExpression(expression2)
|
||||
model.addExpression(expression)
|
||||
expect(model.isValid()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should validate a model with invalid expression as invalid', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
model.addExpression(expression)
|
||||
expect(model.isValid()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should validate an atom with in or contains operator', () => {
|
||||
const atom = new CustomFieldQueryAtom([1, 'in', '[1,2,3]'])
|
||||
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
|
||||
atom.operator = 'contains'
|
||||
atom.value = [1, 2, 3]
|
||||
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
|
||||
atom.value = null
|
||||
expect(model['validateAtom'].apply(null, [atom])).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should check if model is empty', () => {
|
||||
expect(model.isEmpty()).toBeTruthy()
|
||||
model.addExpression()
|
||||
expect(model.isEmpty()).toBeTruthy()
|
||||
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||
model.addAtom(atom)
|
||||
expect(model.isEmpty()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should add an atom to the model', () => {
|
||||
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||
model.addAtom(atom)
|
||||
expect(model.queries.length).toBe(1)
|
||||
expect(
|
||||
(model.queries[0] as CustomFieldQueryExpression).value.length
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('should add an expression to the model, propagate changes', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
model.addExpression(expression)
|
||||
expect(model.queries.length).toBe(1)
|
||||
const expression2 = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[
|
||||
[1, 'icontains', 'test'],
|
||||
[2, 'icontains', 'test'],
|
||||
],
|
||||
])
|
||||
model.addExpression(expression2)
|
||||
const nextSpy = jest.spyOn(model.changed, 'next')
|
||||
expression2.changed.next(expression2)
|
||||
expect(nextSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove an element from the model', () => {
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[
|
||||
[1, 'icontains', 'test'],
|
||||
[2, 'icontains', 'test'],
|
||||
],
|
||||
])
|
||||
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||
const expression2 = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[
|
||||
[3, 'icontains', 'test'],
|
||||
[4, 'icontains', 'test'],
|
||||
],
|
||||
])
|
||||
expression.addAtom(atom)
|
||||
expression2.addExpression(expression)
|
||||
model.addExpression(expression2)
|
||||
model.removeElement(atom)
|
||||
expect(model.queries.length).toBe(1)
|
||||
model.removeElement(expression2)
|
||||
})
|
||||
|
||||
it('should fire changed event when an atom changes', () => {
|
||||
const nextSpy = jest.spyOn(model.changed, 'next')
|
||||
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||
model.addAtom(atom)
|
||||
atom.changed.next(atom)
|
||||
expect(nextSpy).toHaveBeenCalledWith(model)
|
||||
})
|
||||
|
||||
it('should complete changed subject when element is removed', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||
;(expression.value as CustomFieldQueryElement[]).push(atom)
|
||||
model.addExpression(expression)
|
||||
const completeSpy = jest.spyOn(atom.changed, 'complete')
|
||||
model.removeElement(atom)
|
||||
expect(completeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, first, takeUntil } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CustomFieldQueryElementType,
|
||||
CustomFieldQueryOperator,
|
||||
CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE,
|
||||
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
||||
CustomFieldQueryOperatorGroups,
|
||||
CUSTOM_FIELD_QUERY_OPERATOR_LABELS,
|
||||
CUSTOM_FIELD_QUERY_MAX_DEPTH,
|
||||
CUSTOM_FIELD_QUERY_MAX_ATOMS,
|
||||
} from 'src/app/data/custom-field-query'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import {
|
||||
CustomFieldQueryElement,
|
||||
CustomFieldQueryExpression,
|
||||
CustomFieldQueryAtom,
|
||||
} from 'src/app/utils/custom-field-query-element'
|
||||
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
|
||||
|
||||
export class CustomFieldQueriesModel {
|
||||
public queries: CustomFieldQueryElement[] = []
|
||||
|
||||
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
||||
|
||||
public clear(fireEvent = true) {
|
||||
this.queries = []
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return (
|
||||
this.queries.length > 0 &&
|
||||
this.validateExpression(this.queries[0] as CustomFieldQueryExpression)
|
||||
)
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return (
|
||||
this.queries.length === 0 ||
|
||||
(this.queries.length === 1 && this.queries[0].value.length === 0)
|
||||
)
|
||||
}
|
||||
|
||||
private validateAtom(atom: CustomFieldQueryAtom) {
|
||||
let valid = !!(atom.field && atom.operator && atom.value !== null)
|
||||
if (
|
||||
[
|
||||
CustomFieldQueryOperator.In.valueOf(),
|
||||
CustomFieldQueryOperator.Contains.valueOf(),
|
||||
].includes(atom.operator) &&
|
||||
atom.value
|
||||
) {
|
||||
valid = valid && atom.value.length > 0
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
private validateExpression(expression: CustomFieldQueryExpression) {
|
||||
return (
|
||||
expression.operator &&
|
||||
expression.value.length > 0 &&
|
||||
(expression.value as CustomFieldQueryElement[]).every((e) =>
|
||||
e.type === CustomFieldQueryElementType.Atom
|
||||
? this.validateAtom(e as CustomFieldQueryAtom)
|
||||
: this.validateExpression(e as CustomFieldQueryExpression)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public addAtom(atom: CustomFieldQueryAtom) {
|
||||
if (this.queries.length === 0) {
|
||||
this.addExpression()
|
||||
}
|
||||
;(this.queries[0].value as CustomFieldQueryElement[]).push(atom)
|
||||
atom.changed.subscribe(() => {
|
||||
if (atom.field && atom.operator && atom.value) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public addExpression(
|
||||
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
|
||||
) {
|
||||
if (this.queries.length > 0) {
|
||||
;(
|
||||
(this.queries[0] as CustomFieldQueryExpression)
|
||||
.value as CustomFieldQueryElement[]
|
||||
).push(expression)
|
||||
} else {
|
||||
this.queries.push(expression)
|
||||
}
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
private findElement(
|
||||
queryElement: CustomFieldQueryElement,
|
||||
elements: any[]
|
||||
): CustomFieldQueryElement {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (elements[i] === queryElement) {
|
||||
return elements.splice(i, 1)[0]
|
||||
} else if (elements[i].type === CustomFieldQueryElementType.Expression) {
|
||||
return this.findElement(
|
||||
queryElement,
|
||||
elements[i].value as CustomFieldQueryElement[]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public removeElement(queryElement: CustomFieldQueryElement) {
|
||||
let foundComponent
|
||||
for (let i = 0; i < this.queries.length; i++) {
|
||||
let query = this.queries[i]
|
||||
if (query === queryElement) {
|
||||
foundComponent = this.queries.splice(i, 1)[0]
|
||||
break
|
||||
} else if (query.type === CustomFieldQueryElementType.Expression) {
|
||||
foundComponent = this.findElement(queryElement, query.value as any[])
|
||||
}
|
||||
}
|
||||
if (foundComponent) {
|
||||
foundComponent.changed.complete()
|
||||
if (this.isEmpty()) {
|
||||
this.clear()
|
||||
}
|
||||
this.changed.next(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-fields-query-dropdown',
|
||||
templateUrl: './custom-fields-query-dropdown.component.html',
|
||||
styleUrls: ['./custom-fields-query-dropdown.component.scss'],
|
||||
})
|
||||
export class CustomFieldsQueryDropdownComponent {
|
||||
public CustomFieldQueryComponentType = CustomFieldQueryElementType
|
||||
public CustomFieldQueryOperator = CustomFieldQueryOperator
|
||||
public CustomFieldDataType = CustomFieldDataType
|
||||
public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
|
||||
public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
|
||||
public popperOptions = popperOptionsReenablePreventOverflow
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@Input()
|
||||
filterPlaceholder: string = ''
|
||||
|
||||
@Input()
|
||||
icon: string
|
||||
|
||||
@Input()
|
||||
allowSelectNone: boolean = false
|
||||
|
||||
@Input()
|
||||
editing = false
|
||||
|
||||
@Input()
|
||||
applyOnClose = false
|
||||
|
||||
get name(): string {
|
||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||
}
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||
|
||||
private _selectionModel: CustomFieldQueriesModel
|
||||
|
||||
@Input()
|
||||
set selectionModel(model: CustomFieldQueriesModel) {
|
||||
if (this._selectionModel) {
|
||||
this._selectionModel.changed.complete()
|
||||
}
|
||||
model.changed.subscribe(() => {
|
||||
this.onModelChange()
|
||||
})
|
||||
this._selectionModel = model
|
||||
}
|
||||
|
||||
get selectionModel(): CustomFieldQueriesModel {
|
||||
return this._selectionModel
|
||||
}
|
||||
|
||||
private onModelChange() {
|
||||
if (this.selectionModel.isEmpty() || this.selectionModel.isValid()) {
|
||||
this.selectionModelChange.next(this.selectionModel)
|
||||
this.selectionModel.isEmpty() && this.dropdown?.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Output()
|
||||
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
||||
|
||||
customFields: CustomField[] = []
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(protected customFieldsService: CustomFieldsService) {
|
||||
this.selectionModel = new CustomFieldQueriesModel()
|
||||
this.getFields()
|
||||
this.reset()
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(this)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
public onOpenChange(open: boolean) {
|
||||
if (open && this.selectionModel.queries.length === 0) {
|
||||
this.selectionModel.addExpression()
|
||||
}
|
||||
}
|
||||
|
||||
public get isActive(): boolean {
|
||||
return (
|
||||
(this.selectionModel.queries[0] as CustomFieldQueryExpression)?.value
|
||||
?.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
private getFields() {
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => {
|
||||
this.customFields = result.results
|
||||
})
|
||||
}
|
||||
|
||||
public getCustomFieldByID(id: number): CustomField {
|
||||
return this.customFields.find((field) => field.id === id)
|
||||
}
|
||||
|
||||
public addAtom(expression: CustomFieldQueryExpression) {
|
||||
expression.addAtom()
|
||||
}
|
||||
|
||||
public addExpression(expression: CustomFieldQueryExpression) {
|
||||
expression.addExpression()
|
||||
}
|
||||
|
||||
public removeElement(element: CustomFieldQueryElement) {
|
||||
this.selectionModel.removeElement(element)
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.selectionModel.clear(false)
|
||||
this.selectionModel.changed.next(this.selectionModel)
|
||||
}
|
||||
|
||||
getOperatorsForField(
|
||||
fieldID: number
|
||||
): Array<{ value: string; label: string }> {
|
||||
const field = this.customFields.find((field) => field.id === fieldID)
|
||||
const groups: CustomFieldQueryOperatorGroups[] = field
|
||||
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
||||
: [CustomFieldQueryOperatorGroups.Basic]
|
||||
const operators = groups.flatMap(
|
||||
(group) => CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[group]
|
||||
)
|
||||
return operators.map((operator) => ({
|
||||
value: operator,
|
||||
label: CUSTOM_FIELD_QUERY_OPERATOR_LABELS[operator],
|
||||
}))
|
||||
}
|
||||
|
||||
getSelectOptionsForField(fieldID: number): string[] {
|
||||
const field = this.customFields.find((field) => field.id === fieldID)
|
||||
if (field) {
|
||||
return field.extra_data['select_options']
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
@@ -1,50 +1,57 @@
|
||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
@if (title) {
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
@if (removable) {
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<div>
|
||||
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
|
||||
[disabled]="disabled"
|
||||
[items]="foundDocuments$ | async"
|
||||
placeholder="Search for documents"
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="true"
|
||||
bindValue="id"
|
||||
[compareWith]="compareDocuments"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="loading"
|
||||
[typeahead]="documentsInput$"
|
||||
(change)="onChange(selectedDocuments)">
|
||||
<ng-template ng-label-tmp let-document="item">
|
||||
<div class="d-flex align-items-center">
|
||||
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
|
||||
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{document.title}}</span>
|
||||
</a>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template ng-loadingspinner-tmp>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
|
||||
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (minimal) {
|
||||
<ng-container *ngTemplateOutlet="select"></ng-container>
|
||||
} @else {
|
||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
@if (title) {
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
@if (removable) {
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<ng-container *ngTemplateOutlet="select"></ng-container>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #select>
|
||||
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
|
||||
[disabled]="disabled"
|
||||
[items]="foundDocuments$ | async"
|
||||
[placeholder]="placeholder"
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="true"
|
||||
bindValue="id"
|
||||
[compareWith]="compareDocuments"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="loading"
|
||||
[typeahead]="documentsInput$"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
(change)="onChange(selectedDocuments)">
|
||||
<ng-template ng-label-tmp let-document="item">
|
||||
<div class="d-flex align-items-center">
|
||||
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
|
||||
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{document.title}}</span>
|
||||
</a>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template ng-loadingspinner-tmp>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
|
||||
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
</ng-template>
|
||||
|
@@ -46,6 +46,12 @@ export class DocumentLinkComponent
|
||||
@Input()
|
||||
parentDocumentID: number
|
||||
|
||||
@Input()
|
||||
minimal: boolean = false
|
||||
|
||||
@Input()
|
||||
placeholder: string = $localize`Search for documents`
|
||||
|
||||
constructor(private documentsService: DocumentService) {
|
||||
super()
|
||||
}
|
||||
|
@@ -140,7 +140,7 @@
|
||||
} @else {
|
||||
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
|
||||
<div>
|
||||
@for (d of list.documents; track trackByDocumentId($index, d)) {
|
||||
@for (d of list.documents; track d.id) {
|
||||
<pngx-document-card-large
|
||||
[selected]="list.isSelected(d)"
|
||||
(toggleSelected)="toggleSelected(d, $event)"
|
||||
@@ -269,7 +269,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of list.documents; track trackByDocumentId($index, d)) {
|
||||
@for (d of list.documents; track d.id) {
|
||||
<tr (click)="toggleSelected(d, $event); $event.stopPropagation();" (dblclick)="openDocumentDetail(d)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
|
||||
<td>
|
||||
<div class="form-check">
|
||||
@@ -364,7 +364,7 @@
|
||||
}
|
||||
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
|
||||
<div class="row row-cols-paperless-cards">
|
||||
@for (d of list.documents; track trackByDocumentId($index, d)) {
|
||||
@for (d of list.documents; track d.id) {
|
||||
<pngx-document-card-small class="p-0"
|
||||
[selected]="list.isSelected(d)"
|
||||
(toggleSelected)="toggleSelected(d, $event)"
|
||||
|
@@ -383,10 +383,6 @@ export class DocumentListComponent
|
||||
])
|
||||
}
|
||||
|
||||
trackByDocumentId(index, item: Document) {
|
||||
return item.id
|
||||
}
|
||||
|
||||
get notesEnabled(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
|
||||
}
|
||||
|
@@ -86,15 +86,10 @@
|
||||
}
|
||||
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
|
||||
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
||||
[items]="customFields"
|
||||
[manyToOne]="true"
|
||||
[(selectionModel)]="customFieldSelectionModel"
|
||||
<pngx-custom-fields-query-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
|
||||
[(selectionModel)]="customFieldQueriesModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onCustomFieldsDropdownOpen()"
|
||||
[documentCounts]="customFieldDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
></pngx-custom-fields-query-dropdown>
|
||||
}
|
||||
<pngx-dates-dropdown
|
||||
title="Dates" i18n-title
|
||||
|
@@ -17,7 +17,7 @@ import {
|
||||
NgbDropdownItem,
|
||||
NgbTypeaheadModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import {
|
||||
FILTER_TITLE,
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
FILTER_HAS_ANY_CUSTOM_FIELDS,
|
||||
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_CUSTOM_FIELDS_QUERY,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@@ -95,6 +96,12 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { CustomFieldsQueryDropdownComponent } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||
import {
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from 'src/app/data/custom-field-query'
|
||||
import { CustomFieldQueryAtom } from 'src/app/utils/custom-field-query-element'
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
@@ -181,6 +188,7 @@ describe('FilterEditorComponent', () => {
|
||||
ToggleableDropdownButtonComponent,
|
||||
DatesDropdownComponent,
|
||||
CustomDatePipe,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
],
|
||||
imports: [
|
||||
RouterModule,
|
||||
@@ -190,6 +198,7 @@ describe('FilterEditorComponent', () => {
|
||||
NgbDatepickerModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgbTypeaheadModule,
|
||||
NgSelectModule,
|
||||
],
|
||||
providers: [
|
||||
FilterPipe,
|
||||
@@ -838,108 +847,79 @@ describe('FilterEditorComponent', () => {
|
||||
]
|
||||
}))
|
||||
|
||||
it('should ingest filter rules for has all custom fields', fakeAsync(() => {
|
||||
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
|
||||
0
|
||||
)
|
||||
it('should ingest filter rules for custom fields all', fakeAsync(() => {
|
||||
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: '42',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: '43',
|
||||
value: '42,43',
|
||||
},
|
||||
]
|
||||
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.And
|
||||
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
|
||||
CustomFieldQueryLogicalOperator.And
|
||||
)
|
||||
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
|
||||
custom_fields
|
||||
)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: null,
|
||||
},
|
||||
]
|
||||
component.toggleTag(2) // coverage
|
||||
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
|
||||
expect(
|
||||
(
|
||||
component.customFieldQueriesModel.queries[0]
|
||||
.value[0] as CustomFieldQueryAtom
|
||||
).serialize()
|
||||
).toEqual(['42', CustomFieldQueryOperator.Exists, 'true'])
|
||||
}))
|
||||
|
||||
it('should ingest filter rules for has any custom fields', fakeAsync(() => {
|
||||
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
|
||||
0
|
||||
)
|
||||
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
value: '42',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
value: '43',
|
||||
value: '42,43',
|
||||
},
|
||||
]
|
||||
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.Or
|
||||
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
|
||||
CustomFieldQueryLogicalOperator.Or
|
||||
)
|
||||
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
|
||||
custom_fields
|
||||
)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
value: null,
|
||||
},
|
||||
]
|
||||
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
|
||||
expect(
|
||||
(
|
||||
component.customFieldQueriesModel.queries[0]
|
||||
.value[0] as CustomFieldQueryAtom
|
||||
).serialize()
|
||||
).toEqual(['42', CustomFieldQueryOperator.Exists, 'true'])
|
||||
}))
|
||||
|
||||
it('should ingest filter rules for has any custom field', fakeAsync(() => {
|
||||
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
|
||||
0
|
||||
)
|
||||
it('should ingest filter rules for custom field queries', fakeAsync(() => {
|
||||
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
|
||||
value: '1',
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: '["AND", [[42, "exists", "true"],[43, "exists", "true"]]]',
|
||||
},
|
||||
]
|
||||
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
|
||||
1
|
||||
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
|
||||
CustomFieldQueryLogicalOperator.And
|
||||
)
|
||||
expect(component.customFieldSelectionModel.get(null)).toBeTruthy()
|
||||
}))
|
||||
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
|
||||
expect(
|
||||
(
|
||||
component.customFieldQueriesModel.queries[0]
|
||||
.value[0] as CustomFieldQueryAtom
|
||||
).serialize()
|
||||
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
|
||||
|
||||
it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
|
||||
expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
|
||||
0
|
||||
)
|
||||
// atom
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
|
||||
value: '42',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
|
||||
value: '43',
|
||||
},
|
||||
]
|
||||
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.customFieldSelectionModel.getExcludedItems()).toEqual(
|
||||
custom_fields
|
||||
)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
|
||||
value: null,
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: '[42, "exists", "true"]',
|
||||
},
|
||||
]
|
||||
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(1)
|
||||
expect(
|
||||
(
|
||||
component.customFieldQueriesModel.queries[0]
|
||||
.value[0] as CustomFieldQueryAtom
|
||||
).serialize()
|
||||
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
|
||||
}))
|
||||
|
||||
it('should ingest filter rules for owner', fakeAsync(() => {
|
||||
@@ -1453,71 +1433,37 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
}))
|
||||
|
||||
it('should convert user input to correct filter rules on custom field select not assigned', fakeAsync(() => {
|
||||
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(FilterableDropdownComponent)
|
||||
)[4]
|
||||
customFieldsFilterableDropdown.triggerEventHandler('opened')
|
||||
const customFieldButton = customFieldsFilterableDropdown.queryAll(
|
||||
By.directive(ToggleableDropdownButtonComponent)
|
||||
)[0]
|
||||
customFieldButton.triggerEventHandler('toggle')
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
}))
|
||||
|
||||
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
|
||||
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(FilterableDropdownComponent)
|
||||
)[4] // CF dropdown
|
||||
customFieldsFilterableDropdown.triggerEventHandler('opened')
|
||||
const customFieldButtons = customFieldsFilterableDropdown.queryAll(
|
||||
By.directive(ToggleableDropdownButtonComponent)
|
||||
const customFieldsQueryDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(CustomFieldsQueryDropdownComponent)
|
||||
)[0]
|
||||
const customFieldToggleButton = customFieldsQueryDropdown.query(
|
||||
By.css('button')
|
||||
)
|
||||
customFieldButtons[1].triggerEventHandler('toggle')
|
||||
customFieldButtons[2].triggerEventHandler('toggle')
|
||||
customFieldToggleButton.triggerEventHandler('click')
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: custom_fields[0].id.toString(),
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: custom_fields[1].id.toString(),
|
||||
},
|
||||
])
|
||||
const toggleOperatorButtons = customFieldsFilterableDropdown.queryAll(
|
||||
By.css('input[type=radio]')
|
||||
const customFieldButtons = customFieldsQueryDropdown.queryAll(
|
||||
By.css('button')
|
||||
)
|
||||
toggleOperatorButtons[1].nativeElement.checked = true
|
||||
toggleOperatorButtons[1].triggerEventHandler('change')
|
||||
customFieldButtons[1].triggerEventHandler('click')
|
||||
fixture.detectChanges()
|
||||
const query = component.customFieldQueriesModel
|
||||
.queries[0] as CustomFieldQueryAtom
|
||||
query.field = custom_fields[0].id
|
||||
const fieldSelect: NgSelectComponent = customFieldsQueryDropdown.queryAll(
|
||||
By.directive(NgSelectComponent)
|
||||
)[0].componentInstance
|
||||
fieldSelect.open()
|
||||
const options = customFieldsQueryDropdown.queryAll(By.css('.ng-option'))
|
||||
options[0].nativeElement.click()
|
||||
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(1)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
value: custom_fields[0].id.toString(),
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
value: custom_fields[1].id.toString(),
|
||||
},
|
||||
])
|
||||
customFieldButtons[2].triggerEventHandler('exclude')
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: custom_fields[0].id.toString(),
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
|
||||
value: custom_fields[1].id.toString(),
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify([
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
[[custom_fields[0].id, 'exists', 'true']],
|
||||
]),
|
||||
},
|
||||
])
|
||||
}))
|
||||
@@ -1930,21 +1876,11 @@ describe('FilterEditorComponent', () => {
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: '42',
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: '["AND",[["42","exists","true"],["43","exists","true"]]]',
|
||||
},
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual(
|
||||
`Custom fields: ${custom_fields[0].name}`
|
||||
)
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('Without any custom field')
|
||||
expect(component.generateFilterName()).toEqual(`Custom fields query`)
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
|
@@ -12,7 +12,7 @@ import {
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { Observable, Subject, Subscription, from } from 'rxjs'
|
||||
import { Observable, Subject, from } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_ANY_CUSTOM_FIELDS,
|
||||
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
|
||||
FILTER_CUSTOM_FIELDS_QUERY,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
FilterableDropdownSelectionModel,
|
||||
@@ -92,6 +92,15 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import {
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from 'src/app/data/custom-field-query'
|
||||
import { CustomFieldQueriesModel } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||
import {
|
||||
CustomFieldQueryExpression,
|
||||
CustomFieldQueryAtom,
|
||||
} from 'src/app/utils/custom-field-query-element'
|
||||
|
||||
const TEXT_FILTER_TARGET_TITLE = 'title'
|
||||
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
|
||||
@@ -225,15 +234,8 @@ export class FilterEditorComponent
|
||||
return $localize`Without any tag`
|
||||
}
|
||||
|
||||
case FILTER_HAS_CUSTOM_FIELDS_ALL:
|
||||
return $localize`Custom fields: ${
|
||||
this.customFields.find((f) => f.id == +rule.value)?.name
|
||||
}`
|
||||
|
||||
case FILTER_HAS_ANY_CUSTOM_FIELDS:
|
||||
if (rule.value == 'false') {
|
||||
return $localize`Without any custom field`
|
||||
}
|
||||
case FILTER_CUSTOM_FIELDS_QUERY:
|
||||
return $localize`Custom fields query`
|
||||
|
||||
case FILTER_TITLE:
|
||||
return $localize`Title: ${rule.value}`
|
||||
@@ -321,7 +323,7 @@ export class FilterEditorComponent
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
storagePathSelectionModel = new FilterableDropdownSelectionModel()
|
||||
customFieldSelectionModel = new FilterableDropdownSelectionModel()
|
||||
customFieldQueriesModel = new CustomFieldQueriesModel()
|
||||
|
||||
dateCreatedBefore: string
|
||||
dateCreatedAfter: string
|
||||
@@ -356,7 +358,7 @@ export class FilterEditorComponent
|
||||
this.storagePathSelectionModel.clear(false)
|
||||
this.tagSelectionModel.clear(false)
|
||||
this.correspondentSelectionModel.clear(false)
|
||||
this.customFieldSelectionModel.clear(false)
|
||||
this.customFieldQueriesModel.clear(false)
|
||||
this._textFilter = null
|
||||
this._moreLikeId = null
|
||||
this.dateAddedBefore = null
|
||||
@@ -523,34 +525,45 @@ export class FilterEditorComponent
|
||||
false
|
||||
)
|
||||
break
|
||||
case FILTER_CUSTOM_FIELDS_QUERY:
|
||||
try {
|
||||
const query = JSON.parse(rule.value)
|
||||
if (Array.isArray(query)) {
|
||||
if (query.length === 2) {
|
||||
// expression
|
||||
this.customFieldQueriesModel.addExpression(
|
||||
new CustomFieldQueryExpression(query as any)
|
||||
)
|
||||
} else if (query.length === 3) {
|
||||
// atom
|
||||
this.customFieldQueriesModel.addAtom(
|
||||
new CustomFieldQueryAtom(query as any)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// error handled by list view service
|
||||
}
|
||||
break
|
||||
// Legacy custom field filters
|
||||
case FILTER_HAS_CUSTOM_FIELDS_ALL:
|
||||
this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
|
||||
this.customFieldSelectionModel.set(
|
||||
rule.value ? +rule.value : null,
|
||||
ToggleableItemState.Selected,
|
||||
false
|
||||
this.customFieldQueriesModel.addExpression(
|
||||
new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
rule.value
|
||||
.split(',')
|
||||
.map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
|
||||
])
|
||||
)
|
||||
break
|
||||
case FILTER_HAS_CUSTOM_FIELDS_ANY:
|
||||
this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or
|
||||
this.customFieldSelectionModel.set(
|
||||
rule.value ? +rule.value : null,
|
||||
ToggleableItemState.Selected,
|
||||
false
|
||||
)
|
||||
break
|
||||
case FILTER_HAS_ANY_CUSTOM_FIELDS:
|
||||
this.customFieldSelectionModel.set(
|
||||
null,
|
||||
ToggleableItemState.Selected,
|
||||
false
|
||||
)
|
||||
break
|
||||
case FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS:
|
||||
this.customFieldSelectionModel.set(
|
||||
rule.value ? +rule.value : null,
|
||||
ToggleableItemState.Excluded,
|
||||
false
|
||||
this.customFieldQueriesModel.addExpression(
|
||||
new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
rule.value
|
||||
.split(',')
|
||||
.map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
|
||||
])
|
||||
)
|
||||
break
|
||||
case FILTER_ASN_ISNULL:
|
||||
@@ -768,34 +781,14 @@ export class FilterEditorComponent
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.customFieldSelectionModel.isNoneSelected()) {
|
||||
let queries = this.customFieldQueriesModel.queries.map((query) =>
|
||||
query.serialize()
|
||||
)
|
||||
if (queries.length > 0) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
|
||||
value: 'false',
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify(queries[0]),
|
||||
})
|
||||
} else {
|
||||
const customFieldFilterType =
|
||||
this.customFieldSelectionModel.logicalOperator == LogicalOperator.And
|
||||
? FILTER_HAS_CUSTOM_FIELDS_ALL
|
||||
: FILTER_HAS_CUSTOM_FIELDS_ANY
|
||||
this.customFieldSelectionModel
|
||||
.getSelectedItems()
|
||||
.filter((field) => field.id)
|
||||
.forEach((field) => {
|
||||
filterRules.push({
|
||||
rule_type: customFieldFilterType,
|
||||
value: field.id?.toString(),
|
||||
})
|
||||
})
|
||||
this.customFieldSelectionModel
|
||||
.getExcludedItems()
|
||||
.filter((field) => field.id)
|
||||
.forEach((field) => {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
|
||||
value: field.id?.toString(),
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.dateCreatedBefore) {
|
||||
filterRules.push({
|
||||
@@ -1079,10 +1072,6 @@ export class FilterEditorComponent
|
||||
this.storagePathSelectionModel.apply()
|
||||
}
|
||||
|
||||
onCustomFieldsDropdownOpen() {
|
||||
this.customFieldSelectionModel.apply()
|
||||
}
|
||||
|
||||
updateTextFilter(text, updateRules = true) {
|
||||
this._textFilter = text
|
||||
if (updateRules) {
|
||||
|
127
src-ui/src/app/data/custom-field-query.ts
Normal file
127
src-ui/src/app/data/custom-field-query.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { CustomFieldDataType } from './custom-field'
|
||||
|
||||
export enum CustomFieldQueryLogicalOperator {
|
||||
And = 'AND',
|
||||
Or = 'OR',
|
||||
Not = 'NOT',
|
||||
}
|
||||
|
||||
export enum CustomFieldQueryOperator {
|
||||
Exact = 'exact',
|
||||
In = 'in',
|
||||
IsNull = 'isnull',
|
||||
Exists = 'exists',
|
||||
Contains = 'contains',
|
||||
IContains = 'icontains',
|
||||
GreaterThan = 'gt',
|
||||
GreaterThanOrEqual = 'gte',
|
||||
LessThan = 'lt',
|
||||
LessThanOrEqual = 'lte',
|
||||
Range = 'range',
|
||||
}
|
||||
|
||||
export const CUSTOM_FIELD_QUERY_OPERATOR_LABELS = {
|
||||
[CustomFieldQueryOperator.Exact]: $localize`Equal to`,
|
||||
[CustomFieldQueryOperator.In]: $localize`In`,
|
||||
[CustomFieldQueryOperator.IsNull]: $localize`Is null`,
|
||||
[CustomFieldQueryOperator.Exists]: $localize`Exists`,
|
||||
[CustomFieldQueryOperator.Contains]: $localize`Contains`,
|
||||
[CustomFieldQueryOperator.IContains]: $localize`Contains (case-insensitive)`,
|
||||
[CustomFieldQueryOperator.GreaterThan]: $localize`Greater than`,
|
||||
[CustomFieldQueryOperator.GreaterThanOrEqual]: $localize`Greater than or equal to`,
|
||||
[CustomFieldQueryOperator.LessThan]: $localize`Less than`,
|
||||
[CustomFieldQueryOperator.LessThanOrEqual]: $localize`Less than or equal to`,
|
||||
[CustomFieldQueryOperator.Range]: $localize`Range`,
|
||||
}
|
||||
|
||||
export enum CustomFieldQueryOperatorGroups {
|
||||
Basic = 'basic',
|
||||
String = 'string',
|
||||
Arithmetic = 'arithmetic',
|
||||
Containment = 'containment',
|
||||
Subset = 'subset',
|
||||
Date = 'date',
|
||||
}
|
||||
|
||||
// Modified from filters.py > SUPPORTED_EXPR_OPERATORS
|
||||
export const CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP = {
|
||||
[CustomFieldQueryOperatorGroups.Basic]: [
|
||||
CustomFieldQueryOperator.Exists,
|
||||
CustomFieldQueryOperator.IsNull,
|
||||
CustomFieldQueryOperator.Exact,
|
||||
],
|
||||
[CustomFieldQueryOperatorGroups.String]: [CustomFieldQueryOperator.IContains],
|
||||
[CustomFieldQueryOperatorGroups.Arithmetic]: [
|
||||
CustomFieldQueryOperator.GreaterThan,
|
||||
CustomFieldQueryOperator.GreaterThanOrEqual,
|
||||
CustomFieldQueryOperator.LessThan,
|
||||
CustomFieldQueryOperator.LessThanOrEqual,
|
||||
],
|
||||
[CustomFieldQueryOperatorGroups.Containment]: [
|
||||
CustomFieldQueryOperator.Contains,
|
||||
],
|
||||
[CustomFieldQueryOperatorGroups.Subset]: [CustomFieldQueryOperator.In],
|
||||
[CustomFieldQueryOperatorGroups.Date]: [
|
||||
CustomFieldQueryOperator.GreaterThanOrEqual,
|
||||
CustomFieldQueryOperator.LessThanOrEqual,
|
||||
],
|
||||
}
|
||||
|
||||
// filters.py > SUPPORTED_EXPR_CATEGORIES
|
||||
export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
|
||||
[CustomFieldDataType.String]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.String,
|
||||
],
|
||||
[CustomFieldDataType.Url]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.String,
|
||||
],
|
||||
[CustomFieldDataType.Date]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.Date,
|
||||
],
|
||||
[CustomFieldDataType.Boolean]: [CustomFieldQueryOperatorGroups.Basic],
|
||||
[CustomFieldDataType.Integer]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.Arithmetic,
|
||||
],
|
||||
[CustomFieldDataType.Float]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.Arithmetic,
|
||||
],
|
||||
[CustomFieldDataType.Monetary]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.String,
|
||||
CustomFieldQueryOperatorGroups.Arithmetic,
|
||||
],
|
||||
[CustomFieldDataType.DocumentLink]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.Containment,
|
||||
],
|
||||
[CustomFieldDataType.Select]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.Subset,
|
||||
],
|
||||
}
|
||||
|
||||
export const CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR = {
|
||||
[CustomFieldQueryOperator.Exact]: 'string|boolean',
|
||||
[CustomFieldQueryOperator.IsNull]: 'boolean',
|
||||
[CustomFieldQueryOperator.Exists]: 'boolean',
|
||||
[CustomFieldQueryOperator.IContains]: 'string',
|
||||
[CustomFieldQueryOperator.GreaterThanOrEqual]: 'string|number',
|
||||
[CustomFieldQueryOperator.LessThanOrEqual]: 'string|number',
|
||||
[CustomFieldQueryOperator.GreaterThan]: 'number',
|
||||
[CustomFieldQueryOperator.LessThan]: 'number',
|
||||
[CustomFieldQueryOperator.Contains]: 'array',
|
||||
[CustomFieldQueryOperator.In]: 'array',
|
||||
}
|
||||
|
||||
export const CUSTOM_FIELD_QUERY_MAX_DEPTH = 4
|
||||
export const CUSTOM_FIELD_QUERY_MAX_ATOMS = 5
|
||||
|
||||
export enum CustomFieldQueryElementType {
|
||||
Atom = 'Atom',
|
||||
Expression = 'Expression',
|
||||
}
|
@@ -55,6 +55,8 @@ export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
|
||||
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
|
||||
export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
|
||||
|
||||
export const FILTER_CUSTOM_FIELDS_QUERY = 42
|
||||
|
||||
export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
{
|
||||
id: FILTER_TITLE,
|
||||
@@ -317,6 +319,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
multi: false,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
filtervar: 'custom_field_query',
|
||||
datatype: 'string',
|
||||
multi: false,
|
||||
},
|
||||
]
|
||||
|
||||
export interface FilterRuleType {
|
||||
|
245
src-ui/src/app/utils/custom-field-query-element.spec.ts
Normal file
245
src-ui/src/app/utils/custom-field-query-element.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
CustomFieldQueryElement,
|
||||
CustomFieldQueryAtom,
|
||||
CustomFieldQueryExpression,
|
||||
} from './custom-field-query-element'
|
||||
import {
|
||||
CustomFieldQueryElementType,
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from '../data/custom-field-query'
|
||||
import { fakeAsync, tick } from '@angular/core/testing'
|
||||
|
||||
describe('CustomFieldQueryElement', () => {
|
||||
it('should initialize with correct type and id', () => {
|
||||
const element = new CustomFieldQueryElement(
|
||||
CustomFieldQueryElementType.Atom
|
||||
)
|
||||
expect(element.type).toBe(CustomFieldQueryElementType.Atom)
|
||||
expect(element.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('should trigger changed on operator change', () => {
|
||||
const element = new CustomFieldQueryElement(
|
||||
CustomFieldQueryElementType.Atom
|
||||
)
|
||||
element.changed.subscribe((changedElement) => {
|
||||
expect(changedElement).toBe(element)
|
||||
})
|
||||
element.operator = CustomFieldQueryOperator.Exists
|
||||
})
|
||||
|
||||
it('should trigger changed subject on value change', () => {
|
||||
const element = new CustomFieldQueryElement(
|
||||
CustomFieldQueryElementType.Atom
|
||||
)
|
||||
element.changed.subscribe((changedElement) => {
|
||||
expect(changedElement).toBe(element)
|
||||
})
|
||||
element.value = 'new value'
|
||||
})
|
||||
|
||||
it('should throw error on serialize call', () => {
|
||||
const element = new CustomFieldQueryElement(
|
||||
CustomFieldQueryElementType.Atom
|
||||
)
|
||||
expect(() => element.serialize()).toThrow('Implemented in subclass')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CustomFieldQueryAtom', () => {
|
||||
it('should initialize with correct field, operator, and value', () => {
|
||||
const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
|
||||
expect(atom.field).toBe(1)
|
||||
expect(atom.operator).toBe('operator')
|
||||
expect(atom.value).toBe('value')
|
||||
})
|
||||
|
||||
it('should trigger changed subject on field change', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.changed.subscribe((changedAtom) => {
|
||||
expect(changedAtom).toBe(atom)
|
||||
})
|
||||
atom.field = 2
|
||||
})
|
||||
|
||||
it('should set value to null if operator is not found in CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = 'nonexistent_operator'
|
||||
expect(atom.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should set value to empty string if new type is string', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = CustomFieldQueryOperator.IContains
|
||||
expect(atom.value).toBe('')
|
||||
})
|
||||
|
||||
it('should set value to "true" if new type is boolean', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = CustomFieldQueryOperator.Exists
|
||||
expect(atom.value).toBe('true')
|
||||
})
|
||||
|
||||
it('should set value to empty array if new type is array', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = CustomFieldQueryOperator.In
|
||||
expect(atom.value).toEqual([])
|
||||
})
|
||||
|
||||
it('should try to set existing value to number if new type is number', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.value = '42'
|
||||
atom.operator = CustomFieldQueryOperator.GreaterThan
|
||||
expect(atom.value).toBe('42')
|
||||
|
||||
// fallback to null if value is not parseable
|
||||
atom.value = 'not_a_number'
|
||||
atom.operator = CustomFieldQueryOperator.GreaterThan
|
||||
expect(atom.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should change boolean values to empty string if operator is not boolean', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.value = 'true'
|
||||
atom.operator = CustomFieldQueryOperator.Exact
|
||||
expect(atom.value).toBe('')
|
||||
})
|
||||
|
||||
it('should serialize correctly', () => {
|
||||
const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
|
||||
expect(atom.serialize()).toEqual([1, 'operator', 'value'])
|
||||
})
|
||||
|
||||
it('should emit changed on value change after debounce', fakeAsync(() => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
const changeSpy = jest.spyOn(atom.changed, 'next')
|
||||
atom.value = 'new value'
|
||||
tick(1000)
|
||||
expect(changeSpy).toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
|
||||
describe('CustomFieldQueryExpression', () => {
|
||||
it('should initialize with default operator and empty value', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.Or)
|
||||
expect(expression.value).toEqual([])
|
||||
})
|
||||
|
||||
it('should initialize with correct operator and value, propagate changes', () => {
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[
|
||||
[1, 'exists', 'true'],
|
||||
[2, 'exists', 'true'],
|
||||
],
|
||||
])
|
||||
expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.And)
|
||||
expect(expression.value.length).toBe(2)
|
||||
|
||||
// propagate changes
|
||||
const expressionChangeSpy = jest.spyOn(expression.changed, 'next')
|
||||
;(expression.value[0] as CustomFieldQueryAtom).changed.next(
|
||||
expression.value[0] as any
|
||||
)
|
||||
expect(expressionChangeSpy).toHaveBeenCalled()
|
||||
|
||||
const expression2 = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.Not,
|
||||
[[CustomFieldQueryLogicalOperator.Or, []]],
|
||||
])
|
||||
const expressionChangeSpy2 = jest.spyOn(expression2.changed, 'next')
|
||||
;(expression2.value[0] as CustomFieldQueryExpression).changed.next(
|
||||
expression2.value[0] as any
|
||||
)
|
||||
expect(expressionChangeSpy2).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should initialize with a sub-expression i.e. NOT', () => {
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.Not,
|
||||
[
|
||||
'AND',
|
||||
[
|
||||
[1, 'exists', 'true'],
|
||||
[2, 'exists', 'true'],
|
||||
],
|
||||
],
|
||||
])
|
||||
expect(expression.value).toHaveLength(1)
|
||||
const changedSpy = jest.spyOn(expression.changed, 'next')
|
||||
;(expression.value[0] as CustomFieldQueryExpression).changed.next(
|
||||
expression.value[0] as any
|
||||
)
|
||||
expect(changedSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should add atom correctly, propagate changes', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
const atom = new CustomFieldQueryAtom([
|
||||
1,
|
||||
CustomFieldQueryOperator.Exists,
|
||||
'true',
|
||||
])
|
||||
expression.addAtom(atom)
|
||||
expect(expression.value).toContain(atom)
|
||||
const changeSpy = jest.spyOn(expression.changed, 'next')
|
||||
atom.changed.next(atom)
|
||||
expect(changeSpy).toHaveBeenCalled()
|
||||
// coverage
|
||||
expression.addAtom()
|
||||
})
|
||||
|
||||
it('should add expression correctly, propagate changes', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
const subExpression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
[],
|
||||
])
|
||||
expression.addExpression(subExpression)
|
||||
expect(expression.value).toContain(subExpression)
|
||||
const changeSpy = jest.spyOn(expression.changed, 'next')
|
||||
subExpression.changed.next(subExpression)
|
||||
expect(changeSpy).toHaveBeenCalled()
|
||||
// coverage
|
||||
expression.addExpression()
|
||||
})
|
||||
|
||||
it('should serialize correctly', () => {
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[[1, 'exists', 'true']],
|
||||
])
|
||||
expect(expression.serialize()).toEqual([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[[1, 'exists', 'true']],
|
||||
])
|
||||
})
|
||||
|
||||
it('should serialize NOT expressions correctly', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
expression.addExpression(
|
||||
new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[
|
||||
[1, 'exists', 'true'],
|
||||
[2, 'exists', 'true'],
|
||||
],
|
||||
])
|
||||
)
|
||||
expression.operator = CustomFieldQueryLogicalOperator.Not
|
||||
const serialized = expression.serialize()
|
||||
expect(serialized[0]).toBe(CustomFieldQueryLogicalOperator.Not)
|
||||
expect(serialized[1][0]).toBe(CustomFieldQueryLogicalOperator.And)
|
||||
expect(serialized[1][1].length).toBe(2)
|
||||
})
|
||||
|
||||
it('should be negatable if it has one child which is an expression', () => {
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.Not,
|
||||
[[CustomFieldQueryLogicalOperator.Or, []]],
|
||||
])
|
||||
expect(expression.negatable).toBe(true)
|
||||
})
|
||||
})
|
210
src-ui/src/app/utils/custom-field-query-element.ts
Normal file
210
src-ui/src/app/utils/custom-field-query-element.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import {
|
||||
CustomFieldQueryElementType,
|
||||
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR,
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from '../data/custom-field-query'
|
||||
|
||||
export class CustomFieldQueryElement {
|
||||
public readonly type: CustomFieldQueryElementType
|
||||
public changed: Subject<CustomFieldQueryElement>
|
||||
protected valueModelChanged: Subject<
|
||||
string | string[] | number[] | CustomFieldQueryElement[]
|
||||
>
|
||||
public depth: number = 0
|
||||
public id: string = uuidv4()
|
||||
|
||||
constructor(type: CustomFieldQueryElementType) {
|
||||
this.type = type
|
||||
this.changed = new Subject<CustomFieldQueryElement>()
|
||||
this.valueModelChanged = new Subject<string | CustomFieldQueryElement[]>()
|
||||
this.connectValueModelChanged()
|
||||
}
|
||||
|
||||
protected connectValueModelChanged() {
|
||||
// Allows overriding in subclasses
|
||||
this.valueModelChanged.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public serialize() {
|
||||
throw new Error('Implemented in subclass')
|
||||
}
|
||||
|
||||
protected _operator: string = null
|
||||
public set operator(value: string) {
|
||||
this._operator = value
|
||||
this.changed.next(this)
|
||||
}
|
||||
public get operator(): string {
|
||||
return this._operator
|
||||
}
|
||||
|
||||
protected _value: string | string[] | number[] | CustomFieldQueryElement[] =
|
||||
null
|
||||
public set value(
|
||||
value: string | string[] | number[] | CustomFieldQueryElement[]
|
||||
) {
|
||||
this._value = value
|
||||
this.valueModelChanged.next(value)
|
||||
}
|
||||
public get value(): string | string[] | number[] | CustomFieldQueryElement[] {
|
||||
return this._value
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomFieldQueryAtom extends CustomFieldQueryElement {
|
||||
protected _field: number
|
||||
set field(field: any) {
|
||||
this._field = parseInt(field, 10)
|
||||
this.changed.next(this)
|
||||
}
|
||||
get field(): number {
|
||||
return this._field
|
||||
}
|
||||
|
||||
override set operator(operator: string) {
|
||||
const newTypes: string[] =
|
||||
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR[operator]?.split('|')
|
||||
if (!newTypes) {
|
||||
this.value = null
|
||||
} else {
|
||||
if (!newTypes.includes(typeof this.value)) {
|
||||
switch (newTypes[0]) {
|
||||
case 'string':
|
||||
this.value = ''
|
||||
break
|
||||
case 'boolean':
|
||||
this.value = 'true'
|
||||
break
|
||||
case 'array':
|
||||
this.value = []
|
||||
break
|
||||
case 'number':
|
||||
const num = parseFloat(this.value as string)
|
||||
this.value = isNaN(num) ? null : num.toString()
|
||||
break
|
||||
}
|
||||
} else if (
|
||||
['true', 'false'].includes(this.value as string) &&
|
||||
newTypes.includes('string')
|
||||
) {
|
||||
this.value = ''
|
||||
}
|
||||
}
|
||||
super.operator = operator
|
||||
}
|
||||
|
||||
override get operator(): string {
|
||||
// why?
|
||||
return super.operator
|
||||
}
|
||||
|
||||
constructor(queryArray: [number, string, string] = [null, null, null]) {
|
||||
super(CustomFieldQueryElementType.Atom)
|
||||
;[this._field, this._operator, this._value] = queryArray
|
||||
}
|
||||
|
||||
protected override connectValueModelChanged(): void {
|
||||
this.valueModelChanged
|
||||
.pipe(debounceTime(1000), distinctUntilChanged())
|
||||
.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public override serialize() {
|
||||
return [this._field, this._operator, this._value]
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomFieldQueryExpression extends CustomFieldQueryElement {
|
||||
protected _value: string[] | number[] | CustomFieldQueryElement[]
|
||||
|
||||
constructor(
|
||||
expressionArray: [CustomFieldQueryLogicalOperator, any[]] = [
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
null,
|
||||
]
|
||||
) {
|
||||
super(CustomFieldQueryElementType.Expression)
|
||||
let values
|
||||
;[this._operator, values] = expressionArray
|
||||
if (!values || values.length === 0) {
|
||||
this._value = []
|
||||
} else if (values?.length > 0 && values[0] instanceof Array) {
|
||||
this._value = values.map((value) => {
|
||||
if (value.length === 3) {
|
||||
const atom = new CustomFieldQueryAtom(value)
|
||||
atom.depth = this.depth + 1
|
||||
atom.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
return atom
|
||||
} else {
|
||||
const expression = new CustomFieldQueryExpression(value)
|
||||
expression.depth = this.depth + 1
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
return expression
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const expression = new CustomFieldQueryExpression(values as any)
|
||||
expression.depth = this.depth + 1
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
this._value = [expression]
|
||||
}
|
||||
}
|
||||
|
||||
public override serialize() {
|
||||
let value
|
||||
value = this._value.map((element) => element.serialize())
|
||||
// If the expression is negated it should have only one child which is an expression
|
||||
if (
|
||||
this._operator === CustomFieldQueryLogicalOperator.Not &&
|
||||
value.length === 1
|
||||
) {
|
||||
value = value[0]
|
||||
}
|
||||
return [this._operator, value]
|
||||
}
|
||||
|
||||
public addAtom(
|
||||
atom: CustomFieldQueryAtom = new CustomFieldQueryAtom([
|
||||
null,
|
||||
CustomFieldQueryOperator.Exists,
|
||||
'true',
|
||||
])
|
||||
) {
|
||||
atom.depth = this.depth + 1
|
||||
;(this._value as CustomFieldQueryElement[]).push(atom)
|
||||
atom.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public addExpression(
|
||||
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
|
||||
) {
|
||||
expression.depth = this.depth + 1
|
||||
;(this._value as CustomFieldQueryElement[]).push(expression)
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public get negatable(): boolean {
|
||||
return (
|
||||
this.value.length === 1 &&
|
||||
(this.value[0] as CustomFieldQueryElement).type ===
|
||||
CustomFieldQueryElementType.Expression
|
||||
)
|
||||
}
|
||||
}
|
@@ -2,13 +2,17 @@ import { convertToParamMap } from '@angular/router'
|
||||
import { FilterRule } from '../data/filter-rule'
|
||||
import {
|
||||
FILTER_CORRESPONDENT,
|
||||
FILTER_CUSTOM_FIELDS_QUERY,
|
||||
FILTER_HAS_ANY_TAG,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
} from '../data/filter-rule-type'
|
||||
import { paramsToViewState } from './query-params'
|
||||
import { paramsToViewState, transformLegacyFilterRules } from './query-params'
|
||||
import { paramsFromViewState } from './query-params'
|
||||
import { queryParamsFromFilterRules } from './query-params'
|
||||
import { filterRulesFromQueryParams } from './query-params'
|
||||
import { CustomFieldQueryLogicalOperator } from '../data/custom-field-query'
|
||||
|
||||
const tags__id__all = '9'
|
||||
const filterRules: FilterRule[] = [
|
||||
@@ -193,4 +197,58 @@ describe('QueryParams Utils', () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should transform legacy filter rules', () => {
|
||||
let filterRules: FilterRule[] = [
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
value: '2',
|
||||
},
|
||||
]
|
||||
|
||||
let transformedFilterRules = transformLegacyFilterRules(filterRules)
|
||||
|
||||
expect(transformedFilterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify([
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
[
|
||||
[1, 'exists', true],
|
||||
[2, 'exists', true],
|
||||
],
|
||||
]),
|
||||
},
|
||||
])
|
||||
|
||||
filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: '3',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
value: '4',
|
||||
},
|
||||
]
|
||||
|
||||
transformedFilterRules = transformLegacyFilterRules(filterRules)
|
||||
|
||||
expect(transformedFilterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[
|
||||
[3, 'exists', true],
|
||||
[4, 'exists', true],
|
||||
],
|
||||
]),
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
@@ -1,7 +1,17 @@
|
||||
import { ParamMap, Params } from '@angular/router'
|
||||
import { FilterRule } from '../data/filter-rule'
|
||||
import { FilterRuleType, FILTER_RULE_TYPES } from '../data/filter-rule-type'
|
||||
import {
|
||||
FilterRuleType,
|
||||
FILTER_RULE_TYPES,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_CUSTOM_FIELDS_QUERY,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
} from '../data/filter-rule-type'
|
||||
import { ListViewState } from '../services/document-list-view.service'
|
||||
import {
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from '../data/custom-field-query'
|
||||
|
||||
const SORT_FIELD_PARAMETER = 'sort'
|
||||
const SORT_REVERSE_PARAMETER = 'reverse'
|
||||
@@ -40,6 +50,49 @@ export function paramsToViewState(queryParams: ParamMap): ListViewState {
|
||||
}
|
||||
}
|
||||
|
||||
export function transformLegacyFilterRules(
|
||||
filterRules: FilterRule[]
|
||||
): FilterRule[] {
|
||||
const LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES = [
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
]
|
||||
if (
|
||||
filterRules.filter((rule) =>
|
||||
LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES.includes(rule.rule_type)
|
||||
).length
|
||||
) {
|
||||
const anyRules = filterRules.filter(
|
||||
(rule) => rule.rule_type === FILTER_HAS_CUSTOM_FIELDS_ANY
|
||||
)
|
||||
const allRules = filterRules.filter(
|
||||
(rule) => rule.rule_type === FILTER_HAS_CUSTOM_FIELDS_ALL
|
||||
)
|
||||
const customFieldQueryLogicalOperator = allRules.length
|
||||
? CustomFieldQueryLogicalOperator.And
|
||||
: CustomFieldQueryLogicalOperator.Or
|
||||
const valueRules = allRules.length ? allRules : anyRules
|
||||
const customFieldQueryExpression = [
|
||||
customFieldQueryLogicalOperator,
|
||||
[
|
||||
...valueRules.map((rule) => [
|
||||
parseInt(rule.value),
|
||||
CustomFieldQueryOperator.Exists,
|
||||
true,
|
||||
]),
|
||||
],
|
||||
]
|
||||
filterRules.push({
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify(customFieldQueryExpression),
|
||||
})
|
||||
}
|
||||
// TODO: can we support FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS or FILTER_HAS_ANY_CUSTOM_FIELDS?
|
||||
return filterRules.filter(
|
||||
(rule) => !LEGACY_CUSTOM_FIELD_FILTER_RULE_TYPES.includes(rule.rule_type)
|
||||
)
|
||||
}
|
||||
|
||||
export function filterRulesFromQueryParams(
|
||||
queryParams: ParamMap
|
||||
): FilterRule[] {
|
||||
@@ -77,7 +130,9 @@ export function filterRulesFromQueryParams(
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
filterRulesFromQueryParams = transformLegacyFilterRules(
|
||||
filterRulesFromQueryParams
|
||||
)
|
||||
return filterRulesFromQueryParams
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user