Merge pull request #2893 from paperless-ngx/feature-enhanced-object-filtering

Enhancement: support filtering multiple correspondents, doctypes & storage paths
This commit is contained in:
shamoon 2023-03-17 18:46:22 -07:00 committed by GitHub
commit f161722b34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 752 additions and 130 deletions

View File

@ -48,6 +48,26 @@ describe('documents-list', () => {
(d.tags as Array<number>).includes(tag_id) (d.tags as Array<number>).includes(tag_id)
) )
response.count = response.results.length response.count = response.results.length
} else if (req.query.hasOwnProperty('correspondent__id__in')) {
// filtering e.g. http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&correspondent__id__in=9,14
const correspondent_ids = req.query['correspondent__id__in']
.toString()
.split(',')
.map((c) => +c)
response.results = (documentsJson.results as Array<any>).filter((d) =>
correspondent_ids.includes(d.correspondent)
)
response.count = response.results.length
} else if (req.query.hasOwnProperty('correspondent__id__none')) {
// filtering e.g. http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&correspondent__id__none=9,14
const correspondent_ids = req.query['correspondent__id__none']
.toString()
.split(',')
.map((c) => +c)
response.results = (documentsJson.results as Array<any>).filter(
(d) => !correspondent_ids.includes(d.correspondent)
)
response.count = response.results.length
} }
req.reply(response) req.reply(response)
@ -112,6 +132,27 @@ describe('documents-list', () => {
cy.contains('One document') cy.contains('One document')
}) })
it('should filter including multiple correspondents', () => {
cy.get('app-filter-editor app-filterable-dropdown[title="Correspondent"]')
.click()
.within(() => {
cy.contains('button', 'ABC Test Correspondent').click()
cy.contains('button', 'Corresp 11').click()
})
cy.contains('3 documents')
})
it('should filter excluding multiple correspondents', () => {
cy.get('app-filter-editor app-filterable-dropdown[title="Correspondent"]')
.click()
.within(() => {
cy.contains('button', 'ABC Test Correspondent').click()
cy.contains('button', 'Corresp 11').click()
cy.contains('label', 'Exclude').click()
})
cy.contains('One document')
})
it('should apply tags', () => { it('should apply tags', () => {
cy.get('app-document-card-small:first-of-type').click() cy.get('app-document-card-small:first-of-type').click()
cy.get('app-bulk-editor app-filterable-dropdown[title="Tags"]').within( cy.get('app-bulk-editor app-filterable-dropdown[title="Tags"]').within(

View File

@ -232,6 +232,11 @@ describe('documents query params', () => {
it('should show a list of documents filtered by document type', () => { it('should show a list of documents filtered by document type', () => {
cy.visit('/documents?sort=created&reverse=true&document_type__id=1') cy.visit('/documents?sort=created&reverse=true&document_type__id=1')
cy.contains('2 documents')
})
it('should show a list of documents filtered by multiple correspondents', () => {
cy.visit('/documents?sort=created&reverse=true&document_type__id__in=1,2')
cy.contains('3 documents') cy.contains('3 documents')
}) })
@ -245,9 +250,14 @@ describe('documents query params', () => {
cy.contains('2 documents') cy.contains('2 documents')
}) })
it('should show a list of documents filtered by multiple correspondents', () => {
cy.visit('/documents?sort=created&reverse=true&correspondent__id__in=9,14')
cy.contains('3 documents')
})
it('should show a list of documents filtered by no correspondent', () => { it('should show a list of documents filtered by no correspondent', () => {
cy.visit('/documents?sort=created&reverse=true&correspondent__isnull=1') cy.visit('/documents?sort=created&reverse=true&correspondent__isnull=1')
cy.contains('2 documents') cy.contains('One document')
}) })
it('should show a list of documents filtered by storage path', () => { it('should show a list of documents filtered by storage path', () => {

View File

@ -1 +1,257 @@
{"count":27,"next":"http://localhost:8000/api/correspondents/?page=2","previous":null,"results":[{"id":9,"slug":"abc-test-correspondent","name":"ABC Test Correspondent","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":13,"slug":"corresp-10","name":"Corresp 10","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":14,"slug":"corresp-11","name":"Corresp 11","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":15,"slug":"corresp-12","name":"Corresp 12","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":16,"slug":"corresp-13","name":"Corresp 13","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":18,"slug":"corresp-15","name":"Corresp 15","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":19,"slug":"corresp-16","name":"Corresp 16","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":20,"slug":"corresp-17","name":"Corresp 17","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":21,"slug":"corresp-18","name":"Corresp 18","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":22,"slug":"corresp-19","name":"Corresp 19","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":23,"slug":"corresp-20","name":"Corresp 20","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":24,"slug":"corresp-21","name":"Corresp 21","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":25,"slug":"corresp-22","name":"Corresp 22","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":26,"slug":"corresp-23","name":"Corresp 23","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":5,"slug":"corresp-3","name":"Corresp 3","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":6,"slug":"corresp-4","name":"Corresp 4","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":7,"slug":"corresp-5","name":"Corresp 5","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":8,"slug":"corresp-6","name":"Corresp 6","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":10,"slug":"corresp-7","name":"Corresp 7","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":11,"slug":"corresp-8","name":"Corresp 8","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":12,"slug":"corresp-9","name":"Corresp 9","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":17,"slug":"correspondent-14","name":"Correspondent 14","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":2,"slug":"correspondent-2","name":"Correspondent 2","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":7,"last_correspondence":"2021-01-20T23:37:58.204614Z"},{"id":27,"slug":"michael-shamoon","name":"Michael Shamoon","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":1,"last_correspondence":"2022-03-16T03:48:50.089624Z"},{"id":4,"slug":"newest-correspondent","name":"Newest Correspondent","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":1,"last_correspondence":"2021-02-07T08:00:00Z"}]} {
"count": 27,
"next": "http://localhost:8000/api/correspondents/?page=2",
"previous": null,
"results": [
{
"id": 9,
"slug": "abc-test-correspondent",
"name": "ABC Test Correspondent",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 13,
"slug": "corresp-10",
"name": "Corresp 10",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 14,
"slug": "corresp-11",
"name": "Corresp 11",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 15,
"slug": "corresp-12",
"name": "Corresp 12",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 16,
"slug": "corresp-13",
"name": "Corresp 13",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 18,
"slug": "corresp-15",
"name": "Corresp 15",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 19,
"slug": "corresp-16",
"name": "Corresp 16",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 20,
"slug": "corresp-17",
"name": "Corresp 17",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 21,
"slug": "corresp-18",
"name": "Corresp 18",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 22,
"slug": "corresp-19",
"name": "Corresp 19",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 23,
"slug": "corresp-20",
"name": "Corresp 20",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 24,
"slug": "corresp-21",
"name": "Corresp 21",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 25,
"slug": "corresp-22",
"name": "Corresp 22",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 26,
"slug": "corresp-23",
"name": "Corresp 23",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 5,
"slug": "corresp-3",
"name": "Corresp 3",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 6,
"slug": "corresp-4",
"name": "Corresp 4",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 7,
"slug": "corresp-5",
"name": "Corresp 5",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 8,
"slug": "corresp-6",
"name": "Corresp 6",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 10,
"slug": "corresp-7",
"name": "Corresp 7",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 11,
"slug": "corresp-8",
"name": "Corresp 8",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 12,
"slug": "corresp-9",
"name": "Corresp 9",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 17,
"slug": "correspondent-14",
"name": "Correspondent 14",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 0,
"last_correspondence": null
},
{
"id": 2,
"slug": "correspondent-2",
"name": "Correspondent 2",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 7,
"last_correspondence": "2021-01-20T23:37:58.204614Z"
},
{
"id": 27,
"slug": "correspondent-slug",
"name": "Correspondent Slug",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 1,
"last_correspondence": "2022-03-16T03:48:50.089624Z"
},
{
"id": 4,
"slug": "newest-correspondent",
"name": "Newest Correspondent",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 1,
"last_correspondence": "2021-02-07T08:00:00Z"
}
]
}

View File

@ -1 +1,25 @@
{"count":1,"next":null,"previous":null,"results":[{"id":1,"slug":"test","name":"Test Doc Type","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0}]} {
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"slug": "test",
"name": "Test Doc Type",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 1
},
{
"id": 2,
"slug": "test2",
"name": "Test Doc Type 2",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"document_count": 1
}
]
}

View File

@ -43,7 +43,7 @@
}, },
{ {
"id": 3, "id": 3,
"correspondent": null, "correspondent": 14,
"document_type": 1, "document_type": 1,
"storage_path": null, "storage_path": null,
"title": "dolor", "title": "dolor",
@ -64,7 +64,7 @@
{ {
"id": 4, "id": 4,
"correspondent": 9, "correspondent": 9,
"document_type": 1, "document_type": 2,
"storage_path": null, "storage_path": null,
"title": "sit amet", "title": "sit amet",
"content": "Test document PDF", "content": "Test document PDF",

View File

@ -1794,25 +1794,39 @@
<context context-type="linenumber">18</context> <context context-type="linenumber">18</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6381578200008167206" datatype="html">
<source>Include</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">24</context>
</context-group>
</trans-unit>
<trans-unit id="5668077948386857930" datatype="html">
<source>Exclude</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
</trans-unit>
<trans-unit id="4391289919356861627" datatype="html"> <trans-unit id="4391289919356861627" datatype="html">
<source>Apply</source> <source>Apply</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">32</context> <context context-type="linenumber">40</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7780041345210191160" datatype="html"> <trans-unit id="7780041345210191160" datatype="html">
<source>Click again to exclude items.</source> <source>Click again to exclude items.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">38</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7593728289020204896" datatype="html"> <trans-unit id="7593728289020204896" datatype="html">
<source>Not assigned</source> <source>Not assigned</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">262</context> <context context-type="linenumber">321</context>
</context-group> </context-group>
<note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note> <note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
</trans-unit> </trans-unit>
@ -2174,7 +2188,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">162</context> <context context-type="linenumber">172</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/rest/document.service.ts</context> <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
@ -3331,7 +3345,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <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">177</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/rest/document.service.ts</context> <context context-type="sourcefile">src/app/services/rest/document.service.ts</context>
@ -3385,112 +3399,112 @@
<source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) =&gt; c.id == +rule.value)?.name"/></source> <source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) =&gt; c.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">98,100</context> <context context-type="linenumber">108,110</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8170755470576301659" datatype="html"> <trans-unit id="8170755470576301659" datatype="html">
<source>Without correspondent</source> <source>Without correspondent</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">102</context> <context context-type="linenumber">112</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8705701325879965907" datatype="html"> <trans-unit id="8705701325879965907" datatype="html">
<source>Type: <x id="PH" equiv-text="this.documentTypes.find((dt) =&gt; dt.id == +rule.value)?.name"/></source> <source>Type: <x id="PH" equiv-text="this.documentTypes.find((dt) =&gt; dt.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">107,109</context> <context context-type="linenumber">117,119</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4362173610367509215" datatype="html"> <trans-unit id="4362173610367509215" datatype="html">
<source>Without document type</source> <source>Without document type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">111</context> <context context-type="linenumber">121</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8180755793012580465" datatype="html"> <trans-unit id="8180755793012580465" datatype="html">
<source>Tag: <x id="PH" equiv-text="this.tags.find((t) =&gt; t.id == +rule.value)?.name"/></source> <source>Tag: <x id="PH" equiv-text="this.tags.find((t) =&gt; t.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">115,117</context> <context context-type="linenumber">125,127</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6494566478302448576" datatype="html"> <trans-unit id="6494566478302448576" datatype="html">
<source>Without any tag</source> <source>Without any tag</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">121</context> <context context-type="linenumber">131</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6523384805359286307" datatype="html"> <trans-unit id="6523384805359286307" datatype="html">
<source>Title: <x id="PH" equiv-text="rule.value"/></source> <source>Title: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">125</context> <context context-type="linenumber">135</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1872523635812236432" datatype="html"> <trans-unit id="1872523635812236432" datatype="html">
<source>ASN: <x id="PH" equiv-text="rule.value"/></source> <source>ASN: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">128</context> <context context-type="linenumber">138</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3100631071441658964" datatype="html"> <trans-unit id="3100631071441658964" datatype="html">
<source>Title &amp; content</source> <source>Title &amp; content</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">165</context> <context context-type="linenumber">175</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1010505078885609376" datatype="html"> <trans-unit id="1010505078885609376" datatype="html">
<source>Advanced search</source> <source>Advanced search</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">170</context> <context context-type="linenumber">180</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2649431021108393503" datatype="html"> <trans-unit id="2649431021108393503" datatype="html">
<source>More like</source> <source>More like</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">176</context> <context context-type="linenumber">186</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3697582909018473071" datatype="html"> <trans-unit id="3697582909018473071" datatype="html">
<source>equals</source> <source>equals</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <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">205</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5325481293405718739" datatype="html"> <trans-unit id="5325481293405718739" datatype="html">
<source>is empty</source> <source>is empty</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">199</context> <context context-type="linenumber">209</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6166785695326182482" datatype="html"> <trans-unit id="6166785695326182482" datatype="html">
<source>is not empty</source> <source>is not empty</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">203</context> <context context-type="linenumber">213</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4686622206659266699" datatype="html"> <trans-unit id="4686622206659266699" datatype="html">
<source>greater than</source> <source>greater than</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">207</context> <context context-type="linenumber">217</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8014012170270529279" datatype="html"> <trans-unit id="8014012170270529279" datatype="html">
<source>less than</source> <source>less than</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">211</context> <context context-type="linenumber">221</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7210076240260527720" datatype="html"> <trans-unit id="7210076240260527720" datatype="html">

View File

@ -1,21 +1,29 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown"> <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" />
</svg> </svg>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<ng-container *ngIf="!editing && selectionModel.totalCount > 0"> <ng-container *ngIf="!editing && selectionModel.totalCount > 0">
<app-clearable-badge [number]="multiple ? selectionModel.totalCount : undefined" [selected]="!multiple && selectionModel.selectionSize() > 0" (cleared)="reset()"></app-clearable-badge> <app-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></app-clearable-badge>
</ng-container> </ng-container>
</button> </button>
<div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> <div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<div *ngIf="!editing && multiple" class="list-group-item d-flex"> <div *ngIf="!editing && manyToOne" class="list-group-item d-flex">
<div class="btn-group btn-group-xs flex-fill"> <div class="btn-group btn-group-xs flex-fill" role="group">
<input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!operatorToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorAnd" value="and"> <input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!modifierToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorAnd_{{name}}" name="logicalOperatorAnd_{{name}}" value="and">
<label class="btn btn-outline-primary" for="logicalOperatorAnd" i18n>All</label> <label class="btn btn-outline-primary" for="logicalOperatorAnd_{{name}}" i18n>All</label>
<input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!operatorToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorOr" value="or"> <input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!modifierToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorOr_{{name}}" name="logicalOperatorOr_{{name}}" value="or">
<label class="btn btn-outline-primary" for="logicalOperatorOr" i18n>Any</label> <label class="btn btn-outline-primary" for="logicalOperatorOr_{{name}}" i18n>Any</label>
</div>
</div>
<div *ngIf="!editing && !manyToOne" class="list-group-item d-flex">
<div class="btn-group btn-group-xs flex-fill" role="group">
<input [(ngModel)]="selectionModel.intersection" [disabled]="!modifierToggleEnabled" (ngModelChange)="selectionModel.toggleIntersection()" type="radio" class="btn-check" id="intersectionInclude_{{name}}" name="intersectionInclude_{{name}}" value="include">
<label class="btn btn-outline-primary" for="intersectionInclude_{{name}}" i18n>Include</label>
<input [(ngModel)]="selectionModel.intersection" [disabled]="!modifierToggleEnabled" (ngModelChange)="selectionModel.toggleIntersection()" type="radio" class="btn-check" id="intersectionExclude_{{name}}" name="intersectionExclude_{{name}}" value="exclude">
<label class="btn btn-outline-primary" for="intersectionExclude_{{name}}" i18n>Exclude</label>
</div> </div>
</div> </div>
<div class="list-group-item"> <div class="list-group-item">
@ -34,7 +42,7 @@
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" /> <use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
</svg> </svg>
</button> </button>
<div *ngIf="!editing && multiple" class="list-group-item list-group-item-note pt-1 pb-2"> <div *ngIf="!editing && manyToOne" class="list-group-item list-group-item-note pt-1 pb-2">
<small i18n>Click again to exclude items.</small> <small i18n>Click again to exclude items.</small>
</div> </div>
</div> </div>

View File

@ -18,12 +18,25 @@ export interface ChangedItems {
itemsToRemove: MatchingModel[] itemsToRemove: MatchingModel[]
} }
export enum LogicalOperator {
And = 'and',
Or = 'or',
}
export enum Intersection {
Include = 'include',
Exclude = 'exclude',
}
export class FilterableDropdownSelectionModel { export class FilterableDropdownSelectionModel {
changed = new Subject<FilterableDropdownSelectionModel>() changed = new Subject<FilterableDropdownSelectionModel>()
multiple = false manyToOne = false
private _logicalOperator = 'and' singleSelect = false
temporaryLogicalOperator = this._logicalOperator private _logicalOperator: LogicalOperator = LogicalOperator.And
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
private _intersection: Intersection = Intersection.Include
temporaryIntersection: Intersection = this._intersection
items: MatchingModel[] = [] items: MatchingModel[] = []
@ -86,7 +99,30 @@ export class FilterableDropdownSelectionModel {
(state != ToggleableItemState.Selected && (state != ToggleableItemState.Selected &&
state != ToggleableItemState.Excluded) state != ToggleableItemState.Excluded)
) { ) {
this.temporarySelectionStates.set(id, ToggleableItemState.Selected) if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
this.temporarySelectionStates.delete(key)
}
}
}
} else {
let newState =
this.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
if (!id) newState = ToggleableItemState.Selected
if (
state == ToggleableItemState.Excluded &&
this.intersection == Intersection.Exclude
) {
newState = ToggleableItemState.NotSelected
}
this.temporarySelectionStates.set(id, newState)
}
} else if ( } else if (
state == ToggleableItemState.Selected || state == ToggleableItemState.Selected ||
state == ToggleableItemState.Excluded state == ToggleableItemState.Excluded
@ -94,14 +130,6 @@ export class FilterableDropdownSelectionModel {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
} }
if (!this.multiple) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
this.temporarySelectionStates.delete(key)
}
}
}
if (!id) { if (!id) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of this.temporarySelectionStates.keys()) {
if (key) { if (key) {
@ -119,19 +147,36 @@ export class FilterableDropdownSelectionModel {
exclude(id: number, fireEvent: boolean = true) { exclude(id: number, fireEvent: boolean = true) {
let state = this.temporarySelectionStates.get(id) let state = this.temporarySelectionStates.get(id)
if (state == null || state != ToggleableItemState.Excluded) { if (id && (state == null || state != ToggleableItemState.Excluded)) {
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
this.temporaryLogicalOperator = this._logicalOperator = 'and' ? LogicalOperator.And
} else if (state == ToggleableItemState.Excluded) { : LogicalOperator.Or
this.temporarySelectionStates.delete(id)
}
if (!this.multiple) { if (this.manyToOne || this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
if (key != id) {
this.temporarySelectionStates.delete(key) if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) {
if (key != id) {
this.temporarySelectionStates.delete(key)
}
}
} }
} else {
let newState =
this.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
if (
state == ToggleableItemState.Selected &&
this.intersection == Intersection.Include
) {
newState = ToggleableItemState.NotSelected
}
this.temporarySelectionStates.set(id, newState)
} }
} else if (!id || state == ToggleableItemState.Excluded) {
this.temporarySelectionStates.delete(id)
} }
if (fireEvent) { if (fireEvent) {
@ -143,11 +188,11 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected return this.selectionStates.get(id) || ToggleableItemState.NotSelected
} }
get logicalOperator(): string { get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator return this.temporaryLogicalOperator
} }
set logicalOperator(operator: string) { set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator = operator this.temporaryLogicalOperator = operator
} }
@ -155,6 +200,26 @@ export class FilterableDropdownSelectionModel {
this.changed.next(this) this.changed.next(this)
} }
get intersection(): Intersection {
return this.temporaryIntersection
}
set intersection(intersection: Intersection) {
this.temporaryIntersection = intersection
}
toggleIntersection() {
if (this.temporarySelectionStates.size === 0) return
let newState =
this.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded
this.temporarySelectionStates.forEach((state, key) => {
this.temporarySelectionStates.set(key, newState)
})
this.changed.next(this)
}
get(id: number) { get(id: number) {
return ( return (
this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected
@ -171,7 +236,8 @@ export class FilterableDropdownSelectionModel {
clear(fireEvent = true) { clear(fireEvent = true) {
this.temporarySelectionStates.clear() this.temporarySelectionStates.clear()
this.temporaryLogicalOperator = this._logicalOperator = 'and' this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
this.temporaryIntersection = this._intersection = Intersection.Include
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
} }
@ -194,6 +260,8 @@ export class FilterableDropdownSelectionModel {
return true return true
} else if (this.temporaryLogicalOperator !== this._logicalOperator) { } else if (this.temporaryLogicalOperator !== this._logicalOperator) {
return true return true
} else if (this.temporaryIntersection !== this._intersection) {
return true
} else { } else {
return false return false
} }
@ -217,13 +285,18 @@ export class FilterableDropdownSelectionModel {
this.selectionStates.set(key, value) this.selectionStates.set(key, value)
}) })
this._logicalOperator = this.temporaryLogicalOperator this._logicalOperator = this.temporaryLogicalOperator
this._intersection = this.temporaryIntersection
} }
reset() { reset(complete: boolean = false) {
this.temporarySelectionStates.clear() this.temporarySelectionStates.clear()
this.selectionStates.forEach((value, key) => { if (complete) {
this.temporarySelectionStates.set(key, value) this.selectionStates.clear()
}) } else {
this.selectionStates.forEach((value, key) => {
this.temporarySelectionStates.set(key, value)
})
}
} }
diff(): ChangedItems { diff(): ChangedItems {
@ -269,14 +342,16 @@ export class FilterableDropdownComponent {
return this._selectionModel.items return this._selectionModel.items
} }
_selectionModel = new FilterableDropdownSelectionModel() _selectionModel: FilterableDropdownSelectionModel =
new FilterableDropdownSelectionModel()
@Input() @Input()
set selectionModel(model: FilterableDropdownSelectionModel) { set selectionModel(model: FilterableDropdownSelectionModel) {
if (this.selectionModel) { if (this.selectionModel) {
this.selectionModel.changed.complete() this.selectionModel.changed.complete()
model.items = this.selectionModel.items model.items = this.selectionModel.items
model.multiple = this.selectionModel.multiple model.manyToOne = this.selectionModel.manyToOne
model.singleSelect = this.editing && !this.selectionModel.manyToOne
} }
model.changed.subscribe((updatedModel) => { model.changed.subscribe((updatedModel) => {
this.selectionModelChange.next(updatedModel) this.selectionModelChange.next(updatedModel)
@ -292,12 +367,12 @@ export class FilterableDropdownComponent {
selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>() selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
@Input() @Input()
set multiple(value: boolean) { set manyToOne(manyToOne: boolean) {
this.selectionModel.multiple = value this.selectionModel.manyToOne = manyToOne
} }
get multiple() { get manyToOne() {
return this.selectionModel.multiple return this.selectionModel.manyToOne
} }
@Input() @Input()
@ -327,16 +402,20 @@ export class FilterableDropdownComponent {
@Output() @Output()
opened = new EventEmitter() opened = new EventEmitter()
get operatorToggleEnabled(): boolean { get modifierToggleEnabled(): boolean {
return ( return this.manyToOne
this.selectionModel.selectionSize() > 1 && ? this.selectionModel.selectionSize() > 1 &&
this.selectionModel.getExcludedItems().length == 0 this.selectionModel.getExcludedItems().length == 0
) : !this.selectionModel.isNoneSelected()
} }
@Input() @Input()
documentCounts: SelectionDataItem[] documentCounts: SelectionDataItem[]
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
getUpdatedDocumentCount(id: number) { getUpdatedDocumentCount(id: number) {
if (this.documentCounts) { if (this.documentCounts) {
return this.documentCounts.find((c) => c.id === id)?.document_count return this.documentCounts.find((c) => c.id === id)?.document_count
@ -346,7 +425,6 @@ export class FilterableDropdownComponent {
modelIsDirty: boolean = false modelIsDirty: boolean = false
constructor(private filterPipe: FilterPipe) { constructor(private filterPipe: FilterPipe) {
this.selectionModel = new FilterableDropdownSelectionModel()
this.selectionModelChange.subscribe((updatedModel) => { this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty = updatedModel.isDirty() this.modelIsDirty = updatedModel.isDirty()
}) })
@ -400,7 +478,7 @@ export class FilterableDropdownComponent {
} }
reset() { reset() {
this.selectionModel.reset() this.selectionModel.reset(true)
this.selectionModelChange.emit(this.selectionModel) this.selectionModelChange.emit(this.selectionModel)
} }
} }

View File

@ -30,7 +30,7 @@
[items]="tags" [items]="tags"
[disabled]="!userCanEditAll" [disabled]="!userCanEditAll"
[editing]="true" [editing]="true"
[multiple]="true" [manyToOne]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
(opened)="openTagsDropdown()" (opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"

View File

@ -14,19 +14,19 @@
</div> </div>
</div> </div>
<div class="btn-group flex-fill" role="group"> <div class="btn-group flex-fill" role="group">
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails"> <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails" name="displayModeDetails">
<label for="displayModeDetails" class="btn btn-outline-primary btn-sm"> <label for="displayModeDetails" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#list-ul" /> <use xlink:href="assets/bootstrap-icons.svg#list-ul" />
</svg> </svg>
</label> </label>
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall"> <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall" name="displayModeSmall">
<label for="displayModeSmall" class="btn btn-outline-primary btn-sm"> <label for="displayModeSmall" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#grid" /> <use xlink:href="assets/bootstrap-icons.svg#grid" />
</svg> </svg>
</label> </label>
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge"> <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge" name="displayModeLarge">
<label for="displayModeLarge" class="btn btn-outline-primary btn-sm"> <label for="displayModeLarge" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#hdd-stack" /> <use xlink:href="assets/bootstrap-icons.svg#hdd-stack" />

View File

@ -27,7 +27,7 @@
<app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title <app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags" [items]="tags"
[multiple]="true" [manyToOne]="true"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onTagsDropdownOpen()" (opened)="onTagsDropdownOpen()"

View File

@ -21,10 +21,10 @@ import {
FILTER_ADDED_AFTER, FILTER_ADDED_AFTER,
FILTER_ADDED_BEFORE, FILTER_ADDED_BEFORE,
FILTER_ASN, FILTER_ASN,
FILTER_CORRESPONDENT, FILTER_HAS_CORRESPONDENT_ANY,
FILTER_CREATED_AFTER, FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE, FILTER_CREATED_BEFORE,
FILTER_DOCUMENT_TYPE, FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_MORELIKE,
FILTER_FULLTEXT_QUERY, FILTER_FULLTEXT_QUERY,
FILTER_HAS_ANY_TAG, FILTER_HAS_ANY_TAG,
@ -33,12 +33,22 @@ import {
FILTER_DOES_NOT_HAVE_TAG, FILTER_DOES_NOT_HAVE_TAG,
FILTER_TITLE, FILTER_TITLE,
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
FILTER_STORAGE_PATH, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_ASN_ISNULL, FILTER_ASN_ISNULL,
FILTER_ASN_GT, FILTER_ASN_GT,
FILTER_ASN_LT, FILTER_ASN_LT,
FILTER_DOES_NOT_HAVE_CORRESPONDENT,
FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
FILTER_DOES_NOT_HAVE_STORAGE_PATH,
FILTER_DOCUMENT_TYPE,
FILTER_CORRESPONDENT,
FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component' import {
FilterableDropdownSelectionModel,
Intersection,
LogicalOperator,
} from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { import {
DocumentService, DocumentService,
@ -93,7 +103,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
if (this.filterRules.length == 1) { if (this.filterRules.length == 1) {
let rule = this.filterRules[0] let rule = this.filterRules[0]
switch (this.filterRules[0].rule_type) { switch (this.filterRules[0].rule_type) {
case FILTER_CORRESPONDENT: case FILTER_HAS_CORRESPONDENT_ANY:
if (rule.value) { if (rule.value) {
return $localize`Correspondent: ${ return $localize`Correspondent: ${
this.correspondents.find((c) => c.id == +rule.value)?.name this.correspondents.find((c) => c.id == +rule.value)?.name
@ -102,7 +112,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
return $localize`Without correspondent` return $localize`Without correspondent`
} }
case FILTER_DOCUMENT_TYPE: case FILTER_HAS_DOCUMENT_TYPE_ANY:
if (rule.value) { if (rule.value) {
return $localize`Type: ${ return $localize`Type: ${
this.documentTypes.find((dt) => dt.id == +rule.value)?.name this.documentTypes.find((dt) => dt.id == +rule.value)?.name
@ -335,6 +345,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this.dateAddedBefore = rule.value this.dateAddedBefore = rule.value
break break
case FILTER_HAS_TAGS_ALL: case FILTER_HAS_TAGS_ALL:
this.tagSelectionModel.logicalOperator = LogicalOperator.And
this.tagSelectionModel.set( this.tagSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
@ -342,7 +353,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
) )
break break
case FILTER_HAS_TAGS_ANY: case FILTER_HAS_TAGS_ANY:
this.tagSelectionModel.logicalOperator = 'or' this.tagSelectionModel.logicalOperator = LogicalOperator.Or
this.tagSelectionModel.set( this.tagSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
@ -360,26 +371,59 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
) )
break break
case FILTER_CORRESPONDENT: case FILTER_CORRESPONDENT:
case FILTER_HAS_CORRESPONDENT_ANY:
this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or
this.correspondentSelectionModel.intersection = Intersection.Include
this.correspondentSelectionModel.set( this.correspondentSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
false false
) )
break break
case FILTER_DOES_NOT_HAVE_CORRESPONDENT:
this.correspondentSelectionModel.intersection = Intersection.Exclude
this.correspondentSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_DOCUMENT_TYPE: case FILTER_DOCUMENT_TYPE:
case FILTER_HAS_DOCUMENT_TYPE_ANY:
this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or
this.documentTypeSelectionModel.intersection = Intersection.Include
this.documentTypeSelectionModel.set( this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
false false
) )
break break
case FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE:
this.documentTypeSelectionModel.intersection = Intersection.Exclude
this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_STORAGE_PATH: case FILTER_STORAGE_PATH:
case FILTER_HAS_STORAGE_PATH_ANY:
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
this.storagePathSelectionModel.intersection = Intersection.Include
this.storagePathSelectionModel.set( this.storagePathSelectionModel.set(
rule.value ? +rule.value : null, rule.value ? +rule.value : null,
ToggleableItemState.Selected, ToggleableItemState.Selected,
false false
) )
break break
case FILTER_DOES_NOT_HAVE_STORAGE_PATH:
this.storagePathSelectionModel.intersection = Intersection.Exclude
this.storagePathSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_ASN_ISNULL: case FILTER_ASN_ISNULL:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier = this.textFilterModifier =
@ -469,7 +513,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
filterRules.push({ rule_type: FILTER_HAS_ANY_TAG, value: 'false' }) filterRules.push({ rule_type: FILTER_HAS_ANY_TAG, value: 'false' })
} else { } else {
const tagFilterType = const tagFilterType =
this.tagSelectionModel.logicalOperator == 'and' this.tagSelectionModel.logicalOperator == LogicalOperator.And
? FILTER_HAS_TAGS_ALL ? FILTER_HAS_TAGS_ALL
: FILTER_HAS_TAGS_ANY : FILTER_HAS_TAGS_ANY
this.tagSelectionModel this.tagSelectionModel
@ -491,28 +535,66 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
}) })
}) })
} }
this.correspondentSelectionModel if (this.correspondentSelectionModel.isNoneSelected()) {
.getSelectedItems() filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null })
.forEach((correspondent) => { } else {
filterRules.push({ this.correspondentSelectionModel
rule_type: FILTER_CORRESPONDENT, .getSelectedItems()
value: correspondent.id?.toString(), .forEach((correspondent) => {
filterRules.push({
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
value: correspondent.id?.toString(),
})
}) })
}) this.correspondentSelectionModel
this.documentTypeSelectionModel .getExcludedItems()
.getSelectedItems() .forEach((correspondent) => {
.forEach((documentType) => { filterRules.push({
filterRules.push({ rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
rule_type: FILTER_DOCUMENT_TYPE, value: correspondent.id?.toString(),
value: documentType.id?.toString(), })
}) })
}) }
this.storagePathSelectionModel.getSelectedItems().forEach((storagePath) => { if (this.documentTypeSelectionModel.isNoneSelected()) {
filterRules.push({ filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null })
rule_type: FILTER_STORAGE_PATH, } else {
value: storagePath.id?.toString(), this.documentTypeSelectionModel
}) .getSelectedItems()
}) .forEach((documentType) => {
filterRules.push({
rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
value: documentType.id?.toString(),
})
})
this.documentTypeSelectionModel
.getExcludedItems()
.forEach((documentType) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
value: documentType.id?.toString(),
})
})
}
if (this.storagePathSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
} else {
this.storagePathSelectionModel
.getSelectedItems()
.forEach((storagePath) => {
filterRules.push({
rule_type: FILTER_HAS_STORAGE_PATH_ANY,
value: storagePath.id?.toString(),
})
})
this.storagePathSelectionModel
.getExcludedItems()
.forEach((storagePath) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
value: storagePath.id?.toString(),
})
})
}
if (this.dateCreatedBefore) { if (this.dateCreatedBefore) {
filterRules.push({ filterRules.push({
rule_type: FILTER_CREATED_BEFORE, rule_type: FILTER_CREATED_BEFORE,

View File

@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type' import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@ -35,7 +35,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService, permissionsService,
FILTER_CORRESPONDENT, FILTER_HAS_CORRESPONDENT_ANY,
$localize`correspondent`, $localize`correspondent`,
$localize`correspondents`, $localize`correspondents`,
PermissionType.Correspondent, PermissionType.Correspondent,

View File

@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type' import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { import {
@ -32,7 +32,7 @@ export class DocumentTypeListComponent extends ManagementListComponent<Paperless
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService, permissionsService,
FILTER_DOCUMENT_TYPE, FILTER_HAS_DOCUMENT_TYPE_ANY,
$localize`document type`, $localize`document type`,
$localize`document types`, $localize`document types`,
PermissionType.DocumentType, PermissionType.DocumentType,

View File

@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_STORAGE_PATH } from 'src/app/data/filter-rule-type' import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { import {
@ -32,7 +32,7 @@ export class StoragePathListComponent extends ManagementListComponent<PaperlessS
toastService, toastService,
documentListViewService, documentListViewService,
permissionsService, permissionsService,
FILTER_STORAGE_PATH, FILTER_HAS_STORAGE_PATH_ANY,
$localize`storage path`, $localize`storage path`,
$localize`storage paths`, $localize`storage paths`,
PermissionType.StoragePath, PermissionType.StoragePath,

View File

@ -8,8 +8,12 @@ export const FILTER_ASN_GT = 23
export const FILTER_ASN_LT = 24 export const FILTER_ASN_LT = 24
export const FILTER_CORRESPONDENT = 3 export const FILTER_CORRESPONDENT = 3
export const FILTER_HAS_CORRESPONDENT_ANY = 26
export const FILTER_DOES_NOT_HAVE_CORRESPONDENT = 27
export const FILTER_DOCUMENT_TYPE = 4 export const FILTER_DOCUMENT_TYPE = 4
export const FILTER_HAS_DOCUMENT_TYPE_ANY = 28
export const FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE = 29
export const FILTER_IS_IN_INBOX = 5 export const FILTER_IS_IN_INBOX = 5
export const FILTER_HAS_TAGS_ALL = 6 export const FILTER_HAS_TAGS_ALL = 6
@ -18,6 +22,8 @@ export const FILTER_DOES_NOT_HAVE_TAG = 17
export const FILTER_HAS_TAGS_ANY = 22 export const FILTER_HAS_TAGS_ANY = 22
export const FILTER_STORAGE_PATH = 25 export const FILTER_STORAGE_PATH = 25
export const FILTER_HAS_STORAGE_PATH_ANY = 30
export const FILTER_DOES_NOT_HAVE_STORAGE_PATH = 31
export const FILTER_CREATED_BEFORE = 8 export const FILTER_CREATED_BEFORE = 8
export const FILTER_CREATED_AFTER = 9 export const FILTER_CREATED_AFTER = 9
@ -63,6 +69,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'correspondent', datatype: 'correspondent',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_CORRESPONDENT_ANY,
filtervar: 'correspondent__id__in',
datatype: 'correspondent',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
filtervar: 'correspondent__id__none',
datatype: 'correspondent',
multi: true,
},
{ {
id: FILTER_STORAGE_PATH, id: FILTER_STORAGE_PATH,
filtervar: 'storage_path__id', filtervar: 'storage_path__id',
@ -70,6 +88,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'storage_path', datatype: 'storage_path',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_STORAGE_PATH_ANY,
filtervar: 'storage_path__id__in',
datatype: 'storage_path',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
filtervar: 'storage_path__id__none',
datatype: 'storage_path',
multi: true,
},
{ {
id: FILTER_DOCUMENT_TYPE, id: FILTER_DOCUMENT_TYPE,
filtervar: 'document_type__id', filtervar: 'document_type__id',
@ -77,6 +107,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'document_type', datatype: 'document_type',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_DOCUMENT_TYPE_ANY,
filtervar: 'document_type__id__in',
datatype: 'document_type',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
filtervar: 'document_type__id__none',
datatype: 'document_type',
multi: true,
},
{ {
id: FILTER_IS_IN_INBOX, id: FILTER_IS_IN_INBOX,
filtervar: 'is_in_inbox', filtervar: 'is_in_inbox',

View File

@ -86,12 +86,12 @@ export function queryParamsFromFilterRules(filterRules: FilterRule[]): Params {
let params = {} let params = {}
for (let rule of filterRules) { for (let rule of filterRules) {
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type) let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
if (ruleType.multi) { if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = 1
} else if (ruleType.multi) {
params[ruleType.filtervar] = params[ruleType.filtervar] params[ruleType.filtervar] = params[ruleType.filtervar]
? params[ruleType.filtervar] + ',' + rule.value ? params[ruleType.filtervar] + ',' + rule.value
: rule.value : rule.value
} else if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = 1
} else { } else {
params[ruleType.filtervar] = rule.value params[ruleType.filtervar] = rule.value
if (ruleType.datatype == 'boolean') if (ruleType.datatype == 'boolean')

View File

@ -36,29 +36,30 @@ class DocumentTypeFilterSet(FilterSet):
fields = {"name": CHAR_KWARGS} fields = {"name": CHAR_KWARGS}
class TagsFilter(Filter): class ObjectFilter(Filter):
def __init__(self, exclude=False, in_list=False): def __init__(self, exclude=False, in_list=False, field_name=""):
super().__init__() super().__init__()
self.exclude = exclude self.exclude = exclude
self.in_list = in_list self.in_list = in_list
self.field_name = field_name
def filter(self, qs, value): def filter(self, qs, value):
if not value: if not value:
return qs return qs
try: try:
tag_ids = [int(x) for x in value.split(",")] object_ids = [int(x) for x in value.split(",")]
except ValueError: except ValueError:
return qs return qs
if self.in_list: if self.in_list:
qs = qs.filter(tags__id__in=tag_ids).distinct() qs = qs.filter(**{f"{self.field_name}__id__in": object_ids}).distinct()
else: else:
for tag_id in tag_ids: for obj_id in object_ids:
if self.exclude: if self.exclude:
qs = qs.exclude(tags__id=tag_id) qs = qs.exclude(**{f"{self.field_name}__id": obj_id})
else: else:
qs = qs.filter(tags__id=tag_id) qs = qs.filter(**{f"{self.field_name}__id": obj_id})
return qs return qs
@ -90,11 +91,17 @@ class DocumentFilterSet(FilterSet):
exclude=True, exclude=True,
) )
tags__id__all = TagsFilter() tags__id__all = ObjectFilter(field_name="tags")
tags__id__none = TagsFilter(exclude=True) tags__id__none = ObjectFilter(field_name="tags", exclude=True)
tags__id__in = TagsFilter(in_list=True) tags__id__in = ObjectFilter(field_name="tags", in_list=True)
correspondent__id__none = ObjectFilter(field_name="correspondent", exclude=True)
document_type__id__none = ObjectFilter(field_name="document_type", exclude=True)
storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True)
is_in_inbox = InboxFilter() is_in_inbox = InboxFilter()

View File

@ -0,0 +1,54 @@
# Generated by Django 4.1.5 on 2023-03-15 07:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("documents", "1033_alter_documenttype_options_alter_tag_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="savedviewfilterrule",
name="rule_type",
field=models.PositiveIntegerField(
choices=[
(0, "title contains"),
(1, "content contains"),
(2, "ASN is"),
(3, "correspondent is"),
(4, "document type is"),
(5, "is in inbox"),
(6, "has tag"),
(7, "has any tag"),
(8, "created before"),
(9, "created after"),
(10, "created year is"),
(11, "created month is"),
(12, "created day is"),
(13, "added before"),
(14, "added after"),
(15, "modified before"),
(16, "modified after"),
(17, "does not have tag"),
(18, "does not have ASN"),
(19, "title or content contains"),
(20, "fulltext query"),
(21, "more like this"),
(22, "has tags in"),
(23, "ASN greater than"),
(24, "ASN less than"),
(25, "storage path is"),
(26, "has correspondent in"),
(27, "does not have correspondent in"),
(28, "has document type in"),
(29, "does not have document type in"),
(30, "has storage path in"),
(31, "does not have storage path in"),
],
verbose_name="rule type",
),
),
]

View File

@ -447,6 +447,12 @@ class SavedViewFilterRule(models.Model):
(23, _("ASN greater than")), (23, _("ASN greater than")),
(24, _("ASN less than")), (24, _("ASN less than")),
(25, _("storage path is")), (25, _("storage path is")),
(26, _("has correspondent in")),
(27, _("does not have correspondent in")),
(28, _("has document type in")),
(29, _("does not have document type in")),
(30, _("has storage path in")),
(31, _("does not have storage path in")),
] ]
saved_view = models.ForeignKey( saved_view = models.ForeignKey(