Skip to content

Commit 9c7e4c9

Browse files
matteiusclaude
andcommitted
feat: add SharedOptionList for centrally managed reusable option lists
New SharedOptionList model with name, slug, items (JSON), and is_active. FormField gains a shared_option_list FK that, when set, overrides inline choices for any choice-based field (select, radio, multiselect, checkboxes). Choice resolution priority (both DynamicForm and ApprovalStepForm): 1. Database prefill source (return_choices queries) 2. SharedOptionList (centrally managed) 3. Inline choices on the field definition Also includes: - Django admin with SharedOptionListAdmin - Form builder UI integration (shared list dropdown in field properties) - Builder API endpoints (list/save/delete shared lists) - sync_api export/import support (by slug) - FormFieldInline and FormFieldAdmin exposure - Migration 0084 - 16 new tests (model + form choice resolution) Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent ba3b5b1 commit 9c7e4c9

11 files changed

Lines changed: 578 additions & 56 deletions

File tree

django_forms_workflows/admin.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
NotificationRule,
4040
PostSubmissionAction,
4141
PrefillSource,
42+
SharedOptionList,
4243
StageApprovalGroup,
4344
SubWorkflowDefinition,
4445
SubWorkflowInstance,
@@ -166,6 +167,7 @@ class FormFieldInline(nested_admin.NestedStackedInline):
166167
"classes": ("collapse",),
167168
"fields": (
168169
"choices",
170+
"shared_option_list",
169171
"prefill_source_config",
170172
"default_value",
171173
"formula",
@@ -304,7 +306,7 @@ class FormFieldAdmin(admin.ModelAdmin):
304306
),
305307
(
306308
"Choices (for select/radio/checkbox fields)",
307-
{"fields": ("choices",), "classes": ("collapse",)},
309+
{"fields": ("shared_option_list", "choices"), "classes": ("collapse",)},
308310
),
309311
(
310312
"Formula (for calculated fields)",
@@ -1420,6 +1422,23 @@ def get_urls(self):
14201422
self.admin_site.admin_view(form_builder_views.form_builder_clone),
14211423
name="form_builder_api_clone",
14221424
),
1425+
path(
1426+
"builder/api/shared-lists/",
1427+
self.admin_site.admin_view(form_builder_views.shared_option_list_api),
1428+
name="form_builder_api_shared_lists",
1429+
),
1430+
path(
1431+
"builder/api/shared-lists/save/",
1432+
self.admin_site.admin_view(form_builder_views.shared_option_list_save),
1433+
name="form_builder_api_shared_list_save",
1434+
),
1435+
path(
1436+
"builder/api/shared-lists/delete/<int:list_id>/",
1437+
self.admin_site.admin_view(
1438+
form_builder_views.shared_option_list_delete
1439+
),
1440+
name="form_builder_api_shared_list_delete",
1441+
),
14231442
path(
14241443
"builder/api/doc-templates/<int:form_id>/",
14251444
self.admin_site.admin_view(form_builder_views.document_template_list),
@@ -2205,6 +2224,44 @@ class DocumentTemplateAdmin(admin.ModelAdmin):
22052224
)
22062225

22072226

2227+
# --- Shared Option List Admin ---
2228+
2229+
2230+
@admin.register(SharedOptionList)
2231+
class SharedOptionListAdmin(admin.ModelAdmin):
2232+
"""Admin for centrally managed reusable option lists."""
2233+
2234+
list_display = ("name", "slug", "item_count", "is_active", "updated_at")
2235+
list_filter = ("is_active",)
2236+
search_fields = ("name", "slug")
2237+
prepopulated_fields = {"slug": ("name",)}
2238+
readonly_fields = ("created_at", "updated_at")
2239+
2240+
fieldsets = (
2241+
(None, {"fields": ("name", "slug", "is_active")}),
2242+
(
2243+
"Options",
2244+
{
2245+
"fields": ("items",),
2246+
"description": (
2247+
"JSON array of options. Each item is either a string "
2248+
'(e.g. "Engineering") or an object (e.g. '
2249+
'{"value": "eng", "label": "Engineering"}).'
2250+
),
2251+
},
2252+
),
2253+
(
2254+
"Timestamps",
2255+
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
2256+
),
2257+
)
2258+
2259+
def item_count(self, obj):
2260+
return len(obj.items or [])
2261+
2262+
item_count.short_description = "Options"
2263+
2264+
22082265
# --- File Upload Configuration Admin ---
22092266

22102267

django_forms_workflows/form_builder_views.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
FormField,
2222
FormTemplate,
2323
PrefillSource,
24+
SharedOptionList,
2425
StageApprovalGroup,
2526
SubWorkflowDefinition,
2627
WorkflowDefinition,
@@ -248,9 +249,15 @@ def form_builder_view(request, form_id=None):
248249
[{"value": ft[0], "label": ft[1]} for ft in FormField.FIELD_TYPES]
249250
)
250251

252+
# Get shared option lists for the field property panel
253+
shared_option_lists = SharedOptionList.objects.filter(is_active=True).order_by(
254+
"name"
255+
)
256+
251257
context = {
252258
"form_definition": form_definition,
253259
"prefill_sources": prefill_sources,
260+
"shared_option_lists": shared_option_lists,
254261
"field_types": field_types_json,
255262
"is_new": form_id is None,
256263
}
@@ -286,6 +293,7 @@ def form_builder_load(request, form_id):
286293
"choices": field.choices or "",
287294
"default_value": field.default_value or "",
288295
"prefill_source_id": field.prefill_source_config_id,
296+
"shared_option_list_id": field.shared_option_list_id,
289297
"validation": {
290298
"min_value": field.min_value,
291299
"max_value": field.max_value,
@@ -453,6 +461,8 @@ def form_builder_save(request):
453461
"choices": field_data.get("choices", ""),
454462
"default_value": field_data.get("default_value", ""),
455463
"prefill_source_config_id": field_data.get("prefill_source_id"),
464+
"shared_option_list_id": field_data.get("shared_option_list_id")
465+
or None,
456466
}
457467

458468
# Add validation properties
@@ -785,3 +795,81 @@ def document_template_delete(request, form_id, template_id):
785795
)
786796
tpl.delete()
787797
return JsonResponse({"success": True, "message": "Template deleted"})
798+
799+
800+
# ---------------------------------------------------------------------------
801+
# Shared Option List API endpoints
802+
# ---------------------------------------------------------------------------
803+
804+
805+
@staff_member_required
806+
@require_GET
807+
def shared_option_list_api(request):
808+
"""List all shared option lists (for form builder dropdowns)."""
809+
lists = SharedOptionList.objects.filter(is_active=True).order_by("name")
810+
return JsonResponse(
811+
{
812+
"success": True,
813+
"lists": [
814+
{
815+
"id": ol.id,
816+
"name": ol.name,
817+
"slug": ol.slug,
818+
"item_count": len(ol.items or []),
819+
}
820+
for ol in lists
821+
],
822+
}
823+
)
824+
825+
826+
@staff_member_required
827+
@require_POST
828+
def shared_option_list_save(request):
829+
"""Create or update a shared option list."""
830+
try:
831+
data = json.loads(request.body)
832+
list_id = data.get("id")
833+
name = data.get("name", "").strip()
834+
slug = data.get("slug", "").strip()
835+
items = data.get("items", [])
836+
is_active = data.get("is_active", True)
837+
838+
if not name:
839+
return JsonResponse(
840+
{"success": False, "error": "Name is required"}, status=400
841+
)
842+
if not slug:
843+
slug = name.lower().replace(" ", "-")
844+
845+
if list_id:
846+
ol = get_object_or_404(SharedOptionList, id=list_id)
847+
ol.name = name
848+
ol.slug = slug
849+
ol.items = items
850+
ol.is_active = is_active
851+
ol.save()
852+
else:
853+
ol = SharedOptionList.objects.create(
854+
name=name, slug=slug, items=items, is_active=is_active
855+
)
856+
857+
return JsonResponse(
858+
{"success": True, "id": ol.id, "message": "List saved successfully"}
859+
)
860+
except json.JSONDecodeError:
861+
return JsonResponse({"success": False, "error": "Invalid JSON"}, status=400)
862+
except Exception:
863+
logger.exception("Error saving shared option list")
864+
return JsonResponse(
865+
{"success": False, "error": "An internal error occurred."}, status=500
866+
)
867+
868+
869+
@staff_member_required
870+
@require_POST
871+
def shared_option_list_delete(request, list_id):
872+
"""Delete a shared option list."""
873+
ol = get_object_or_404(SharedOptionList, id=list_id)
874+
ol.delete()
875+
return JsonResponse({"success": True, "message": "List deleted"})

django_forms_workflows/forms.py

Lines changed: 42 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,27 @@ def _parse_choices(self, choices):
384384

385385
return []
386386

387+
def _resolve_choices(self, field_def):
388+
"""Resolve choices for a field from all sources in priority order.
389+
390+
1. Database prefill source (return_choices query)
391+
2. SharedOptionList (centrally managed reusable list)
392+
3. Inline choices on the field definition
393+
394+
Returns a list of (value, label) tuples.
395+
"""
396+
db_choices = self._get_choices_from_prefill_source(field_def)
397+
if db_choices is not None:
398+
return db_choices
399+
400+
if field_def.shared_option_list_id:
401+
try:
402+
return field_def.shared_option_list.get_choices()
403+
except Exception:
404+
pass
405+
406+
return self._parse_choices(field_def.choices)
407+
387408
def _get_choices_from_prefill_source(self, field_def):
388409
"""
389410
Return a list of (value, label) tuples from a database choices query when the
@@ -531,47 +552,27 @@ def add_field(self, field_def, initial_data):
531552
self.fields[field_def.field_name] = forms.URLField(**field_args)
532553

533554
elif field_def.field_type == "select":
534-
_db_choices = self._get_choices_from_prefill_source(field_def)
535-
choices = [("", "-- Select --")] + (
536-
_db_choices
537-
if _db_choices is not None
538-
else self._parse_choices(field_def.choices)
539-
)
555+
choices = [("", "-- Select --")] + self._resolve_choices(field_def)
540556
self.fields[field_def.field_name] = forms.ChoiceField(
541557
choices=choices, **field_args
542558
)
543559

544560
elif field_def.field_type == "multiselect":
545-
_db_choices = self._get_choices_from_prefill_source(field_def)
546-
choices = (
547-
_db_choices
548-
if _db_choices is not None
549-
else self._parse_choices(field_def.choices)
550-
)
561+
choices = self._resolve_choices(field_def)
551562
self.fields[field_def.field_name] = forms.MultipleChoiceField(
552563
choices=choices, widget=forms.CheckboxSelectMultiple, **field_args
553564
)
554565

555566
elif field_def.field_type == "multiselect_list":
556-
_db_choices = self._get_choices_from_prefill_source(field_def)
557-
choices = (
558-
_db_choices
559-
if _db_choices is not None
560-
else self._parse_choices(field_def.choices)
561-
)
567+
choices = self._resolve_choices(field_def)
562568
self.fields[field_def.field_name] = forms.MultipleChoiceField(
563569
choices=choices,
564570
widget=forms.SelectMultiple(attrs={"class": "form-select"}),
565571
**field_args,
566572
)
567573

568574
elif field_def.field_type == "radio":
569-
_db_choices = self._get_choices_from_prefill_source(field_def)
570-
choices = (
571-
_db_choices
572-
if _db_choices is not None
573-
else self._parse_choices(field_def.choices)
574-
)
575+
choices = self._resolve_choices(field_def)
575576
self.fields[field_def.field_name] = forms.ChoiceField(
576577
choices=choices, widget=forms.RadioSelect, **field_args
577578
)
@@ -585,12 +586,7 @@ def add_field(self, field_def, initial_data):
585586
)
586587

587588
elif field_def.field_type == "checkboxes":
588-
_db_choices = self._get_choices_from_prefill_source(field_def)
589-
choices = (
590-
_db_choices
591-
if _db_choices is not None
592-
else self._parse_choices(field_def.choices)
593-
)
589+
choices = self._resolve_choices(field_def)
594590
self.fields[field_def.field_name] = forms.MultipleChoiceField(
595591
choices=choices,
596592
widget=forms.CheckboxSelectMultiple,
@@ -1505,51 +1501,31 @@ def _create_field(self, field_def, field_args, widget_attrs, is_editable):
15051501
self.fields[field_def.field_name] = forms.EmailField(**field_args)
15061502

15071503
elif field_def.field_type == "select":
1508-
_db_choices = self._get_choices_from_prefill_source(field_def)
1509-
choices = [("", "-- Select --")] + (
1510-
_db_choices
1511-
if _db_choices is not None
1512-
else self._parse_choices(field_def.choices)
1513-
)
1504+
choices = [("", "-- Select --")] + self._resolve_choices(field_def)
15141505
if widget_attrs:
15151506
field_args["widget"] = forms.Select(attrs=widget_attrs)
15161507
self.fields[field_def.field_name] = forms.ChoiceField(
15171508
choices=choices, **field_args
15181509
)
15191510

15201511
elif field_def.field_type == "multiselect":
1521-
_db_choices = self._get_choices_from_prefill_source(field_def)
1522-
choices = (
1523-
_db_choices
1524-
if _db_choices is not None
1525-
else self._parse_choices(field_def.choices)
1526-
)
1512+
choices = self._resolve_choices(field_def)
15271513
self.fields[field_def.field_name] = forms.MultipleChoiceField(
15281514
choices=choices,
15291515
widget=forms.CheckboxSelectMultiple,
15301516
**field_args,
15311517
)
15321518

15331519
elif field_def.field_type == "multiselect_list":
1534-
_db_choices = self._get_choices_from_prefill_source(field_def)
1535-
choices = (
1536-
_db_choices
1537-
if _db_choices is not None
1538-
else self._parse_choices(field_def.choices)
1539-
)
1520+
choices = self._resolve_choices(field_def)
15401521
self.fields[field_def.field_name] = forms.MultipleChoiceField(
15411522
choices=choices,
15421523
widget=forms.SelectMultiple(attrs={"class": "form-select"}),
15431524
**field_args,
15441525
)
15451526

15461527
elif field_def.field_type == "radio":
1547-
_db_choices = self._get_choices_from_prefill_source(field_def)
1548-
choices = (
1549-
_db_choices
1550-
if _db_choices is not None
1551-
else self._parse_choices(field_def.choices)
1552-
)
1528+
choices = self._resolve_choices(field_def)
15531529
self.fields[field_def.field_name] = forms.ChoiceField(
15541530
choices=choices,
15551531
widget=forms.RadioSelect(attrs=widget_attrs),
@@ -1675,6 +1651,18 @@ def _parse_choices(self, choices):
16751651
return [(c.strip(), c.strip()) for c in choices.split(",") if c.strip()]
16761652
return []
16771653

1654+
def _resolve_choices(self, field_def):
1655+
"""Resolve choices from all sources (DB prefill > shared list > inline)."""
1656+
db_choices = self._get_choices_from_prefill_source(field_def)
1657+
if db_choices is not None:
1658+
return db_choices
1659+
if field_def.shared_option_list_id:
1660+
try:
1661+
return field_def.shared_option_list.get_choices()
1662+
except Exception:
1663+
pass
1664+
return self._parse_choices(field_def.choices)
1665+
16781666
def _get_choices_from_prefill_source(self, field_def):
16791667
"""
16801668
Return (value, label) tuples from a database choices query when the field's

0 commit comments

Comments
 (0)