Skip to content

Commit e7b4756

Browse files
authored
Merge pull request #62 from workfloworchestrator/1830-listfield
1830 listfield
2 parents 1784838 + 527638d commit e7b4756

26 files changed

+728
-306
lines changed

backend/main.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
Hidden,
2929
Choice,
3030
choice_list,
31+
unique_conlist
3132
)
3233

3334
# Choice,
@@ -81,7 +82,6 @@ def example_backend_validation(val: int) -> bool:
8182
int, Ge(1), Le(10), MultipleOf(multiple_of=3), Predicate(example_backend_validation)
8283
]
8384

84-
8585
class DropdownChoices(Choice):
8686
_1 = ("1", "Option 1")
8787
_2 = ("2", "Option 2")
@@ -122,14 +122,32 @@ class Person(BaseModel):
122122
age: Annotated[int, Ge(18), Le(99)]
123123
education: Education
124124

125+
def example_list_validation(val: int) -> bool:
126+
return True
127+
128+
TestList = Annotated[
129+
unique_conlist(str, min_items=2, max_items=5), Predicate(example_backend_validation)
130+
]
131+
132+
TestExampleNumberList = Annotated[
133+
unique_conlist(NumberExample, min_items=2, max_items=5), Predicate(example_list_validation)
134+
]
135+
136+
TestPersonList = Annotated[
137+
unique_conlist(Person, min_items=2, max_items=5), Predicate(example_list_validation)
138+
]
125139

126140
@app.post("/form")
127141
async def form(form_data: list[dict] = []):
128142
def form_generator(state: State):
129143
class TestForm0(FormPage):
130144
model_config = ConfigDict(title="Form Title Page 0")
131145

132-
number0: Annotated[int, Ge(18), Le(99)] = 17
146+
numberList: TestExampleNumberList
147+
# personList: TestPersonList = []
148+
# ingleNumber: NumberExample
149+
150+
# number0: Annotated[int, Ge(18), Le(99)] = 17
133151

134152
form_data_0 = yield TestForm0
135153

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pydantic-forms': minor
3+
---
4+
5+
Adds array field

frontend/package-lock.json

Lines changed: 152 additions & 135 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/packages/pydantic-forms/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"name": "pydantic-forms",
33
"version": "0.0.20",
4+
"type": "module",
45
"description": "Library that turns JSONSchema into frontend forms",
56
"author": {
67
"name": "Workflow Orchestrator Programme",

frontend/packages/pydantic-forms/src/components/componentMatcher.tsx

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,16 @@ export const getMatcher = (
2828
};
2929

3030
export const getClientSideValidationRule = (
31-
field: PydanticFormField,
31+
field: PydanticFormField | undefined,
3232
rhf?: ReturnType<typeof useForm>,
3333
customComponentMatcher?: PydanticFormsContextConfig['componentMatcher'],
3434
) => {
35+
if (!field) return z.unknown();
3536
const matcher = getMatcher(customComponentMatcher);
3637

3738
const componentMatch = matcher(field);
3839

39-
let validationRule = componentMatch?.validator?.(field, rhf) ?? z.string();
40+
let validationRule = componentMatch?.validator?.(field, rhf) ?? z.unknown();
4041

4142
if (!field.required) {
4243
validationRule = validationRule.optional();
@@ -49,30 +50,40 @@ export const getClientSideValidationRule = (
4950
return validationRule;
5051
};
5152

52-
export const componentMatcher = (
53+
export const componentsMatcher = (
5354
properties: Properties,
5455
customComponentMatcher: PydanticFormsContextConfig['componentMatcher'],
5556
): PydanticFormComponents => {
56-
const matcher = getMatcher(customComponentMatcher);
57-
5857
const components: PydanticFormComponents = Object.entries(properties).map(
5958
([, pydanticFormField]) => {
60-
const matchedComponent = matcher(pydanticFormField);
61-
62-
const ElementMatch: ElementMatch = matchedComponent
63-
? matchedComponent.ElementMatch
64-
: {
65-
Element: TextField,
66-
isControlledElement: true,
67-
};
68-
69-
// Defaults to textField when there are no matches
70-
return {
71-
Element: ElementMatch,
72-
pydanticFormField: pydanticFormField,
73-
};
59+
return fieldToComponentMatcher(
60+
pydanticFormField,
61+
customComponentMatcher,
62+
);
7463
},
7564
);
7665

7766
return components;
7867
};
68+
69+
const defaultComponent: ElementMatch = {
70+
Element: TextField,
71+
isControlledElement: true,
72+
};
73+
74+
export const fieldToComponentMatcher = (
75+
pydanticFormField: PydanticFormField,
76+
customComponentMatcher: PydanticFormsContextConfig['componentMatcher'],
77+
) => {
78+
const matcher = getMatcher(customComponentMatcher);
79+
const matchedComponent = matcher(pydanticFormField);
80+
81+
const ElementMatch: ElementMatch = matchedComponent
82+
? matchedComponent.ElementMatch
83+
: defaultComponent; // Defaults to textField when there are no matches
84+
85+
return {
86+
Element: ElementMatch,
87+
pydanticFormField: pydanticFormField,
88+
};
89+
};

frontend/packages/pydantic-forms/src/components/defaultComponentMatchers.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,26 @@
44
* We will search for the first field that returns a positive match
55
*/
66
import {
7+
ArrayField,
78
CheckboxField,
89
DividerField,
910
DropdownField,
1011
HiddenField,
1112
IntegerField,
1213
LabelField,
13-
ListField,
1414
MultiCheckboxField,
15+
MultiSelectField,
16+
ObjectField,
1517
RadioField,
1618
TextAreaField,
19+
TextField,
1720
} from '@/components/fields';
1821
import {
1922
PydanticComponentMatcher,
2023
PydanticFormFieldFormat,
2124
PydanticFormFieldType,
2225
} from '@/types';
2326

24-
import { ObjectField } from './fields/ObjectField';
2527
import { zodValidationPresets } from './zodValidationsPresets';
2628

2729
const defaultComponentMatchers: PydanticComponentMatcher[] = [
@@ -137,21 +139,25 @@ const defaultComponentMatchers: PydanticComponentMatcher[] = [
137139
matcher(field) {
138140
return (
139141
field.type === PydanticFormFieldType.ARRAY &&
142+
field.options.length > 0 &&
140143
field.options.length <= 5
141144
);
142145
},
143-
validator: zodValidationPresets.array,
146+
validator: zodValidationPresets.multiSelect,
144147
},
145148
{
146149
id: 'list',
147150
ElementMatch: {
148-
Element: ListField,
151+
Element: MultiSelectField,
149152
isControlledElement: true,
150153
},
151154
matcher(field) {
152-
return field.type === PydanticFormFieldType.ARRAY;
155+
return (
156+
field.options.length > 0 &&
157+
field.type === PydanticFormFieldType.ARRAY
158+
);
153159
},
154-
validator: zodValidationPresets.array,
160+
validator: zodValidationPresets.multiSelect,
155161
},
156162
{
157163
id: 'object',
@@ -163,6 +169,27 @@ const defaultComponentMatchers: PydanticComponentMatcher[] = [
163169
return field.type === PydanticFormFieldType.OBJECT;
164170
},
165171
},
172+
{
173+
id: 'array',
174+
ElementMatch: {
175+
Element: ArrayField,
176+
isControlledElement: true,
177+
},
178+
matcher(field) {
179+
return field.type === PydanticFormFieldType.ARRAY;
180+
},
181+
},
182+
{
183+
id: 'text',
184+
ElementMatch: {
185+
Element: TextField,
186+
isControlledElement: true,
187+
},
188+
matcher(field) {
189+
return field.type === PydanticFormFieldType.STRING;
190+
},
191+
validator: zodValidationPresets.string,
192+
},
166193
];
167194

168195
// If nothing matches, it defaults to Text field in the mapToComponent function
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import React from 'react';
2+
import { useFieldArray } from 'react-hook-form';
3+
4+
import { usePydanticFormContext } from '@/core';
5+
import { fieldToComponentMatcher } from '@/core';
6+
import { PydanticFormElementProps } from '@/types';
7+
import { itemizeArrayItem } from '@/utils';
8+
9+
import { RenderFields } from '../render';
10+
11+
export const ArrayField = ({ pydanticFormField }: PydanticFormElementProps) => {
12+
const { rhf, config } = usePydanticFormContext();
13+
const { control } = rhf;
14+
const { id: arrayName, arrayItem } = pydanticFormField;
15+
const { minItems, maxItems } = pydanticFormField.validations;
16+
const { fields, append, remove } = useFieldArray({
17+
control,
18+
name: arrayName,
19+
});
20+
if (!arrayItem) return '';
21+
22+
const component = fieldToComponentMatcher(
23+
arrayItem,
24+
config?.componentMatcher,
25+
);
26+
27+
const renderField = (field: Record<'id', string>, index: number) => {
28+
const arrayField = itemizeArrayItem(index, arrayItem);
29+
30+
return (
31+
<div
32+
key={field.id}
33+
style={{
34+
display: 'flex',
35+
gap: '10px',
36+
alignItems: 'center',
37+
margin: '4px 0',
38+
}}
39+
>
40+
<RenderFields
41+
components={[
42+
{
43+
Element: component.Element,
44+
pydanticFormField: arrayField,
45+
},
46+
]}
47+
extraTriggerFields={[arrayName]}
48+
/>
49+
{!minItems ||
50+
(minItems && fields.length > minItems && (
51+
<span
52+
style={{ fontSize: '24px' }}
53+
onClick={() => remove(index)}
54+
>
55+
-
56+
</span>
57+
))}
58+
</div>
59+
);
60+
};
61+
62+
return (
63+
<div
64+
style={{
65+
border: 'thin solid green',
66+
padding: '1rem',
67+
marginTop: '16px',
68+
display: 'flex',
69+
flexDirection: 'column',
70+
flexGrow: 1,
71+
}}
72+
>
73+
{fields.map(renderField)}
74+
75+
{(!maxItems || (maxItems && fields.length < maxItems)) && (
76+
<div
77+
onClick={() => {
78+
append({
79+
[arrayName]: undefined,
80+
});
81+
}}
82+
style={{
83+
cursor: 'pointer',
84+
fontSize: '32px',
85+
display: 'flex',
86+
justifyContent: 'end',
87+
marginTop: '8px',
88+
marginBottom: '8px',
89+
padding: '16px',
90+
}}
91+
>
92+
+
93+
</div>
94+
)}
95+
</div>
96+
);
97+
};

frontend/packages/pydantic-forms/src/components/fields/FormRow.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,21 @@ export const FormRow = ({
1919
}: FormRowProps) => {
2020
return (
2121
<div>
22-
<label>
23-
{title} {required && <span style={{ color: 'red' }}>*</span>}
24-
</label>
25-
{description && <div>{description}</div>}
22+
{title && (
23+
<label
24+
style={{
25+
margin: '8px 0',
26+
display: 'block',
27+
fontWeight: '600',
28+
}}
29+
>
30+
{title}{' '}
31+
{required && <span style={{ color: 'red' }}>*</span>}
32+
</label>
33+
)}
34+
{description && (
35+
<div style={{ margin: '4px 0' }}>{description}</div>
36+
)}
2637
{children}
2738
{error}
2839
</div>

frontend/packages/pydantic-forms/src/components/fields/IntegerField.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React from 'react';
22

3+
import { isObject } from 'lodash';
4+
35
import type { PydanticFormControlledElementProps } from '@/types';
46

57
export const IntegerField = ({
@@ -16,8 +18,12 @@ export const IntegerField = ({
1618
onChange(value);
1719
}}
1820
disabled={disabled}
19-
value={value}
21+
value={!isObject(value) ? value : ''} // Value can be an object when it is created from an ArrayField
2022
type="number"
23+
style={{
24+
padding: '8px',
25+
margin: '8px 0',
26+
}}
2127
/>
2228
);
2329
};

0 commit comments

Comments
 (0)