From cb537780dc1fada72a28e7cb25094bb595f2e40d Mon Sep 17 00:00:00 2001 From: Lukas <76838159+wolflu05@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:00:44 +0100 Subject: [PATCH] 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 --- InvenTree/part/serializers.py | 2 +- src/frontend/package.json | 1 + src/frontend/src/components/forms/ApiForm.tsx | 467 ++++++++++-------- .../components/forms/fields/ApiFormField.tsx | 235 ++++----- .../components/forms/fields/ChoiceField.tsx | 71 +-- .../forms/fields/NestedObjectField.tsx | 39 ++ .../forms/fields/RelatedModelField.tsx | 149 +++--- .../src/components/settings/SettingItem.tsx | 6 +- .../tables/purchasing/SupplierPartTable.tsx | 72 +-- src/frontend/src/contexts/LanguageContext.tsx | 38 +- src/frontend/src/forms/CompanyForms.tsx | 95 ++-- src/frontend/src/forms/PartForms.tsx | 29 ++ src/frontend/src/forms/PurchaseOrderForms.tsx | 7 +- src/frontend/src/forms/StockForms.tsx | 185 +++---- src/frontend/src/functions/forms.tsx | 182 +++++-- src/frontend/src/hooks/UseForm.tsx | 117 +++++ src/frontend/src/hooks/UseModal.tsx | 44 ++ src/frontend/src/pages/Index/Playground.tsx | 93 +++- src/frontend/src/pages/stock/StockDetail.tsx | 14 +- src/frontend/yarn.lock | 5 + 20 files changed, 1161 insertions(+), 690 deletions(-) create mode 100644 src/frontend/src/components/forms/fields/NestedObjectField.tsx create mode 100644 src/frontend/src/hooks/UseForm.tsx create mode 100644 src/frontend/src/hooks/UseModal.tsx diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index e2897a3c14..29c8c8860a 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -819,7 +819,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize # Create initial stock entry if initial_stock: 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: stockitem = stock.models.StockItem( diff --git a/src/frontend/package.json b/src/frontend/package.json index c0cce21416..176e4a7716 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -39,6 +39,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-grid-layout": "^1.4.2", + "react-hook-form": "^7.48.2", "react-router-dom": "^6.17.0", "react-select": "^5.7.7", "react-simplemde-editor": "^5.2.0", diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index db2ade1a30..bde9e0a2ff 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -1,120 +1,214 @@ 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 { useForm } from '@mantine/form'; -import { modals } from '@mantine/modals'; +import { useId } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useState } from 'react'; +import { + FieldValues, + SubmitErrorHandler, + SubmitHandler, + useForm +} from 'react-hook-form'; import { api, queryClient } from '../../App'; 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 { 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 * @param url : The API endpoint to fetch the form data from * @param pk : Optional primary-key value when editing an existing object - * @param title : The title to display in the form header + * @param method : Optional HTTP method to use when submitting the form (default: GET) * @param fields : The fields to render in the form * @param submitText : Optional custom text to display on the submit button (default: Submit)4 * @param submitColor : Optional custom color for the submit button (default: green) - * @param cancelText : Optional custom text to display on the cancel button (default: Cancel) - * @param cancelColor : Optional custom color for the cancel button (default: blue) * @param fetchInitialData : Optional flag to fetch initial data from the server (default: true) - * @param method : Optional HTTP method to use when submitting the form (default: GET) * @param preFormContent : Optional content to render before the form fields * @param postFormContent : Optional content to render after the form fields * @param successMessage : Optional message to display on successful form submission - * @param onClose : A callback function to call when the form is closed. * @param onFormSuccess : A callback function to call when the form is submitted successfully. * @param onFormError : A callback function to call when the form is submitted with errors. */ export interface ApiFormProps { url: ApiPaths; pk?: number | string | undefined; - title: string; + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; fields?: ApiFormFieldSet; - cancelText?: string; submitText?: string; submitColor?: string; - cancelColor?: string; fetchInitialData?: boolean; ignorePermissionCheck?: boolean; - method?: string; - preFormContent?: JSX.Element | (() => JSX.Element); - postFormContent?: JSX.Element | (() => JSX.Element); + preFormContent?: JSX.Element; + postFormContent?: JSX.Element; successMessage?: string; - onClose?: () => void; onFormSuccess?: (data: any) => 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 | 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 ; + } + + return ; } /** * An ApiForm component is a modal form which is rendered dynamically, * based on an API endpoint. */ -export function ApiForm({ - modalId, - props, - fieldDefinitions -}: { - modalId: string; - props: ApiFormProps; - fieldDefinitions: ApiFormFieldSet; -}) { +export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { + const defaultValues: FieldValues = useMemo( + () => + mapFields(props.fields ?? {}, (_path, field) => { + return field.default ?? undefined; + }), + [props.fields] + ); + // Form errors which are not associated with a specific field const [nonFieldErrors, setNonFieldErrors] = useState([]); // Form state - const form = useForm({}); + const form = useForm({ + criteriaMode: 'all', + defaultValues + }); + const { isValid, isDirty, isLoading: isFormLoading } = form.formState; // Cache URL - const url = useMemo(() => constructFormUrl(props), [props]); + const url = useMemo( + () => constructFormUrl(props.url, props.pk), + [props.url, props.pk] + ); - // Render pre-form content - // TODO: Future work will allow this content to be updated dynamically based on the form data - const preFormElement: JSX.Element | null = useMemo(() => { - if (props.preFormContent === undefined) { - return null; - } else if (props.preFormContent instanceof Function) { - return props.preFormContent(); - } else { - return props.preFormContent; - } - }, [props]); - - // Render post-form content - // TODO: Future work will allow this content to be updated dynamically based on the form data - const postFormElement: JSX.Element | null = useMemo(() => { - if (props.postFormContent === undefined) { - return null; - } else if (props.postFormContent instanceof Function) { - return props.postFormContent(); - } else { - return props.postFormContent; - } - }, [props]); - - // Query manager for retrieiving initial data from the server + // Query manager for retrieving initial data from the server const initialDataQuery = useQuery({ 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 () => { return api .get(url) .then((response) => { - // Update form values, but only for the fields specified for the form - Object.keys(props.fields ?? {}).forEach((fieldName) => { - if (fieldName in response.data) { - form.setValues({ - [fieldName]: response.data[fieldName] - }); + const processFields = (fields: ApiFormFieldSet, data: NestedDict) => { + const res: NestedDict = {}; + + for (const [k, field] of Object.entries(fields)) { + 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; }) @@ -126,144 +220,122 @@ export function ApiForm({ // Fetch initial data on form load 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 if (props.fetchInitialData) { queryClient.removeQueries({ - queryKey: [ - 'form-initial-data', - modalId, - props.method, - props.url, - props.pk - ] + queryKey: ['form-initial-data', id, props.method, props.url, props.pk] }); initialDataQuery.refetch(); } }, []); - // Query manager for submitting data - const submitQuery = useQuery({ - enabled: false, - queryKey: ['form-submit', modalId, props.method, props.url, props.pk], - queryFn: async () => { - let method = props.method?.toLowerCase() ?? 'get'; + const submitForm: SubmitHandler = async (data) => { + setNonFieldErrors([]); - return api({ - method: method, - url: url, - data: form.values, - headers: { - 'Content-Type': 'multipart/form-data' + let method = props.method?.toLowerCase() ?? 'get'; + + let hasFiles = false; + mapFields(props.fields ?? {}, (_path, field) => { + if (field.field_type === 'file upload') { + hasFiles = true; + } + }); + + return api({ + method: method, + url: url, + data: data, + headers: { + 'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json' + } + }) + .then((response) => { + switch (response.status) { + case 200: + case 201: + case 204: + // Form was submitted successfully + + // Optionally call the onFormSuccess callback + if (props.onFormSuccess) { + props.onFormSuccess(response.data); + } + + // Optionally show a success message + if (props.successMessage) { + notifications.show({ + title: t`Success`, + message: props.successMessage, + color: 'green' + }); + } + + break; + default: + // Unexpected state on form success + invalidResponse(response.status); + props.onFormError?.(); + break; } + + return response; }) - .then((response) => { - switch (response.status) { - case 200: - case 201: - case 204: - // Form was submitted successfully + .catch((error) => { + if (error.response) { + switch (error.response.status) { + case 400: + // Data validation errors + const nonFieldErrors: string[] = []; + const processErrors = (errors: any, _path?: string) => { + for (const [k, v] of Object.entries(errors)) { + const path = _path ? `${_path}.${k}` : k; - // Optionally call the onFormSuccess callback - if (props.onFormSuccess) { - props.onFormSuccess(response.data); - } + if (k === 'non_field_errors') { + nonFieldErrors.push((v as string[]).join(', ')); + continue; + } - // Optionally show a success message - if (props.successMessage) { - notifications.show({ - title: t`Success`, - message: props.successMessage, - color: 'green' - }); - } + if (typeof v === 'object' && Array.isArray(v)) { + form.setError(path, { message: v.join(', ') }); + } else { + processErrors(v, path); + } + } + }; - closeForm(); + processErrors(error.response.data); + setNonFieldErrors(nonFieldErrors); break; default: - // Unexpected state on form success - invalidResponse(response.status); - closeForm(); + // Unexpected state on form error + invalidResponse(error.response.status); + props.onFormError?.(); break; } + } else { + invalidResponse(0); + props.onFormError?.(); + } - return response; - }) - .catch((error) => { - if (error.response) { - switch (error.response.status) { - case 400: - // Data validation error - form.setErrors(error.response.data); - setNonFieldErrors(error.response.data.non_field_errors ?? []); - setIsLoading(false); - break; - default: - // Unexpected state on form error - invalidResponse(error.response.status); - closeForm(); - break; - } - } else { - invalidResponse(0); - closeForm(); - } + return error; + }); + }; - return error; - }); - }, - refetchOnMount: false, - refetchOnWindowFocus: false - }); + const isLoading = useMemo( + () => isFormLoading || initialDataQuery.isFetching, + [isFormLoading, initialDataQuery.isFetching] + ); - // Data loading state - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - setIsLoading(submitQuery.isFetching || initialDataQuery.isFetching); - }, [initialDataQuery.status, submitQuery.status]); - - /** - * Callback to perform form submission - */ - function submitForm() { - setIsLoading(true); - submitQuery.refetch(); - } - - /** - * Callback to close the form - * Note that the calling function might implement an onClose() callback, - * which will be automatically called - */ - function closeForm() { - modals.close(modalId); - } + const onFormError = useCallback>(() => { + props.onFormError?.(); + }, [props.onFormError]); return ( - {(Object.keys(form.errors).length > 0 || nonFieldErrors.length > 0) && ( + {(!isValid || nonFieldErrors.length > 0) && ( {nonFieldErrors.length > 0 && ( @@ -274,41 +346,38 @@ export function ApiForm({ )} )} - {preFormElement} + {props.preFormContent} - {Object.entries(props.fields ?? {}).map( - ([fieldName, field]) => - !field.hidden && ( - - ) - )} + {Object.entries(props.fields ?? {}).map(([fieldName, field]) => ( + + ))} - {postFormElement} + {props.postFormContent} + {props.actions?.map((action, i) => ( + + ))} - diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index dd34567e1e..b7cd508c2f 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -11,27 +11,18 @@ import { DateInput } from '@mantine/dates'; import { UseFormReturnType } from '@mantine/form'; import { useId } from '@mantine/hooks'; import { IconX } from '@tabler/icons-react'; -import { ReactNode } from 'react'; +import { ReactNode, useCallback, useEffect } from 'react'; import { useMemo } from 'react'; +import { Control, FieldValues, useController } from 'react-hook-form'; import { ModelType } from '../../../enums/ModelType'; -import { ApiFormProps } from '../ApiForm'; import { ChoiceField } from './ChoiceField'; +import { NestedObjectField } from './NestedObjectField'; import { RelatedModelField } from './RelatedModelField'; export type ApiFormData = UseFormReturnType>; -/** - * 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. +/** Definition of the ApiForm field component. * - The 'name' attribute *must* be provided * - All other attributes are optional, and may be provided by the API * - However, they can be overridden by the user @@ -60,10 +51,25 @@ export type ApiFormFieldType = { value?: any; default?: any; 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; model?: ModelType; + modelRenderer?: (instance: any) => ReactNode; filters?: any; + children?: { [key: string]: ApiFormFieldType }; required?: boolean; choices?: any[]; hidden?: boolean; @@ -71,127 +77,66 @@ export type ApiFormFieldType = { read_only?: boolean; placeholder?: string; description?: string; - preFieldContent?: JSX.Element | (() => JSX.Element); - postFieldContent?: JSX.Element | (() => JSX.Element); - onValueChange?: (change: ApiFormChangeCallback) => void; - adjustFilters?: (filters: any, form: ApiFormData) => any; + preFieldContent?: JSX.Element; + postFieldContent?: JSX.Element; + onValueChange?: (value: any) => void; + adjustFilters?: (filters: any) => any; }; -/* - * Build a complete field definition based on the provided data - */ -export function constructField({ - form, - fieldName, - field, - definitions -}: { - form: UseFormReturnType>; - fieldName: string; - field: ApiFormFieldType; - definitions: Record; -}) { - 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 */ export function ApiFormField({ - formProps, - form, fieldName, - field, - error, - definitions + definition, + control }: { - formProps: ApiFormProps; - form: UseFormReturnType>; fieldName: string; - field: ApiFormFieldType; - error: ReactNode; - definitions: Record; + definition: ApiFormFieldType; + control: Control; }) { - 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 - // Where user has provided specific data, override the API definition - const definition: ApiFormFieldType = useMemo( - () => - constructField({ - form: form, - fieldName: fieldName, - field: field, - definitions: definitions - }), - [fieldName, field, definitions] - ); + useEffect(() => { + if (definition.field_type === 'nested object') return; - const preFieldElement: JSX.Element | null = useMemo(() => { - if (field.preFieldContent === undefined) { - return null; - } else if (field.preFieldContent instanceof Function) { - return field.preFieldContent(); - } else { - return field.preFieldContent; + // hook up the value state to the input field + if (definition.value !== undefined) { + field.onChange(definition.value); } - }, [field]); + }, [definition.value]); - const postFieldElement: JSX.Element | null = useMemo(() => { - if (field.postFieldContent === undefined) { - return null; - } else if (field.postFieldContent instanceof Function) { - return field.postFieldContent(); - } else { - return field.postFieldContent; - } - }, [field]); + // pull out onValueChange as this can cause strange errors when passing the + // definition to the input components via spread syntax + const reducedDefinition = useMemo(() => { + return { + ...definition, + onValueChange: undefined, + adjustFilters: undefined, + read_only: undefined, + children: undefined + }; + }, [definition]); // Callback helper when form value changes - function onChange(value: any) { - form.setValues({ [fieldName]: value }); + const onChange = useCallback( + (value: any) => { + field.onChange(value); - // Run custom callback for this field - if (definition.onValueChange) { - definition.onValueChange({ - name: fieldName, - value: value, - field: definition, - form: form - }); - } - } - - const value: any = useMemo(() => form.values[fieldName], [form.values]); + // Run custom callback for this field + definition.onValueChange?.(value); + }, + [fieldName, definition] + ); // Coerce the value to a numerical value const numericalValue: number | undefined = useMemo(() => { @@ -223,12 +168,9 @@ export function ApiFormField({ case 'related field': return ( ); case 'email': @@ -236,11 +178,12 @@ export function ApiFormField({ case 'string': return ( onChange(event.currentTarget.value)} rightSection={ @@ -253,23 +196,25 @@ export function ApiFormField({ case 'boolean': return ( onChange(event.currentTarget.checked)} /> ); case 'date': return ( onChange(value)} @@ -282,11 +227,12 @@ export function ApiFormField({ case 'number': return ( { let v: any = parseFloat(value); @@ -303,24 +249,31 @@ export function ApiFormField({ case 'choice': return ( ); case 'file upload': return ( onChange(payload)} /> ); + case 'nested object': + return ( + + ); default: return ( @@ -331,11 +284,15 @@ export function ApiFormField({ } } + if (definition.hidden) { + return null; + } + return ( - {preFieldElement} + {definition.preFieldContent} {buildField()} - {postFieldElement} + {definition.postFieldContent} ); } diff --git a/src/frontend/src/components/forms/fields/ChoiceField.tsx b/src/frontend/src/components/forms/fields/ChoiceField.tsx index 51ddab179b..6c5762bd32 100644 --- a/src/frontend/src/components/forms/fields/ChoiceField.tsx +++ b/src/frontend/src/components/forms/fields/ChoiceField.tsx @@ -1,47 +1,30 @@ import { Select } from '@mantine/core'; -import { UseFormReturnType } from '@mantine/form'; import { useId } from '@mantine/hooks'; -import { ReactNode } from 'react'; +import { useCallback } from 'react'; import { useMemo } from 'react'; +import { FieldValues, UseControllerReturn } from 'react-hook-form'; -import { constructField } from './ApiFormField'; -import { ApiFormFieldSet, ApiFormFieldType } from './ApiFormField'; +import { ApiFormFieldType } from './ApiFormField'; /** * Render a 'select' field for selecting from a list of choices */ export function ChoiceField({ - error, - form, - fieldName, - field, - definitions + controller, + definition }: { - error: ReactNode; - form: UseFormReturnType>; - field: ApiFormFieldType; + controller: UseControllerReturn; + definition: ApiFormFieldType; fieldName: string; - definitions: ApiFormFieldSet; }) { - // Extract field definition from provided data - // 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 - }); + const fieldId = useId(); - return def; - }, [fieldName, field, definitions]); - - const fieldId = useId(fieldName); - - const value: any = useMemo(() => form.values[fieldName], [form.values]); + const { + field, + fieldState: { error } + } = controller; // Build a set of choices for the field - // TODO: In future, allow this to be created dynamically? const choices: any[] = useMemo(() => { let choices = definition.choices ?? []; @@ -53,30 +36,28 @@ export function ChoiceField({ label: choice.display_name }; }); - }, [definition]); + }, [definition.choices]); - // Callback when an option is selected - function onChange(value: any) { - form.setFieldValue(fieldName, value); + // Update form values when the selected value changes + const onChange = useCallback( + (value: any) => { + field.onChange(value); - if (definition.onValueChange) { - definition.onValueChange({ - name: fieldName, - value: value, - field: definition, - form: form - }); - } - } + // Run custom callback for this field (if provided) + definition.onValueChange?.(value); + }, + [field.onChange, definition] + ); return ( item.value == pk)} + value={currentValue} options={data} filterOption={null} onInputChange={(value: any) => { diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index b92df30879..52b0d02547 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -8,7 +8,7 @@ import { api } from '../../App'; import { openModalApiForm } from '../../functions/forms'; import { apiUrl } from '../../states/ApiState'; import { SettingsStateProps } from '../../states/SettingsState'; -import { Setting } from '../../states/states'; +import { Setting, SettingType } from '../../states/states'; /** * Render a single setting value @@ -44,10 +44,10 @@ function SettingValue({ // Callback function to open the edit dialog (for non-boolean settings) function onEditButton() { - let field_type: string = setting?.type ?? 'string'; + let field_type = setting?.type ?? 'string'; if (setting?.choices && setting?.choices?.length > 0) { - field_type = 'choice'; + field_type = SettingType.Choice; } openModalApiForm({ diff --git a/src/frontend/src/components/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/components/tables/purchasing/SupplierPartTable.tsx index 952f28e4d2..7b9a1b3dd6 100644 --- a/src/frontend/src/components/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/components/tables/purchasing/SupplierPartTable.tsx @@ -4,13 +4,10 @@ import { ReactNode, useCallback, useMemo } from 'react'; import { ApiPaths } from '../../../enums/ApiEndpoints'; import { UserRoles } from '../../../enums/Roles'; -import { supplierPartFields } from '../../../forms/CompanyForms'; -import { - openCreateApiForm, - openDeleteApiForm, - openEditApiForm -} from '../../../functions/forms'; +import { useSupplierPartFields } from '../../../forms/CompanyForms'; +import { openDeleteApiForm, openEditApiForm } from '../../../functions/forms'; import { useTableRefresh } from '../../../hooks/TableRefresh'; +import { useCreateApiFormModal } from '../../../hooks/UseForm'; import { apiUrl } from '../../../states/ApiState'; import { useUserState } from '../../../states/UserState'; import { AddItemButton } from '../../buttons/AddItemButton'; @@ -155,30 +152,36 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode { ]; }, [params]); - const addSupplierPart = useCallback(() => { - let fields = supplierPartFields(); - - fields.part.value = params?.part; - fields.supplier.value = params?.supplier; - - openCreateApiForm({ + const addSupplierPartFields = useSupplierPartFields({ + partPk: params?.part, + supplierPk: params?.supplier, + hidePart: true + }); + const { modal: addSupplierPartModal, open: openAddSupplierPartForm } = + useCreateApiFormModal({ url: ApiPaths.supplier_part_list, title: t`Add Supplier Part`, - fields: fields, + fields: addSupplierPartFields, onFormSuccess: refreshTable, successMessage: t`Supplier part created` }); - }, [params]); // Table actions const tableActions = useMemo(() => { // TODO: Hide actions based on user permissions return [ - + ]; }, [user]); + const editSupplierPartFields = useSupplierPartFields({ + hidePart: true + }); + // Row action callback const rowActions = useCallback( (record: any) => { @@ -191,7 +194,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode { url: ApiPaths.supplier_part_list, pk: record.pk, title: t`Edit Supplier Part`, - fields: supplierPartFields(), + fields: editSupplierPartFields, onFormSuccess: refreshTable, successMessage: t`Supplier part updated` }); @@ -215,24 +218,27 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode { }) ]; }, - [user] + [user, editSupplierPartFields] ); return ( - + <> + {addSupplierPartModal} + + ); } diff --git a/src/frontend/src/contexts/LanguageContext.tsx b/src/frontend/src/contexts/LanguageContext.tsx index 9d23610ad2..2dc0332e99 100644 --- a/src/frontend/src/contexts/LanguageContext.tsx +++ b/src/frontend/src/contexts/LanguageContext.tsx @@ -1,7 +1,8 @@ import { i18n } from '@lingui/core'; import { t } from '@lingui/macro'; 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 { useLocalState } from '../states/LocalState'; @@ -45,10 +46,43 @@ export const languages: Record = { export function LanguageContext({ children }: { children: JSX.Element }) { const [language] = useLocalState((state) => [state.language]); + const [loadedState, setLoadedState] = useState< + 'loading' | 'loaded' | 'error' + >('loading'); + const isMounted = useRef(true); + 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]); + if (loadedState === 'loading') { + return ; + } + + if (loadedState === 'error') { + return ( + + An error occurred while loading translations, see browser console for + details. + + ); + } + + // only render the i18n Provider if the locales are fully activated, otherwise we end + // up with an error in the browser console return {children}; } diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index d7fdfb45ad..0cc178cc67 100644 --- a/src/frontend/src/forms/CompanyForms.tsx +++ b/src/frontend/src/forms/CompanyForms.tsx @@ -9,55 +9,76 @@ import { IconPackage, IconPhone } from '@tabler/icons-react'; +import { useEffect, useMemo, useState } from 'react'; -import { - ApiFormData, - ApiFormFieldSet -} from '../components/forms/fields/ApiFormField'; +import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; import { ApiPaths } from '../enums/ApiEndpoints'; import { openEditApiForm } from '../functions/forms'; /** * Field set for SupplierPart instance */ -export function supplierPartFields(): ApiFormFieldSet { - return { - part: { - filters: { - purchaseable: true - } - }, - manufacturer_part: { - filters: { - part_detail: true, - manufacturer_detail: true - }, - adjustFilters: (filters: any, form: ApiFormData) => { - let part = form.values.part; +export function useSupplierPartFields({ + partPk, + supplierPk, + hidePart +}: { + partPk?: number; + supplierPk?: number; + hidePart?: boolean; +}) { + const [part, setPart] = useState(partPk); - if (part) { - filters.part = part; + useEffect(() => { + setPart(partPk); + }, [partPk]); + + return useMemo(() => { + const fields: ApiFormFieldSet = { + part: { + hidden: hidePart, + value: part, + onValueChange: setPart, + filters: { + purchaseable: true } + }, + manufacturer_part: { + filters: { + part_detail: true, + manufacturer_detail: true + }, + adjustFilters: (filters: any) => { + if (part) { + filters.part = part; + } - return filters; + return filters; + } + }, + supplier: {}, + SKU: { + icon: + }, + description: {}, + link: { + icon: + }, + note: { + icon: + }, + pack_quantity: {}, + packaging: { + icon: } - }, - supplier: {}, - SKU: { - icon: - }, - description: {}, - link: { - icon: - }, - note: { - icon: - }, - pack_quantity: {}, - packaging: { - icon: + }; + + if (supplierPk !== undefined) { + fields.supplier.value = supplierPk; } - }; + + return fields; + }, [part]); } /** diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index dbf8c5227e..0a46a2c8de 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -1,4 +1,5 @@ import { t } from '@lingui/macro'; +import { IconPackages } from '@tabler/icons-react'; import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; import { ApiPaths } from '../enums/ApiEndpoints'; @@ -54,8 +55,36 @@ export function partFields({ // TODO: Set the value of the category field } + // Additional fields for creation if (!editing) { // TODO: Hide 'active' field + + fields.copy_category_parameters = {}; + + fields.initial_stock = { + icon: , + 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 diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index a621ced059..17c7fbb55f 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -7,10 +7,7 @@ import { IconSitemap } from '@tabler/icons-react'; -import { - ApiFormData, - ApiFormFieldSet -} from '../components/forms/fields/ApiFormField'; +import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; /* * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance @@ -38,7 +35,7 @@ export function purchaseOrderLineItemFields({ supplier_detail: true, supplier: supplierId }, - adjustFilters: (filters: any, _form: ApiFormData) => { + adjustFilters: (filters: any) => { // TODO: Filter by the supplier associated with the order return filters; } diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 56969aff45..d1113ffe8a 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -1,113 +1,112 @@ import { t } from '@lingui/macro'; +import { useMemo, useState } from 'react'; -import { - ApiFormChangeCallback, - ApiFormData, - ApiFormFieldSet -} from '../components/forms/fields/ApiFormField'; +import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; 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 */ -export function stockFields({ +export function useStockFields({ create = false }: { create: boolean; }): ApiFormFieldSet { - let fields: ApiFormFieldSet = { - part: { - hidden: !create, - onValueChange: (change: ApiFormChangeCallback) => { - // TODO: implement remaining functionality from old stock.py + const [part, setPart] = useState(null); + const [supplierPart, setSupplierPart] = useState(null); - // Clear the 'supplier_part' field if the part is changed - change.form.setValues({ - supplier_part: null - }); - } - }, - supplier_part: { - // TODO: icon - filters: { - part_detail: true, - supplier_detail: true - }, - adjustFilters: (filters: any, form: ApiFormData) => { - let part = form.values.part; - if (part) { - filters.part = part; + return useMemo(() => { + const fields: ApiFormFieldSet = { + part: { + value: part, + hidden: !create, + onValueChange: (change) => { + setPart(change); + // TODO: implement remaining functionality from old stock.py + + // Clear the 'supplier_part' field if the part is changed + setSupplierPart(null); } + }, + supplier_part: { + // TODO: icon + value: supplierPart, + onValueChange: setSupplierPart, + filters: { + part_detail: true, + supplier_detail: true, + ...(part ? { part } : {}) + } + }, + use_pack_size: { + hidden: !create, + description: t`Add given quantity as packs instead of individual items` + }, + location: { + hidden: !create, + filters: { + structural: false + } + // TODO: icon + }, + quantity: { + hidden: !create, + description: t`Enter initial quantity for this stock item` + }, + serial_numbers: { + // TODO: icon + field_type: 'string', + label: t`Serial Numbers`, + description: t`Enter serial numbers for new stock (or leave blank)`, + required: false, + hidden: !create + }, + serial: { + hidden: create + // TODO: icon + }, + batch: { + // TODO: icon + }, + status: {}, + expiry_date: { + // TODO: icon + }, + purchase_price: { + // TODO: icon + }, + purchase_price_currency: { + // TODO: icon + }, + packaging: { + // TODO: icon, + }, + link: { + // TODO: icon + }, + owner: { + // TODO: icon + }, + delete_on_deplete: {} + }; - return filters; - } - }, - use_pack_size: { - hidden: !create, - description: t`Add given quantity as packs instead of individual items` - }, - location: { - hidden: !create, - filters: { - structural: false - } - // TODO: icon - }, - quantity: { - hidden: !create, - description: t`Enter initial quantity for this stock item` - }, - serial_numbers: { - // TODO: icon - field_type: 'string', - label: t`Serial Numbers`, - description: t`Enter serial numbers for new stock (or leave blank)`, - required: false, - hidden: !create - }, - serial: { - hidden: create - // TODO: icon - }, - batch: { - // TODO: icon - }, - status: {}, - expiry_date: { - // TODO: icon - }, - purchase_price: { - // TODO: icon - }, - purchase_price_currency: { - // TODO: icon - }, - packaging: { - // TODO: icon, - }, - link: { - // TODO: icon - }, - owner: { - // TODO: icon - }, - delete_on_deplete: {} - }; + // TODO: Handle custom field management based on provided options + // TODO: refer to stock.py in original codebase - // TODO: Handle custom field management based on provided options - // TODO: refer to stock.py in original codebase - - return fields; + return fields; + }, [part, supplierPart]); } /** * Launch a form to create a new StockItem instance */ -export function createStockItem() { - openCreateApiForm({ +export function useCreateStockItem() { + const fields = useStockFields({ create: true }); + + return useCreateApiFormModal({ url: ApiPaths.stock_item_list, - fields: stockFields({ create: true }), + fields: fields, title: t`Create Stock Item` }); } @@ -116,17 +115,19 @@ export function createStockItem() { * Launch a form to edit an existing StockItem instance * @param item : primary key of the StockItem to edit */ -export function editStockItem({ +export function useEditStockItem({ item_id, callback }: { item_id: number; callback?: () => void; }) { - openEditApiForm({ + const fields = useStockFields({ create: false }); + + return useEditApiFormModal({ url: ApiPaths.stock_item_list, pk: item_id, - fields: stockFields({ create: false }), + fields: fields, title: t`Edit Stock Item`, successMessage: t`Stock item updated`, onFormSuccess: callback diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx index d9fb12a256..031b076721 100644 --- a/src/frontend/src/functions/forms.tsx +++ b/src/frontend/src/functions/forms.tsx @@ -5,8 +5,12 @@ import { AxiosResponse } from 'axios'; import { api } from '../App'; 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 { ApiPaths } from '../enums/ApiEndpoints'; import { apiUrl } from '../states/ApiState'; import { invalidResponse, permissionDenied } from './notifications'; import { generateUniqueId } from './uid'; @@ -14,8 +18,8 @@ import { generateUniqueId } from './uid'; /** * Construct an API url from the provided ApiFormProps object */ -export function constructFormUrl(props: ApiFormProps): string { - return apiUrl(props.url, props.pk); +export function constructFormUrl(url: ApiPaths, pk?: string | number): string { + return apiUrl(url, pk); } /** @@ -28,7 +32,7 @@ export function extractAvailableFields( method?: string ): Record | null { // OPTIONS request *must* return 200 status - if (response.status != 200) { + if (response.status !== 200) { invalidResponse(response.status); return null; } @@ -61,31 +65,118 @@ export function extractAvailableFields( return null; } - let fields: Record = {}; + const processFields = (fields: any, _path?: string) => { + const _fields: ApiFormFieldSet = {}; - for (const fieldName in actions[method]) { - const field = actions[method][fieldName]; - fields[fieldName] = { - ...field, - name: fieldName, - field_type: field.type, - description: field.help_text, - value: field.value ?? field.default, - disabled: field.read_only ?? false - }; + for (const [fieldName, field] of Object.entries(fields) as any) { + const path = _path ? `${_path}.${fieldName}` : fieldName; + _fields[fieldName] = { + ...field, + name: path, + field_type: field.type, + description: field.help_text, + value: field.value ?? field.default, + disabled: field.read_only ?? false + }; - // Remove the 'read_only' field - plays havoc with react components - delete fields['read_only']; + // Remove the 'read_only' field - plays havoc with react components + delete _fields[fieldName].read_only; + + if ( + _fields[fieldName].field_type === 'nested object' && + _fields[fieldName].children + ) { + _fields[fieldName].children = processFields( + _fields[fieldName].children, + path + ); + } + } + + return _fields; + }; + + return processFields(actions[method]); +} + +export type NestedDict = { [key: string]: string | number | NestedDict }; +export function mapFields( + fields: ApiFormFieldSet, + fieldFunction: (path: string, value: ApiFormFieldType, key: string) => any, + _path?: string +): NestedDict { + const res: NestedDict = {}; + + for (const [k, v] of Object.entries(fields)) { + const path = _path ? `${_path}.${k}` : k; + let value; + + if (v.field_type === 'nested object' && v.children) { + value = mapFields(v.children, fieldFunction, path); + } else { + value = fieldFunction(path, v, k); + } + + if (value !== undefined) res[k] = value; } - return fields; + return res; +} + +/* + * Build a complete field definition based on the provided data + */ +export function constructField({ + field, + definition +}: { + field: ApiFormFieldType; + definition?: ApiFormFieldType; +}) { + const def = { + ...definition, + ...field + }; + + switch (def.field_type) { + case 'date': + // Change value to a date object if required + if (def.value) { + def.value = new Date(def.value); + } + break; + case 'nested object': + def.children = {}; + for (const k of Object.keys(field.children ?? {})) { + def.children[k] = constructField({ + field: field.children?.[k] ?? {}, + definition: definition?.children?.[k] ?? {} + }); + } + break; + default: + break; + } + + // Clear out the 'read_only' attribute + def.disabled = def.disabled ?? def.read_only ?? false; + delete def['read_only']; + + return def; +} + +export interface OpenApiFormProps extends ApiFormProps { + title: string; + cancelText?: string; + cancelColor?: string; + onClose?: () => void; } /* * Construct and open a modal form * @param title : */ -export function openModalApiForm(props: ApiFormProps) { +export function openModalApiForm(props: OpenApiFormProps) { // method property *must* be supplied if (!props.method) { notifications.show({ @@ -96,7 +187,28 @@ export function openModalApiForm(props: ApiFormProps) { 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 api @@ -114,10 +226,16 @@ export function openModalApiForm(props: ApiFormProps) { } } - // Generate a random modal ID for controller - let modalId: string = - `modal-${props.title}-${props.url}-${props.method}` + - generateUniqueId(); + const _props = { ...props }; + + if (_props.fields) { + for (const [k, v] of Object.entries(_props.fields)) { + _props.fields[k] = constructField({ + field: v, + definition: fields?.[k] + }); + } + } modals.open({ title: {props.title}, @@ -126,9 +244,7 @@ export function openModalApiForm(props: ApiFormProps) { onClose: () => { props.onClose ? props.onClose() : null; }, - children: ( - - ) + children: }); }) .catch((error) => { @@ -148,8 +264,8 @@ export function openModalApiForm(props: ApiFormProps) { /** * Opens a modal form to create a new model instance */ -export function openCreateApiForm(props: ApiFormProps) { - let createProps: ApiFormProps = { +export function openCreateApiForm(props: OpenApiFormProps) { + let createProps: OpenApiFormProps = { ...props, method: 'POST' }; @@ -160,8 +276,8 @@ export function openCreateApiForm(props: ApiFormProps) { /** * Open a modal form to edit a model instance */ -export function openEditApiForm(props: ApiFormProps) { - let editProps: ApiFormProps = { +export function openEditApiForm(props: OpenApiFormProps) { + let editProps: OpenApiFormProps = { ...props, fetchInitialData: props.fetchInitialData ?? true, method: 'PUT' @@ -173,8 +289,8 @@ export function openEditApiForm(props: ApiFormProps) { /** * Open a modal form to delete a model instancel */ -export function openDeleteApiForm(props: ApiFormProps) { - let deleteProps: ApiFormProps = { +export function openDeleteApiForm(props: OpenApiFormProps) { + let deleteProps: OpenApiFormProps = { ...props, method: 'DELETE', submitText: t`Delete`, diff --git a/src/frontend/src/hooks/UseForm.tsx b/src/frontend/src/hooks/UseForm.tsx new file mode 100644 index 0000000000..8cf0de9e80 --- /dev/null +++ b/src/frontend/src/hooks/UseForm.tsx @@ -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( + () => ({ + ...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: + }); + + 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( + () => ({ + ...props, + method: 'POST' + }), + [props] + ); + + return useApiFormModal(createProps); +} + +/** + * Open a modal form to edit a model instance + */ +export function useEditApiFormModal(props: ApiFormModalProps) { + const editProps = useMemo( + () => ({ + ...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( + () => ({ + ...props, + method: 'DELETE', + submitText: t`Delete`, + submitColor: 'red', + fields: {} + }), + [props] + ); + + return useApiFormModal(deleteProps); +} diff --git a/src/frontend/src/hooks/UseModal.tsx b/src/frontend/src/hooks/UseModal.tsx new file mode 100644 index 0000000000..3eb7331738 --- /dev/null +++ b/src/frontend/src/hooks/UseModal.tsx @@ -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: ( + {props.title}} + > + {props.children} + + ) + }; +} diff --git a/src/frontend/src/pages/Index/Playground.tsx b/src/frontend/src/pages/Index/Playground.tsx index 7fbace4e08..beb9b54e7a 100644 --- a/src/frontend/src/pages/Index/Playground.tsx +++ b/src/frontend/src/pages/Index/Playground.tsx @@ -1,10 +1,10 @@ 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 { 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 { StylishText } from '../../components/items/StylishText'; import { StatusRenderer } from '../../components/render/StatusRenderer'; @@ -13,23 +13,28 @@ import { ModelType } from '../../enums/ModelType'; import { createPart, editPart, - partCategoryFields + partCategoryFields, + partFields } from '../../forms/PartForms'; -import { createStockItem } from '../../forms/StockForms'; -import { openCreateApiForm, openEditApiForm } from '../../functions/forms'; +import { useCreateStockItem } from '../../forms/StockForms'; +import { + OpenApiFormProps, + openCreateApiForm, + openEditApiForm +} from '../../functions/forms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; // Generate some example forms using the modal API forms interface +const fields = partCategoryFields({}); function ApiFormsPlayground() { - let fields = partCategoryFields({}); - - const editCategoryForm: ApiFormProps = { + const editCategoryForm: OpenApiFormProps = { url: ApiPaths.category_list, pk: 2, title: 'Edit Category', fields: fields }; - const createAttachmentForm: ApiFormProps = { + const createAttachmentForm: OpenApiFormProps = { url: ApiPaths.part_attachment_list, title: 'Create Attachment', successMessage: 'Attachment uploaded', @@ -41,21 +46,83 @@ function ApiFormsPlayground() { comment: {} } }; + const [active, setActive] = useState(true); + const [name, setName] = useState('Hello'); + + const partFieldsState: any = useMemo(() => { + 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: ( + + ) + }); + + const { modal: createStockItemModal, open: openCreateStockItem } = + useCreateStockItem(); return ( - <> + - + + + {createStockItemModal} + + + + + {createPartModal} - + + + + ); } diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 520178dc26..3e4272fc48 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -36,7 +36,7 @@ import { StockLocationTree } from '../../components/nav/StockLocationTree'; import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; import { NotesEditor } from '../../components/widgets/MarkdownEditor'; import { ApiPaths } from '../../enums/ApiEndpoints'; -import { editStockItem } from '../../forms/StockForms'; +import { useEditStockItem } from '../../forms/StockForms'; import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; @@ -141,6 +141,11 @@ export default function StockDetail() { [stockitem] ); + const editStockItem = useEditStockItem({ + item_id: stockitem.pk, + callback: () => refreshInstance() + }); + const stockActions = useMemo( () => /* TODO: Disable actions based on user permissions*/ [ { - stockitem.pk && - editStockItem({ - item_id: stockitem.pk, - callback: () => refreshInstance - }); + stockitem.pk && editStockItem.open(); } }), DeleteItemAction({}) @@ -231,6 +232,7 @@ export default function StockDetail() { actions={stockActions} /> + {editStockItem.modal} ); } diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 338a5c7829..3b89f1bb00 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2528,6 +2528,11 @@ react-grid-layout@^1.4.2: react-resizable "^3.0.5" 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: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"