From 7fe82074631d9853813b6338fa09ab387b04e361 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 1 Feb 2024 00:38:59 +1100 Subject: [PATCH] Forms initial data (#6365) * Use PATCH for edit form * Add "localhost:8000" server option * Add initialData property for forms - Allows user to specify an initial dataset to override default values * Override field values when constructing form props * Remove debug messages * Wrap ApiForm in FormProvider - Allows lower elements to access all form data without rebuild - Pass all form data through to adjustFilters routine * Fixes for RelatedModelField - Ensure that the saved data are cleared when filters change * Fix debug message for token creation * Fix address rendering for modals * Refactor "address" forms - Use new "hook" form structure * Update Contacts table * Prevent related model fields from fetching on initial form open - Only fetch when the drop-down is actually opened - Significantly reduces the number of API calls * Fix for ChoiceField - Display label / description / placeholder text * Fix for DateInput - Correct conversion of datatype * Implement "new purchase order" form - Uses modal form hook - Supply initial data - Adjust filters according to selected company * Add new company from company table * Edit company from detail page * More table updates - StockLocation - PartCategory * Update more tables / forms: - PartParameter table - PartParameterTemplate table - Cleanup unused imports * Update ProjectCode table * CustomUnits table * Update RelatedPart table * Update PartTestTemplate table * Cleanup PartParameterTable * Add "IPN" column to PartParameterTable * Update BuildOrder table * Update BuildDetail page * PurchaseOrderLineItem table * Simplify - Move fields which are only used in one location * Create new salesorder with context - Also consolidate translated strings - Also improve consistency of inline rendering (with missing image) * Revert change to RenderInlineModel * Fix for build table - Use apiUrl wrapper around ApiEndpoint * Fix parameter for PurchaseOrderTable * Adjust server selector - Only show localhost:8000 if in dev mode * Tweak URL * Add extra test to playground - Check initial value works for nested field * Cleanup playground * Cleanup unused vars * memoize fields * Fix typo host -> host * Fix part editing * Cleanup unused * update group table --- InvenTree/users/api.py | 10 +- src/frontend/src/components/forms/ApiForm.tsx | 56 ++++-- .../components/forms/fields/ApiFormField.tsx | 24 +-- .../components/forms/fields/ChoiceField.tsx | 4 + .../src/components/forms/fields/DateField.tsx | 60 ++++++ .../forms/fields/RelatedModelField.tsx | 54 ++++-- .../src/components/render/Company.tsx | 8 +- .../tables/build/BuildOrderTable.tsx | 76 ++++++-- .../tables/company/AddressTable.tsx | 127 ++++++++----- .../tables/company/CompanyTable.tsx | 70 +++++-- .../tables/company/ContactTable.tsx | 118 +++++++----- .../tables/part/PartCategoryTable.tsx | 100 +++++----- .../tables/part/PartParameterTable.tsx | 176 +++++++++--------- .../part/PartParameterTemplateTable.tsx | 105 ++++++----- .../tables/part/PartTestTemplateTable.tsx | 120 +++++++----- .../tables/part/RelatedPartTable.tsx | 121 ++++++------ .../purchasing/PurchaseOrderLineItemTable.tsx | 174 +++++++++-------- .../tables/purchasing/PurchaseOrderTable.tsx | 75 +++++--- .../tables/sales/SalesOrderTable.tsx | 91 +++++---- .../tables/settings/CustomUnitsTable.tsx | 103 +++++----- .../components/tables/settings/GroupTable.tsx | 48 +++-- .../tables/settings/ProjectCodeTable.tsx | 103 +++++----- .../tables/stock/StockLocationTable.tsx | 103 +++++----- src/frontend/src/forms/CommonForms.tsx | 21 +++ src/frontend/src/forms/CompanyForms.tsx | 54 ------ src/frontend/src/forms/PartForms.tsx | 58 ------ src/frontend/src/forms/PurchaseOrderForms.tsx | 85 +++++++-- src/frontend/src/forms/SalesOrderForms.tsx | 44 +++++ src/frontend/src/forms/StockForms.tsx | 2 +- src/frontend/src/hooks/UseForm.tsx | 13 +- src/frontend/src/main.tsx | 14 +- src/frontend/src/pages/Index/Playground.tsx | 77 +++++--- src/frontend/src/pages/build/BuildDetail.tsx | 50 ++--- src/frontend/src/pages/build/BuildIndex.tsx | 32 +--- .../src/pages/company/CompanyDetail.tsx | 53 +++--- .../src/pages/company/SupplierPartDetail.tsx | 2 +- src/frontend/src/pages/part/PartDetail.tsx | 40 ++-- .../src/pages/purchasing/PurchasingIndex.tsx | 1 - .../src/pages/sales/SalesOrderDetail.tsx | 6 +- 39 files changed, 1418 insertions(+), 1060 deletions(-) create mode 100644 src/frontend/src/components/forms/fields/DateField.tsx create mode 100644 src/frontend/src/forms/CommonForms.tsx create mode 100644 src/frontend/src/forms/SalesOrderForms.tsx diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index 241b69815a..582b5f7f32 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -253,6 +253,12 @@ class GetAuthToken(APIView): # User is authenticated, and requesting a token against the provided name. token = ApiToken.objects.create(user=request.user, name=name) + logger.info( + "Created new API token for user '%s' (name='%s')", + user.username, + name, + ) + # Add some metadata about the request token.set_metadata('user_agent', request.META.get('HTTP_USER_AGENT', '')) token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', '')) @@ -263,10 +269,6 @@ class GetAuthToken(APIView): data = {'token': token.key, 'name': token.name, 'expiry': token.expiry} - logger.info( - "Created new API token for user '%s' (name='%s')", user.username, name - ) - # Ensure that the users session is logged in (PUI -> CUI login) if not get_user(request).is_authenticated: login(request, user) diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 2c832f1dbe..a2f63933ad 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useState } from 'react'; import { FieldValues, + FormProvider, SubmitErrorHandler, SubmitHandler, useForm @@ -65,6 +66,7 @@ export interface ApiFormProps { pathParams?: PathParams; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; fields?: ApiFormFieldSet; + initialData?: FieldValues; submitText?: string; submitColor?: string; fetchInitialData?: boolean; @@ -146,6 +148,13 @@ export function OptionsApiForm({ field: v, definition: data?.[k] }); + + // If the user has specified initial data, use that value here + let value = _props?.initialData?.[k]; + + if (value) { + _props.fields[k].value = value; + } } return _props; @@ -163,13 +172,23 @@ export function OptionsApiForm({ * based on an API endpoint. */ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { - const defaultValues: FieldValues = useMemo( - () => - mapFields(props.fields ?? {}, (_path, field) => { - return field.default ?? undefined; - }), - [props.fields] - ); + const defaultValues: FieldValues = useMemo(() => { + let defaultValuesMap = mapFields(props.fields ?? {}, (_path, field) => { + return field.value ?? field.default ?? undefined; + }); + + // If the user has specified initial data, use that instead + if (props.initialData) { + defaultValuesMap = { + ...defaultValuesMap, + ...props.initialData + }; + } + + // Update the form values, but only for the fields specified for this form + + return defaultValuesMap; + }, [props.fields, props.initialData]); // Form errors which are not associated with a specific field const [nonFieldErrors, setNonFieldErrors] = useState([]); @@ -179,6 +198,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { criteriaMode: 'all', defaultValues }); + const { isValid, isDirty, @@ -390,16 +410,18 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { {props.preFormWarning} )} - - {Object.entries(props.fields ?? {}).map(([fieldName, field]) => ( - - ))} - + + + {Object.entries(props.fields ?? {}).map(([fieldName, field]) => ( + + ))} + + {props.postFormContent} diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index b7cd508c2f..706cc3d748 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -7,7 +7,6 @@ import { Switch, TextInput } from '@mantine/core'; -import { DateInput } from '@mantine/dates'; import { UseFormReturnType } from '@mantine/form'; import { useId } from '@mantine/hooks'; import { IconX } from '@tabler/icons-react'; @@ -17,11 +16,17 @@ import { Control, FieldValues, useController } from 'react-hook-form'; import { ModelType } from '../../../enums/ModelType'; import { ChoiceField } from './ChoiceField'; +import DateField from './DateField'; import { NestedObjectField } from './NestedObjectField'; import { RelatedModelField } from './RelatedModelField'; export type ApiFormData = UseFormReturnType>; +export type ApiFormAdjustFilterType = { + filters: any; + data: FieldValues; +}; + /** Definition of the ApiForm field component. * - The 'name' attribute *must* be provided * - All other attributes are optional, and may be provided by the API @@ -80,7 +85,7 @@ export type ApiFormFieldType = { preFieldContent?: JSX.Element; postFieldContent?: JSX.Element; onValueChange?: (value: any) => void; - adjustFilters?: (filters: any) => any; + adjustFilters?: (value: ApiFormAdjustFilterType) => any; }; /** @@ -207,20 +212,7 @@ export function ApiFormField({ /> ); case 'date': - return ( - onChange(value)} - valueFormat="YYYY-MM-DD" - /> - ); + return ; case 'integer': case 'decimal': case 'float': diff --git a/src/frontend/src/components/forms/fields/ChoiceField.tsx b/src/frontend/src/components/forms/fields/ChoiceField.tsx index 6c5762bd32..e5903bd3b8 100644 --- a/src/frontend/src/components/forms/fields/ChoiceField.tsx +++ b/src/frontend/src/components/forms/fields/ChoiceField.tsx @@ -58,6 +58,10 @@ export function ChoiceField({ onChange={onChange} data={choices} value={field.value} + label={definition.label} + description={definition.description} + placeholder={definition.placeholder} + icon={definition.icon} withinPortal={true} /> ); diff --git a/src/frontend/src/components/forms/fields/DateField.tsx b/src/frontend/src/components/forms/fields/DateField.tsx new file mode 100644 index 0000000000..c6492ffdd5 --- /dev/null +++ b/src/frontend/src/components/forms/fields/DateField.tsx @@ -0,0 +1,60 @@ +import { DateInput } from '@mantine/dates'; +import { useCallback, useId, useMemo } from 'react'; +import { FieldValues, UseControllerReturn } from 'react-hook-form'; + +import { ApiFormFieldType } from './ApiFormField'; + +export default function DateField({ + controller, + definition +}: { + controller: UseControllerReturn; + definition: ApiFormFieldType; +}) { + const fieldId = useId(); + + const { + field, + fieldState: { error } + } = controller; + + const onChange = useCallback( + (value: any) => { + // Convert the returned date object to a string + if (value) { + value = value.toString(); + let date = new Date(value); + value = date.toISOString().split('T')[0]; + } + + field.onChange(value); + definition.onValueChange?.(value); + }, + [field.onChange, definition] + ); + + const dateValue = useMemo(() => { + if (field.value) { + return new Date(field.value); + } else { + return undefined; + } + }, [field.value]); + + return ( + + ); +} diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index 52df2308f1..cf4836e3bb 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -4,7 +4,11 @@ import { useDebouncedValue } from '@mantine/hooks'; import { useId } from '@mantine/hooks'; import { useQuery } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FieldValues, UseControllerReturn } from 'react-hook-form'; +import { + FieldValues, + UseControllerReturn, + useFormContext +} from 'react-hook-form'; import Select from 'react-select'; import { api } from '../../../App'; @@ -32,6 +36,8 @@ export function RelatedModelField({ fieldState: { error } } = controller; + const form = useFormContext(); + // Keep track of the primary key value for this field const [pk, setPk] = useState(null); @@ -40,6 +46,8 @@ export function RelatedModelField({ const [data, setData] = useState([]); const dataRef = useRef([]); + const [isOpen, setIsOpen] = useState(false); + // If an initial value is provided, load from the API useEffect(() => { // If the value is unchanged, do nothing @@ -71,28 +79,49 @@ export function RelatedModelField({ const [value, setValue] = useState(''); const [searchText, cancelSearchText] = useDebouncedValue(value, 250); + const [filters, setFilters] = useState({}); + + const resetSearch = useCallback(() => { + setOffset(0); + setData([]); + dataRef.current = []; + }, []); + // reset current data on search value change useEffect(() => { - dataRef.current = []; - setData([]); - }, [searchText]); + resetSearch(); + }, [searchText, filters]); const selectQuery = useQuery({ - enabled: !definition.disabled && !!definition.api_url && !definition.hidden, + enabled: + isOpen && + !definition.disabled && + !!definition.api_url && + !definition.hidden, queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText], queryFn: async () => { if (!definition.api_url) { return null; } - let filters = definition.filters ?? {}; + let _filters = definition.filters ?? {}; if (definition.adjustFilters) { - filters = definition.adjustFilters(filters); + _filters = + definition.adjustFilters({ + filters: _filters, + data: form.getValues() + }) ?? _filters; + } + + // If the filters have changed, clear the data + if (_filters != filters) { + resetSearch(); + setFilters(_filters); } let params = { - ...filters, + ..._filters, search: searchText, offset: offset, limit: limit @@ -189,16 +218,19 @@ export function RelatedModelField({ filterOption={null} onInputChange={(value: any) => { setValue(value); - setOffset(0); - setData([]); + resetSearch(); }} onChange={onChange} onMenuScrollToBottom={() => setOffset(offset + limit)} onMenuOpen={() => { + setIsOpen(true); setValue(''); - setOffset(0); + resetSearch(); selectQuery.refetch(); }} + onMenuClose={() => { + setIsOpen(false); + }} isLoading={ selectQuery.isFetching || selectQuery.isLoading || diff --git a/src/frontend/src/components/render/Company.tsx b/src/frontend/src/components/render/Company.tsx index 23771f0ec2..6a15a6a515 100644 --- a/src/frontend/src/components/render/Company.tsx +++ b/src/frontend/src/components/render/Company.tsx @@ -7,7 +7,6 @@ import { RenderInlineModel } from './Instance'; */ export function RenderAddress({ instance }: { instance: any }): ReactNode { let text = [ - instance.title, instance.country, instance.postal_code, instance.postal_city, @@ -18,12 +17,7 @@ export function RenderAddress({ instance }: { instance: any }): ReactNode { .filter(Boolean) .join(', '); - return ( - - ); + return ; } /** diff --git a/src/frontend/src/components/tables/build/BuildOrderTable.tsx b/src/frontend/src/components/tables/build/BuildOrderTable.tsx index c861b75c77..b824c9beb9 100644 --- a/src/frontend/src/components/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/components/tables/build/BuildOrderTable.tsx @@ -5,9 +5,14 @@ import { useNavigate } from 'react-router-dom'; import { renderDate } from '../../../defaults/formatters'; import { ApiEndpoints } from '../../../enums/ApiEndpoints'; import { ModelType } from '../../../enums/ModelType'; +import { UserRoles } from '../../../enums/Roles'; +import { buildOrderFields } from '../../../forms/BuildForms'; import { getDetailUrl } from '../../../functions/urls'; +import { useCreateApiFormModal } from '../../../hooks/UseForm'; import { useTable } from '../../../hooks/UseTable'; import { apiUrl } from '../../../states/ApiState'; +import { useUserState } from '../../../states/UserState'; +import { AddItemButton } from '../../buttons/AddItemButton'; import { PartHoverCard } from '../../images/Thumbnail'; import { ProgressBar } from '../../items/ProgressBar'; import { RenderUser } from '../../render/User'; @@ -88,7 +93,15 @@ function buildOrderTableColumns(): TableColumn[] { /* * Construct a table of build orders, according to the provided parameters */ -export function BuildOrderTable({ params = {} }: { params?: any }) { +export function BuildOrderTable({ + partId, + parentBuildId, + salesOrderId +}: { + partId?: number; + parentBuildId?: number; + salesOrderId?: number; +}) { const tableColumns = useMemo(() => buildOrderTableColumns(), []); const tableFilters: TableFilter[] = useMemo(() => { @@ -130,23 +143,56 @@ export function BuildOrderTable({ params = {} }: { params?: any }) { }, []); const navigate = useNavigate(); + const user = useUserState(); const table = useTable('buildorder'); + const newBuild = useCreateApiFormModal({ + url: ApiEndpoints.build_order_list, + title: t`Add Build Order`, + fields: buildOrderFields(), + initialData: { + part: partId, + sales_order: salesOrderId, + parent: parentBuildId + }, + onFormSuccess: (data: any) => { + if (data.pk) { + navigate(getDetailUrl(ModelType.build, data.pk)); + } + } + }); + + const tableActions = useMemo(() => { + return [ +