Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RPC framework #207

Merged
merged 55 commits into from
Jul 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
38bab9a
Add basic support for form groups (#205)
silviogutierrez Aug 2, 2022
1e9fbe9
Check in
silviogutierrez Aug 2, 2022
459ca00
Working
silviogutierrez Aug 2, 2022
682f9c8
Our first test
silviogutierrez Aug 3, 2022
8b57f38
Fix
silviogutierrez Aug 3, 2022
278ef6e
Empty RPC
silviogutierrez Aug 3, 2022
a9b0a59
Empty forms
silviogutierrez Aug 3, 2022
0bb6d8c
Detail view
silviogutierrez Aug 3, 2022
2c51373
Form group
silviogutierrez Aug 3, 2022
75df4f5
Better built in serialization
silviogutierrez Aug 7, 2022
eb8ff25
Better built in serialization
silviogutierrez Aug 7, 2022
4cdaa00
Proper exports
silviogutierrez Aug 9, 2022
5a448b3
Skip lib check
silviogutierrez Aug 9, 2022
52db288
Checkin
silviogutierrez Aug 10, 2022
63c653e
Merge remote-tracking branch 'origin/main' into feature/rpc-generation
silviogutierrez Aug 10, 2022
b9a4111
Partial initial
silviogutierrez Aug 10, 2022
1e7d841
Turn on mypy
silviogutierrez Aug 10, 2022
c776433
Fix mypy
silviogutierrez Aug 10, 2022
518b6b7
RPC requester
silviogutierrez Aug 11, 2022
c461574
bind RPC methods
silviogutierrez Aug 13, 2022
5b35dc6
Provide request in result
silviogutierrez Aug 17, 2022
7dbf70f
Merge branch 'main' into feature/rpc-generation
silviogutierrez Aug 18, 2022
391dd3f
Fix tests
silviogutierrez Aug 18, 2022
081dd69
Permissions
silviogutierrez Aug 18, 2022
4c45afd
Auth checks
silviogutierrez Aug 18, 2022
022005f
GET requests
silviogutierrez Aug 19, 2022
70fa9d7
FMT
silviogutierrez Aug 19, 2022
afa11d0
Static handling and restore form set errors
silviogutierrez Aug 26, 2022
24f8269
Errors and values exposed
silviogutierrez Sep 1, 2022
db4ac00
RPC-level validation errors
silviogutierrez Sep 6, 2022
b2ceaeb
Validation error support
silviogutierrez Sep 9, 2022
beb4f98
Inject the request if specified in forms
silviogutierrez Sep 12, 2022
47f664d
Form group support for rpc.process
silviogutierrez Sep 12, 2022
ce5d0a2
Form group errors
silviogutierrez Sep 14, 2022
4aad73c
Better, but still flawed, union support
silviogutierrez Sep 16, 2022
8c667d8
Union tests and snapshots
silviogutierrez Sep 20, 2022
53bedb4
Merge remote-tracking branch 'origin/main' into feature/rpc-generation
silviogutierrez Sep 20, 2022
aa7fdbe
Nonce fix
silviogutierrez Sep 20, 2022
f79be36
Tweaks
silviogutierrez Sep 21, 2022
1f84e22
Add a test
silviogutierrez Sep 21, 2022
3067dd1
Fix unions
silviogutierrez Sep 22, 2022
1d1f7d9
Redirect example
silviogutierrez Sep 25, 2022
828bc40
Better literal types
silviogutierrez Sep 26, 2022
0ee8501
Check for invalid unions
silviogutierrez Sep 30, 2022
249a4db
Proper serialization of numbers
silviogutierrez Oct 5, 2022
ba56b90
Merge branch 'main' into feature/rpc-generation
silviogutierrez Oct 5, 2022
c86edca
Ints
silviogutierrez Oct 5, 2022
18f4ce8
Merge branch 'main' into feature/rpc-generation
silviogutierrez Oct 12, 2022
e6c8e2d
Handle lazily-translated strings
silviogutierrez Feb 1, 2023
f14b311
Merge remote-tracking branch 'origin/main' into feature/rpc-generation
silviogutierrez May 20, 2023
70f275f
Merge remote-tracking branch 'origin/main' into feature/rpc-generation
silviogutierrez May 21, 2023
66f675c
Merge remote-tracking branch 'origin/main' into feature/rpc-generation
silviogutierrez May 23, 2023
2ad483f
Fixes for exports
silviogutierrez May 23, 2023
03a72bc
Merge branch 'main' into feature/rpc-generation
silviogutierrez Jul 6, 2023
f1c0e62
Merge branch 'main' into feature/rpc-generation
silviogutierrez Jul 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
ignore = E501, W503, E203, E231, F405, F403, E731, F821
exclude =
.git,
__pycache__,
__pycache__
.venv
node_modules
2 changes: 2 additions & 0 deletions RFC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- [ ] For an action like logout, which has to be post. Do we want: `form: None`, or `form: EmptyForm`. or a specialized action like `@rpc.no_input`?
- [ ] Do we want RPC to be a class that you import and instantiate if you need to customize? Or configure with a global? Pros of import: clean, no mutation. Cons: you need a file called `@client/rpc` or something to put it in. Cons of config: hard to find where it is.
6 changes: 6 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ ignore_errors = True
[mypy-tests.autocomplete]
ignore_errors = True

[mypy-tests.forms]
ignore_errors = True

[mypy-tests.exports]
ignore_errors = True

Expand All @@ -47,6 +50,9 @@ ignore_errors = True
[mypy-tests.renderer]
ignore_errors = True

[mypy-tests.rpc]
disallow_untyped_defs = False

[mypy-tests.templates]
ignore_errors = True

Expand Down
1 change: 1 addition & 0 deletions packages/reactivated/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"./dist/renderer": "./dist/renderer.js",
"./dist/forms": "./dist/forms/index.js",
"./dist/context": "./dist/context.js",
"./dist/rpc": "./dist/rpc.js",
"./dist/babel.config.js": "./dist/babel.config.js"
},
"typings": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/reactivated/scripts/setup_environment.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# This script is meant to be sourced, not run in a subshell.
SOURCE_DATE_EPOCH=$(date +%s)
export SOURCE_DATE_EPOCH
VIRTUAL_ENV=$PWD/.venv
export VIRTUAL_ENV=$PWD/.venv
PATH=$VIRTUAL_ENV/bin:$PATH
POSTGRESQL_DATA="$VIRTUAL_ENV/postgresql"
POSTGRESQL_LOGS="$VIRTUAL_ENV/postgresql/logs.txt"
Expand Down
13 changes: 0 additions & 13 deletions packages/reactivated/src/components/Widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,19 +177,6 @@ export type WidgetType =
| SelectDateWidget
| ClearableFileInput;

export const getValue = (optgroup: Optgroup) => {
const rawValue = optgroup[1][0].value;

if (rawValue == null) {
return "";
} else if (rawValue === true) {
return "True";
} else if (rawValue === false) {
return "False";
}
return rawValue;
};

export const getValueForSelect = (widget: Select | Autocomplete | SelectMultiple) => {
if (isMultiple(widget)) {
return widget.value;
Expand Down
4 changes: 4 additions & 0 deletions packages/reactivated/src/eslintrc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ module.exports = {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",

// For when we do RPC calls and forget to call await on the promise
// to check against a null return.
"@typescript-eslint/no-unnecessary-condition": "error",

// We use empty callbacks that are no-ops sometimes.
"@typescript-eslint/no-empty-function": ["error", {allow: ["arrowFunctions"]}],

Expand Down
197 changes: 154 additions & 43 deletions packages/reactivated/src/forms/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import produce, {castDraft} from "immer";
import {produce, castDraft} from "immer";
import {FileInfoResult} from "prettier";
import React from "react";

Expand Down Expand Up @@ -79,6 +79,7 @@ export interface FormHandler<T extends FieldMap> {
nonFieldErrors: string[] | null;

setValue: <K extends keyof T>(name: K, value: FormValues<T>[K]) => void;
setValues: (values: FormValues<T>) => void;
setErrors: (errors: FormErrors<T>) => void;
iterate: (
iterator: Array<Extract<keyof T, string>>,
Expand Down Expand Up @@ -116,16 +117,15 @@ export const getInitialFormState = <T extends FieldMap>(form: FormLike<T>) => {

export const getInitialFormSetState = <T extends FieldMap>(
forms: Array<FormLike<T>>,
initial?: FormValues<T>[],
) => {
return Object.fromEntries(
forms.map((form) => [form.prefix, getInitialFormState(form)] as const),
);
return forms.map((form, index) => initial?.[index] ?? getInitialFormState(form));
};

export const getInitialFormSetErrors = <T extends FieldMap>(
forms: Array<FormLike<T>>,
) => {
return Object.fromEntries(forms.map((form) => [form.prefix, form.errors] as const));
return forms.map((form, index) => form.errors);
};

export const getFormHandler = <T extends FieldMap>({
Expand Down Expand Up @@ -311,6 +311,9 @@ export const getFormHandler = <T extends FieldMap>({
iterate,
reset,
setErrors,
setValues: (values) => {
setValues(() => values);
},
setValue: (fieldName, value) => {
changeValues(fieldName, (prevValues) => ({
...prevValues,
Expand All @@ -320,23 +323,34 @@ export const getFormHandler = <T extends FieldMap>({
};
};

export const useForm = <T extends FieldMap>({
form,
...options
}: {
export const useForm = <
T extends FieldMap,
S extends Array<keyof T> = [],
R extends {[P in Exclude<keyof T, S[number]>]: T[P]} = {
[P in Exclude<keyof T, S[number]>]: T[P];
},
>(options: {
form: FormLike<T>;
initial?: Partial<FormValues<R>>;
exclude?: [...S];
fieldInterceptor?: (
fieldName: keyof T,
field: FieldHandler<T[keyof T]["widget"]>,
values: FormValues<T>,
fieldName: keyof R,
field: FieldHandler<R[keyof R]["widget"]>,
values: FormValues<R>,
) => typeof field;
changeInterceptor?: (
name: keyof T,
prevValues: FormValues<T>,
nextValues: FormValues<T>,
) => FormValues<T>;
}): FormHandler<T> => {
const initial = getInitialFormState(form);
name: keyof R,
prevValues: FormValues<R>,
nextValues: FormValues<R>,
) => FormValues<R>;
}): FormHandler<R> => {
const form = {
...(options.form as any as FormLike<R>),
iterator: options.form.iterator.filter(
(field) => options.exclude == null || !options.exclude.includes(field),
),
} as any as FormLike<R>;
const initial = {...getInitialFormState(form), ...options.initial};
const [values, formSetValues] = React.useState(initial);
const [errors, setErrors] = React.useState(form.errors);

Expand Down Expand Up @@ -492,11 +506,19 @@ export const Widget = (props: {field: FieldHandler<widgets.CoreWidget>}) => {
onChange={field.handler}
/>
);
} else if (field.tag === "django.forms.widgets.PasswordInput") {
return (
<widgets.TextInput
name={field.name}
value={field.value}
onChange={field.handler}
type="password"
/>
);
} else if (
field.tag === "django.forms.widgets.TextInput" ||
field.tag === "django.forms.widgets.DateInput" ||
field.tag === "django.forms.widgets.URLInput" ||
field.tag === "django.forms.widgets.PasswordInput" ||
field.tag === "django.forms.widgets.EmailInput" ||
field.tag === "django.forms.widgets.TimeInput" ||
field.tag === "django.forms.widgets.NumberInput"
Expand All @@ -506,6 +528,7 @@ export const Widget = (props: {field: FieldHandler<widgets.CoreWidget>}) => {
name={field.name}
value={field.value}
onChange={field.handler}
placeholder={field.widget.attrs.placeholder}
/>
);
} else if (field.tag === "django.forms.widgets.Select") {
Expand Down Expand Up @@ -573,6 +596,7 @@ export const ManagementForm = <T extends FieldMap>({
export const useFormSet = <T extends FieldMap>(options: {
formSet: FormSetLike<T>;
onAddForm?: (form: FormLike<T>) => void;
initial?: FormValues<T>[];
fieldInterceptor?: (
fieldName: keyof T,
field: FieldHandler<T[keyof T]["widget"]>,
Expand All @@ -584,14 +608,40 @@ export const useFormSet = <T extends FieldMap>(options: {
nextValues: FormValues<T>,
) => FormValues<T>;
}) => {
const [formSet, setFormSet] = React.useState(options.formSet);
const createForm = (index: number) => {
return produce(options.formSet.empty_form, (draftState) => {
for (const fieldName of draftState.iterator) {
const prefix = `${options.formSet.prefix}-${index}`;
const field = draftState.fields[fieldName];
const htmlName = `${prefix}-${field.name}`;
draftState.fields[fieldName].widget.name = htmlName;
draftState.fields[fieldName].widget.attrs.id = `id_${htmlName}`;
draftState.prefix = prefix;
}
});
};

const formSetFromInitialValues = produce(options.formSet, (draftState) => {
if (options.initial == null) {
return;
}
draftState.total_form_count = options.initial.length;
draftState.forms = options.initial.map((_, index) => {
return castDraft(createForm(index));
});
});

const [formSet, setFormSet] = React.useState(formSetFromInitialValues);

const initialFormSetState = getInitialFormSetState(options.formSet.forms);
const initialFormSetState = getInitialFormSetState(
formSetFromInitialValues.forms,
options.initial,
);
const initialFormSetErrors = getInitialFormSetErrors(options.formSet.forms);
const [values, formSetSetValues] =
React.useState<Partial<typeof initialFormSetState>>(initialFormSetState);
React.useState<typeof initialFormSetState>(initialFormSetState);
const [errors, formSetSetErrors] =
React.useState<Partial<typeof initialFormSetErrors>>(initialFormSetErrors);
React.useState<typeof initialFormSetErrors>(initialFormSetErrors);

const emptyFormValues = getInitialFormState(formSet.empty_form);

Expand All @@ -600,43 +650,33 @@ export const useFormSet = <T extends FieldMap>(options: {
form,
changeInterceptor: options.changeInterceptor,
fieldInterceptor: options.fieldInterceptor,
values: values[form.prefix] ?? emptyFormValues,
errors: errors[form.prefix] ?? {},
values: values[index] ?? emptyFormValues,
errors: errors[index] ?? {},
setErrors: (nextErrors) => {
formSetSetErrors((prevErrors) => ({
...prevErrors,
[form.prefix]: nextErrors,
[index]: nextErrors,
}));
},
initial: initialFormSetState[index] ?? emptyFormValues,
setValues: (getValuesToSetFromPrevValues) => {
formSetSetValues((prevValues) => {
const nextValues = getValuesToSetFromPrevValues(
prevValues[form.prefix] ?? emptyFormValues,
prevValues[index] ?? emptyFormValues,
);
return {
...prevValues,
[form.prefix]: nextValues,
};
return produce(prevValues, (draftState) => {
draftState[index] = castDraft(nextValues);
});
});
},
});
});

const addForm = () => {
const {total_form_count} = formSet;
type AdditionalForm = (typeof formSet)["forms"][number];

const extraForm = produce(formSet.empty_form, (draftState) => {
for (const fieldName of draftState.iterator) {
const prefix = `${formSet.prefix}-${formSet.total_form_count}`;
const field = draftState.fields[fieldName];
const htmlName = `${prefix}-${field.name}`;
draftState.fields[fieldName].widget.name = htmlName;
draftState.fields[fieldName].widget.attrs.id = `id_${htmlName}`;
draftState.prefix = prefix;
}
});
const extraForm = createForm(formSet.total_form_count);

const updated = produce(formSet, (draftState) => {
draftState.forms.push(castDraft(extraForm));
draftState.total_form_count += 1;
Expand All @@ -646,7 +686,38 @@ export const useFormSet = <T extends FieldMap>(options: {
options.onAddForm?.(extraForm);
};

return {schema: formSet, values, forms, addForm};
const clear = () => {
setFormSet(
produce(formSet, (draftState) => {
formSetSetValues([]);
draftState.forms = [];
draftState.total_form_count = 0;
}),
);
};

const setFormsAndValues = (values: FormValues<T>[]) => {
formSetSetValues(values);

const updated = produce(formSet, (draftState) => {
draftState.total_form_count = values.length;
draftState.forms = values.map((_, index) => {
return castDraft(createForm(index));
});
});
setFormSet(updated);
};

return {
schema: formSet,
values,
forms,
addForm,
clear,
setFormsAndValues,
setErrors: formSetSetErrors,
setValues: formSetSetValues,
};
};

export const bindWidgetType = <W extends WidgetLike>() => {
Expand Down Expand Up @@ -767,3 +838,43 @@ export const FormSet = <T extends FieldMap<widgets.CoreWidget>>(props: {
</>
);
};

export type UnknownFormValues<T extends FieldMap> = {
[K in keyof T]: T[K] extends {enum: unknown} ? T[K]["enum"] | null : unknown;
};

// TODO: Should be T extends Record<string, FormLike<any> | FormSetLike<any>>
// but jsonschema outputs interfaces instead of types. Figure out how to output a type.
export type FormOrFormSetValues<T> = T extends {tag: "FormGroup"}
? Omit<{[K in keyof T]: FormOrFormSetValues<T[K]>}, "tag">
: T extends FormLike<any>
? UnknownFormValues<T["fields"]>
: T extends FormSetLike<any>
? Array<UnknownFormValues<T["empty_form"]["fields"]>>
: T extends null
? null
: never;

export type FormOrFormSetErrors<T> = T extends {tag: "FormGroup"}
? Omit<{[K in keyof T]?: FormOrFormSetErrors<T[K]>}, "tag">
: T extends FormLike<any>
? NonNullable<T["errors"]>
: T extends FormSetLike<any>
? Array<NonNullable<T["empty_form"]["errors"]>>
: T extends null
? null
: never;

// Used by Joy, but unsure what this is for.
export const getValue = (optgroup: Optgroup) => {
const rawValue = optgroup[1][0].value;

if (rawValue == null) {
return "";
} else if (rawValue === true) {
return "True";
} else if (rawValue === false) {
return "False";
}
return rawValue;
};
Loading
Loading