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",