Skip to content

Commit

Permalink
Merge branch 'master' of https://github.com/inventree/InvenTree into …
Browse files Browse the repository at this point in the history
…settings-ui
  • Loading branch information
matmair committed Sep 8, 2023
2 parents 99e12fc + baa9f36 commit 7fd4194
Show file tree
Hide file tree
Showing 26 changed files with 1,976 additions and 75 deletions.
1 change: 1 addition & 0 deletions src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"react-dom": "^18.2.0",
"react-grid-layout": "^1.3.4",
"react-router-dom": "^6.15.0",
"react-select": "^5.7.4",
"zustand": "^4.4.1"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useSessionState } from './states/SessionState';

// API
export const api = axios.create({});

export function setApiDefaults() {
const host = useLocalState.getState().host;
const token = useSessionState.getState().token;
Expand Down
312 changes: 312 additions & 0 deletions src/frontend/src/components/forms/ApiForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import { t } from '@lingui/macro';
import {
Alert,
Divider,
LoadingOverlay,
ScrollArea,
Text
} from '@mantine/core';
import { Button, Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { useState } from 'react';

import { api } from '../../App';
import { constructFormUrl } from '../../functions/forms';
import { invalidResponse } from '../../functions/notifications';
import {
ApiFormField,
ApiFormFieldSet,
ApiFormFieldType
} from './fields/ApiFormField';

/**
* Properties for the ApiForm component
* @param name : The name (identifier) for this form
* @param url : The API endpoint to fetch the form data from
* @param pk : Optional primary-key value when editing an existing object
* @param title : The title to display in the form header
* @param fields : The fields to render in the form
* @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 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 method : Optional HTTP method to use when submitting the form (default: GET)
* @param preFormContent : Optional content to render before the form fields
* @param postFormContent : Optional content to render after the form fields
* @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 onFormError : A callback function to call when the form is submitted with errors.
*/
export interface ApiFormProps {
name: string;
url: string;
pk?: number;
title: string;
fields: ApiFormFieldSet;
cancelText?: string;
submitText?: string;
submitColor?: string;
cancelColor?: string;
fetchInitialData?: boolean;
method?: string;
preFormContent?: JSX.Element | (() => JSX.Element);
postFormContent?: JSX.Element | (() => JSX.Element);
successMessage?: string;
onClose?: () => void;
onFormSuccess?: () => void;
onFormError?: () => void;
}

/**
* An ApiForm component is a modal form which is rendered dynamically,
* based on an API endpoint.
*/
export function ApiForm({
modalId,
props,
fieldDefinitions
}: {
modalId: string;
props: ApiFormProps;
fieldDefinitions: ApiFormFieldSet;
}) {
// Form errors which are not associated with a specific field
const [nonFieldErrors, setNonFieldErrors] = useState<string[]>([]);

// Form state
const form = useForm({});

// Cache URL
const url = useMemo(() => constructFormUrl(props), [props]);

// Render pre-form content
// 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({
enabled: false,
queryKey: ['form-initial-data', props.name, props.url, props.pk],
queryFn: async () => {
return api
.get(url)
.then((response) => {
// Update form values, but only for the fields specified for the form
Object.keys(props.fields).forEach((fieldName) => {
if (fieldName in response.data) {
form.setValues({
[fieldName]: response.data[fieldName]
});
}
});

return response;
})
.catch((error) => {
console.error('Error fetching initial data:', error);
});
}
});

// Fetch initial data on form load
useEffect(() => {
// Provide initial form data
Object.entries(props.fields).forEach(([fieldName, field]) => {
if (field.value !== undefined) {
form.setValues({
[fieldName]: field.value
});
} else if (field.default !== undefined) {
form.setValues({
[fieldName]: field.default
});
}
});

// Fetch initial data if the fetchInitialData property is set
if (props.fetchInitialData) {
initialDataQuery.refetch();
}
}, []);

// Query manager for submitting data
const submitQuery = useQuery({
enabled: false,
queryKey: ['form-submit', props.name, props.url, props.pk],
queryFn: async () => {
let method = props.method?.toLowerCase() ?? 'get';

api({
method: method,
url: url,
data: form.values,
headers: {
'Content-Type': 'multipart/form-data'
}
})
.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();
}

// Optionally show a success message
if (props.successMessage) {
notifications.show({
title: t`Success`,
message: props.successMessage,
color: 'green'
});
}

closeForm();
break;
default:
// Unexpected state on form success
invalidResponse(response.status);
closeForm();
break;
}
})
.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 ?? []);
break;
default:
// Unexpected state on form error
invalidResponse(error.response.status);
closeForm();
break;
}
} else {
invalidResponse(0);
closeForm();
}

return error;
});
},
refetchOnMount: false,
refetchOnWindowFocus: false
});

// Data loading state
const [isLoading, setIsLoading] = useState<boolean>(true);

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 (
<Stack>
<Divider />
<Stack spacing="sm">
<LoadingOverlay visible={isLoading} />
{(Object.keys(form.errors).length > 0 || nonFieldErrors.length > 0) && (
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
{nonFieldErrors.length > 0 && (
<Stack spacing="xs">
{nonFieldErrors.map((message) => (
<Text key={message}>{message}</Text>
))}
</Stack>
)}
</Alert>
)}
{preFormElement}
<ScrollArea>
<Stack spacing="xs">
{Object.entries(props.fields).map(
([fieldName, field]) =>
!field.hidden && (
<ApiFormField
key={fieldName}
field={field}
fieldName={fieldName}
formProps={props}
form={form}
error={form.errors[fieldName] ?? null}
definitions={fieldDefinitions}
/>
)
)}
</Stack>
</ScrollArea>
{postFormElement}
</Stack>
<Divider />
<Group position="right">
<Button
onClick={closeForm}
variant="outline"
radius="sm"
color={props.cancelColor ?? 'blue'}
>
{props.cancelText ?? t`Cancel`}
</Button>
<Button
onClick={submitForm}
variant="outline"
radius="sm"
color={props.submitColor ?? 'green'}
disabled={isLoading}
>
{props.submitText ?? t`Submit`}
</Button>
</Group>
</Stack>
);
}
Loading

0 comments on commit 7fd4194

Please sign in to comment.