diff --git a/admin_ui/src/components/ChoiceSelect.vue b/admin_ui/src/components/ChoiceSelect.vue index 39fb28be..678727cb 100644 --- a/admin_ui/src/components/ChoiceSelect.vue +++ b/admin_ui/src/components/ChoiceSelect.vue @@ -56,12 +56,16 @@ export default defineComponent({ }, data() { return { - localValue: "" as string | undefined + localValue: undefined as string | number | undefined } }, emits: ["updated"], mounted() { - this.localValue = this.isFilter ? "all" : this.value + if (this.isFilter) { + this.localValue = "all" + } else if (this.value !== undefined) { + this.localValue = this.value + } }, watch: { value(newValue) { diff --git a/admin_ui/src/components/NewForm.vue b/admin_ui/src/components/NewForm.vue index d31a7703..0128faae 100644 --- a/admin_ui/src/components/NewForm.vue +++ b/admin_ui/src/components/NewForm.vue @@ -12,6 +12,7 @@ v-bind:type="getType(property)" v-bind:value="property.default" v-bind:isNullable="isNullable(property)" + v-bind:choices="property.extra?.choices" v-bind:timeResolution=" schema?.extra?.time_resolution[columnName] " diff --git a/docs/source/custom_forms/index.rst b/docs/source/custom_forms/index.rst index 60489e2e..1152750c 100644 --- a/docs/source/custom_forms/index.rst +++ b/docs/source/custom_forms/index.rst @@ -39,6 +39,13 @@ Here's a more advanced example where we send an email, then return a string: .. literalinclude:: ../../../piccolo_admin/example/forms/email.py +``Enum`` +-------- + +Custom forms support ``Enum`` type. Here's a example: + +.. literalinclude:: ../../../piccolo_admin/example/forms/enum.py + ``FileResponse`` ---------------- diff --git a/e2e/test_forms.py b/e2e/test_forms.py index 9af3cd92..9d265390 100644 --- a/e2e/test_forms.py +++ b/e2e/test_forms.py @@ -1,6 +1,7 @@ from playwright.sync_api import Page from piccolo_admin.example.forms.csv import FORM as CSV_FORM +from piccolo_admin.example.forms.enum import FORM as ENUM_FORM from piccolo_admin.example.forms.image import FORM as IMAGE_FORM from piccolo_admin.example.forms.nullable import FORM as NULLABLE_FORM @@ -79,3 +80,28 @@ def test_nullable_form(page: Page, dev_server): and response.status == 200 ): form_page.submit_form() + + +def test_form_enum(page: Page, dev_server): + """ + Make sure custom forms support the usage of Enum's. + """ + login_page = LoginPage(page=page) + login_page.reset() + login_page.login() + + form_page = FormPage( + page=page, + form_slug=ENUM_FORM.slug, + ) + form_page.reset() + page.locator('input[name="username"]').fill("piccolo") + page.locator('input[name="email"]').fill("piccolo@example.com") + page.locator('select[name="permissions"]').select_option("admissions") + + with page.expect_response( + lambda response: response.url == f"{BASE_URL}/api/forms/enum-form/" + and response.request.method == "POST" + and response.status == 200 + ): + form_page.submit_form() diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py index 8bdbdec3..0ea21ef1 100644 --- a/piccolo_admin/endpoints.py +++ b/piccolo_admin/endpoints.py @@ -4,6 +4,7 @@ from __future__ import annotations +import enum import inspect import io import itertools @@ -14,7 +15,7 @@ from dataclasses import dataclass from datetime import timedelta from functools import partial -from typing import Any, Optional, TypeVar, Union +from typing import Any, Optional, TypeVar, Union, cast import typing_extensions from fastapi import FastAPI, File, Form, UploadFile @@ -50,6 +51,7 @@ from piccolo_api.session_auth.middleware import SessionsAuthBackend from piccolo_api.session_auth.tables import SessionsBase from pydantic import BaseModel, Field, ValidationError +from pydantic_core import PydanticUndefined from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.exceptions import HTTPException @@ -63,6 +65,7 @@ TranslationListItem, TranslationListResponse, ) +from .utils import convert_enum_to_choices from .version import __VERSION__ as PICCOLO_ADMIN_VERSION logger = logging.getLogger(__name__) @@ -404,6 +407,75 @@ def __init__( self.description = description self.form_group = form_group self.slug = self.name.replace(" ", "-").lower() + for ( + field_name, + field_value, + ) in self.pydantic_model.model_fields.items(): + # Extract the actual type, handling Optional/Union + field_type = field_value.annotation + is_optional = False + assert field_type + + # Check if it's Optional (Union with None) + if ( + hasattr(field_type, "__origin__") + and field_type.__origin__ is Union + ): + # Get all args from Union, filter out NoneType + type_args = [ + arg for arg in field_type.__args__ if arg is not type(None) + ] + if len(type_args) == 1: + field_type = type_args[0] + is_optional = True + + if inspect.isclass(field_type) and issubclass( + field_type, enum.Enum + ): + # Get the default value if it exists + default_value = None + if ( + field_value.default is not None + and field_value.default is not PydanticUndefined + ): + # If default is an Enum instance, get its value + if isinstance(field_value.default, enum.Enum): + default_value = field_value.default.value + else: + default_value = field_value.default + + # Update model fields, field annotation and + # rebuild the model for the changes to take effect + json_schema_extra_dict: dict[str, Any] = { + "extra": {"choices": convert_enum_to_choices(field_type)} + } + + # Add default value if it exists + if default_value is not None: + json_schema_extra_dict["extra"]["default"] = default_value + + pydantic_model.model_fields[field_name] = Field( # type:ignore + default=default_value, + description=field_value.description, + title=field_value.title, + json_schema_extra=json_schema_extra_dict, + ) + + # Detect the enum value type (int, str, etc.) + enum_member = next(iter(field_type)) + enum_value_type = type(enum_member.value) + + # Set annotation to Optional[type] or type depending + # on original + new_annotation = ( + enum_value_type | None if is_optional else enum_value_type + ) + + pydantic_model.model_fields[field_name].annotation = cast( + Any, new_annotation + ) + + pydantic_model.model_rebuild(force=True) class FormConfigResponseModel(BaseModel): diff --git a/piccolo_admin/example/forms/__init__.py b/piccolo_admin/example/forms/__init__.py index 6cf310ae..f009d574 100644 --- a/piccolo_admin/example/forms/__init__.py +++ b/piccolo_admin/example/forms/__init__.py @@ -1,6 +1,7 @@ from .calculator import FORM as CALCULATOR_FORM from .csv import FORM as CSV_FORM from .email import FORM as EMAIL_FORM +from .enum import FORM as ENUM_FORM from .image import FORM as IMAGE_FORM from .nullable import FORM as MEGA_FORM @@ -10,4 +11,5 @@ EMAIL_FORM, IMAGE_FORM, MEGA_FORM, + ENUM_FORM, ] diff --git a/piccolo_admin/example/forms/enum.py b/piccolo_admin/example/forms/enum.py new file mode 100644 index 00000000..adf3e4fb --- /dev/null +++ b/piccolo_admin/example/forms/enum.py @@ -0,0 +1,44 @@ +import enum +from typing import Optional + +from pydantic import BaseModel, EmailStr +from starlette.requests import Request + +from piccolo_admin.endpoints import FormConfig + + +# An example of using Python enum in custom forms +class Permission(str, enum.Enum): + admissions = "admissions" + gallery = "gallery" + notices = "notices" + uploads = "uploads" + + +class LogLevel(int, enum.Enum): + error = 2 + warn = 3 + info = 4 + debug = 5 + + +class NewStaffModel(BaseModel): + username: str + email: EmailStr + superuser: bool + permissions: Permission = Permission.gallery + log_level: Optional[LogLevel] + + +def new_staff_endpoint(request: Request, data: NewStaffModel) -> str: + print(data) + return "A new staff member has been successfully created." + + +FORM = FormConfig( + name="Enum form", + pydantic_model=NewStaffModel, + endpoint=new_staff_endpoint, + description="Make a enum form.", + form_group="Text forms", +) diff --git a/piccolo_admin/translations/data.py b/piccolo_admin/translations/data.py index 0730214c..2e1322a6 100644 --- a/piccolo_admin/translations/data.py +++ b/piccolo_admin/translations/data.py @@ -1279,8 +1279,7 @@ "Edit": "Szerkesztés", "Export CSV": "CSV exportálása", "Filter": "Szűrés", - "For timestamps which are timezone aware, they will be displayed in this timezone by default.": - "Az időzóna-érzékeny időbélyegek alapértelmezés szerint ebben az időzónában jelennek meg.", + "For timestamps which are timezone aware, they will be displayed in this timezone by default.": "Az időzóna-érzékeny időbélyegek alapértelmezés szerint ebben az időzónában jelennek meg.", "Form submitted": "Űrlap elküldve", "Forms": "Űrlapok", "Go to page": "Ugrás az oldalra", @@ -1300,10 +1299,8 @@ "New password": "Új jelszó", "New value": "Új érték", "No results found": "Nincs találat", - "Note: Large data sets may take a while.": - "Megjegyzés: Nagy adathalmazok feldolgozása eltarthat egy ideig.", - "Note: They are converted to UTC when stored in the database.": - "Megjegyzés: Az adatbázisban UTC időzónára vannak konvertálva.", + "Note: Large data sets may take a while.": "Megjegyzés: Nagy adathalmazok feldolgozása eltarthat egy ideig.", + "Note: They are converted to UTC when stored in the database.": "Megjegyzés: Az adatbázisban UTC időzónára vannak konvertálva.", "of": "/", "page": "oldal", "Password": "Jelszó", @@ -1313,8 +1310,7 @@ "Seconds": "Másodpercek", "Select a column to update": "Válasszon egy frissítendő oszlopot", "Select a Column": "Oszlop kiválasztása", - "Select a table in the sidebar to get started.": - "A kezdéshez válasszon egy táblát az oldalsávban.", + "Select a table in the sidebar to get started.": "A kezdéshez válasszon egy táblát az oldalsávban.", "selected result(s) on": "kiválasztott találat itt:", "Semicolon": "Pontosvessző", "Set Timezone": "Időzóna beállítása", @@ -1334,7 +1330,7 @@ "Weeks": "Hetek", "Welcome to": "Üdvözöljük itt:", "with a matching": "egyező értékkel", - } + }, ) SLOVAK = Translation( @@ -1365,8 +1361,7 @@ "Edit": "Upraviť", "Export CSV": "Exportovať CSV", "Filter": "Filter", - "For timestamps which are timezone aware, they will be displayed in this timezone by default.": - "Časové značky s informáciou o časovom pásme sa predvolene zobrazujú v tomto časovom pásme.", + "For timestamps which are timezone aware, they will be displayed in this timezone by default.": "Časové značky s informáciou o časovom pásme sa predvolene zobrazujú v tomto časovom pásme.", "Form submitted": "Formulár odoslaný", "Forms": "Formuláre", "Go to page": "Prejsť na stránku", @@ -1386,10 +1381,8 @@ "New password": "Nové heslo", "New value": "Nová hodnota", "No results found": "Neboli nájdené žiadne výsledky", - "Note: Large data sets may take a while.": - "Poznámka: Spracovanie veľkých dátových súborov môže chvíľu trvať.", - "Note: They are converted to UTC when stored in the database.": - "Poznámka: Pri ukladaní do databázy sa konvertujú na UTC.", + "Note: Large data sets may take a while.": "Poznámka: Spracovanie veľkých dátových súborov môže chvíľu trvať.", + "Note: They are converted to UTC when stored in the database.": "Poznámka: Pri ukladaní do databázy sa konvertujú na UTC.", "of": "z", "page": "strana", "Password": "Heslo", @@ -1399,8 +1392,7 @@ "Seconds": "Sekundy", "Select a column to update": "Vyberte stĺpec na aktualizáciu", "Select a Column": "Vybrať stĺpec", - "Select a table in the sidebar to get started.": - "Ak chcete začať, vyberte tabuľku na bočnom paneli.", + "Select a table in the sidebar to get started.": "Ak chcete začať, vyberte tabuľku na bočnom paneli.", "selected result(s) on": "vybrané výsledky na", "Semicolon": "Bodkočiarka", "Set Timezone": "Nastaviť časové pásmo", @@ -1420,7 +1412,7 @@ "Weeks": "Týždne", "Welcome to": "Vitajte v", "with a matching": "so zhodou", - } + }, ) TRANSLATIONS: list[Translation] = [ diff --git a/piccolo_admin/utils.py b/piccolo_admin/utils.py new file mode 100644 index 00000000..5a1fdd08 --- /dev/null +++ b/piccolo_admin/utils.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + + +def convert_enum_to_choices(enum_data: Any) -> dict[str, Any]: + choices = {} + for item in enum_data: + choices[item.name] = { + "display_name": item.name.replace("_", " "), + "value": item.value, + } + return choices diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 6eadb312..b76fdee8 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -311,6 +311,11 @@ def test_forms(self): "name": "Nullable fields", "slug": "nullable-fields", }, + { + "name": "Enum form", + "slug": "enum-form", + "description": "Make a enum form.", + }, ], ) @@ -526,7 +531,12 @@ def test_forms_grouped(self): "description": "Make a booking for a customer.", "name": "Booking form", "slug": "booking-form", - } + }, + { + "name": "Enum form", + "slug": "enum-form", + "description": "Make a enum form.", + }, ], "Test forms": [ {