]> git.ipfire.org Git - thirdparty/paperless-ngx.git/blob
fc4e8ef199fb0fabf47e6c3957e779fa01d24fdb
[thirdparty/paperless-ngx.git] /
1 import { NgTemplateOutlet } from '@angular/common'
2 import {
3 Component,
4 EventEmitter,
5 inject,
6 Input,
7 Output,
8 QueryList,
9 ViewChild,
10 ViewChildren,
11 } from '@angular/core'
12 import { FormsModule, ReactiveFormsModule } from '@angular/forms'
13 import {
14 NgbDatepickerModule,
15 NgbDropdown,
16 NgbDropdownModule,
17 } from '@ng-bootstrap/ng-bootstrap'
18 import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
19 import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
20 import { first, Subject, takeUntil } from 'rxjs'
21 import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
22 import {
23 CUSTOM_FIELD_QUERY_MAX_ATOMS,
24 CUSTOM_FIELD_QUERY_MAX_DEPTH,
25 CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE,
26 CUSTOM_FIELD_QUERY_OPERATOR_LABELS,
27 CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
28 CustomFieldQueryElementType,
29 CustomFieldQueryOperator,
30 CustomFieldQueryOperatorGroups,
31 } from 'src/app/data/custom-field-query'
32 import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
33 import {
34 CustomFieldQueryAtom,
35 CustomFieldQueryElement,
36 CustomFieldQueryExpression,
37 } from 'src/app/utils/custom-field-query-element'
38 import { pngxPopperOptions } from 'src/app/utils/popper-options'
39 import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
40 import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
41 import { DocumentLinkComponent } from '../input/document-link/document-link.component'
42
43 export class CustomFieldQueriesModel {
44 public queries: CustomFieldQueryElement[] = []
45
46 public readonly changed = new Subject<CustomFieldQueriesModel>()
47
48 public clear(fireEvent = true) {
49 this.queries = []
50 if (fireEvent) {
51 this.changed.next(this)
52 }
53 }
54
55 public isValid(): boolean {
56 return (
57 this.queries.length > 0 &&
58 this.validateExpression(this.queries[0] as CustomFieldQueryExpression)
59 )
60 }
61
62 public isEmpty(): boolean {
63 return (
64 this.queries.length === 0 ||
65 (this.queries.length === 1 && this.queries[0].value.length === 0)
66 )
67 }
68
69 private validateAtom(atom: CustomFieldQueryAtom) {
70 let valid = !!(atom.field && atom.operator && atom.value !== null)
71 if (
72 [
73 CustomFieldQueryOperator.In.valueOf(),
74 CustomFieldQueryOperator.Contains.valueOf(),
75 ].includes(atom.operator) &&
76 atom.value
77 ) {
78 valid = valid && atom.value.length > 0
79 }
80 return valid
81 }
82
83 private validateExpression(expression: CustomFieldQueryExpression) {
84 return (
85 expression.operator &&
86 expression.value.length > 0 &&
87 (expression.value as CustomFieldQueryElement[]).every((e) =>
88 e.type === CustomFieldQueryElementType.Atom
89 ? this.validateAtom(e as CustomFieldQueryAtom)
90 : this.validateExpression(e as CustomFieldQueryExpression)
91 )
92 )
93 }
94
95 public addAtom(atom: CustomFieldQueryAtom) {
96 if (this.queries.length === 0) {
97 this.addExpression()
98 }
99 ;(this.queries[0].value as CustomFieldQueryElement[]).push(atom)
100 atom.changed.subscribe(() => {
101 if (atom.field && atom.operator && atom.value) {
102 this.changed.next(this)
103 }
104 })
105 }
106
107 public addExpression(
108 expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
109 ) {
110 if (this.queries.length > 0) {
111 ;(
112 (this.queries[0] as CustomFieldQueryExpression)
113 .value as CustomFieldQueryElement[]
114 ).push(expression)
115 } else {
116 this.queries.push(expression)
117 }
118 expression.changed.subscribe(() => {
119 this.changed.next(this)
120 })
121 }
122
123 addInitialAtom() {
124 this.addAtom(
125 new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true'])
126 )
127 }
128
129 private findElement(
130 queryElement: CustomFieldQueryElement,
131 elements: any[]
132 ): CustomFieldQueryElement {
133 let foundElement
134 for (let i = 0; i < elements.length; i++) {
135 if (elements[i] === queryElement) {
136 foundElement = elements.splice(i, 1)[0]
137 } else if (elements[i].type === CustomFieldQueryElementType.Expression) {
138 foundElement = this.findElement(
139 queryElement,
140 elements[i].value as CustomFieldQueryElement[]
141 )
142 }
143 if (foundElement) {
144 break
145 }
146 }
147 return foundElement
148 }
149
150 public removeElement(queryElement: CustomFieldQueryElement) {
151 let foundComponent
152 for (let i = 0; i < this.queries.length; i++) {
153 let query = this.queries[i]
154 if (query === queryElement) {
155 foundComponent = this.queries.splice(i, 1)[0]
156 break
157 } else if (query.type === CustomFieldQueryElementType.Expression) {
158 foundComponent = this.findElement(queryElement, query.value as any[])
159 }
160 }
161 if (foundComponent) {
162 foundComponent.changed.complete()
163 if (this.isEmpty()) {
164 this.clear()
165 }
166 this.changed.next(this)
167 }
168 }
169 }
170
171 @Component({
172 selector: 'pngx-custom-fields-query-dropdown',
173 templateUrl: './custom-fields-query-dropdown.component.html',
174 styleUrls: ['./custom-fields-query-dropdown.component.scss'],
175 imports: [
176 ClearableBadgeComponent,
177 FormsModule,
178 DocumentLinkComponent,
179 ReactiveFormsModule,
180 NgbDatepickerModule,
181 NgTemplateOutlet,
182 NgSelectModule,
183 NgxBootstrapIconsModule,
184 NgbDropdownModule,
185 ],
186 })
187 export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPermissions {
188 protected customFieldsService = inject(CustomFieldsService)
189
190 public CustomFieldQueryComponentType = CustomFieldQueryElementType
191 public CustomFieldQueryOperator = CustomFieldQueryOperator
192 public CustomFieldDataType = CustomFieldDataType
193 public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
194 public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
195 public popperOptions = pngxPopperOptions
196
197 @Input()
198 title: string
199
200 @Input()
201 filterPlaceholder: string = ''
202
203 @Input()
204 icon: string
205
206 @Input()
207 allowSelectNone: boolean = false
208
209 @Input()
210 editing = false
211
212 @Input()
213 applyOnClose = false
214
215 @Input()
216 useDropdown: boolean = true
217
218 get name(): string {
219 return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
220 }
221
222 @Input()
223 disabled: boolean = false
224
225 @ViewChild('dropdown') dropdown: NgbDropdown
226
227 @ViewChildren(NgSelectComponent) fieldSelects!: QueryList<NgSelectComponent>
228
229 private _selectionModel: CustomFieldQueriesModel
230
231 @Input()
232 set selectionModel(model: CustomFieldQueriesModel) {
233 if (this._selectionModel) {
234 this._selectionModel.changed.complete()
235 }
236 model.changed.subscribe(() => {
237 this.onModelChange()
238 })
239 this._selectionModel = model
240 }
241
242 get selectionModel(): CustomFieldQueriesModel {
243 return this._selectionModel
244 }
245
246 private onModelChange() {
247 if (this.selectionModel.isEmpty() || this.selectionModel.isValid()) {
248 this.selectionModelChange.next(this.selectionModel)
249 this.selectionModel.isEmpty() && this.dropdown?.close()
250 }
251 }
252
253 @Output()
254 selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
255
256 customFields: CustomField[] = []
257
258 public readonly today: string = new Date().toLocaleDateString('en-CA')
259
260 constructor() {
261 super()
262 this.selectionModel = new CustomFieldQueriesModel()
263 this.getFields()
264 this.reset()
265 }
266
267 public onOpenChange(open: boolean) {
268 if (open) {
269 if (this.selectionModel.queries.length === 0) {
270 this.selectionModel.addInitialAtom()
271 }
272 if (
273 this.selectionModel.queries.length === 1 &&
274 (
275 (this.selectionModel.queries[0] as CustomFieldQueryExpression)
276 ?.value[0] as CustomFieldQueryAtom
277 )?.field === null
278 ) {
279 setTimeout(() => {
280 this.fieldSelects.first?.focus()
281 }, 0)
282 }
283 }
284 }
285
286 public get isActive(): boolean {
287 return this.selectionModel.isValid()
288 }
289
290 private getFields() {
291 this.customFieldsService
292 .listAll()
293 .pipe(first(), takeUntil(this.unsubscribeNotifier))
294 .subscribe((result) => {
295 this.customFields = result.results
296 })
297 }
298
299 public getCustomFieldByID(id: number): CustomField {
300 return this.customFields.find((field) => field.id === id)
301 }
302
303 public addAtom(expression: CustomFieldQueryExpression) {
304 expression.addAtom()
305 }
306
307 public addExpression(expression: CustomFieldQueryExpression) {
308 expression.addExpression()
309 }
310
311 public removeElement(element: CustomFieldQueryElement) {
312 this.selectionModel.removeElement(element)
313 }
314
315 public reset() {
316 this.selectionModel.clear(false)
317 this.selectionModel.changed.next(this.selectionModel)
318 }
319
320 getOperatorsForField(
321 fieldID: number
322 ): Array<{ value: string; label: string }> {
323 const field = this.customFields.find((field) => field.id === fieldID)
324 const groups: CustomFieldQueryOperatorGroups[] = field
325 ? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
326 : [CustomFieldQueryOperatorGroups.Basic]
327 const operators = groups.flatMap(
328 (group) => CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[group]
329 )
330 return operators.map((operator) => ({
331 value: operator,
332 label: CUSTOM_FIELD_QUERY_OPERATOR_LABELS[operator],
333 }))
334 }
335
336 getSelectOptionsForField(
337 fieldID: number
338 ): Array<{ label: string; id: string }> {
339 const field = this.customFields.find((field) => field.id === fieldID)
340 if (field) {
341 return field.extra_data['select_options']
342 }
343 return []
344 }
345 }