mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 19:46:46 +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:
parent
0d7b4f2f17
commit
cb537780dc
@ -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(
|
||||||
|
@ -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",
|
||||||
|
@ -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,53 +220,33 @@ 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';
|
let method = props.method?.toLowerCase() ?? 'get';
|
||||||
|
|
||||||
|
let hasFiles = false;
|
||||||
|
mapFields(props.fields ?? {}, (_path, field) => {
|
||||||
|
if (field.field_type === 'file upload') {
|
||||||
|
hasFiles = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return api({
|
return api({
|
||||||
method: method,
|
method: method,
|
||||||
url: url,
|
url: url,
|
||||||
data: form.values,
|
data: data,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@ -196,12 +270,11 @@ export function ApiForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
closeForm();
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Unexpected state on form success
|
// Unexpected state on form success
|
||||||
invalidResponse(response.status);
|
invalidResponse(response.status);
|
||||||
closeForm();
|
props.onFormError?.();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,59 +284,58 @@ export function ApiForm({
|
|||||||
if (error.response) {
|
if (error.response) {
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 400:
|
case 400:
|
||||||
// Data validation error
|
// Data validation errors
|
||||||
form.setErrors(error.response.data);
|
const nonFieldErrors: string[] = [];
|
||||||
setNonFieldErrors(error.response.data.non_field_errors ?? []);
|
const processErrors = (errors: any, _path?: string) => {
|
||||||
setIsLoading(false);
|
for (const [k, v] of Object.entries(errors)) {
|
||||||
|
const path = _path ? `${_path}.${k}` : k;
|
||||||
|
|
||||||
|
if (k === 'non_field_errors') {
|
||||||
|
nonFieldErrors.push((v as string[]).join(', '));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof v === 'object' && Array.isArray(v)) {
|
||||||
|
form.setError(path, { message: v.join(', ') });
|
||||||
|
} else {
|
||||||
|
processErrors(v, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processErrors(error.response.data);
|
||||||
|
setNonFieldErrors(nonFieldErrors);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Unexpected state on form error
|
// Unexpected state on form error
|
||||||
invalidResponse(error.response.status);
|
invalidResponse(error.response.status);
|
||||||
closeForm();
|
props.onFormError?.();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
invalidResponse(0);
|
invalidResponse(0);
|
||||||
closeForm();
|
props.onFormError?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
return error;
|
return error;
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Data loading state
|
const isLoading = useMemo(
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
() => isFormLoading || initialDataQuery.isFetching,
|
||||||
|
[isFormLoading, initialDataQuery.isFetching]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
|
||||||
setIsLoading(submitQuery.isFetching || initialDataQuery.isFetching);
|
props.onFormError?.();
|
||||||
}, [initialDataQuery.status, submitQuery.status]);
|
}, [props.onFormError]);
|
||||||
|
|
||||||
/**
|
|
||||||
* 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]) =>
|
|
||||||
!field.hidden && (
|
|
||||||
<ApiFormField
|
<ApiFormField
|
||||||
key={fieldName}
|
key={fieldName}
|
||||||
field={field}
|
|
||||||
fieldName={fieldName}
|
fieldName={fieldName}
|
||||||
formProps={props}
|
definition={field}
|
||||||
form={form}
|
control={form.control}
|
||||||
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
|
<Button
|
||||||
onClick={closeForm}
|
key={i}
|
||||||
variant="outline"
|
onClick={action.onClick}
|
||||||
|
variant={action.variant ?? 'outline'}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
color={props.cancelColor ?? 'blue'}
|
color={action.color}
|
||||||
>
|
>
|
||||||
{props.cancelText ?? t`Cancel`}
|
{action.text}
|
||||||
</Button>
|
</Button>
|
||||||
|
))}
|
||||||
<Button
|
<Button
|
||||||
onClick={submitForm}
|
onClick={form.handleSubmit(submitForm, onFormError)}
|
||||||
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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,77 +1,53 @@
|
|||||||
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 (form.values) {
|
|
||||||
let formPk = form.values[fieldName] ?? null;
|
|
||||||
|
|
||||||
// If the value is unchanged, do nothing
|
// If the value is unchanged, do nothing
|
||||||
if (formPk == pk) {
|
if (field.value === pk) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formPk != null) {
|
if (field.value !== null) {
|
||||||
let url = (definition.api_url || '') + formPk + '/';
|
const url = `${definition.api_url}${field.value}/`;
|
||||||
|
|
||||||
api.get(url).then((response) => {
|
api.get(url).then((response) => {
|
||||||
let data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
if (data && data.pk) {
|
if (data && data.pk) {
|
||||||
let value = {
|
const value = {
|
||||||
value: data.pk,
|
value: data.pk,
|
||||||
data: data
|
data: data
|
||||||
};
|
};
|
||||||
@ -83,8 +59,7 @@ export function RelatedModelField({
|
|||||||
} else {
|
} else {
|
||||||
setPk(null);
|
setPk(null);
|
||||||
}
|
}
|
||||||
}
|
}, [definition.api_url, field.value]);
|
||||||
}, [form.values[fieldName]]);
|
|
||||||
|
|
||||||
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(
|
||||||
|
(value: any) => {
|
||||||
let _pk = value?.value ?? null;
|
let _pk = value?.value ?? null;
|
||||||
form.setValues({ [fieldName]: _pk });
|
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) => {
|
||||||
|
@ -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({
|
||||||
|
@ -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,10 +218,12 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
[user]
|
[user, editSupplierPartFields]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{addSupplierPartModal}
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiPaths.supplier_part_list)}
|
url={apiUrl(ApiPaths.supplier_part_list)}
|
||||||
tableKey={tableKey}
|
tableKey={tableKey}
|
||||||
@ -234,5 +239,6 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
|
|||||||
customActionGroups: tableActions
|
customActionGroups: tableActions
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,20 +9,36 @@ 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,
|
||||||
|
supplierPk,
|
||||||
|
hidePart
|
||||||
|
}: {
|
||||||
|
partPk?: number;
|
||||||
|
supplierPk?: number;
|
||||||
|
hidePart?: boolean;
|
||||||
|
}) {
|
||||||
|
const [part, setPart] = useState<number | undefined>(partPk);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPart(partPk);
|
||||||
|
}, [partPk]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
part: {
|
part: {
|
||||||
|
hidden: hidePart,
|
||||||
|
value: part,
|
||||||
|
onValueChange: setPart,
|
||||||
filters: {
|
filters: {
|
||||||
purchaseable: true
|
purchaseable: true
|
||||||
}
|
}
|
||||||
@ -32,9 +48,7 @@ export function supplierPartFields(): ApiFormFieldSet {
|
|||||||
part_detail: true,
|
part_detail: true,
|
||||||
manufacturer_detail: true
|
manufacturer_detail: true
|
||||||
},
|
},
|
||||||
adjustFilters: (filters: any, form: ApiFormData) => {
|
adjustFilters: (filters: any) => {
|
||||||
let part = form.values.part;
|
|
||||||
|
|
||||||
if (part) {
|
if (part) {
|
||||||
filters.part = part;
|
filters.part = part;
|
||||||
}
|
}
|
||||||
@ -58,6 +72,13 @@ export function supplierPartFields(): ApiFormFieldSet {
|
|||||||
icon: <IconPackage />
|
icon: <IconPackage />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (supplierPk !== undefined) {
|
||||||
|
fields.supplier.value = supplierPk;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}, [part]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -1,46 +1,42 @@
|
|||||||
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);
|
||||||
|
const [supplierPart, setSupplierPart] = useState<number | null>(null);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const fields: ApiFormFieldSet = {
|
||||||
part: {
|
part: {
|
||||||
|
value: part,
|
||||||
hidden: !create,
|
hidden: !create,
|
||||||
onValueChange: (change: ApiFormChangeCallback) => {
|
onValueChange: (change) => {
|
||||||
|
setPart(change);
|
||||||
// TODO: implement remaining functionality from old stock.py
|
// TODO: implement remaining functionality from old stock.py
|
||||||
|
|
||||||
// Clear the 'supplier_part' field if the part is changed
|
// Clear the 'supplier_part' field if the part is changed
|
||||||
change.form.setValues({
|
setSupplierPart(null);
|
||||||
supplier_part: null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
supplier_part: {
|
supplier_part: {
|
||||||
// TODO: icon
|
// TODO: icon
|
||||||
|
value: supplierPart,
|
||||||
|
onValueChange: setSupplierPart,
|
||||||
filters: {
|
filters: {
|
||||||
part_detail: true,
|
part_detail: true,
|
||||||
supplier_detail: true
|
supplier_detail: true,
|
||||||
},
|
...(part ? { part } : {})
|
||||||
adjustFilters: (filters: any, form: ApiFormData) => {
|
|
||||||
let part = form.values.part;
|
|
||||||
if (part) {
|
|
||||||
filters.part = part;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filters;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
use_pack_size: {
|
use_pack_size: {
|
||||||
@ -99,15 +95,18 @@ export function stockFields({
|
|||||||
// TODO: refer to stock.py in original codebase
|
// TODO: refer to stock.py in original codebase
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
|
}, [part, supplierPart]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
@ -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,13 +65,14 @@ 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,
|
||||||
@ -75,17 +80,103 @@ export function extractAvailableFields(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 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 _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 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`,
|
||||||
|
117
src/frontend/src/hooks/UseForm.tsx
Normal file
117
src/frontend/src/hooks/UseForm.tsx
Normal 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);
|
||||||
|
}
|
44
src/frontend/src/hooks/UseModal.tsx
Normal file
44
src/frontend/src/hooks/UseModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user