diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index add7878f4..048a04798 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -188,7 +188,7 @@ - +
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 5facc5cce..b9ffa1506 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -16,7 +16,7 @@ import { NgbAccordionModule, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { first } from 'rxjs' import { Correspondent } from 'src/app/data/correspondent' -import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' +import { CustomField } from 'src/app/data/custom-field' import { DocumentType } from 'src/app/data/document-type' import { MailRule } from 'src/app/data/mail-rule' import { @@ -38,7 +38,6 @@ import { WorkflowTriggerType, } from 'src/app/data/workflow-trigger' import { CorrespondentService } from 'src/app/services/rest/correspondent.service' -import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service' @@ -47,6 +46,7 @@ import { WorkflowService } from 'src/app/services/rest/workflow.service' import { SettingsService } from 'src/app/services/settings.service' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { CheckComponent } from '../../input/check/check.component' +import { CustomFieldsSelectComponent } from '../../input/custom-fields-select/custom-fields-select.component' import { EntriesComponent } from '../../input/entries/entries.component' import { NumberComponent } from '../../input/number/number.component' import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' @@ -148,6 +148,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( SwitchComponent, NumberComponent, TextComponent, + CustomFieldsSelectComponent, SelectComponent, TextAreaComponent, TagsComponent, @@ -174,7 +175,6 @@ export class WorkflowEditDialogComponent documentTypes: DocumentType[] storagePaths: StoragePath[] mailRules: MailRule[] - customFields: CustomField[] dateCustomFields: CustomField[] expandedItem: number = null @@ -189,8 +189,7 @@ export class WorkflowEditDialogComponent storagePathService: StoragePathService, mailRuleService: MailRuleService, userService: UserService, - settingsService: SettingsService, - customFieldsService: CustomFieldsService + settingsService: SettingsService ) { super(service, activeModal, userService, settingsService) @@ -213,16 +212,6 @@ export class WorkflowEditDialogComponent .listAll() .pipe(first()) .subscribe((result) => (this.mailRules = result.results)) - - customFieldsService - .listAll() - .pipe(first()) - .subscribe((result) => { - this.customFields = result.results - this.dateCustomFields = this.customFields?.filter( - (f) => f.data_type === CustomFieldDataType.Date - ) - }) } getCreateTitle() { @@ -263,6 +252,8 @@ export class WorkflowEditDialogComponent } private checkRemovalActionFields(formWorkflow: Workflow) { + console.log('checkRemovalActionFields', formWorkflow) + formWorkflow.actions .filter((action) => action.type === WorkflowActionType.Removal) .forEach((action, i) => { @@ -438,7 +429,9 @@ export class WorkflowEditDialogComponent assign_view_groups: new FormControl(action.assign_view_groups), assign_change_users: new FormControl(action.assign_change_users), assign_change_groups: new FormControl(action.assign_change_groups), - assign_custom_fields: new FormControl(action.assign_custom_fields), + assign_custom_fields_w_values: new FormControl( + action.assign_custom_fields_w_values + ), remove_tags: new FormControl(action.remove_tags), remove_all_tags: new FormControl(action.remove_all_tags), remove_document_types: new FormControl(action.remove_document_types), @@ -564,7 +557,7 @@ export class WorkflowEditDialogComponent assign_view_groups: [], assign_change_users: [], assign_change_groups: [], - assign_custom_fields: [], + assign_custom_fields_w_values: [], remove_tags: [], remove_all_tags: false, remove_document_types: [], diff --git a/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.html b/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.html new file mode 100644 index 000000000..3c42a59bc --- /dev/null +++ b/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.html @@ -0,0 +1,113 @@ +
+
+
+ @if (title) { + + } +
+
+
+ + + {{item.name}} + + + @if (selectedFields.length) { +
+ @for (fieldId of selectedFields; track fieldId) { +
+ @switch (getCustomField(fieldId)?.data_type) { + @case (CustomFieldDataType.String) { + + } + @case (CustomFieldDataType.Date) { + + } + @case (CustomFieldDataType.Integer) { + + } + @case (CustomFieldDataType.Float) { + + } + @case (CustomFieldDataType.Monetary) { + + } + @case (CustomFieldDataType.Boolean) { + + } + @case (CustomFieldDataType.Url) { + + } + @case (CustomFieldDataType.DocumentLink) { + + } + @case (CustomFieldDataType.Select) { + + } + } + +
+ } +
+ } +
+
+ {{error}} +
+ @if (hint) { + {{hint}} + } +
+
+
diff --git a/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.scss b/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.scss new file mode 100644 index 000000000..4141a6ca1 --- /dev/null +++ b/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.scss @@ -0,0 +1,41 @@ +// styles for ng-select child are in styles.scss +.paperless-input-select.disabled { + .input-group, + div > div { + cursor: not-allowed; + } + + ::ng-deep ng-select { + pointer-events: none; + + .ng-select-container { + background-color: var(--pngx-bg-disabled) !important; + } + } +} + +::ng-deep .private .ng-value-container { + font-style: italic; + opacity: .75; +} + +::ng-deep .is-invalid ng-select .ng-select-container input { + // replicate bootstrap + padding-right: calc(1.5em + 0.75rem) !important; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") !important; + background-repeat: no-repeat !important; + background-position: right calc(0.375em + 0.1875rem) center !important; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem) !important; +} + +.input-group .ng-select-taggable:first-child:nth-last-child(2) { + max-width: calc(100% - 45px); // fudge factor for (1x) ng-select button width +} + +.input-group .ng-select-taggable:first-child:nth-last-child(3) { + max-width: calc(100% - 90px); // fudge factor for (2x) ng-select button width +} + +:host ::ng-deep .list-group-item .mb-3 { + margin-bottom: 0 !important; +} diff --git a/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.spec.ts b/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.spec.ts new file mode 100644 index 000000000..d93703ab1 --- /dev/null +++ b/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.spec.ts @@ -0,0 +1,135 @@ +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, +} from '@angular/forms' +import { RouterTestingModule } from '@angular/router/testing' +import { NgSelectModule } from '@ng-select/ng-select' +import { + DEFAULT_MATCHING_ALGORITHM, + MATCH_ALL, +} from 'src/app/data/matching-model' +import { Tag } from 'src/app/data/tag' +import { SelectComponent } from './select.component' + +const items: Tag[] = [ + { + id: 1, + name: 'Tag1', + is_inbox_tag: false, + matching_algorithm: DEFAULT_MATCHING_ALGORITHM, + }, + { + id: 2, + name: 'Tag2', + is_inbox_tag: true, + matching_algorithm: MATCH_ALL, + match: 'str', + }, + { + id: 10, + name: 'Tag10', + is_inbox_tag: false, + matching_algorithm: DEFAULT_MATCHING_ALGORITHM, + }, +] + +describe('SelectComponent', () => { + let component: SelectComponent + let fixture: ComponentFixture + let input: HTMLInputElement + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [], + imports: [ + FormsModule, + ReactiveFormsModule, + NgSelectModule, + RouterTestingModule, + SelectComponent, + ], + }).compileComponents() + + fixture = TestBed.createComponent(SelectComponent) + fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should support private items', () => { + component.value = 3 + component.items = items + expect(component.items).toContainEqual({ + id: 3, + name: 'Private', + private: true, + }) + + component.checkForPrivateItems([4, 5]) + expect(component.items).toContainEqual({ + id: 4, + name: 'Private', + private: true, + }) + expect(component.items).toContainEqual({ + id: 5, + name: 'Private', + private: true, + }) + }) + + it('should support suggestions', () => { + expect(component.value).toBeUndefined() + component.items = items + component.suggestions = [1, 2] + fixture.detectChanges() + const suggestionAnchor: HTMLAnchorElement = + fixture.nativeElement.querySelector('a') + suggestionAnchor.click() + expect(component.value).toEqual(1) + }) + + it('should support create new and emit the value', () => { + expect(component.allowCreateNew).toBeFalsy() + component.items = items + let createNewVal + component.createNew.subscribe((v) => (createNewVal = v)) + expect(component.allowCreateNew).toBeTruthy() + component.onSearch({ term: 'foo' }) + component.addItem(undefined) + expect(createNewVal).toEqual('foo') + component.addItem('bar') + expect(createNewVal).toEqual('bar') + component.onSearch({ term: 'baz' }) + component.clickNew() + expect(createNewVal).toEqual('baz') + }) + + it('should clear search term on blur after delay', fakeAsync(() => { + const clearSpy = jest.spyOn(component, 'clearLastSearchTerm') + component.onBlur() + tick(3000) + expect(clearSpy).toHaveBeenCalled() + })) + + it('should emit filtered documents', () => { + component.value = 10 + component.items = items + const emitSpy = jest.spyOn(component.filterDocuments, 'emit') + component.onFilterDocuments() + expect(emitSpy).toHaveBeenCalledWith([items[2]]) + }) + + it('should return the correct filter button title', () => { + component.title = 'Tag' + const expectedTitle = `Filter documents with this ${component.title}` + expect(component.filterButtonTitle).toEqual(expectedTitle) + }) +}) diff --git a/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.ts b/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.ts new file mode 100644 index 000000000..8ff82370f --- /dev/null +++ b/src-ui/src/app/components/common/input/custom-fields-select/custom-fields-select.component.ts @@ -0,0 +1,92 @@ +import { Component, forwardRef } from '@angular/core' +import { + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, +} from '@angular/forms' +import { RouterModule } from '@angular/router' +import { NgSelectModule } from '@ng-select/ng-select' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' +import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' +import { AbstractInputComponent } from '../abstract-input' +import { CheckComponent } from '../check/check.component' +import { DateComponent } from '../date/date.component' +import { DocumentLinkComponent } from '../document-link/document-link.component' +import { MonetaryComponent } from '../monetary/monetary.component' +import { NumberComponent } from '../number/number.component' +import { SelectComponent } from '../select/select.component' +import { TextComponent } from '../text/text.component' +import { UrlComponent } from '../url/url.component' + +@Component({ + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CustomFieldsSelectComponent), + multi: true, + }, + ], + selector: 'pngx-input-custom-fields-select', + templateUrl: './custom-fields-select.component.html', + styleUrls: ['./custom-fields-select.component.scss'], + imports: [ + TextComponent, + DateComponent, + NumberComponent, + DocumentLinkComponent, + UrlComponent, + SelectComponent, + MonetaryComponent, + CheckComponent, + NgSelectModule, + FormsModule, + ReactiveFormsModule, + RouterModule, + NgxBootstrapIconsModule, + ], +}) +export class CustomFieldsSelectComponent extends AbstractInputComponent { + public CustomFieldDataType = CustomFieldDataType + + constructor(customFieldsService: CustomFieldsService) { + super() + customFieldsService.listAll().subscribe((items) => { + this.fields = items.results + }) + } + + fields: CustomField[] + + _selectedFields: number[] + set selectedFields(newFields: number[]) { + this._selectedFields = newFields + // map the selected fields to an object with field_id as key and value as value + this.value = newFields.reduce((acc, fieldId) => { + acc[fieldId] = this.value?.[fieldId] || null + return acc + }, {}) + this.onChange(this.value) + } + get selectedFields(): number[] { + return this._selectedFields + } + + writeValue(newValue: Object): void { + // value will be a json object with field_id as key and value as value + this._selectedFields = newValue + ? this.fields + .filter((field) => field.id in newValue) + .map((field) => field.id) + : [] + super.writeValue(newValue) + } + + public getCustomField(id: number): CustomField { + return this.fields.find((field) => field.id === id) + } + + public removeField(fieldId: number): void { + this.selectedFields = this.selectedFields.filter((id) => id !== fieldId) + } +} diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts index 0d8316ecb..7b9aa181c 100644 --- a/src-ui/src/app/data/workflow-action.ts +++ b/src-ui/src/app/data/workflow-action.ts @@ -56,7 +56,7 @@ export interface WorkflowAction extends ObjectWithId { assign_change_groups?: number[] // [Group.id] - assign_custom_fields?: number[] // [CustomField.id] + assign_custom_fields_w_values?: number[] // { [CustomField.id]: value } remove_tags?: number[] // Tag.id diff --git a/src/documents/migrations/1064_remove_workflowaction_assign_custom_fields_and_more.py b/src/documents/migrations/1064_remove_workflowaction_assign_custom_fields_and_more.py new file mode 100644 index 000000000..9a3141086 --- /dev/null +++ b/src/documents/migrations/1064_remove_workflowaction_assign_custom_fields_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.6 on 2025-03-01 04:49 + +from django.db import migrations +from django.db import models + +import documents.models + + +def convert_assign_custom_fields(apps, schema_editor): + # Convert the old assign_custom_fields ManyToManyField to the new assign_custom_fields_w_values JSONField + WorkflowAction = apps.get_model("documents", "WorkflowAction") + for workflow_action in WorkflowAction.objects.all(): + if workflow_action.assign_custom_fields.exists(): + workflow_action.assign_custom_fields_w_values = { + custom_field.id: None + for custom_field in workflow_action.assign_custom_fields.all() + } + workflow_action.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "1063_paperlesstask_type_alter_paperlesstask_task_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="workflowaction", + name="assign_custom_fields_w_values", + field=models.JSONField( + blank=True, + help_text="assign these custom fields, with optional values", + null=True, + verbose_name=documents.models.CustomField, + ), + ), + migrations.RunPython(convert_assign_custom_fields, migrations.RunPython.noop), + migrations.RemoveField( + model_name="workflowaction", + name="assign_custom_fields", + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index e40ee8115..c0c7e53e7 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1264,11 +1264,13 @@ class WorkflowAction(models.Model): verbose_name=_("grant change permissions to these groups"), ) - assign_custom_fields = models.ManyToManyField( + assign_custom_fields_w_values = models.JSONField( CustomField, blank=True, - related_name="+", - verbose_name=_("assign these custom fields"), + null=True, + help_text=_( + "assign these custom fields, with optional values", + ), ) remove_tags = models.ManyToManyField( diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index c0487b7b8..a63bc7852 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2017,7 +2017,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer): "assign_view_groups", "assign_change_users", "assign_change_groups", - "assign_custom_fields", + "assign_custom_fields_w_values", "remove_all_tags", "remove_tags", "remove_all_correspondents", @@ -2135,7 +2135,6 @@ class WorkflowSerializer(serializers.ModelSerializer): assign_view_groups = action.pop("assign_view_groups", None) assign_change_users = action.pop("assign_change_users", None) assign_change_groups = action.pop("assign_change_groups", None) - assign_custom_fields = action.pop("assign_custom_fields", None) remove_tags = action.pop("remove_tags", None) remove_correspondents = action.pop("remove_correspondents", None) remove_document_types = action.pop("remove_document_types", None) @@ -2185,8 +2184,6 @@ class WorkflowSerializer(serializers.ModelSerializer): action_instance.assign_change_users.set(assign_change_users) if assign_change_groups is not None: action_instance.assign_change_groups.set(assign_change_groups) - if assign_custom_fields is not None: - action_instance.assign_custom_fields.set(assign_custom_fields) if remove_tags is not None: action_instance.remove_tags.set(remove_tags) if remove_correspondents is not None: diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 7241924e4..3fe540ac6 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -576,6 +576,8 @@ def cleanup_custom_field_deletion(sender, instance: CustomField, **kwargs): f"Removing custom field {instance} from sort field of {views_with_sort_updated} views", ) + # Remove from workflow actions + def add_to_index(sender, document, **kwargs): from documents import index