2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Make modals/forms more reactive (#5897)

* First draft for refactoring the api forms including modals

* Fix merging errors

* Fix deepsource

* Fix jsdoc

* trigger: deepsource

* Try to improve performance by not passing the whole definition down

* First draft for switching to react-hook-form

* Fix warning log in console with i18n when locale is not loaded

* Fix: deepsource

* Fixed RelatedModelField initial value loading and disable submit if form is not 'dirty'

* Make field state hookable to state

* Added nested object field to PUI form framework

* Fix ts errors while integrating the new forms api into a few places

* Fix: deepsource

* Fix some values were not present in the submit data if the field is hidden

* Handle error while loading locales

* Fix: deepsource
This commit is contained in:
Lukas 2023-11-20 14:00:44 +01:00 committed by GitHub
parent 0d7b4f2f17
commit cb537780dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1161 additions and 690 deletions

View File

@ -819,7 +819,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
# Create initial stock entry # Create initial stock entry
if initial_stock: if initial_stock:
quantity = initial_stock['quantity'] quantity = initial_stock['quantity']
location = initial_stock['location'] or instance.default_location location = initial_stock.get('location', None) or instance.default_location
if quantity > 0: if quantity > 0:
stockitem = stock.models.StockItem( stockitem = stock.models.StockItem(

View File

@ -39,6 +39,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-grid-layout": "^1.4.2", "react-grid-layout": "^1.4.2",
"react-hook-form": "^7.48.2",
"react-router-dom": "^6.17.0", "react-router-dom": "^6.17.0",
"react-select": "^5.7.7", "react-select": "^5.7.7",
"react-simplemde-editor": "^5.2.0", "react-simplemde-editor": "^5.2.0",

View File

@ -1,120 +1,214 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Alert, Divider, LoadingOverlay, Text } from '@mantine/core'; import {
Alert,
DefaultMantineColor,
Divider,
LoadingOverlay,
Text
} from '@mantine/core';
import { Button, Group, Stack } from '@mantine/core'; import { Button, Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useId } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import {
FieldValues,
SubmitErrorHandler,
SubmitHandler,
useForm
} from 'react-hook-form';
import { api, queryClient } from '../../App'; import { api, queryClient } from '../../App';
import { ApiPaths } from '../../enums/ApiEndpoints'; import { ApiPaths } from '../../enums/ApiEndpoints';
import { constructFormUrl } from '../../functions/forms'; import {
NestedDict,
constructField,
constructFormUrl,
extractAvailableFields,
mapFields
} from '../../functions/forms';
import { invalidResponse } from '../../functions/notifications'; import { invalidResponse } from '../../functions/notifications';
import { ApiFormField, ApiFormFieldSet } from './fields/ApiFormField'; import {
ApiFormField,
ApiFormFieldSet,
ApiFormFieldType
} from './fields/ApiFormField';
export interface ApiFormAction {
text: string;
variant?: 'outline';
color?: DefaultMantineColor;
onClick: () => void;
}
/** /**
* Properties for the ApiForm component * Properties for the ApiForm component
* @param url : The API endpoint to fetch the form data from * @param url : The API endpoint to fetch the form data from
* @param pk : Optional primary-key value when editing an existing object * @param pk : Optional primary-key value when editing an existing object
* @param title : The title to display in the form header * @param method : Optional HTTP method to use when submitting the form (default: GET)
* @param fields : The fields to render in the form * @param fields : The fields to render in the form
* @param submitText : Optional custom text to display on the submit button (default: Submit)4 * @param submitText : Optional custom text to display on the submit button (default: Submit)4
* @param submitColor : Optional custom color for the submit button (default: green) * @param submitColor : Optional custom color for the submit button (default: green)
* @param cancelText : Optional custom text to display on the cancel button (default: Cancel)
* @param cancelColor : Optional custom color for the cancel button (default: blue)
* @param fetchInitialData : Optional flag to fetch initial data from the server (default: true) * @param fetchInitialData : Optional flag to fetch initial data from the server (default: true)
* @param method : Optional HTTP method to use when submitting the form (default: GET)
* @param preFormContent : Optional content to render before the form fields * @param preFormContent : Optional content to render before the form fields
* @param postFormContent : Optional content to render after the form fields * @param postFormContent : Optional content to render after the form fields
* @param successMessage : Optional message to display on successful form submission * @param successMessage : Optional message to display on successful form submission
* @param onClose : A callback function to call when the form is closed.
* @param onFormSuccess : A callback function to call when the form is submitted successfully. * @param onFormSuccess : A callback function to call when the form is submitted successfully.
* @param onFormError : A callback function to call when the form is submitted with errors. * @param onFormError : A callback function to call when the form is submitted with errors.
*/ */
export interface ApiFormProps { export interface ApiFormProps {
url: ApiPaths; url: ApiPaths;
pk?: number | string | undefined; pk?: number | string | undefined;
title: string; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
fields?: ApiFormFieldSet; fields?: ApiFormFieldSet;
cancelText?: string;
submitText?: string; submitText?: string;
submitColor?: string; submitColor?: string;
cancelColor?: string;
fetchInitialData?: boolean; fetchInitialData?: boolean;
ignorePermissionCheck?: boolean; ignorePermissionCheck?: boolean;
method?: string; preFormContent?: JSX.Element;
preFormContent?: JSX.Element | (() => JSX.Element); postFormContent?: JSX.Element;
postFormContent?: JSX.Element | (() => JSX.Element);
successMessage?: string; successMessage?: string;
onClose?: () => void;
onFormSuccess?: (data: any) => void; onFormSuccess?: (data: any) => void;
onFormError?: () => void; onFormError?: () => void;
actions?: ApiFormAction[];
}
export function OptionsApiForm({
props: _props,
id: pId
}: {
props: ApiFormProps;
id?: string;
}) {
const props = useMemo(
() => ({
..._props,
method: _props.method || 'GET'
}),
[_props]
);
const id = useId(pId);
const url = useMemo(
() => constructFormUrl(props.url, props.pk),
[props.url, props.pk]
);
const { data } = useQuery({
enabled: true,
queryKey: ['form-options-data', id, props.method, props.url, props.pk],
queryFn: () =>
api.options(url).then((res) => {
let fields: Record<string, ApiFormFieldType> | null = {};
if (!props.ignorePermissionCheck) {
fields = extractAvailableFields(res, props.method);
}
return fields;
}),
throwOnError: (error: any) => {
if (error.response) {
invalidResponse(error.response.status);
} else {
notifications.show({
title: t`Form Error`,
message: error.message,
color: 'red'
});
}
return false;
}
});
const formProps: ApiFormProps = useMemo(() => {
const _props = { ...props };
if (!_props.fields) return _props;
for (const [k, v] of Object.entries(_props.fields)) {
_props.fields[k] = constructField({
field: v,
definition: data?.[k]
});
}
return _props;
}, [data, props]);
if (!data) {
return <LoadingOverlay visible={true} />;
}
return <ApiForm id={id} props={formProps} />;
} }
/** /**
* An ApiForm component is a modal form which is rendered dynamically, * An ApiForm component is a modal form which is rendered dynamically,
* based on an API endpoint. * based on an API endpoint.
*/ */
export function ApiForm({ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
modalId, const defaultValues: FieldValues = useMemo(
props, () =>
fieldDefinitions mapFields(props.fields ?? {}, (_path, field) => {
}: { return field.default ?? undefined;
modalId: string; }),
props: ApiFormProps; [props.fields]
fieldDefinitions: ApiFormFieldSet; );
}) {
// Form errors which are not associated with a specific field // Form errors which are not associated with a specific field
const [nonFieldErrors, setNonFieldErrors] = useState<string[]>([]); const [nonFieldErrors, setNonFieldErrors] = useState<string[]>([]);
// Form state // Form state
const form = useForm({}); const form = useForm({
criteriaMode: 'all',
defaultValues
});
const { isValid, isDirty, isLoading: isFormLoading } = form.formState;
// Cache URL // Cache URL
const url = useMemo(() => constructFormUrl(props), [props]); const url = useMemo(
() => constructFormUrl(props.url, props.pk),
[props.url, props.pk]
);
// Render pre-form content // Query manager for retrieving initial data from the server
// TODO: Future work will allow this content to be updated dynamically based on the form data
const preFormElement: JSX.Element | null = useMemo(() => {
if (props.preFormContent === undefined) {
return null;
} else if (props.preFormContent instanceof Function) {
return props.preFormContent();
} else {
return props.preFormContent;
}
}, [props]);
// Render post-form content
// TODO: Future work will allow this content to be updated dynamically based on the form data
const postFormElement: JSX.Element | null = useMemo(() => {
if (props.postFormContent === undefined) {
return null;
} else if (props.postFormContent instanceof Function) {
return props.postFormContent();
} else {
return props.postFormContent;
}
}, [props]);
// Query manager for retrieiving initial data from the server
const initialDataQuery = useQuery({ const initialDataQuery = useQuery({
enabled: false, enabled: false,
queryKey: ['form-initial-data', modalId, props.method, props.url, props.pk], queryKey: ['form-initial-data', id, props.method, props.url, props.pk],
queryFn: async () => { queryFn: async () => {
return api return api
.get(url) .get(url)
.then((response) => { .then((response) => {
// Update form values, but only for the fields specified for the form const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
Object.keys(props.fields ?? {}).forEach((fieldName) => { const res: NestedDict = {};
if (fieldName in response.data) {
form.setValues({ for (const [k, field] of Object.entries(fields)) {
[fieldName]: response.data[fieldName] const dataValue = data[k];
});
if (
field.field_type === 'nested object' &&
field.children &&
typeof dataValue === 'object'
) {
res[k] = processFields(field.children, dataValue);
} else {
res[k] = dataValue;
}
} }
});
return res;
};
const initialData: any = processFields(
props.fields ?? {},
response.data
);
// Update form values, but only for the fields specified for this form
form.reset(initialData);
return response; return response;
}) })
@ -126,144 +220,122 @@ export function ApiForm({
// Fetch initial data on form load // Fetch initial data on form load
useEffect(() => { useEffect(() => {
// Provide initial form data
Object.entries(props.fields ?? {}).forEach(([fieldName, field]) => {
// fieldDefinition is supplied by the API, and can serve as a backup
let fieldDefinition = fieldDefinitions[fieldName] ?? {};
let v =
field.value ??
field.default ??
fieldDefinition.value ??
fieldDefinition.default ??
undefined;
if (v !== undefined) {
form.setValues({
[fieldName]: v
});
}
});
// Fetch initial data if the fetchInitialData property is set // Fetch initial data if the fetchInitialData property is set
if (props.fetchInitialData) { if (props.fetchInitialData) {
queryClient.removeQueries({ queryClient.removeQueries({
queryKey: [ queryKey: ['form-initial-data', id, props.method, props.url, props.pk]
'form-initial-data',
modalId,
props.method,
props.url,
props.pk
]
}); });
initialDataQuery.refetch(); initialDataQuery.refetch();
} }
}, []); }, []);
// Query manager for submitting data const submitForm: SubmitHandler<FieldValues> = async (data) => {
const submitQuery = useQuery({ setNonFieldErrors([]);
enabled: false,
queryKey: ['form-submit', modalId, props.method, props.url, props.pk],
queryFn: async () => {
let method = props.method?.toLowerCase() ?? 'get';
return api({ let method = props.method?.toLowerCase() ?? 'get';
method: method,
url: url, let hasFiles = false;
data: form.values, mapFields(props.fields ?? {}, (_path, field) => {
headers: { if (field.field_type === 'file upload') {
'Content-Type': 'multipart/form-data' hasFiles = true;
}
});
return api({
method: method,
url: url,
data: data,
headers: {
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
}
})
.then((response) => {
switch (response.status) {
case 200:
case 201:
case 204:
// Form was submitted successfully
// Optionally call the onFormSuccess callback
if (props.onFormSuccess) {
props.onFormSuccess(response.data);
}
// Optionally show a success message
if (props.successMessage) {
notifications.show({
title: t`Success`,
message: props.successMessage,
color: 'green'
});
}
break;
default:
// Unexpected state on form success
invalidResponse(response.status);
props.onFormError?.();
break;
} }
return response;
}) })
.then((response) => { .catch((error) => {
switch (response.status) { if (error.response) {
case 200: switch (error.response.status) {
case 201: case 400:
case 204: // Data validation errors
// Form was submitted successfully const nonFieldErrors: string[] = [];
const processErrors = (errors: any, _path?: string) => {
for (const [k, v] of Object.entries(errors)) {
const path = _path ? `${_path}.${k}` : k;
// Optionally call the onFormSuccess callback if (k === 'non_field_errors') {
if (props.onFormSuccess) { nonFieldErrors.push((v as string[]).join(', '));
props.onFormSuccess(response.data); continue;
} }
// Optionally show a success message if (typeof v === 'object' && Array.isArray(v)) {
if (props.successMessage) { form.setError(path, { message: v.join(', ') });
notifications.show({ } else {
title: t`Success`, processErrors(v, path);
message: props.successMessage, }
color: 'green' }
}); };
}
closeForm(); processErrors(error.response.data);
setNonFieldErrors(nonFieldErrors);
break; break;
default: default:
// Unexpected state on form success // Unexpected state on form error
invalidResponse(response.status); invalidResponse(error.response.status);
closeForm(); props.onFormError?.();
break; break;
} }
} else {
invalidResponse(0);
props.onFormError?.();
}
return response; return error;
}) });
.catch((error) => { };
if (error.response) {
switch (error.response.status) {
case 400:
// Data validation error
form.setErrors(error.response.data);
setNonFieldErrors(error.response.data.non_field_errors ?? []);
setIsLoading(false);
break;
default:
// Unexpected state on form error
invalidResponse(error.response.status);
closeForm();
break;
}
} else {
invalidResponse(0);
closeForm();
}
return error; const isLoading = useMemo(
}); () => isFormLoading || initialDataQuery.isFetching,
}, [isFormLoading, initialDataQuery.isFetching]
refetchOnMount: false, );
refetchOnWindowFocus: false
});
// Data loading state const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
const [isLoading, setIsLoading] = useState<boolean>(true); props.onFormError?.();
}, [props.onFormError]);
useEffect(() => {
setIsLoading(submitQuery.isFetching || initialDataQuery.isFetching);
}, [initialDataQuery.status, submitQuery.status]);
/**
* Callback to perform form submission
*/
function submitForm() {
setIsLoading(true);
submitQuery.refetch();
}
/**
* Callback to close the form
* Note that the calling function might implement an onClose() callback,
* which will be automatically called
*/
function closeForm() {
modals.close(modalId);
}
return ( return (
<Stack> <Stack>
<Divider /> <Divider />
<Stack spacing="sm"> <Stack spacing="sm">
<LoadingOverlay visible={isLoading} /> <LoadingOverlay visible={isLoading} />
{(Object.keys(form.errors).length > 0 || nonFieldErrors.length > 0) && ( {(!isValid || nonFieldErrors.length > 0) && (
<Alert radius="sm" color="red" title={t`Form Errors Exist`}> <Alert radius="sm" color="red" title={t`Form Errors Exist`}>
{nonFieldErrors.length > 0 && ( {nonFieldErrors.length > 0 && (
<Stack spacing="xs"> <Stack spacing="xs">
@ -274,41 +346,38 @@ export function ApiForm({
)} )}
</Alert> </Alert>
)} )}
{preFormElement} {props.preFormContent}
<Stack spacing="xs"> <Stack spacing="xs">
{Object.entries(props.fields ?? {}).map( {Object.entries(props.fields ?? {}).map(([fieldName, field]) => (
([fieldName, field]) => <ApiFormField
!field.hidden && ( key={fieldName}
<ApiFormField fieldName={fieldName}
key={fieldName} definition={field}
field={field} control={form.control}
fieldName={fieldName} />
formProps={props} ))}
form={form}
error={form.errors[fieldName] ?? null}
definitions={fieldDefinitions}
/>
)
)}
</Stack> </Stack>
{postFormElement} {props.postFormContent}
</Stack> </Stack>
<Divider /> <Divider />
<Group position="right"> <Group position="right">
{props.actions?.map((action, i) => (
<Button
key={i}
onClick={action.onClick}
variant={action.variant ?? 'outline'}
radius="sm"
color={action.color}
>
{action.text}
</Button>
))}
<Button <Button
onClick={closeForm} onClick={form.handleSubmit(submitForm, onFormError)}
variant="outline"
radius="sm"
color={props.cancelColor ?? 'blue'}
>
{props.cancelText ?? t`Cancel`}
</Button>
<Button
onClick={submitForm}
variant="outline" variant="outline"
radius="sm" radius="sm"
color={props.submitColor ?? 'green'} color={props.submitColor ?? 'green'}
disabled={isLoading} disabled={isLoading || (props.fetchInitialData && !isDirty)}
> >
{props.submitText ?? t`Submit`} {props.submitText ?? t`Submit`}
</Button> </Button>

View File

@ -11,27 +11,18 @@ import { DateInput } from '@mantine/dates';
import { UseFormReturnType } from '@mantine/form'; import { UseFormReturnType } from '@mantine/form';
import { useId } from '@mantine/hooks'; import { useId } from '@mantine/hooks';
import { IconX } from '@tabler/icons-react'; import { IconX } from '@tabler/icons-react';
import { ReactNode } from 'react'; import { ReactNode, useCallback, useEffect } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Control, FieldValues, useController } from 'react-hook-form';
import { ModelType } from '../../../enums/ModelType'; import { ModelType } from '../../../enums/ModelType';
import { ApiFormProps } from '../ApiForm';
import { ChoiceField } from './ChoiceField'; import { ChoiceField } from './ChoiceField';
import { NestedObjectField } from './NestedObjectField';
import { RelatedModelField } from './RelatedModelField'; import { RelatedModelField } from './RelatedModelField';
export type ApiFormData = UseFormReturnType<Record<string, unknown>>; export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
/** /** Definition of the ApiForm field component.
* Callback function type when a form field value changes
*/
export type ApiFormChangeCallback = {
name: string;
value: any;
field: ApiFormFieldType;
form: ApiFormData;
};
/* Definition of the ApiForm field component.
* - The 'name' attribute *must* be provided * - The 'name' attribute *must* be provided
* - All other attributes are optional, and may be provided by the API * - All other attributes are optional, and may be provided by the API
* - However, they can be overridden by the user * - However, they can be overridden by the user
@ -60,10 +51,25 @@ export type ApiFormFieldType = {
value?: any; value?: any;
default?: any; default?: any;
icon?: ReactNode; icon?: ReactNode;
field_type?: string; field_type?:
| 'related field'
| 'email'
| 'url'
| 'string'
| 'boolean'
| 'date'
| 'integer'
| 'decimal'
| 'float'
| 'number'
| 'choice'
| 'file upload'
| 'nested object';
api_url?: string; api_url?: string;
model?: ModelType; model?: ModelType;
modelRenderer?: (instance: any) => ReactNode;
filters?: any; filters?: any;
children?: { [key: string]: ApiFormFieldType };
required?: boolean; required?: boolean;
choices?: any[]; choices?: any[];
hidden?: boolean; hidden?: boolean;
@ -71,127 +77,66 @@ export type ApiFormFieldType = {
read_only?: boolean; read_only?: boolean;
placeholder?: string; placeholder?: string;
description?: string; description?: string;
preFieldContent?: JSX.Element | (() => JSX.Element); preFieldContent?: JSX.Element;
postFieldContent?: JSX.Element | (() => JSX.Element); postFieldContent?: JSX.Element;
onValueChange?: (change: ApiFormChangeCallback) => void; onValueChange?: (value: any) => void;
adjustFilters?: (filters: any, form: ApiFormData) => any; adjustFilters?: (filters: any) => any;
}; };
/*
* Build a complete field definition based on the provided data
*/
export function constructField({
form,
fieldName,
field,
definitions
}: {
form: UseFormReturnType<Record<string, unknown>>;
fieldName: string;
field: ApiFormFieldType;
definitions: Record<string, ApiFormFieldType>;
}) {
let def = definitions[fieldName] || field;
def = {
...def,
...field
};
// Retrieve the latest value from the form
let value = form.values[fieldName];
if (value != undefined) {
def.value = value;
}
// Change value to a date object if required
switch (def.field_type) {
case 'date':
if (def.value) {
def.value = new Date(def.value);
}
break;
default:
break;
}
// Clear out the 'read_only' attribute
def.disabled = def.disabled ?? def.read_only ?? false;
delete def['read_only'];
return def;
}
/** /**
* Render an individual form field * Render an individual form field
*/ */
export function ApiFormField({ export function ApiFormField({
formProps,
form,
fieldName, fieldName,
field, definition,
error, control
definitions
}: { }: {
formProps: ApiFormProps;
form: UseFormReturnType<Record<string, unknown>>;
fieldName: string; fieldName: string;
field: ApiFormFieldType; definition: ApiFormFieldType;
error: ReactNode; control: Control<FieldValues, any>;
definitions: Record<string, ApiFormFieldType>;
}) { }) {
const fieldId = useId(fieldName); const fieldId = useId();
const controller = useController({
name: fieldName,
control
});
const {
field,
fieldState: { error }
} = controller;
const { value, ref } = field;
// Extract field definition from provided data useEffect(() => {
// Where user has provided specific data, override the API definition if (definition.field_type === 'nested object') return;
const definition: ApiFormFieldType = useMemo(
() =>
constructField({
form: form,
fieldName: fieldName,
field: field,
definitions: definitions
}),
[fieldName, field, definitions]
);
const preFieldElement: JSX.Element | null = useMemo(() => { // hook up the value state to the input field
if (field.preFieldContent === undefined) { if (definition.value !== undefined) {
return null; field.onChange(definition.value);
} else if (field.preFieldContent instanceof Function) {
return field.preFieldContent();
} else {
return field.preFieldContent;
} }
}, [field]); }, [definition.value]);
const postFieldElement: JSX.Element | null = useMemo(() => { // pull out onValueChange as this can cause strange errors when passing the
if (field.postFieldContent === undefined) { // definition to the input components via spread syntax
return null; const reducedDefinition = useMemo(() => {
} else if (field.postFieldContent instanceof Function) { return {
return field.postFieldContent(); ...definition,
} else { onValueChange: undefined,
return field.postFieldContent; adjustFilters: undefined,
} read_only: undefined,
}, [field]); children: undefined
};
}, [definition]);
// Callback helper when form value changes // Callback helper when form value changes
function onChange(value: any) { const onChange = useCallback(
form.setValues({ [fieldName]: value }); (value: any) => {
field.onChange(value);
// Run custom callback for this field // Run custom callback for this field
if (definition.onValueChange) { definition.onValueChange?.(value);
definition.onValueChange({ },
name: fieldName, [fieldName, definition]
value: value, );
field: definition,
form: form
});
}
}
const value: any = useMemo(() => form.values[fieldName], [form.values]);
// Coerce the value to a numerical value // Coerce the value to a numerical value
const numericalValue: number | undefined = useMemo(() => { const numericalValue: number | undefined = useMemo(() => {
@ -223,12 +168,9 @@ export function ApiFormField({
case 'related field': case 'related field':
return ( return (
<RelatedModelField <RelatedModelField
error={error} controller={controller}
formProps={formProps} definition={definition}
form={form}
field={definition}
fieldName={fieldName} fieldName={fieldName}
definitions={definitions}
/> />
); );
case 'email': case 'email':
@ -236,11 +178,12 @@ export function ApiFormField({
case 'string': case 'string':
return ( return (
<TextInput <TextInput
{...definition} {...reducedDefinition}
ref={ref}
id={fieldId} id={fieldId}
type={definition.field_type} type={definition.field_type}
value={value || ''} value={value || ''}
error={error} error={error?.message}
radius="sm" radius="sm"
onChange={(event) => onChange(event.currentTarget.value)} onChange={(event) => onChange(event.currentTarget.value)}
rightSection={ rightSection={
@ -253,23 +196,25 @@ export function ApiFormField({
case 'boolean': case 'boolean':
return ( return (
<Switch <Switch
{...definition} {...reducedDefinition}
ref={ref}
id={fieldId} id={fieldId}
radius="lg" radius="lg"
size="sm" size="sm"
checked={value ?? false} checked={value ?? false}
error={error} error={error?.message}
onChange={(event) => onChange(event.currentTarget.checked)} onChange={(event) => onChange(event.currentTarget.checked)}
/> />
); );
case 'date': case 'date':
return ( return (
<DateInput <DateInput
{...definition} {...reducedDefinition}
ref={ref}
id={fieldId} id={fieldId}
radius="sm" radius="sm"
type={undefined} type={undefined}
error={error} error={error?.message}
value={value} value={value}
clearable={!definition.required} clearable={!definition.required}
onChange={(value) => onChange(value)} onChange={(value) => onChange(value)}
@ -282,11 +227,12 @@ export function ApiFormField({
case 'number': case 'number':
return ( return (
<NumberInput <NumberInput
{...definition} {...reducedDefinition}
radius="sm" radius="sm"
ref={ref}
id={fieldId} id={fieldId}
value={numericalValue} value={numericalValue}
error={error} error={error?.message}
formatter={(value) => { formatter={(value) => {
let v: any = parseFloat(value); let v: any = parseFloat(value);
@ -303,24 +249,31 @@ export function ApiFormField({
case 'choice': case 'choice':
return ( return (
<ChoiceField <ChoiceField
error={error} controller={controller}
form={form}
fieldName={fieldName} fieldName={fieldName}
field={definition} definition={definition}
definitions={definitions}
/> />
); );
case 'file upload': case 'file upload':
return ( return (
<FileInput <FileInput
{...definition} {...reducedDefinition}
id={fieldId} id={fieldId}
ref={ref}
radius="sm" radius="sm"
value={value} value={value}
error={error} error={error?.message}
onChange={(payload: File | null) => onChange(payload)} onChange={(payload: File | null) => onChange(payload)}
/> />
); );
case 'nested object':
return (
<NestedObjectField
definition={definition}
fieldName={fieldName}
control={control}
/>
);
default: default:
return ( return (
<Alert color="red" title={t`Error`}> <Alert color="red" title={t`Error`}>
@ -331,11 +284,15 @@ export function ApiFormField({
} }
} }
if (definition.hidden) {
return null;
}
return ( return (
<Stack> <Stack>
{preFieldElement} {definition.preFieldContent}
{buildField()} {buildField()}
{postFieldElement} {definition.postFieldContent}
</Stack> </Stack>
); );
} }

View File

@ -1,47 +1,30 @@
import { Select } from '@mantine/core'; import { Select } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useId } from '@mantine/hooks'; import { useId } from '@mantine/hooks';
import { ReactNode } from 'react'; import { useCallback } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
import { constructField } from './ApiFormField'; import { ApiFormFieldType } from './ApiFormField';
import { ApiFormFieldSet, ApiFormFieldType } from './ApiFormField';
/** /**
* Render a 'select' field for selecting from a list of choices * Render a 'select' field for selecting from a list of choices
*/ */
export function ChoiceField({ export function ChoiceField({
error, controller,
form, definition
fieldName,
field,
definitions
}: { }: {
error: ReactNode; controller: UseControllerReturn<FieldValues, any>;
form: UseFormReturnType<Record<string, unknown>>; definition: ApiFormFieldType;
field: ApiFormFieldType;
fieldName: string; fieldName: string;
definitions: ApiFormFieldSet;
}) { }) {
// Extract field definition from provided data const fieldId = useId();
// Where user has provided specific data, override the API definition
const definition: ApiFormFieldType = useMemo(() => {
let def = constructField({
form: form,
field: field,
fieldName: fieldName,
definitions: definitions
});
return def; const {
}, [fieldName, field, definitions]); field,
fieldState: { error }
const fieldId = useId(fieldName); } = controller;
const value: any = useMemo(() => form.values[fieldName], [form.values]);
// Build a set of choices for the field // Build a set of choices for the field
// TODO: In future, allow this to be created dynamically?
const choices: any[] = useMemo(() => { const choices: any[] = useMemo(() => {
let choices = definition.choices ?? []; let choices = definition.choices ?? [];
@ -53,30 +36,28 @@ export function ChoiceField({
label: choice.display_name label: choice.display_name
}; };
}); });
}, [definition]); }, [definition.choices]);
// Callback when an option is selected // Update form values when the selected value changes
function onChange(value: any) { const onChange = useCallback(
form.setFieldValue(fieldName, value); (value: any) => {
field.onChange(value);
if (definition.onValueChange) { // Run custom callback for this field (if provided)
definition.onValueChange({ definition.onValueChange?.(value);
name: fieldName, },
value: value, [field.onChange, definition]
field: definition, );
form: form
});
}
}
return ( return (
<Select <Select
id={fieldId} id={fieldId}
error={error?.message}
radius="sm" radius="sm"
{...definition} {...field}
onChange={onChange}
data={choices} data={choices}
value={value} value={field.value}
onChange={(value) => onChange(value)}
withinPortal={true} withinPortal={true}
/> />
); );

View File

@ -0,0 +1,39 @@
import { Accordion, Divider, Stack, Text } from '@mantine/core';
import { Control, FieldValues } from 'react-hook-form';
import { ApiFormField, ApiFormFieldType } from './ApiFormField';
export function NestedObjectField({
control,
fieldName,
definition
}: {
control: Control<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
}) {
return (
<Accordion defaultValue={'OpenByDefault'} variant="contained">
<Accordion.Item value={'OpenByDefault'}>
<Accordion.Control icon={definition.icon}>
<Text>{definition.label}</Text>
</Accordion.Control>
<Accordion.Panel>
<Divider sx={{ marginTop: '-10px', marginBottom: '10px' }} />
<Stack spacing="xs">
{Object.entries(definition.children ?? {}).map(
([childFieldName, field]) => (
<ApiFormField
key={childFieldName}
fieldName={`${fieldName}.${childFieldName}`}
definition={field}
control={control}
/>
)
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@ -1,90 +1,65 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Input } from '@mantine/core'; import { Input } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { useId } from '@mantine/hooks'; import { useId } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ReactNode, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form';
import Select from 'react-select'; import Select from 'react-select';
import { api } from '../../../App'; import { api } from '../../../App';
import { RenderInstance } from '../../render/Instance'; import { RenderInstance } from '../../render/Instance';
import { ApiFormProps } from '../ApiForm'; import { ApiFormFieldType } from './ApiFormField';
import { ApiFormFieldSet, ApiFormFieldType } from './ApiFormField';
import { constructField } from './ApiFormField';
/** /**
* Render a 'select' field for searching the database against a particular model type * Render a 'select' field for searching the database against a particular model type
*/ */
export function RelatedModelField({ export function RelatedModelField({
error, controller,
formProps,
form,
fieldName, fieldName,
field, definition,
definitions,
limit = 10 limit = 10
}: { }: {
error: ReactNode; controller: UseControllerReturn<FieldValues, any>;
formProps: ApiFormProps; definition: ApiFormFieldType;
form: UseFormReturnType<Record<string, unknown>>;
field: ApiFormFieldType;
fieldName: string; fieldName: string;
definitions: ApiFormFieldSet;
limit?: number; limit?: number;
}) { }) {
const fieldId = useId(fieldName); const fieldId = useId();
// Extract field definition from provided data const {
// Where user has provided specific data, override the API definition field,
const definition: ApiFormFieldType = useMemo(() => { fieldState: { error }
let def = constructField({ } = controller;
form: form,
field: field,
fieldName: fieldName,
definitions: definitions
});
// Remove the 'read_only' attribute (causes issues with Mantine)
delete def['read_only'];
return def;
}, [form.values, field, definitions]);
// Keep track of the primary key value for this field // Keep track of the primary key value for this field
const [pk, setPk] = useState<number | null>(null); const [pk, setPk] = useState<number | null>(null);
// If an initial value is provided, load from the API // If an initial value is provided, load from the API
useEffect(() => { useEffect(() => {
// If a value is provided, load the related object // If the value is unchanged, do nothing
if (form.values) { if (field.value === pk) return;
let formPk = form.values[fieldName] ?? null;
// If the value is unchanged, do nothing if (field.value !== null) {
if (formPk == pk) { const url = `${definition.api_url}${field.value}/`;
return;
}
if (formPk != null) { api.get(url).then((response) => {
let url = (definition.api_url || '') + formPk + '/'; const data = response.data;
api.get(url).then((response) => { if (data && data.pk) {
let data = response.data; const value = {
value: data.pk,
data: data
};
if (data && data.pk) { setData([value]);
let value = { setPk(data.pk);
value: data.pk, }
data: data });
}; } else {
setPk(null);
setData([value]);
setPk(data.pk);
}
});
} else {
setPk(null);
}
} }
}, [form.values[fieldName]]); }, [definition.api_url, field.value]);
const [offset, setOffset] = useState<number>(0); const [offset, setOffset] = useState<number>(0);
@ -96,7 +71,7 @@ export function RelatedModelField({
const selectQuery = useQuery({ const selectQuery = useQuery({
enabled: !definition.disabled && !!definition.api_url && !definition.hidden, enabled: !definition.disabled && !!definition.api_url && !definition.hidden,
queryKey: [`related-field-${fieldName}`, offset, searchText], queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText],
queryFn: async () => { queryFn: async () => {
if (!definition.api_url) { if (!definition.api_url) {
return null; return null;
@ -105,7 +80,7 @@ export function RelatedModelField({
let filters = definition.filters ?? {}; let filters = definition.filters ?? {};
if (definition.adjustFilters) { if (definition.adjustFilters) {
filters = definition.adjustFilters(filters, form); filters = definition.adjustFilters(filters);
} }
let params = { let params = {
@ -120,11 +95,15 @@ export function RelatedModelField({
params: params params: params
}) })
.then((response) => { .then((response) => {
let values: any[] = [...data]; const values: any[] = [...data];
const alreadyPresentPks = values.map((x) => x.value);
let results = response.data?.results ?? response.data ?? []; const results = response.data?.results ?? response.data ?? [];
results.forEach((item: any) => { results.forEach((item: any) => {
// do not push already existing items into the values array
if (alreadyPresentPks.includes(item.pk)) return;
values.push({ values.push({
value: item.pk ?? -1, value: item.pk ?? -1,
data: item data: item
@ -144,33 +123,34 @@ export function RelatedModelField({
/** /**
* Format an option for display in the select field * Format an option for display in the select field
*/ */
function formatOption(option: any) { const formatOption = useCallback(
let data = option.data ?? option; (option: any) => {
const data = option.data ?? option;
// TODO: If a custom render function is provided, use that if (definition.modelRenderer) {
return <definition.modelRenderer instance={data} />;
}
return ( return (
<RenderInstance instance={data} model={definition.model ?? undefined} /> <RenderInstance instance={data} model={definition.model ?? undefined} />
); );
} },
[definition.model, definition.modelRenderer]
);
// Update form values when the selected value changes // Update form values when the selected value changes
function onChange(value: any) { const onChange = useCallback(
let _pk = value?.value ?? null; (value: any) => {
form.setValues({ [fieldName]: _pk }); let _pk = value?.value ?? null;
field.onChange(_pk);
setPk(_pk); setPk(_pk);
// Run custom callback for this field (if provided) // Run custom callback for this field (if provided)
if (definition.onValueChange) { definition.onValueChange?.(_pk);
definition.onValueChange({ },
name: fieldName, [field.onChange, definition]
value: _pk, );
field: definition,
form: form
});
}
}
/* Construct a "cut-down" version of the definition, /* Construct a "cut-down" version of the definition,
* which does not include any attributes that the lower components do not recognize * which does not include any attributes that the lower components do not recognize
@ -184,11 +164,16 @@ export function RelatedModelField({
}; };
}, [definition]); }, [definition]);
const currentValue = useMemo(
() => pk !== null && data.find((item) => item.value === pk),
[pk, data]
);
return ( return (
<Input.Wrapper {...fieldDefinition} error={error}> <Input.Wrapper {...fieldDefinition} error={error?.message}>
<Select <Select
id={fieldId} id={fieldId}
value={pk != null && data.find((item) => item.value == pk)} value={currentValue}
options={data} options={data}
filterOption={null} filterOption={null}
onInputChange={(value: any) => { onInputChange={(value: any) => {

View File

@ -8,7 +8,7 @@ import { api } from '../../App';
import { openModalApiForm } from '../../functions/forms'; import { openModalApiForm } from '../../functions/forms';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { SettingsStateProps } from '../../states/SettingsState'; import { SettingsStateProps } from '../../states/SettingsState';
import { Setting } from '../../states/states'; import { Setting, SettingType } from '../../states/states';
/** /**
* Render a single setting value * Render a single setting value
@ -44,10 +44,10 @@ function SettingValue({
// Callback function to open the edit dialog (for non-boolean settings) // Callback function to open the edit dialog (for non-boolean settings)
function onEditButton() { function onEditButton() {
let field_type: string = setting?.type ?? 'string'; let field_type = setting?.type ?? 'string';
if (setting?.choices && setting?.choices?.length > 0) { if (setting?.choices && setting?.choices?.length > 0) {
field_type = 'choice'; field_type = SettingType.Choice;
} }
openModalApiForm({ openModalApiForm({

View File

@ -4,13 +4,10 @@ import { ReactNode, useCallback, useMemo } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints'; import { ApiPaths } from '../../../enums/ApiEndpoints';
import { UserRoles } from '../../../enums/Roles'; import { UserRoles } from '../../../enums/Roles';
import { supplierPartFields } from '../../../forms/CompanyForms'; import { useSupplierPartFields } from '../../../forms/CompanyForms';
import { import { openDeleteApiForm, openEditApiForm } from '../../../functions/forms';
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh'; import { useTableRefresh } from '../../../hooks/TableRefresh';
import { useCreateApiFormModal } from '../../../hooks/UseForm';
import { apiUrl } from '../../../states/ApiState'; import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState'; import { useUserState } from '../../../states/UserState';
import { AddItemButton } from '../../buttons/AddItemButton'; import { AddItemButton } from '../../buttons/AddItemButton';
@ -155,30 +152,36 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
]; ];
}, [params]); }, [params]);
const addSupplierPart = useCallback(() => { const addSupplierPartFields = useSupplierPartFields({
let fields = supplierPartFields(); partPk: params?.part,
supplierPk: params?.supplier,
fields.part.value = params?.part; hidePart: true
fields.supplier.value = params?.supplier; });
const { modal: addSupplierPartModal, open: openAddSupplierPartForm } =
openCreateApiForm({ useCreateApiFormModal({
url: ApiPaths.supplier_part_list, url: ApiPaths.supplier_part_list,
title: t`Add Supplier Part`, title: t`Add Supplier Part`,
fields: fields, fields: addSupplierPartFields,
onFormSuccess: refreshTable, onFormSuccess: refreshTable,
successMessage: t`Supplier part created` successMessage: t`Supplier part created`
}); });
}, [params]);
// Table actions // Table actions
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
// TODO: Hide actions based on user permissions // TODO: Hide actions based on user permissions
return [ return [
<AddItemButton tooltip={t`Add supplier part`} onClick={addSupplierPart} /> <AddItemButton
tooltip={t`Add supplier part`}
onClick={openAddSupplierPartForm}
/>
]; ];
}, [user]); }, [user]);
const editSupplierPartFields = useSupplierPartFields({
hidePart: true
});
// Row action callback // Row action callback
const rowActions = useCallback( const rowActions = useCallback(
(record: any) => { (record: any) => {
@ -191,7 +194,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
url: ApiPaths.supplier_part_list, url: ApiPaths.supplier_part_list,
pk: record.pk, pk: record.pk,
title: t`Edit Supplier Part`, title: t`Edit Supplier Part`,
fields: supplierPartFields(), fields: editSupplierPartFields,
onFormSuccess: refreshTable, onFormSuccess: refreshTable,
successMessage: t`Supplier part updated` successMessage: t`Supplier part updated`
}); });
@ -215,24 +218,27 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
}) })
]; ];
}, },
[user] [user, editSupplierPartFields]
); );
return ( return (
<InvenTreeTable <>
url={apiUrl(ApiPaths.supplier_part_list)} {addSupplierPartModal}
tableKey={tableKey} <InvenTreeTable
columns={tableColumns} url={apiUrl(ApiPaths.supplier_part_list)}
props={{ tableKey={tableKey}
params: { columns={tableColumns}
...params, props={{
part_detail: true, params: {
supplier_detail: true, ...params,
manufacturer_detail: true part_detail: true,
}, supplier_detail: true,
rowActions: rowActions, manufacturer_detail: true
customActionGroups: tableActions },
}} rowActions: rowActions,
/> customActionGroups: tableActions
}}
/>
</>
); );
} }

View File

@ -1,7 +1,8 @@
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { I18nProvider } from '@lingui/react'; import { I18nProvider } from '@lingui/react';
import { useEffect } from 'react'; import { LoadingOverlay, Text } from '@mantine/core';
import { useEffect, useRef, useState } from 'react';
import { api } from '../App'; import { api } from '../App';
import { useLocalState } from '../states/LocalState'; import { useLocalState } from '../states/LocalState';
@ -45,10 +46,43 @@ export const languages: Record<string, string> = {
export function LanguageContext({ children }: { children: JSX.Element }) { export function LanguageContext({ children }: { children: JSX.Element }) {
const [language] = useLocalState((state) => [state.language]); const [language] = useLocalState((state) => [state.language]);
const [loadedState, setLoadedState] = useState<
'loading' | 'loaded' | 'error'
>('loading');
const isMounted = useRef(true);
useEffect(() => { useEffect(() => {
activateLocale(language); isMounted.current = true;
activateLocale(language)
.then(() => {
if (isMounted.current) setLoadedState('loaded');
})
.catch((err) => {
console.error('Failed loading translations', err);
if (isMounted.current) setLoadedState('error');
});
return () => {
isMounted.current = false;
};
}, [language]); }, [language]);
if (loadedState === 'loading') {
return <LoadingOverlay visible={true} />;
}
if (loadedState === 'error') {
return (
<Text>
An error occurred while loading translations, see browser console for
details.
</Text>
);
}
// only render the i18n Provider if the locales are fully activated, otherwise we end
// up with an error in the browser console
return <I18nProvider i18n={i18n}>{children}</I18nProvider>; return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
} }

View File

@ -9,55 +9,76 @@ import {
IconPackage, IconPackage,
IconPhone IconPhone
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
ApiFormData,
ApiFormFieldSet
} from '../components/forms/fields/ApiFormField';
import { ApiPaths } from '../enums/ApiEndpoints'; import { ApiPaths } from '../enums/ApiEndpoints';
import { openEditApiForm } from '../functions/forms'; import { openEditApiForm } from '../functions/forms';
/** /**
* Field set for SupplierPart instance * Field set for SupplierPart instance
*/ */
export function supplierPartFields(): ApiFormFieldSet { export function useSupplierPartFields({
return { partPk,
part: { supplierPk,
filters: { hidePart
purchaseable: true }: {
} partPk?: number;
}, supplierPk?: number;
manufacturer_part: { hidePart?: boolean;
filters: { }) {
part_detail: true, const [part, setPart] = useState<number | undefined>(partPk);
manufacturer_detail: true
},
adjustFilters: (filters: any, form: ApiFormData) => {
let part = form.values.part;
if (part) { useEffect(() => {
filters.part = part; setPart(partPk);
}, [partPk]);
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {
hidden: hidePart,
value: part,
onValueChange: setPart,
filters: {
purchaseable: true
} }
},
manufacturer_part: {
filters: {
part_detail: true,
manufacturer_detail: true
},
adjustFilters: (filters: any) => {
if (part) {
filters.part = part;
}
return filters; return filters;
}
},
supplier: {},
SKU: {
icon: <IconHash />
},
description: {},
link: {
icon: <IconLink />
},
note: {
icon: <IconNote />
},
pack_quantity: {},
packaging: {
icon: <IconPackage />
} }
}, };
supplier: {},
SKU: { if (supplierPk !== undefined) {
icon: <IconHash /> fields.supplier.value = supplierPk;
},
description: {},
link: {
icon: <IconLink />
},
note: {
icon: <IconNote />
},
pack_quantity: {},
packaging: {
icon: <IconPackage />
} }
};
return fields;
}, [part]);
} }
/** /**

View File

@ -1,4 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { IconPackages } from '@tabler/icons-react';
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import { ApiPaths } from '../enums/ApiEndpoints'; import { ApiPaths } from '../enums/ApiEndpoints';
@ -54,8 +55,36 @@ export function partFields({
// TODO: Set the value of the category field // TODO: Set the value of the category field
} }
// Additional fields for creation
if (!editing) { if (!editing) {
// TODO: Hide 'active' field // TODO: Hide 'active' field
fields.copy_category_parameters = {};
fields.initial_stock = {
icon: <IconPackages />,
children: {
quantity: {},
location: {}
}
};
fields.initial_supplier = {
children: {
supplier: {
filters: {
is_supplier: true
}
},
sku: {},
manufacturer: {
filters: {
is_manufacturer: true
}
},
mpn: {}
}
};
} }
// TODO: pop 'expiry' field if expiry not enabled // TODO: pop 'expiry' field if expiry not enabled

View File

@ -7,10 +7,7 @@ import {
IconSitemap IconSitemap
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
ApiFormData,
ApiFormFieldSet
} from '../components/forms/fields/ApiFormField';
/* /*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
@ -38,7 +35,7 @@ export function purchaseOrderLineItemFields({
supplier_detail: true, supplier_detail: true,
supplier: supplierId supplier: supplierId
}, },
adjustFilters: (filters: any, _form: ApiFormData) => { adjustFilters: (filters: any) => {
// TODO: Filter by the supplier associated with the order // TODO: Filter by the supplier associated with the order
return filters; return filters;
} }

View File

@ -1,113 +1,112 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useMemo, useState } from 'react';
import { import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
ApiFormChangeCallback,
ApiFormData,
ApiFormFieldSet
} from '../components/forms/fields/ApiFormField';
import { ApiPaths } from '../enums/ApiEndpoints'; import { ApiPaths } from '../enums/ApiEndpoints';
import { openCreateApiForm, openEditApiForm } from '../functions/forms'; import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
/** /**
* Construct a set of fields for creating / editing a StockItem instance * Construct a set of fields for creating / editing a StockItem instance
*/ */
export function stockFields({ export function useStockFields({
create = false create = false
}: { }: {
create: boolean; create: boolean;
}): ApiFormFieldSet { }): ApiFormFieldSet {
let fields: ApiFormFieldSet = { const [part, setPart] = useState<number | null>(null);
part: { const [supplierPart, setSupplierPart] = useState<number | null>(null);
hidden: !create,
onValueChange: (change: ApiFormChangeCallback) => {
// TODO: implement remaining functionality from old stock.py
// Clear the 'supplier_part' field if the part is changed return useMemo(() => {
change.form.setValues({ const fields: ApiFormFieldSet = {
supplier_part: null part: {
}); value: part,
} hidden: !create,
}, onValueChange: (change) => {
supplier_part: { setPart(change);
// TODO: icon // TODO: implement remaining functionality from old stock.py
filters: {
part_detail: true, // Clear the 'supplier_part' field if the part is changed
supplier_detail: true setSupplierPart(null);
},
adjustFilters: (filters: any, form: ApiFormData) => {
let part = form.values.part;
if (part) {
filters.part = part;
} }
},
supplier_part: {
// TODO: icon
value: supplierPart,
onValueChange: setSupplierPart,
filters: {
part_detail: true,
supplier_detail: true,
...(part ? { part } : {})
}
},
use_pack_size: {
hidden: !create,
description: t`Add given quantity as packs instead of individual items`
},
location: {
hidden: !create,
filters: {
structural: false
}
// TODO: icon
},
quantity: {
hidden: !create,
description: t`Enter initial quantity for this stock item`
},
serial_numbers: {
// TODO: icon
field_type: 'string',
label: t`Serial Numbers`,
description: t`Enter serial numbers for new stock (or leave blank)`,
required: false,
hidden: !create
},
serial: {
hidden: create
// TODO: icon
},
batch: {
// TODO: icon
},
status: {},
expiry_date: {
// TODO: icon
},
purchase_price: {
// TODO: icon
},
purchase_price_currency: {
// TODO: icon
},
packaging: {
// TODO: icon,
},
link: {
// TODO: icon
},
owner: {
// TODO: icon
},
delete_on_deplete: {}
};
return filters; // TODO: Handle custom field management based on provided options
} // TODO: refer to stock.py in original codebase
},
use_pack_size: {
hidden: !create,
description: t`Add given quantity as packs instead of individual items`
},
location: {
hidden: !create,
filters: {
structural: false
}
// TODO: icon
},
quantity: {
hidden: !create,
description: t`Enter initial quantity for this stock item`
},
serial_numbers: {
// TODO: icon
field_type: 'string',
label: t`Serial Numbers`,
description: t`Enter serial numbers for new stock (or leave blank)`,
required: false,
hidden: !create
},
serial: {
hidden: create
// TODO: icon
},
batch: {
// TODO: icon
},
status: {},
expiry_date: {
// TODO: icon
},
purchase_price: {
// TODO: icon
},
purchase_price_currency: {
// TODO: icon
},
packaging: {
// TODO: icon,
},
link: {
// TODO: icon
},
owner: {
// TODO: icon
},
delete_on_deplete: {}
};
// TODO: Handle custom field management based on provided options return fields;
// TODO: refer to stock.py in original codebase }, [part, supplierPart]);
return fields;
} }
/** /**
* Launch a form to create a new StockItem instance * Launch a form to create a new StockItem instance
*/ */
export function createStockItem() { export function useCreateStockItem() {
openCreateApiForm({ const fields = useStockFields({ create: true });
return useCreateApiFormModal({
url: ApiPaths.stock_item_list, url: ApiPaths.stock_item_list,
fields: stockFields({ create: true }), fields: fields,
title: t`Create Stock Item` title: t`Create Stock Item`
}); });
} }
@ -116,17 +115,19 @@ export function createStockItem() {
* Launch a form to edit an existing StockItem instance * Launch a form to edit an existing StockItem instance
* @param item : primary key of the StockItem to edit * @param item : primary key of the StockItem to edit
*/ */
export function editStockItem({ export function useEditStockItem({
item_id, item_id,
callback callback
}: { }: {
item_id: number; item_id: number;
callback?: () => void; callback?: () => void;
}) { }) {
openEditApiForm({ const fields = useStockFields({ create: false });
return useEditApiFormModal({
url: ApiPaths.stock_item_list, url: ApiPaths.stock_item_list,
pk: item_id, pk: item_id,
fields: stockFields({ create: false }), fields: fields,
title: t`Edit Stock Item`, title: t`Edit Stock Item`,
successMessage: t`Stock item updated`, successMessage: t`Stock item updated`,
onFormSuccess: callback onFormSuccess: callback

View File

@ -5,8 +5,12 @@ import { AxiosResponse } from 'axios';
import { api } from '../App'; import { api } from '../App';
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm'; import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
import { ApiFormFieldType } from '../components/forms/fields/ApiFormField'; import {
ApiFormFieldSet,
ApiFormFieldType
} from '../components/forms/fields/ApiFormField';
import { StylishText } from '../components/items/StylishText'; import { StylishText } from '../components/items/StylishText';
import { ApiPaths } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState'; import { apiUrl } from '../states/ApiState';
import { invalidResponse, permissionDenied } from './notifications'; import { invalidResponse, permissionDenied } from './notifications';
import { generateUniqueId } from './uid'; import { generateUniqueId } from './uid';
@ -14,8 +18,8 @@ import { generateUniqueId } from './uid';
/** /**
* Construct an API url from the provided ApiFormProps object * Construct an API url from the provided ApiFormProps object
*/ */
export function constructFormUrl(props: ApiFormProps): string { export function constructFormUrl(url: ApiPaths, pk?: string | number): string {
return apiUrl(props.url, props.pk); return apiUrl(url, pk);
} }
/** /**
@ -28,7 +32,7 @@ export function extractAvailableFields(
method?: string method?: string
): Record<string, ApiFormFieldType> | null { ): Record<string, ApiFormFieldType> | null {
// OPTIONS request *must* return 200 status // OPTIONS request *must* return 200 status
if (response.status != 200) { if (response.status !== 200) {
invalidResponse(response.status); invalidResponse(response.status);
return null; return null;
} }
@ -61,31 +65,118 @@ export function extractAvailableFields(
return null; return null;
} }
let fields: Record<string, ApiFormFieldType> = {}; const processFields = (fields: any, _path?: string) => {
const _fields: ApiFormFieldSet = {};
for (const fieldName in actions[method]) { for (const [fieldName, field] of Object.entries(fields) as any) {
const field = actions[method][fieldName]; const path = _path ? `${_path}.${fieldName}` : fieldName;
fields[fieldName] = { _fields[fieldName] = {
...field, ...field,
name: fieldName, name: path,
field_type: field.type, field_type: field.type,
description: field.help_text, description: field.help_text,
value: field.value ?? field.default, value: field.value ?? field.default,
disabled: field.read_only ?? false disabled: field.read_only ?? false
}; };
// Remove the 'read_only' field - plays havoc with react components // Remove the 'read_only' field - plays havoc with react components
delete fields['read_only']; delete _fields[fieldName].read_only;
if (
_fields[fieldName].field_type === 'nested object' &&
_fields[fieldName].children
) {
_fields[fieldName].children = processFields(
_fields[fieldName].children,
path
);
}
}
return _fields;
};
return processFields(actions[method]);
}
export type NestedDict = { [key: string]: string | number | NestedDict };
export function mapFields(
fields: ApiFormFieldSet,
fieldFunction: (path: string, value: ApiFormFieldType, key: string) => any,
_path?: string
): NestedDict {
const res: NestedDict = {};
for (const [k, v] of Object.entries(fields)) {
const path = _path ? `${_path}.${k}` : k;
let value;
if (v.field_type === 'nested object' && v.children) {
value = mapFields(v.children, fieldFunction, path);
} else {
value = fieldFunction(path, v, k);
}
if (value !== undefined) res[k] = value;
} }
return fields; return res;
}
/*
* Build a complete field definition based on the provided data
*/
export function constructField({
field,
definition
}: {
field: ApiFormFieldType;
definition?: ApiFormFieldType;
}) {
const def = {
...definition,
...field
};
switch (def.field_type) {
case 'date':
// Change value to a date object if required
if (def.value) {
def.value = new Date(def.value);
}
break;
case 'nested object':
def.children = {};
for (const k of Object.keys(field.children ?? {})) {
def.children[k] = constructField({
field: field.children?.[k] ?? {},
definition: definition?.children?.[k] ?? {}
});
}
break;
default:
break;
}
// Clear out the 'read_only' attribute
def.disabled = def.disabled ?? def.read_only ?? false;
delete def['read_only'];
return def;
}
export interface OpenApiFormProps extends ApiFormProps {
title: string;
cancelText?: string;
cancelColor?: string;
onClose?: () => void;
} }
/* /*
* Construct and open a modal form * Construct and open a modal form
* @param title : * @param title :
*/ */
export function openModalApiForm(props: ApiFormProps) { export function openModalApiForm(props: OpenApiFormProps) {
// method property *must* be supplied // method property *must* be supplied
if (!props.method) { if (!props.method) {
notifications.show({ notifications.show({
@ -96,7 +187,28 @@ export function openModalApiForm(props: ApiFormProps) {
return; return;
} }
let url = constructFormUrl(props); // Generate a random modal ID for controller
let modalId: string =
`modal-${props.title}-${props.url}-${props.method}` + generateUniqueId();
props.actions = [
...(props.actions || []),
{
text: props.cancelText ?? t`Cancel`,
color: props.cancelColor ?? 'blue',
onClick: () => {
modals.close(modalId);
}
}
];
const oldFormSuccess = props.onFormSuccess;
props.onFormSuccess = (data) => {
oldFormSuccess?.(data);
modals.close(modalId);
};
let url = constructFormUrl(props.url, props.pk);
// Make OPTIONS request first // Make OPTIONS request first
api api
@ -114,10 +226,16 @@ export function openModalApiForm(props: ApiFormProps) {
} }
} }
// Generate a random modal ID for controller const _props = { ...props };
let modalId: string =
`modal-${props.title}-${props.url}-${props.method}` + if (_props.fields) {
generateUniqueId(); for (const [k, v] of Object.entries(_props.fields)) {
_props.fields[k] = constructField({
field: v,
definition: fields?.[k]
});
}
}
modals.open({ modals.open({
title: <StylishText size="xl">{props.title}</StylishText>, title: <StylishText size="xl">{props.title}</StylishText>,
@ -126,9 +244,7 @@ export function openModalApiForm(props: ApiFormProps) {
onClose: () => { onClose: () => {
props.onClose ? props.onClose() : null; props.onClose ? props.onClose() : null;
}, },
children: ( children: <ApiForm id={modalId} props={props} />
<ApiForm modalId={modalId} props={props} fieldDefinitions={fields} />
)
}); });
}) })
.catch((error) => { .catch((error) => {
@ -148,8 +264,8 @@ export function openModalApiForm(props: ApiFormProps) {
/** /**
* Opens a modal form to create a new model instance * Opens a modal form to create a new model instance
*/ */
export function openCreateApiForm(props: ApiFormProps) { export function openCreateApiForm(props: OpenApiFormProps) {
let createProps: ApiFormProps = { let createProps: OpenApiFormProps = {
...props, ...props,
method: 'POST' method: 'POST'
}; };
@ -160,8 +276,8 @@ export function openCreateApiForm(props: ApiFormProps) {
/** /**
* Open a modal form to edit a model instance * Open a modal form to edit a model instance
*/ */
export function openEditApiForm(props: ApiFormProps) { export function openEditApiForm(props: OpenApiFormProps) {
let editProps: ApiFormProps = { let editProps: OpenApiFormProps = {
...props, ...props,
fetchInitialData: props.fetchInitialData ?? true, fetchInitialData: props.fetchInitialData ?? true,
method: 'PUT' method: 'PUT'
@ -173,8 +289,8 @@ export function openEditApiForm(props: ApiFormProps) {
/** /**
* Open a modal form to delete a model instancel * Open a modal form to delete a model instancel
*/ */
export function openDeleteApiForm(props: ApiFormProps) { export function openDeleteApiForm(props: OpenApiFormProps) {
let deleteProps: ApiFormProps = { let deleteProps: OpenApiFormProps = {
...props, ...props,
method: 'DELETE', method: 'DELETE',
submitText: t`Delete`, submitText: t`Delete`,

View File

@ -0,0 +1,117 @@
import { t } from '@lingui/macro';
import { useId } from '@mantine/hooks';
import { useEffect, useMemo, useRef } from 'react';
import { ApiFormProps, OptionsApiForm } from '../components/forms/ApiForm';
import { useModal } from './UseModal';
/**
* @param title : The title to display in the modal header
* @param cancelText : Optional custom text to display on the cancel button (default: Cancel)
* @param cancelColor : Optional custom color for the cancel button (default: blue)
* @param onClose : A callback function to call when the modal is closed.
* @param onOpen : A callback function to call when the modal is opened.
*/
export interface ApiFormModalProps extends ApiFormProps {
title: string;
cancelText?: string;
cancelColor?: string;
onClose?: () => void;
onOpen?: () => void;
}
/**
* Construct and open a modal form
*/
export function useApiFormModal(props: ApiFormModalProps) {
const id = useId();
const modalClose = useRef(() => {});
const formProps = useMemo<ApiFormModalProps>(
() => ({
...props,
actions: [
...(props.actions || []),
{
text: props.cancelText ?? t`Cancel`,
color: props.cancelColor ?? 'blue',
onClick: () => {
modalClose.current();
}
}
],
onFormSuccess: (data) => {
modalClose.current();
props.onFormSuccess?.(data);
},
onFormError: () => {
modalClose.current();
props.onFormError?.();
}
}),
[props]
);
const modal = useModal({
title: formProps.title,
onOpen: formProps.onOpen,
onClose: formProps.onClose,
size: 'xl',
children: <OptionsApiForm props={formProps} id={id} />
});
useEffect(() => {
modalClose.current = modal.close;
}, [modal.close]);
return modal;
}
/**
* Open a modal form to create a new model instance
*/
export function useCreateApiFormModal(props: ApiFormModalProps) {
const createProps = useMemo<ApiFormModalProps>(
() => ({
...props,
method: 'POST'
}),
[props]
);
return useApiFormModal(createProps);
}
/**
* Open a modal form to edit a model instance
*/
export function useEditApiFormModal(props: ApiFormModalProps) {
const editProps = useMemo<ApiFormModalProps>(
() => ({
...props,
fetchInitialData: props.fetchInitialData ?? true,
method: 'PUT'
}),
[props]
);
return useApiFormModal(editProps);
}
/**
* Open a modal form to delete a model instance
*/
export function useDeleteApiFormModal(props: ApiFormModalProps) {
const deleteProps = useMemo<ApiFormModalProps>(
() => ({
...props,
method: 'DELETE',
submitText: t`Delete`,
submitColor: 'red',
fields: {}
}),
[props]
);
return useApiFormModal(deleteProps);
}

View File

@ -0,0 +1,44 @@
import { MantineNumberSize, Modal } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import React, { useCallback } from 'react';
import { StylishText } from '../components/items/StylishText';
export interface UseModalProps {
title: string;
children: React.ReactElement;
size?: MantineNumberSize;
onOpen?: () => void;
onClose?: () => void;
}
export function useModal(props: UseModalProps) {
const onOpen = useCallback(() => {
props.onOpen?.();
}, [props.onOpen]);
const onClose = useCallback(() => {
props.onClose?.();
}, [props.onClose]);
const [opened, { open, close, toggle }] = useDisclosure(false, {
onOpen,
onClose
});
return {
open,
close,
toggle,
modal: (
<Modal
opened={opened}
onClose={close}
size={props.size ?? 'xl'}
title={<StylishText size="xl">{props.title}</StylishText>}
>
{props.children}
</Modal>
)
};
}

View File

@ -1,10 +1,10 @@
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { Button, TextInput } from '@mantine/core'; import { Button, Card, Stack, TextInput } from '@mantine/core';
import { Group, Text } from '@mantine/core'; import { Group, Text } from '@mantine/core';
import { Accordion } from '@mantine/core'; import { Accordion } from '@mantine/core';
import { ReactNode, useState } from 'react'; import { ReactNode, useMemo, useState } from 'react';
import { ApiFormProps } from '../../components/forms/ApiForm'; import { OptionsApiForm } from '../../components/forms/ApiForm';
import { PlaceholderPill } from '../../components/items/Placeholder'; import { PlaceholderPill } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText'; import { StylishText } from '../../components/items/StylishText';
import { StatusRenderer } from '../../components/render/StatusRenderer'; import { StatusRenderer } from '../../components/render/StatusRenderer';
@ -13,23 +13,28 @@ import { ModelType } from '../../enums/ModelType';
import { import {
createPart, createPart,
editPart, editPart,
partCategoryFields partCategoryFields,
partFields
} from '../../forms/PartForms'; } from '../../forms/PartForms';
import { createStockItem } from '../../forms/StockForms'; import { useCreateStockItem } from '../../forms/StockForms';
import { openCreateApiForm, openEditApiForm } from '../../functions/forms'; import {
OpenApiFormProps,
openCreateApiForm,
openEditApiForm
} from '../../functions/forms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
// Generate some example forms using the modal API forms interface // Generate some example forms using the modal API forms interface
const fields = partCategoryFields({});
function ApiFormsPlayground() { function ApiFormsPlayground() {
let fields = partCategoryFields({}); const editCategoryForm: OpenApiFormProps = {
const editCategoryForm: ApiFormProps = {
url: ApiPaths.category_list, url: ApiPaths.category_list,
pk: 2, pk: 2,
title: 'Edit Category', title: 'Edit Category',
fields: fields fields: fields
}; };
const createAttachmentForm: ApiFormProps = { const createAttachmentForm: OpenApiFormProps = {
url: ApiPaths.part_attachment_list, url: ApiPaths.part_attachment_list,
title: 'Create Attachment', title: 'Create Attachment',
successMessage: 'Attachment uploaded', successMessage: 'Attachment uploaded',
@ -41,21 +46,83 @@ function ApiFormsPlayground() {
comment: {} comment: {}
} }
}; };
const [active, setActive] = useState(true);
const [name, setName] = useState('Hello');
const partFieldsState: any = useMemo<any>(() => {
const fields = partFields({});
fields.name = {
...fields.name,
value: name,
onValueChange: setName
};
fields.active = {
...fields.active,
value: active,
onValueChange: setActive
};
fields.responsible = {
...fields.responsible,
disabled: !active
};
return fields;
}, [name, active]);
const { modal: createPartModal, open: openCreatePart } =
useCreateApiFormModal({
url: ApiPaths.part_list,
title: 'Create part',
fields: partFieldsState,
preFormContent: (
<Button onClick={() => setName('Hello world')}>
Set name="Hello world"
</Button>
)
});
const { modal: createStockItemModal, open: openCreateStockItem } =
useCreateStockItem();
return ( return (
<> <Stack>
<Group> <Group>
<Button onClick={() => createPart()}>Create New Part</Button> <Button onClick={() => createPart()}>Create New Part</Button>
<Button onClick={() => editPart({ part_id: 1 })}>Edit Part</Button> <Button onClick={() => editPart({ part_id: 1 })}>Edit Part</Button>
<Button onClick={() => createStockItem()}>Create Stock Item</Button>
<Button onClick={() => openCreateStockItem()}>Create Stock Item</Button>
{createStockItemModal}
<Button onClick={() => openEditApiForm(editCategoryForm)}> <Button onClick={() => openEditApiForm(editCategoryForm)}>
Edit Category Edit Category
</Button> </Button>
<Button onClick={() => openCreateApiForm(createAttachmentForm)}> <Button onClick={() => openCreateApiForm(createAttachmentForm)}>
Create Attachment Create Attachment
</Button> </Button>
<Button onClick={() => openCreatePart()}>Create Part new Modal</Button>
{createPartModal}
</Group> </Group>
</> <Card sx={{ padding: '30px' }}>
<OptionsApiForm
props={{
url: ApiPaths.part_list,
method: 'POST',
fields: {
active: {
value: active,
onValueChange: setActive
},
keywords: {
disabled: !active,
value: 'default,test,placeholder'
}
}
}}
id={'this is very unique'}
/>
</Card>
</Stack>
); );
} }

View File

@ -36,7 +36,7 @@ import { StockLocationTree } from '../../components/nav/StockLocationTree';
import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { ApiPaths } from '../../enums/ApiEndpoints'; import { ApiPaths } from '../../enums/ApiEndpoints';
import { editStockItem } from '../../forms/StockForms'; import { useEditStockItem } from '../../forms/StockForms';
import { useInstance } from '../../hooks/UseInstance'; import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -141,6 +141,11 @@ export default function StockDetail() {
[stockitem] [stockitem]
); );
const editStockItem = useEditStockItem({
item_id: stockitem.pk,
callback: () => refreshInstance()
});
const stockActions = useMemo( const stockActions = useMemo(
() => /* TODO: Disable actions based on user permissions*/ [ () => /* TODO: Disable actions based on user permissions*/ [
<BarcodeActionDropdown <BarcodeActionDropdown
@ -193,11 +198,7 @@ export default function StockDetail() {
}, },
EditItemAction({ EditItemAction({
onClick: () => { onClick: () => {
stockitem.pk && stockitem.pk && editStockItem.open();
editStockItem({
item_id: stockitem.pk,
callback: () => refreshInstance
});
} }
}), }),
DeleteItemAction({}) DeleteItemAction({})
@ -231,6 +232,7 @@ export default function StockDetail() {
actions={stockActions} actions={stockActions}
/> />
<PanelGroup pageKey="stockitem" panels={stockPanels} /> <PanelGroup pageKey="stockitem" panels={stockPanels} />
{editStockItem.modal}
</Stack> </Stack>
); );
} }

View File

@ -2528,6 +2528,11 @@ react-grid-layout@^1.4.2:
react-resizable "^3.0.5" react-resizable "^3.0.5"
resize-observer-polyfill "^1.5.1" resize-observer-polyfill "^1.5.1"
react-hook-form@^7.48.2:
version "7.48.2"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.48.2.tgz#01150354d2be61412ff56a030b62a119283b9935"
integrity sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"