mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06: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