Skip to content

Commit 7676b55

Browse files
authored
430 Fix bug with optional fields in custom forms (#432)
* fix bug with optional fields in custom forms * try older Ubuntu version for Playwright tests microsoft/playwright#30368 * fix linter errors * remove fastapi version pin We need the latest version to get around a breaking change in Starlette's test client: Kludex/starlette#2770 * ignore linter errors I'm pretty sure the type annotations on Starlette are wrong - passing a HTTPEndpoint in should be allowed. * add logic to work out if form fields are optional * added a mega form for testing * fix a small bug with resetting the form It would remove all of the defaults - we're better just letting Vue recreate the form. * allow new line characters in form response * update unit tests with new form * rename form * add a playwright test * refactor so It's backwards compatible
1 parent 0a36afc commit 7676b55

File tree

8 files changed

+137
-13
lines changed

8 files changed

+137
-13
lines changed

admin_ui/src/components/FormAdd.vue

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
<div v-show="successMessage">
1212
<h1>{{ $t("Form submitted") }}</h1>
13-
<p>{{ successMessage }}</p>
13+
<p id="success_message">{{ successMessage }}</p>
1414
<ul>
1515
<li>
1616
<a href="#" @click.prevent="resetForm">{{
@@ -25,7 +25,7 @@
2525
</ul>
2626
</div>
2727

28-
<div v-show="!successMessage">
28+
<div v-if="!successMessage">
2929
<FormErrors :errors="errors" v-if="errors.length > 0" />
3030

3131
<form
@@ -93,8 +93,6 @@ export default defineComponent({
9393
},
9494
methods: {
9595
resetForm() {
96-
const form = this.$refs.form as HTMLFormElement
97-
form.reset()
9896
this.successMessage = null
9997
this.errors = []
10098
},
@@ -185,4 +183,8 @@ export default defineComponent({
185183
h1 {
186184
text-transform: capitalize;
187185
}
186+
187+
p#success_message {
188+
white-space: pre-wrap;
189+
}
188190
</style>

admin_ui/src/components/NewForm.vue

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
v-bind:columnName="String(columnName)"
1212
v-bind:type="getType(property)"
1313
v-bind:value="property.default"
14+
v-bind:isNullable="isNullable(property)"
1415
v-bind:timeResolution="
1516
schema?.extra?.time_resolution[columnName]
1617
"
17-
v-bind:format="property.format"
18+
v-bind:format="getFormat(property)"
1819
/>
1920
</div>
2021
</div>
@@ -23,7 +24,7 @@
2324
<script lang="ts">
2425
import { defineComponent, type PropType } from "vue"
2526
import InputField from "./InputField.vue"
26-
import { type Schema, getType } from "@/interfaces"
27+
import { type Schema, getType, getFormat, isNullable } from "@/interfaces"
2728
2829
export default defineComponent({
2930
props: {
@@ -37,7 +38,9 @@ export default defineComponent({
3738
},
3839
setup() {
3940
return {
40-
getType
41+
getFormat,
42+
getType,
43+
isNullable
4144
}
4245
}
4346
})

admin_ui/src/interfaces.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ export const getType = (property: Property): string => {
176176
return (property.type || property.anyOf?.[0].type) as string
177177
}
178178

179+
// Determines if the property is nullable based off the OpenAPI type.
180+
export const isNullable = (property: Property): boolean => {
181+
return (property.anyOf ?? []).filter((i) => i.type == "null").length > 0
182+
}
183+
179184
export const getFormat = (property: Property): string | undefined => {
180185
if (property.format) {
181186
return property.format

admin_ui/src/utils.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { type Schema, type OrderByConfig, getType } from "@/interfaces"
1+
import {
2+
type Schema,
3+
type OrderByConfig,
4+
getType,
5+
isNullable
6+
} from "@/interfaces"
27
import router from "./router"
38
import moment from "moment"
49

@@ -135,13 +140,25 @@ export function convertFormValue(params: {
135140
}): any {
136141
let { key, value, schema } = params
137142

143+
if (value == "null") {
144+
return null
145+
}
146+
138147
const property = schema.properties[key]
139148

140-
if (value == "null") {
141-
value = null
142-
} else if (property.extra?.nullable && value == "") {
143-
value = null
144-
} else if (getType(property) == "array") {
149+
const nullable = property.extra?.nullable
150+
151+
if (nullable == true && value == "") {
152+
return null
153+
}
154+
155+
// For Piccolo custom forms, there is no `extra` attribute, instead we
156+
// have to look at the OpenAPI schema:
157+
if (nullable == undefined && isNullable(property) && value == "") {
158+
return null
159+
}
160+
161+
if (getType(property) == "array") {
145162
value = JSON.parse(String(value))
146163
}
147164

e2e/test_forms.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
from piccolo_admin.example.forms.csv import FORM as CSV_FORM
44
from piccolo_admin.example.forms.image import FORM as IMAGE_FORM
5+
from piccolo_admin.example.forms.nullable import FORM as NULLABLE_FORM
56

7+
from .conftest import BASE_URL
68
from .pages import FormPage, LoginPage
79

810

@@ -54,3 +56,26 @@ def test_image_form(page: Page, dev_server):
5456

5557
download = download_info.value
5658
assert download.suggested_filename == "movie_listings.jpg"
59+
60+
61+
def test_nullable_form(page: Page, dev_server):
62+
"""
63+
Make sure a form with nullable fields can be submitted successfully.
64+
"""
65+
login_page = LoginPage(page=page)
66+
login_page.reset()
67+
login_page.login()
68+
69+
form_page = FormPage(
70+
page=page,
71+
form_slug=NULLABLE_FORM.slug,
72+
)
73+
form_page.reset()
74+
75+
with page.expect_response(
76+
lambda response: response.url
77+
== f"{BASE_URL}/api/forms/nullable-fields/"
78+
and response.request.method == "POST"
79+
and response.status == 200
80+
):
81+
form_page.submit_form()

piccolo_admin/example/forms/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from .csv import FORM as CSV_FORM
33
from .email import FORM as EMAIL_FORM
44
from .image import FORM as IMAGE_FORM
5+
from .nullable import FORM as MEGA_FORM
56

67
FORMS = [
78
CALCULATOR_FORM,
89
CSV_FORM,
910
EMAIL_FORM,
1011
IMAGE_FORM,
12+
MEGA_FORM,
1113
]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import datetime
2+
import typing as t
3+
4+
from pydantic import BaseModel
5+
from starlette.requests import Request
6+
7+
from piccolo_admin.endpoints import FormConfig
8+
9+
10+
class NullableFieldsModel(BaseModel):
11+
"""
12+
Used for testing a wide variety of field types.
13+
"""
14+
15+
boolean_field: bool = True
16+
boolean_field_nullable: t.Optional[bool] = None
17+
18+
float_field: float = 1.0
19+
float_field_nullable: t.Optional[float] = None
20+
21+
integer_field: int = 1
22+
integer_field_nullable: t.Optional[int] = None
23+
24+
string_field: str = "Hello world"
25+
string_nullable: t.Optional[str] = None
26+
27+
list_field: t.List[str] = ["a", "b", "c"]
28+
list_field_nullable: t.Optional[t.List[str]] = None
29+
30+
time_field: datetime.time = datetime.time(hour=12, minute=30)
31+
time_field_nullable: t.Optional[datetime.time] = None
32+
33+
date_field: datetime.date = datetime.date(year=1999, month=12, day=31)
34+
date_field_nullable: t.Optional[datetime.date] = None
35+
36+
datetime_field: datetime.datetime = datetime.datetime(
37+
year=1999, month=12, day=31, hour=12, minute=30
38+
)
39+
datetime_field_nullable: t.Optional[datetime.datetime] = None
40+
41+
timedelta_field: datetime.timedelta = datetime.timedelta(hours=1)
42+
timedelta_field_nullable: t.Optional[datetime.timedelta] = None
43+
44+
45+
async def handle_form(request: Request, data: NullableFieldsModel) -> str:
46+
return data.model_dump_json(indent=4)
47+
48+
49+
FORM = FormConfig(
50+
name="Nullable fields",
51+
pydantic_model=NullableFieldsModel,
52+
endpoint=handle_form,
53+
description="Used for testing nullable fields.",
54+
form_group="Test forms",
55+
)

tests/test_endpoints.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ def test_forms(self):
279279

280280
response = client.get("/api/forms/")
281281
self.assertEqual(response.status_code, 200)
282+
282283
self.assertEqual(
283284
response.json(),
284285
[
@@ -305,6 +306,11 @@ def test_forms(self):
305306
"slug": "download-schedule",
306307
"description": "Download the schedule for the day.",
307308
},
309+
{
310+
"description": "Used for testing nullable fields.",
311+
"name": "Nullable fields",
312+
"slug": "nullable-fields",
313+
},
308314
],
309315
)
310316

@@ -522,6 +528,15 @@ def test_forms_grouped(self):
522528
"slug": "booking-form",
523529
}
524530
],
531+
"Test forms": [
532+
{
533+
"description": (
534+
"Used for testing nullable fields."
535+
),
536+
"name": "Nullable fields",
537+
"slug": "nullable-fields",
538+
}
539+
],
525540
},
526541
"ungrouped": [
527542
{

0 commit comments

Comments
 (0)