diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts index ea60034e4..824e1e05b 100644 --- a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts +++ b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts @@ -17,7 +17,11 @@ const customFields: CustomField[] = [ name: 'Field 4', data_type: CustomFieldDataType.Select, extra_data: { - select_options: ['Option 1', 'Option 2', 'Option 3'], + select_options: [ + { label: 'Option 1', id: 'abc-123' }, + { label: 'Option 2', id: 'def-456' }, + { label: 'Option 3', id: 'ghi-789' }, + ], }, }, { @@ -131,6 +135,8 @@ describe('CustomFieldDisplayComponent', () => { }) it('should show select value', () => { - expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3') + expect(component.getSelectValue(customFields[3], 'ghi-789')).toEqual( + 'Option 3' + ) }) }) diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts index f541f0e47..1ab831f46 100644 --- a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts +++ b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts @@ -117,8 +117,8 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy { return this.docLinkDocuments?.find((d) => d.id === docId)?.title } - public getSelectValue(field: CustomField, index: number): string { - return field.extra_data.select_options[index] + public getSelectValue(field: CustomField, id: string): string { + return field.extra_data.select_options?.find((o) => o.id === id)?.label } ngOnDestroy(): void { diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts index 9b42ff4e6..dc915c24d 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts @@ -39,7 +39,12 @@ const customFields = [ id: 2, name: 'Test Select Field', data_type: CustomFieldDataType.Select, - extra_data: { select_options: ['Option 1', 'Option 2'] }, + extra_data: { + select_options: [ + { label: 'Option 1', id: 'abc-123' }, + { label: 'Option 2', id: 'def-456' }, + ], + }, }, ] @@ -128,11 +133,19 @@ describe('CustomFieldsQueryDropdownComponent', () => { id: 1, name: 'Test Field', data_type: CustomFieldDataType.Select, - extra_data: { select_options: ['Option 1', 'Option 2'] }, + extra_data: { + select_options: [ + { label: 'Option 1', id: 'abc-123' }, + { label: 'Option 2', id: 'def-456' }, + ], + }, } component.customFields = [field] const options = component.getSelectOptionsForField(1) - expect(options).toEqual(['Option 1', 'Option 2']) + expect(options).toEqual([ + { label: 'Option 1', id: 'abc-123' }, + { label: 'Option 2', id: 'def-456' }, + ]) // Fallback to empty array if field is not found const options2 = component.getSelectOptionsForField(2) diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts index b0d446dd0..2233fc5c4 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts @@ -311,7 +311,9 @@ export class CustomFieldsQueryDropdownComponent implements OnDestroy { })) } - getSelectOptionsForField(fieldID: number): string[] { + getSelectOptionsForField( + fieldID: number + ): Array<{ label: string; id: string }> { const field = this.customFields.find((field) => field.id === fieldID) if (field) { return field.extra_data['select_options'] diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html index d48c0788b..d96c47943 100644 --- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html @@ -21,15 +21,13 @@
@for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) { -
- +
+ +
}
- @if (object?.id) { - Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here - } } @case (CustomFieldDataType.Monetary) {
diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts index 2de17577f..6ecf72b5d 100644 --- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts @@ -80,7 +80,11 @@ describe('CustomFieldEditDialogComponent', () => { name: 'Field 1', data_type: CustomFieldDataType.Select, extra_data: { - select_options: ['Option 1', 'Option 2', 'Option 3'], + select_options: [ + { label: 'Option 1', id: '123-xyz' }, + { label: 'Option 2', id: '456-abc' }, + { label: 'Option 3', id: '789-123' }, + ], }, } fixture.detectChanges() @@ -94,6 +98,10 @@ describe('CustomFieldEditDialogComponent', () => { component.dialogMode = EditDialogMode.CREATE fixture.detectChanges() component.ngOnInit() + expect( + component.objectForm.get('extra_data').get('select_options').value.length + ).toBe(0) + component.addSelectOption() expect( component.objectForm.get('extra_data').get('select_options').value.length ).toBe(1) @@ -101,14 +109,10 @@ describe('CustomFieldEditDialogComponent', () => { expect( component.objectForm.get('extra_data').get('select_options').value.length ).toBe(2) - component.addSelectOption() - expect( - component.objectForm.get('extra_data').get('select_options').value.length - ).toBe(3) component.removeSelectOption(0) expect( component.objectForm.get('extra_data').get('select_options').value.length - ).toBe(2) + ).toBe(1) }) it('should focus on last select option input', () => { diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts index b27ec9fcd..e39e27edd 100644 --- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts @@ -57,9 +57,16 @@ export class CustomFieldEditDialogComponent } if (this.object?.data_type === CustomFieldDataType.Select) { this.selectOptions.clear() - this.object.extra_data.select_options.forEach((option) => - this.selectOptions.push(new FormControl(option)) - ) + this.object.extra_data.select_options + .filter((option) => option) + .forEach((option) => + this.selectOptions.push( + new FormGroup({ + label: new FormControl(option.label), + id: new FormControl(option.id), + }) + ) + ) } } @@ -89,7 +96,7 @@ export class CustomFieldEditDialogComponent name: new FormControl(null), data_type: new FormControl(null), extra_data: new FormGroup({ - select_options: new FormArray([new FormControl(null)]), + select_options: new FormArray([]), default_currency: new FormControl(null), }), }) @@ -104,7 +111,9 @@ export class CustomFieldEditDialogComponent } public addSelectOption() { - this.selectOptions.push(new FormControl('')) + this.selectOptions.push( + new FormGroup({ label: new FormControl(null), id: new FormControl(null) }) + ) } public removeSelectOption(index: number) { diff --git a/src-ui/src/app/components/common/input/select/select.component.spec.ts b/src-ui/src/app/components/common/input/select/select.component.spec.ts index 2c39035a2..79eec16e8 100644 --- a/src-ui/src/app/components/common/input/select/select.component.spec.ts +++ b/src-ui/src/app/components/common/input/select/select.component.spec.ts @@ -132,12 +132,4 @@ describe('SelectComponent', () => { const expectedTitle = `Filter documents with this ${component.title}` expect(component.filterButtonTitle).toEqual(expectedTitle) }) - - it('should support setting items as a plain array', () => { - component.itemsArray = ['foo', 'bar'] - expect(component.items).toEqual([ - { id: 0, name: 'foo' }, - { id: 1, name: 'bar' }, - ]) - }) }) diff --git a/src-ui/src/app/components/common/input/select/select.component.ts b/src-ui/src/app/components/common/input/select/select.component.ts index d9976698e..19f6375ad 100644 --- a/src-ui/src/app/components/common/input/select/select.component.ts +++ b/src-ui/src/app/components/common/input/select/select.component.ts @@ -34,11 +34,6 @@ export class SelectComponent extends AbstractInputComponent { if (items && this.value) this.checkForPrivateItems(this.value) } - @Input() - set itemsArray(items: any[]) { - this._items = items.map((item, index) => ({ id: index, name: item })) - } - writeValue(newValue: any): void { if (newValue && this._items) { this.checkForPrivateItems(newValue) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 6a39b13bd..258fd6ead 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -190,7 +190,8 @@ @case (CustomFieldDataType.Select) { default_currency?: string } document_count?: number diff --git a/src/documents/migrations/1057_alter_customfieldinstance_value_select.py b/src/documents/migrations/1057_alter_customfieldinstance_value_select.py new file mode 100644 index 000000000..aa1555b5a --- /dev/null +++ b/src/documents/migrations/1057_alter_customfieldinstance_value_select.py @@ -0,0 +1,79 @@ +# Generated by Django 5.1.1 on 2024-11-13 05:14 + +import uuid + +from django.db import migrations +from django.db import models +from django.db import transaction + + +def migrate_customfield_selects(apps, schema_editor): + """ + Migrate the custom field selects from a simple list of strings to a list of dictionaries with + label and id. Then update all instances of the custom field to use the new format. + """ + CustomFieldInstance = apps.get_model("documents", "CustomFieldInstance") + CustomField = apps.get_model("documents", "CustomField") + + with transaction.atomic(): + for custom_field in CustomField.objects.all(): + if custom_field.data_type == "select": # CustomField.FieldDataType.SELECT + old_select_options = custom_field.extra_data["select_options"] + custom_field.extra_data["select_options"] = [ + {"id": str(uuid.uuid4()), "label": value} + for value in old_select_options + ] + custom_field.save() + + for instance in CustomFieldInstance.objects.filter(field=custom_field): + if instance.value_select: + instance.value_select = custom_field.extra_data[ + "select_options" + ][int(instance.value_select)]["id"] + instance.save() + + +def reverse_migrate_customfield_selects(apps, schema_editor): + """ + Reverse the migration of the custom field selects from a list of dictionaries with label and id + to a simple list of strings. Then update all instances of the custom field to use the old format, + which is just the index of the selected option. + """ + CustomFieldInstance = apps.get_model("documents", "CustomFieldInstance") + CustomField = apps.get_model("documents", "CustomField") + + with transaction.atomic(): + for custom_field in CustomField.objects.all(): + if custom_field.data_type == "select": # CustomField.FieldDataType.SELECT + old_select_options = custom_field.extra_data["select_options"] + custom_field.extra_data["select_options"] = [ + option["label"] + for option in custom_field.extra_data["select_options"] + ] + custom_field.save() + + for instance in CustomFieldInstance.objects.filter(field=custom_field): + instance.value_select = next( + index + for index, option in enumerate(old_select_options) + if option.id == instance.value_select + ) + instance.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "1056_customfieldinstance_deleted_at_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="customfieldinstance", + name="value_select", + field=models.CharField(max_length=128, null=True), + ), + migrations.RunPython( + migrate_customfield_selects, + reverse_migrate_customfield_selects, + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 4528d5127..99e1ab447 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -947,7 +947,7 @@ class CustomFieldInstance(SoftDeleteModel): value_document_ids = models.JSONField(null=True) - value_select = models.PositiveSmallIntegerField(null=True) + value_select = models.CharField(null=True, max_length=128) class Meta: ordering = ("created",) @@ -962,7 +962,11 @@ class CustomFieldInstance(SoftDeleteModel): def __str__(self) -> str: value = ( - self.field.extra_data["select_options"][self.value_select] + next( + option.get("label") + for option in self.field.extra_data["select_options"] + if option.get("id") == self.value_select + ) if ( self.field.data_type == CustomField.FieldDataType.SELECT and self.value_select is not None diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 45bf672d8..90f568e5b 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -539,7 +539,7 @@ class CustomFieldSerializer(serializers.ModelSerializer): or not isinstance(attrs["extra_data"]["select_options"], list) or len(attrs["extra_data"]["select_options"]) == 0 or not all( - isinstance(option, str) and len(option) > 0 + len(option.get("label", "")) > 0 for option in attrs["extra_data"]["select_options"] ) ) @@ -646,10 +646,14 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): elif field.data_type == CustomField.FieldDataType.SELECT: select_options = field.extra_data["select_options"] try: - select_options[data["value"]] + next( + option + for option in select_options + if option["id"] == data["value"] + ) except Exception: raise serializers.ValidationError( - f"Value must be index of an element in {select_options}", + f"Value must be an id of an element in {select_options}", ) elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK: doc_ids = data["value"] diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index 108ad0c81..cbe621d77 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -253,7 +253,11 @@ def get_custom_fields_context( ): options = field_instance.field.extra_data["select_options"] value = pathvalidate.sanitize_filename( - options[int(field_instance.value)], + next( + option["label"] + for option in options + if option["id"] == field_instance.value + ), replacement_text="-", ) else: diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index 02e856c27..29f136c86 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -61,7 +61,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): "data_type": "select", "name": "Select Field", "extra_data": { - "select_options": ["Option 1", "Option 2"], + "select_options": [ + {"label": "Option 1", "id": "abc-123"}, + {"label": "Option 2", "id": "def-456"}, + ], }, }, ), @@ -73,7 +76,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): self.assertCountEqual( data["extra_data"]["select_options"], - ["Option 1", "Option 2"], + [ + {"label": "Option 1", "id": "abc-123"}, + {"label": "Option 2", "id": "def-456"}, + ], ) def test_create_custom_field_nonunique_name(self): @@ -261,7 +267,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): name="Test Custom Field Select", data_type=CustomField.FieldDataType.SELECT, extra_data={ - "select_options": ["Option 1", "Option 2"], + "select_options": [ + {"label": "Option 1", "id": "abc-123"}, + {"label": "Option 2", "id": "def-456"}, + ], }, ) @@ -309,7 +318,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): }, { "field": custom_field_select.id, - "value": 0, + "value": "abc-123", }, ], }, @@ -332,7 +341,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): {"field": custom_field_monetary.id, "value": "EUR11.10"}, {"field": custom_field_monetary2.id, "value": "11.1"}, {"field": custom_field_documentlink.id, "value": [doc2.id]}, - {"field": custom_field_select.id, "value": 0}, + {"field": custom_field_select.id, "value": "abc-123"}, ], ) @@ -722,7 +731,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): name="Test Custom Field SELECT", data_type=CustomField.FieldDataType.SELECT, extra_data={ - "select_options": ["Option 1", "Option 2"], + "select_options": [ + {"label": "Option 1", "id": "abc-123"}, + {"label": "Option 2", "id": "def-456"}, + ], }, ) @@ -730,7 +742,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): f"/api/documents/{doc.id}/", data={ "custom_fields": [ - {"field": custom_field_select.id, "value": 3}, + {"field": custom_field_select.id, "value": "not an option"}, ], }, format="json",