mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 21:25:42 +00:00 
			
		
		
		
	Modal api forms (#5355)
* Very basic form implementation * Fetch field definition data via AP * Add cancel and submit buttons * Render basic field stack, and extract field data from API * Extract specific field definition * Handle text fields * Add some more fields * Implement boolean and number fields * Add callback for value changes * Use form state to update values * Add skeleton for a 'related field' * Framework for related field query manager * Handle date type fields * Make date input clearable * Fix error messae * Fix for optional callback function * Use LoadingOverlay component * Support url and email fields * Add icon support - Cannot hash react nodes! * Create components for different form types - Create - Edit - Delete * Split ApiFormField into separate file * Add support for pre-form and post-form content * Don't render hidden fields * Smaller spacing * More demo data * Add icon to clear text input value * Account for "read only" property * Framework for a submit data query * Return 404 on API requests other than GET - Other request methods need love too! * Starting work on dynamically opening forms * Check validity of OPTIONS response * refactor * Launch modal form with provided props * Refactor tractor: - Handle simple form submission - Handle simple error messages * Improve support for content pre and post form * Allow custom content to be inserted between fields * Pass form props down to individual fields * Update playground page with API forms functionality * Simplify form submission to handle different methods * Handle passing of initial form data values * Improve docstrings * Code cleanup and add translations * Add comment * Ignore icon for checkbox input * Add custom callback function for individual form fields * Use Switch instead of Checkbox * Add react-select * Implement very simple related field select input - No custom rendering yet - Simple pk / name combination * FIrst pass at retrieving data from API * Updates: - Implement "filters" for each form field - Prevent duplicate searches from doing weird things * Rearrange files * Load initial values for related fields from the API - Requires cleanup * Display error message for related field * Create some basic functions for construction field sets * Display non-field-errors in form * Improved error rendering * Change field definition from list to Record type - In line with current (javascript) implementation - Cleaner / simpler to work with * Correctly use default values on first form load * Improve date input * define a set of stockitem fields * Implement "Choice" field using mantine.select * Implement useForm hook for better performance * Show permission denied error * Improved callback "onChangeValue" functionality - Define proper return type - Access all form data * Cleanup * Implement components for rendering database model instance - Not fully featured yet (still a lot of work to go) - Porting code across from existing "model_renderers.js" * Update packages * Handle file input fields * Improved loading overlay for form submission * Utilize modal renderers in search results * SearchDrawer cleanup * Temporary fix for image pathing issue * Cleanup table action buttons - Now use a dropdown menu - Implement "edit part" directly from the table - This is only as an example for now * Fix playground * Generate random ID with useId hook * Fix abortController to use ref * Use AbortController for search panel * Fix TableColumn type definition * Improved generation of unique form ID values
This commit is contained in:
		| @@ -38,6 +38,7 @@ | ||||
|         "react-dom": "^18.2.0", | ||||
|         "react-grid-layout": "^1.3.4", | ||||
|         "react-router-dom": "^6.15.0", | ||||
|         "react-select": "^5.7.4", | ||||
|         "zustand": "^4.4.1" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import { useSessionState } from './states/SessionState'; | ||||
|  | ||||
| // API | ||||
| export const api = axios.create({}); | ||||
|  | ||||
| export function setApiDefaults() { | ||||
|   const host = useLocalState.getState().host; | ||||
|   const token = useSessionState.getState().token; | ||||
|   | ||||
							
								
								
									
										312
									
								
								src/frontend/src/components/forms/ApiForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										312
									
								
								src/frontend/src/components/forms/ApiForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,312 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { | ||||
|   Alert, | ||||
|   Divider, | ||||
|   LoadingOverlay, | ||||
|   ScrollArea, | ||||
|   Text | ||||
| } from '@mantine/core'; | ||||
| import { Button, Group, Stack } from '@mantine/core'; | ||||
| import { useForm } from '@mantine/form'; | ||||
| import { modals } from '@mantine/modals'; | ||||
| import { notifications } from '@mantine/notifications'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { useEffect, useMemo } from 'react'; | ||||
| import { useState } from 'react'; | ||||
|  | ||||
| import { api } from '../../App'; | ||||
| import { constructFormUrl } from '../../functions/forms'; | ||||
| import { invalidResponse } from '../../functions/notifications'; | ||||
| import { | ||||
|   ApiFormField, | ||||
|   ApiFormFieldSet, | ||||
|   ApiFormFieldType | ||||
| } from './fields/ApiFormField'; | ||||
|  | ||||
| /** | ||||
|  * Properties for the ApiForm component | ||||
|  * @param name : The name (identifier) for this form | ||||
|  * @param url : The API endpoint to fetch the form data from | ||||
|  * @param pk : Optional primary-key value when editing an existing object | ||||
|  * @param title : The title to display in the form header | ||||
|  * @param fields : The fields to render in the form | ||||
|  * @param submitText : Optional custom text to display on the submit button (default: Submit)4 | ||||
|  * @param submitColor : Optional custom color for the submit button (default: green) | ||||
|  * @param cancelText : Optional custom text to display on the cancel button (default: Cancel) | ||||
|  * @param cancelColor : Optional custom color for the cancel button (default: blue) | ||||
|  * @param fetchInitialData : Optional flag to fetch initial data from the server (default: true) | ||||
|  * @param method : Optional HTTP method to use when submitting the form (default: GET) | ||||
|  * @param preFormContent : Optional content to render before the form fields | ||||
|  * @param postFormContent : Optional content to render after the form fields | ||||
|  * @param successMessage : Optional message to display on successful form submission | ||||
|  * @param onClose : A callback function to call when the form is closed. | ||||
|  * @param onFormSuccess : A callback function to call when the form is submitted successfully. | ||||
|  * @param onFormError : A callback function to call when the form is submitted with errors. | ||||
|  */ | ||||
| export interface ApiFormProps { | ||||
|   name: string; | ||||
|   url: string; | ||||
|   pk?: number; | ||||
|   title: string; | ||||
|   fields: ApiFormFieldSet; | ||||
|   cancelText?: string; | ||||
|   submitText?: string; | ||||
|   submitColor?: string; | ||||
|   cancelColor?: string; | ||||
|   fetchInitialData?: boolean; | ||||
|   method?: string; | ||||
|   preFormContent?: JSX.Element | (() => JSX.Element); | ||||
|   postFormContent?: JSX.Element | (() => JSX.Element); | ||||
|   successMessage?: string; | ||||
|   onClose?: () => void; | ||||
|   onFormSuccess?: () => void; | ||||
|   onFormError?: () => void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * An ApiForm component is a modal form which is rendered dynamically, | ||||
|  * based on an API endpoint. | ||||
|  */ | ||||
| export function ApiForm({ | ||||
|   modalId, | ||||
|   props, | ||||
|   fieldDefinitions | ||||
| }: { | ||||
|   modalId: string; | ||||
|   props: ApiFormProps; | ||||
|   fieldDefinitions: ApiFormFieldSet; | ||||
| }) { | ||||
|   // Form errors which are not associated with a specific field | ||||
|   const [nonFieldErrors, setNonFieldErrors] = useState<string[]>([]); | ||||
|  | ||||
|   // Form state | ||||
|   const form = useForm({}); | ||||
|  | ||||
|   // Cache URL | ||||
|   const url = useMemo(() => constructFormUrl(props), [props]); | ||||
|  | ||||
|   // Render pre-form content | ||||
|   // TODO: Future work will allow this content to be updated dynamically based on the form data | ||||
|   const preFormElement: JSX.Element | null = useMemo(() => { | ||||
|     if (props.preFormContent === undefined) { | ||||
|       return null; | ||||
|     } else if (props.preFormContent instanceof Function) { | ||||
|       return props.preFormContent(); | ||||
|     } else { | ||||
|       return props.preFormContent; | ||||
|     } | ||||
|   }, [props]); | ||||
|  | ||||
|   // Render post-form content | ||||
|   // TODO: Future work will allow this content to be updated dynamically based on the form data | ||||
|   const postFormElement: JSX.Element | null = useMemo(() => { | ||||
|     if (props.postFormContent === undefined) { | ||||
|       return null; | ||||
|     } else if (props.postFormContent instanceof Function) { | ||||
|       return props.postFormContent(); | ||||
|     } else { | ||||
|       return props.postFormContent; | ||||
|     } | ||||
|   }, [props]); | ||||
|  | ||||
|   // Query manager for retrieiving initial data from the server | ||||
|   const initialDataQuery = useQuery({ | ||||
|     enabled: false, | ||||
|     queryKey: ['form-initial-data', props.name, props.url, props.pk], | ||||
|     queryFn: async () => { | ||||
|       return api | ||||
|         .get(url) | ||||
|         .then((response) => { | ||||
|           // Update form values, but only for the fields specified for the form | ||||
|           Object.keys(props.fields).forEach((fieldName) => { | ||||
|             if (fieldName in response.data) { | ||||
|               form.setValues({ | ||||
|                 [fieldName]: response.data[fieldName] | ||||
|               }); | ||||
|             } | ||||
|           }); | ||||
|  | ||||
|           return response; | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Error fetching initial data:', error); | ||||
|         }); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   // Fetch initial data on form load | ||||
|   useEffect(() => { | ||||
|     // Provide initial form data | ||||
|     Object.entries(props.fields).forEach(([fieldName, field]) => { | ||||
|       if (field.value !== undefined) { | ||||
|         form.setValues({ | ||||
|           [fieldName]: field.value | ||||
|         }); | ||||
|       } else if (field.default !== undefined) { | ||||
|         form.setValues({ | ||||
|           [fieldName]: field.default | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // Fetch initial data if the fetchInitialData property is set | ||||
|     if (props.fetchInitialData) { | ||||
|       initialDataQuery.refetch(); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   // Query manager for submitting data | ||||
|   const submitQuery = useQuery({ | ||||
|     enabled: false, | ||||
|     queryKey: ['form-submit', props.name, props.url, props.pk], | ||||
|     queryFn: async () => { | ||||
|       let method = props.method?.toLowerCase() ?? 'get'; | ||||
|  | ||||
|       api({ | ||||
|         method: method, | ||||
|         url: url, | ||||
|         data: form.values, | ||||
|         headers: { | ||||
|           'Content-Type': 'multipart/form-data' | ||||
|         } | ||||
|       }) | ||||
|         .then((response) => { | ||||
|           switch (response.status) { | ||||
|             case 200: | ||||
|             case 201: | ||||
|             case 204: | ||||
|               // Form was submitted successfully | ||||
|  | ||||
|               // Optionally call the onFormSuccess callback | ||||
|               if (props.onFormSuccess) { | ||||
|                 props.onFormSuccess(); | ||||
|               } | ||||
|  | ||||
|               // Optionally show a success message | ||||
|               if (props.successMessage) { | ||||
|                 notifications.show({ | ||||
|                   title: t`Success`, | ||||
|                   message: props.successMessage, | ||||
|                   color: 'green' | ||||
|                 }); | ||||
|               } | ||||
|  | ||||
|               closeForm(); | ||||
|               break; | ||||
|             default: | ||||
|               // Unexpected state on form success | ||||
|               invalidResponse(response.status); | ||||
|               closeForm(); | ||||
|               break; | ||||
|           } | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           if (error.response) { | ||||
|             switch (error.response.status) { | ||||
|               case 400: | ||||
|                 // Data validation error | ||||
|                 form.setErrors(error.response.data); | ||||
|                 setNonFieldErrors(error.response.data.non_field_errors ?? []); | ||||
|                 break; | ||||
|               default: | ||||
|                 // Unexpected state on form error | ||||
|                 invalidResponse(error.response.status); | ||||
|                 closeForm(); | ||||
|                 break; | ||||
|             } | ||||
|           } else { | ||||
|             invalidResponse(0); | ||||
|             closeForm(); | ||||
|           } | ||||
|  | ||||
|           return error; | ||||
|         }); | ||||
|     }, | ||||
|     refetchOnMount: false, | ||||
|     refetchOnWindowFocus: false | ||||
|   }); | ||||
|  | ||||
|   // Data loading state | ||||
|   const [isLoading, setIsLoading] = useState<boolean>(true); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setIsLoading(submitQuery.isFetching || initialDataQuery.isFetching); | ||||
|   }, [initialDataQuery.status, submitQuery.status]); | ||||
|  | ||||
|   /** | ||||
|    * Callback to perform form submission | ||||
|    */ | ||||
|   function submitForm() { | ||||
|     setIsLoading(true); | ||||
|     submitQuery.refetch(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Callback to close the form | ||||
|    * Note that the calling function might implement an onClose() callback, | ||||
|    * which will be automatically called | ||||
|    */ | ||||
|   function closeForm() { | ||||
|     modals.close(modalId); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Stack> | ||||
|       <Divider /> | ||||
|       <Stack spacing="sm"> | ||||
|         <LoadingOverlay visible={isLoading} /> | ||||
|         {(Object.keys(form.errors).length > 0 || nonFieldErrors.length > 0) && ( | ||||
|           <Alert radius="sm" color="red" title={t`Form Errors Exist`}> | ||||
|             {nonFieldErrors.length > 0 && ( | ||||
|               <Stack spacing="xs"> | ||||
|                 {nonFieldErrors.map((message) => ( | ||||
|                   <Text key={message}>{message}</Text> | ||||
|                 ))} | ||||
|               </Stack> | ||||
|             )} | ||||
|           </Alert> | ||||
|         )} | ||||
|         {preFormElement} | ||||
|         <ScrollArea> | ||||
|           <Stack spacing="xs"> | ||||
|             {Object.entries(props.fields).map( | ||||
|               ([fieldName, field]) => | ||||
|                 !field.hidden && ( | ||||
|                   <ApiFormField | ||||
|                     key={fieldName} | ||||
|                     field={field} | ||||
|                     fieldName={fieldName} | ||||
|                     formProps={props} | ||||
|                     form={form} | ||||
|                     error={form.errors[fieldName] ?? null} | ||||
|                     definitions={fieldDefinitions} | ||||
|                   /> | ||||
|                 ) | ||||
|             )} | ||||
|           </Stack> | ||||
|         </ScrollArea> | ||||
|         {postFormElement} | ||||
|       </Stack> | ||||
|       <Divider /> | ||||
|       <Group position="right"> | ||||
|         <Button | ||||
|           onClick={closeForm} | ||||
|           variant="outline" | ||||
|           radius="sm" | ||||
|           color={props.cancelColor ?? 'blue'} | ||||
|         > | ||||
|           {props.cancelText ?? t`Cancel`} | ||||
|         </Button> | ||||
|         <Button | ||||
|           onClick={submitForm} | ||||
|           variant="outline" | ||||
|           radius="sm" | ||||
|           color={props.submitColor ?? 'green'} | ||||
|           disabled={isLoading} | ||||
|         > | ||||
|           {props.submitText ?? t`Submit`} | ||||
|         </Button> | ||||
|       </Group> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										302
									
								
								src/frontend/src/components/forms/fields/ApiFormField.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								src/frontend/src/components/forms/fields/ApiFormField.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,302 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { | ||||
|   Alert, | ||||
|   FileInput, | ||||
|   NumberInput, | ||||
|   Stack, | ||||
|   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'; | ||||
| import { ReactNode } from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| import { ApiFormProps } from '../ApiForm'; | ||||
| import { ChoiceField } from './ChoiceField'; | ||||
| import { RelatedModelField } from './RelatedModelField'; | ||||
|  | ||||
| /** | ||||
|  * Callback function type when a form field value changes | ||||
|  */ | ||||
| export type ApiFormChangeCallback = { | ||||
|   name: string; | ||||
|   value: any; | ||||
|   field: ApiFormFieldType; | ||||
|   form: UseFormReturnType<Record<string, unknown>>; | ||||
| }; | ||||
|  | ||||
| /* 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 | ||||
|  * | ||||
|  * @param name : The name of the field | ||||
|  * @param label : The label to display for the field | ||||
|  * @param value : The value of the field | ||||
|  * @param default : The default value of the field | ||||
|  * @param icon : An icon to display next to the field | ||||
|  * @param fieldType : The type of field to render | ||||
|  * @param api_url : The API endpoint to fetch data from (for related fields) | ||||
|  * @param read_only : Whether the field is read-only | ||||
|  * @param model : The model to use for related fields | ||||
|  * @param filters : Optional API filters to apply to related fields | ||||
|  * @param required : Whether the field is required | ||||
|  * @param hidden : Whether the field is hidden | ||||
|  * @param disabled : Whether the field is disabled | ||||
|  * @param placeholder : The placeholder text to display | ||||
|  * @param description : The description to display for the field | ||||
|  * @param preFieldContent : Content to render before the field | ||||
|  * @param postFieldContent : Content to render after the field | ||||
|  * @param onValueChange : Callback function to call when the field value changes | ||||
|  */ | ||||
| export type ApiFormFieldType = { | ||||
|   label?: string; | ||||
|   value?: any; | ||||
|   default?: any; | ||||
|   icon?: ReactNode; | ||||
|   fieldType?: string; | ||||
|   api_url?: string; | ||||
|   read_only?: boolean; | ||||
|   model?: string; | ||||
|   filters?: any; | ||||
|   required?: boolean; | ||||
|   choices?: any[]; | ||||
|   hidden?: boolean; | ||||
|   disabled?: boolean; | ||||
|   placeholder?: string; | ||||
|   description?: string; | ||||
|   preFieldContent?: JSX.Element | (() => JSX.Element); | ||||
|   postFieldContent?: JSX.Element | (() => JSX.Element); | ||||
|   onValueChange?: (change: ApiFormChangeCallback) => void; | ||||
| }; | ||||
|  | ||||
| /* | ||||
|  * 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 | ||||
|   }; | ||||
|  | ||||
|   def.disabled = def.disabled || def.read_only; | ||||
|  | ||||
|   // 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.fieldType) { | ||||
|     case 'date': | ||||
|       if (def.value) { | ||||
|         def.value = new Date(def.value); | ||||
|       } | ||||
|       break; | ||||
|     default: | ||||
|       break; | ||||
|   } | ||||
|  | ||||
|   return def; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Render an individual form field | ||||
|  */ | ||||
| export function ApiFormField({ | ||||
|   formProps, | ||||
|   form, | ||||
|   fieldName, | ||||
|   field, | ||||
|   error, | ||||
|   definitions | ||||
| }: { | ||||
|   formProps: ApiFormProps; | ||||
|   form: UseFormReturnType<Record<string, unknown>>; | ||||
|   fieldName: string; | ||||
|   field: ApiFormFieldType; | ||||
|   error: ReactNode; | ||||
|   definitions: Record<string, ApiFormFieldType>; | ||||
| }) { | ||||
|   const fieldId = useId(fieldName); | ||||
|  | ||||
|   // 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] | ||||
|   ); | ||||
|  | ||||
|   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; | ||||
|     } | ||||
|   }, [field]); | ||||
|  | ||||
|   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]); | ||||
|  | ||||
|   // Callback helper when form value changes | ||||
|   function onChange(value: any) { | ||||
|     form.setValues({ [fieldName]: 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]); | ||||
|  | ||||
|   // Construct the individual field | ||||
|   function buildField() { | ||||
|     switch (definition.fieldType) { | ||||
|       case 'related field': | ||||
|         return ( | ||||
|           <RelatedModelField | ||||
|             error={error} | ||||
|             formProps={formProps} | ||||
|             form={form} | ||||
|             field={definition} | ||||
|             fieldName={fieldName} | ||||
|             definitions={definitions} | ||||
|           /> | ||||
|         ); | ||||
|       case 'email': | ||||
|       case 'url': | ||||
|       case 'string': | ||||
|         return ( | ||||
|           <TextInput | ||||
|             {...definition} | ||||
|             id={fieldId} | ||||
|             type={definition.fieldType} | ||||
|             value={value} | ||||
|             error={error} | ||||
|             radius="sm" | ||||
|             onChange={(event) => onChange(event.currentTarget.value)} | ||||
|             rightSection={ | ||||
|               definition.value && !definition.required ? ( | ||||
|                 <IconX size="1rem" color="red" onClick={() => onChange('')} /> | ||||
|               ) : null | ||||
|             } | ||||
|           /> | ||||
|         ); | ||||
|       case 'boolean': | ||||
|         return ( | ||||
|           <Switch | ||||
|             {...definition} | ||||
|             id={fieldId} | ||||
|             radius="lg" | ||||
|             size="sm" | ||||
|             checked={value ?? false} | ||||
|             error={error} | ||||
|             onChange={(event) => onChange(event.currentTarget.checked)} | ||||
|           /> | ||||
|         ); | ||||
|       case 'date': | ||||
|         return ( | ||||
|           <DateInput | ||||
|             {...definition} | ||||
|             id={fieldId} | ||||
|             radius="sm" | ||||
|             type={undefined} | ||||
|             error={error} | ||||
|             value={value} | ||||
|             clearable={!definition.required} | ||||
|             onChange={(value) => onChange(value)} | ||||
|             valueFormat="YYYY-MM-DD" | ||||
|           /> | ||||
|         ); | ||||
|       case 'integer': | ||||
|       case 'decimal': | ||||
|       case 'float': | ||||
|       case 'number': | ||||
|         return ( | ||||
|           <NumberInput | ||||
|             {...definition} | ||||
|             radius="sm" | ||||
|             id={fieldId} | ||||
|             value={value} | ||||
|             error={error} | ||||
|             onChange={(value: number) => onChange(value)} | ||||
|           /> | ||||
|         ); | ||||
|       case 'choice': | ||||
|         return ( | ||||
|           <ChoiceField | ||||
|             error={error} | ||||
|             form={form} | ||||
|             fieldName={fieldName} | ||||
|             field={definition} | ||||
|             definitions={definitions} | ||||
|           /> | ||||
|         ); | ||||
|       case 'file upload': | ||||
|         return ( | ||||
|           <FileInput | ||||
|             {...definition} | ||||
|             id={fieldId} | ||||
|             radius="sm" | ||||
|             value={value} | ||||
|             error={error} | ||||
|             onChange={(payload: File | null) => onChange(payload)} | ||||
|           /> | ||||
|         ); | ||||
|       default: | ||||
|         return ( | ||||
|           <Alert color="red" title={t`Error`}> | ||||
|             Invalid field type for field '{fieldName}': '{definition.fieldType}' | ||||
|           </Alert> | ||||
|         ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Stack> | ||||
|       {preFieldElement} | ||||
|       {buildField()} | ||||
|       {postFieldElement} | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export type ApiFormFieldSet = Record<string, ApiFormFieldType>; | ||||
							
								
								
									
										85
									
								
								src/frontend/src/components/forms/fields/ChoiceField.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/frontend/src/components/forms/fields/ChoiceField.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Select } from '@mantine/core'; | ||||
| import { UseFormReturnType } from '@mantine/form'; | ||||
| import { useId } from '@mantine/hooks'; | ||||
| import { ReactNode } from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| import { constructField } from './ApiFormField'; | ||||
| import { ApiFormFieldSet, ApiFormFieldType } from './ApiFormField'; | ||||
|  | ||||
| /** | ||||
|  * Render a 'select' field for selecting from a list of choices | ||||
|  */ | ||||
| export function ChoiceField({ | ||||
|   error, | ||||
|   form, | ||||
|   fieldName, | ||||
|   field, | ||||
|   definitions | ||||
| }: { | ||||
|   error: ReactNode; | ||||
|   form: UseFormReturnType<Record<string, unknown>>; | ||||
|   field: 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 | ||||
|     }); | ||||
|  | ||||
|     form.setValues({ [fieldName]: def.value ?? def.default }); | ||||
|  | ||||
|     return def; | ||||
|   }, [fieldName, field, definitions]); | ||||
|  | ||||
|   const fieldId = useId(fieldName); | ||||
|  | ||||
|   const value: any = useMemo(() => form.values[fieldName], [form.values]); | ||||
|  | ||||
|   // 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 ?? []; | ||||
|  | ||||
|     // TODO: Allow provision of custom render function also | ||||
|  | ||||
|     return choices.map((choice) => { | ||||
|       return { | ||||
|         value: choice.value, | ||||
|         label: choice.display_name | ||||
|       }; | ||||
|     }); | ||||
|   }, [definition]); | ||||
|  | ||||
|   // Callback when an option is selected | ||||
|   function onChange(value: any) { | ||||
|     form.setFieldValue(fieldName, value); | ||||
|  | ||||
|     if (definition.onValueChange) { | ||||
|       definition.onValueChange({ | ||||
|         name: fieldName, | ||||
|         value: value, | ||||
|         field: definition, | ||||
|         form: form | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Select | ||||
|       id={fieldId} | ||||
|       radius="sm" | ||||
|       {...definition} | ||||
|       data={choices} | ||||
|       value={value} | ||||
|       onChange={(value) => onChange(value)} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										221
									
								
								src/frontend/src/components/forms/fields/RelatedModelField.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								src/frontend/src/components/forms/fields/RelatedModelField.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,221 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Input } from '@mantine/core'; | ||||
| import { UseFormReturnType } from '@mantine/form'; | ||||
| import { useDebouncedValue } from '@mantine/hooks'; | ||||
| import { useId } from '@mantine/hooks'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { | ||||
|   ReactNode, | ||||
|   useCallback, | ||||
|   useEffect, | ||||
|   useMemo, | ||||
|   useRef, | ||||
|   useState | ||||
| } from 'react'; | ||||
| import Select from 'react-select'; | ||||
|  | ||||
| import { api } from '../../../App'; | ||||
| import { RenderInstance } from '../../render/Instance'; | ||||
| import { ApiFormProps } from '../ApiForm'; | ||||
| import { ApiFormFieldSet, ApiFormFieldType } from './ApiFormField'; | ||||
| import { constructField } from './ApiFormField'; | ||||
|  | ||||
| /** | ||||
|  * Render a 'select' field for searching the database against a particular model type | ||||
|  */ | ||||
| export function RelatedModelField({ | ||||
|   error, | ||||
|   formProps, | ||||
|   form, | ||||
|   fieldName, | ||||
|   field, | ||||
|   definitions, | ||||
|   limit = 10 | ||||
| }: { | ||||
|   error: ReactNode; | ||||
|   formProps: ApiFormProps; | ||||
|   form: UseFormReturnType<Record<string, unknown>>; | ||||
|   field: ApiFormFieldType; | ||||
|   fieldName: string; | ||||
|   definitions: ApiFormFieldSet; | ||||
|   limit?: number; | ||||
| }) { | ||||
|   const fieldId = useId(fieldName); | ||||
|  | ||||
|   // Extract field definition from provided data | ||||
|   // Where user has provided specific data, override the API definition | ||||
|   const definition: ApiFormFieldType = useMemo( | ||||
|     () => | ||||
|       constructField({ | ||||
|         form: form, | ||||
|         field: field, | ||||
|         fieldName: fieldName, | ||||
|         definitions: definitions | ||||
|       }), | ||||
|     [form.values, field, definitions] | ||||
|   ); | ||||
|  | ||||
|   // Keep track of the primary key value for this field | ||||
|   const [pk, setPk] = useState<number | null>(null); | ||||
|  | ||||
|   // If an initial value is provided, load from the API | ||||
|   useEffect(() => { | ||||
|     // If a value is provided, load the related object | ||||
|     if (form.values) { | ||||
|       let formPk = form.values[fieldName]; | ||||
|  | ||||
|       if (formPk && formPk != pk) { | ||||
|         let url = (definition.api_url || '') + formPk + '/'; | ||||
|  | ||||
|         // TODO: Fix this!! | ||||
|         if (url.startsWith('/api')) { | ||||
|           url = url.substring(4); | ||||
|         } | ||||
|  | ||||
|         api.get(url).then((response) => { | ||||
|           let data = response.data; | ||||
|  | ||||
|           if (data && data.pk) { | ||||
|             let value = { | ||||
|               value: data.pk, | ||||
|               data: data | ||||
|             }; | ||||
|  | ||||
|             setData([value]); | ||||
|             setPk(data.pk); | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   }, [form]); | ||||
|  | ||||
|   const [offset, setOffset] = useState<number>(0); | ||||
|  | ||||
|   const [data, setData] = useState<any[]>([]); | ||||
|  | ||||
|   // Search input query | ||||
|   const [value, setValue] = useState<string>(''); | ||||
|   const [searchText, cancelSearchText] = useDebouncedValue(value, 250); | ||||
|  | ||||
|   // Query controller | ||||
|   const abortControllerRef = useRef<AbortController | null>(null); | ||||
|   const getAbortController = useCallback(() => { | ||||
|     if (!abortControllerRef.current) { | ||||
|       abortControllerRef.current = new AbortController(); | ||||
|     } | ||||
|  | ||||
|     return abortControllerRef.current; | ||||
|   }, []); | ||||
|  | ||||
|   const selectQuery = useQuery({ | ||||
|     enabled: !definition.disabled && !!definition.api_url && !definition.hidden, | ||||
|     queryKey: [`related-field-${fieldName}`, offset, searchText], | ||||
|     queryFn: async () => { | ||||
|       if (!definition.api_url) { | ||||
|         return null; | ||||
|       } | ||||
|  | ||||
|       // TODO: Fix this in the api controller | ||||
|       let url = definition.api_url; | ||||
|  | ||||
|       if (url.startsWith('/api')) { | ||||
|         url = url.substring(4); | ||||
|       } | ||||
|  | ||||
|       return api | ||||
|         .get(url, { | ||||
|           signal: getAbortController().signal, | ||||
|           params: { | ||||
|             ...definition.filters, | ||||
|             search: searchText, | ||||
|             offset: offset, | ||||
|             limit: limit | ||||
|           } | ||||
|         }) | ||||
|         .then((response) => { | ||||
|           let values: any[] = [...data]; | ||||
|  | ||||
|           let results = response.data?.results ?? []; | ||||
|  | ||||
|           results.forEach((item: any) => { | ||||
|             values.push({ | ||||
|               value: item.pk ?? -1, | ||||
|               data: item | ||||
|             }); | ||||
|           }); | ||||
|  | ||||
|           setData(values); | ||||
|           return response; | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           setData([]); | ||||
|           return error; | ||||
|         }); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   /** | ||||
|    * Format an option for display in the select field | ||||
|    */ | ||||
|   function formatOption(option: any) { | ||||
|     let data = option.data ?? option; | ||||
|  | ||||
|     // TODO: If a custom render function is provided, use that | ||||
|  | ||||
|     return ( | ||||
|       <RenderInstance instance={data} model={definition.model ?? 'undefined'} /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // Update form values when the selected value changes | ||||
|   function onChange(value: any) { | ||||
|     let _pk = value?.value ?? null; | ||||
|     form.setValues({ [fieldName]: _pk }); | ||||
|  | ||||
|     setPk(_pk); | ||||
|  | ||||
|     // Run custom callback for this field (if provided) | ||||
|     if (definition.onValueChange) { | ||||
|       definition.onValueChange({ | ||||
|         name: fieldName, | ||||
|         value: _pk, | ||||
|         field: definition, | ||||
|         form: form | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Input.Wrapper {...definition} error={error}> | ||||
|       <Select | ||||
|         id={fieldId} | ||||
|         value={data.find((item) => item.value == pk)} | ||||
|         options={data} | ||||
|         filterOption={null} | ||||
|         onInputChange={(value: any) => { | ||||
|           getAbortController().abort(); | ||||
|           setValue(value); | ||||
|           setOffset(0); | ||||
|           setData([]); | ||||
|         }} | ||||
|         onChange={onChange} | ||||
|         onMenuScrollToBottom={() => setOffset(offset + limit)} | ||||
|         isLoading={ | ||||
|           selectQuery.isFetching || | ||||
|           selectQuery.isLoading || | ||||
|           selectQuery.isRefetching | ||||
|         } | ||||
|         isClearable={!definition.required} | ||||
|         isDisabled={definition.disabled} | ||||
|         isSearchable={true} | ||||
|         placeholder={definition.placeholder || t`Search` + `...`} | ||||
|         loadingMessage={() => t`Loading` + `...`} | ||||
|         menuPortalTarget={document.body} | ||||
|         noOptionsMessage={() => t`No results found`} | ||||
|         menuPosition="fixed" | ||||
|         styles={{ menuPortal: (base: any) => ({ ...base, zIndex: 9999 }) }} | ||||
|         formatOptionLabel={(option: any) => formatOption(option)} | ||||
|       /> | ||||
|     </Input.Wrapper> | ||||
|   ); | ||||
| } | ||||
| @@ -3,6 +3,8 @@ import { Image } from '@mantine/core'; | ||||
| import { Group } from '@mantine/core'; | ||||
| import { Text } from '@mantine/core'; | ||||
|  | ||||
| import { api } from '../../App'; | ||||
|  | ||||
| export function Thumbnail({ | ||||
|   src, | ||||
|   alt = t`Thumbnail`, | ||||
| @@ -12,11 +14,11 @@ export function Thumbnail({ | ||||
|   alt?: string; | ||||
|   size?: number; | ||||
| }) { | ||||
|   // TODO: Use api to determine the correct URL | ||||
|   let url = 'http://localhost:8000' + src; | ||||
|  | ||||
|   // TODO: Use HoverCard to display a larger version of the image | ||||
|  | ||||
|   // TODO: This is a hack until we work out the /api/ path issue | ||||
|   let url = api.getUri({ url: '..' + src }); | ||||
|  | ||||
|   return ( | ||||
|     <Image | ||||
|       src={url} | ||||
|   | ||||
| @@ -25,9 +25,10 @@ import { | ||||
|   IconX | ||||
| } from '@tabler/icons-react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { useEffect, useState } from 'react'; | ||||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||||
|  | ||||
| import { api } from '../../App'; | ||||
| import { RenderInstance } from '../render/Instance'; | ||||
|  | ||||
| // Define type for handling individual search queries | ||||
| type SearchQuery = { | ||||
| @@ -36,7 +37,6 @@ type SearchQuery = { | ||||
|   enabled: boolean; | ||||
|   parameters: any; | ||||
|   results?: any; | ||||
|   render: (result: any) => JSX.Element; | ||||
| }; | ||||
|  | ||||
| // Placeholder function for permissions checks (will be replaced with a proper implementation) | ||||
| @@ -49,12 +49,6 @@ function settingsCheck(setting: string) { | ||||
|   return true; | ||||
| } | ||||
|  | ||||
| // Placeholder function for rendering an individual search result | ||||
| // In the future, this will be defined individually for each result type | ||||
| function renderResult(result: any) { | ||||
|   return <Text size="sm">Result here - ID = {`${result.pk}`}</Text>; | ||||
| } | ||||
|  | ||||
| /* | ||||
|  * Build a list of search queries based on user permissions | ||||
|  */ | ||||
| @@ -64,7 +58,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|       name: 'part', | ||||
|       title: t`Parts`, | ||||
|       parameters: {}, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('part.view') && | ||||
|         settingsCheck('SEARCH_PREVIEW_SHOW_PARTS') | ||||
| @@ -77,7 +70,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|         supplier_detail: true, | ||||
|         manufacturer_detail: true | ||||
|       }, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('part.view') && | ||||
|         permissionCheck('purchase_order.view') && | ||||
| @@ -91,7 +83,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|         supplier_detail: true, | ||||
|         manufacturer_detail: true | ||||
|       }, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('part.view') && | ||||
|         permissionCheck('purchase_order.view') && | ||||
| @@ -101,7 +92,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|       name: 'partcategory', | ||||
|       title: t`Part Categories`, | ||||
|       parameters: {}, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('part_category.view') && | ||||
|         settingsCheck('SEARCH_PREVIEW_SHOW_CATEGORIES') | ||||
| @@ -113,7 +103,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|         part_detail: true, | ||||
|         location_detail: true | ||||
|       }, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('stock.view') && | ||||
|         settingsCheck('SEARCH_PREVIEW_SHOW_STOCK') | ||||
| @@ -122,7 +111,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|       name: 'stocklocation', | ||||
|       title: t`Stock Locations`, | ||||
|       parameters: {}, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('stock_location.view') && | ||||
|         settingsCheck('SEARCH_PREVIEW_SHOW_LOCATIONS') | ||||
| @@ -133,7 +121,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|       parameters: { | ||||
|         part_detail: true | ||||
|       }, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('build.view') && | ||||
|         settingsCheck('SEARCH_PREVIEW_SHOW_BUILD_ORDERS') | ||||
| @@ -142,7 +129,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|       name: 'company', | ||||
|       title: t`Companies`, | ||||
|       parameters: {}, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         (permissionCheck('sales_order.view') || | ||||
|           permissionCheck('purchase_order.view')) && | ||||
| @@ -154,7 +140,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|       parameters: { | ||||
|         supplier_detail: true | ||||
|       }, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('purchase_order.view') && | ||||
|         settingsCheck(`SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS`) | ||||
| @@ -165,7 +150,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|       parameters: { | ||||
|         customer_detail: true | ||||
|       }, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('sales_order.view') && | ||||
|         settingsCheck(`SEARCH_PREVIEW_SHOW_SALES_ORDERS`) | ||||
| @@ -176,7 +160,6 @@ function buildSearchQueries(): SearchQuery[] { | ||||
|       parameters: { | ||||
|         customer_detail: true | ||||
|       }, | ||||
|       render: renderResult, | ||||
|       enabled: | ||||
|         permissionCheck('return_order.view') && | ||||
|         settingsCheck(`SEARCH_PREVIEW_SHOW_RETURN_ORDERS`) | ||||
| @@ -222,7 +205,9 @@ function QueryResultGroup({ | ||||
|         </Group> | ||||
|         <Divider /> | ||||
|         <Stack> | ||||
|           {query.results.results.map((result: any) => query.render(result))} | ||||
|           {query.results.results.map((result: any) => ( | ||||
|             <RenderInstance instance={result} model={query.name} /> | ||||
|           ))} | ||||
|         </Stack> | ||||
|         <Space /> | ||||
|       </Stack> | ||||
| @@ -255,7 +240,7 @@ export function SearchDrawer({ | ||||
|   // Re-fetch data whenever the search term is updated | ||||
|   useEffect(() => { | ||||
|     // TODO: Implement search functionality | ||||
|     refetch(); | ||||
|     searchQuery.refetch(); | ||||
|   }, [searchText]); | ||||
|  | ||||
|   // Function for performing the actual search query | ||||
| @@ -278,8 +263,14 @@ export function SearchDrawer({ | ||||
|       params[query.name] = query.parameters; | ||||
|     }); | ||||
|  | ||||
|     // Cancel any pending search queries | ||||
|     getAbortController().abort(); | ||||
|  | ||||
|     return api | ||||
|       .post(`/search/`, params) | ||||
|       .post(`/search/`, { | ||||
|         params: params, | ||||
|         signal: getAbortController().signal | ||||
|       }) | ||||
|       .then(function (response) { | ||||
|         return response.data; | ||||
|       }) | ||||
| @@ -290,7 +281,7 @@ export function SearchDrawer({ | ||||
|   }; | ||||
|  | ||||
|   // Search query manager | ||||
|   const { data, isError, isFetching, isLoading, refetch } = useQuery( | ||||
|   const searchQuery = useQuery( | ||||
|     ['search', searchText, searchRegex, searchWhole], | ||||
|     performSearch, | ||||
|     { | ||||
| @@ -303,13 +294,15 @@ export function SearchDrawer({ | ||||
|  | ||||
|   // Update query results whenever the search results change | ||||
|   useEffect(() => { | ||||
|     if (data) { | ||||
|       let queries = searchQueries.filter((query) => query.name in data); | ||||
|     if (searchQuery.data) { | ||||
|       let queries = searchQueries.filter( | ||||
|         (query) => query.name in searchQuery.data | ||||
|       ); | ||||
|  | ||||
|       for (let key in data) { | ||||
|       for (let key in searchQuery.data) { | ||||
|         let query = queries.find((q) => q.name == key); | ||||
|         if (query) { | ||||
|           query.results = data[key]; | ||||
|           query.results = searchQuery.data[key]; | ||||
|         } | ||||
|       } | ||||
|  | ||||
| @@ -320,7 +313,17 @@ export function SearchDrawer({ | ||||
|     } else { | ||||
|       setQueryResults([]); | ||||
|     } | ||||
|   }, [data]); | ||||
|   }, [searchQuery.data]); | ||||
|  | ||||
|   // Controller to cancel previous search queries | ||||
|   const abortControllerRef = useRef<AbortController | null>(null); | ||||
|   const getAbortController = useCallback(() => { | ||||
|     if (!abortControllerRef.current) { | ||||
|       abortControllerRef.current = new AbortController(); | ||||
|     } | ||||
|  | ||||
|     return abortControllerRef.current; | ||||
|   }, []); | ||||
|  | ||||
|   // Callback to remove a set of results from the list | ||||
|   function removeResults(query: string) { | ||||
| @@ -359,7 +362,7 @@ export function SearchDrawer({ | ||||
|             size="lg" | ||||
|             variant="outline" | ||||
|             radius="xs" | ||||
|             onClick={() => refetch()} | ||||
|             onClick={() => searchQuery.refetch()} | ||||
|           > | ||||
|             <IconRefresh /> | ||||
|           </ActionIcon> | ||||
| @@ -396,12 +399,12 @@ export function SearchDrawer({ | ||||
|         </Group> | ||||
|       } | ||||
|     > | ||||
|       {isFetching && ( | ||||
|       {searchQuery.isFetching && ( | ||||
|         <Center> | ||||
|           <Loader /> | ||||
|         </Center> | ||||
|       )} | ||||
|       {!isFetching && !isError && ( | ||||
|       {!searchQuery.isFetching && !searchQuery.isError && ( | ||||
|         <Stack spacing="md"> | ||||
|           {queryResults.map((query) => ( | ||||
|             <QueryResultGroup | ||||
| @@ -411,7 +414,7 @@ export function SearchDrawer({ | ||||
|           ))} | ||||
|         </Stack> | ||||
|       )} | ||||
|       {isError && ( | ||||
|       {searchQuery.isError && ( | ||||
|         <Alert | ||||
|           color="red" | ||||
|           radius="sm" | ||||
| @@ -422,17 +425,20 @@ export function SearchDrawer({ | ||||
|           <Trans>An error occurred during search query</Trans> | ||||
|         </Alert> | ||||
|       )} | ||||
|       {searchText && !isFetching && !isError && queryResults.length == 0 && ( | ||||
|         <Alert | ||||
|           color="blue" | ||||
|           radius="sm" | ||||
|           variant="light" | ||||
|           title={t`No results`} | ||||
|           icon={<IconSearch size="1rem" />} | ||||
|         > | ||||
|           <Trans>No results available for search query</Trans> | ||||
|         </Alert> | ||||
|       )} | ||||
|       {searchText && | ||||
|         !searchQuery.isFetching && | ||||
|         !searchQuery.isError && | ||||
|         queryResults.length == 0 && ( | ||||
|           <Alert | ||||
|             color="blue" | ||||
|             radius="sm" | ||||
|             variant="light" | ||||
|             title={t`No results`} | ||||
|             icon={<IconSearch size="1rem" />} | ||||
|           > | ||||
|             <Trans>No results available for search query</Trans> | ||||
|           </Alert> | ||||
|         )} | ||||
|     </Drawer> | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										72
									
								
								src/frontend/src/components/render/Company.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/frontend/src/components/render/Company.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| import { ReactNode } from 'react'; | ||||
|  | ||||
| import { RenderInlineModel } from './Instance'; | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single Address instance | ||||
|  */ | ||||
| export function RenderAddress({ address }: { address: any }): ReactNode { | ||||
|   let text = [ | ||||
|     address.title, | ||||
|     address.country, | ||||
|     address.postal_code, | ||||
|     address.postal_city, | ||||
|     address.province, | ||||
|     address.line1, | ||||
|     address.line2 | ||||
|   ] | ||||
|     .filter(Boolean) | ||||
|     .join(', '); | ||||
|  | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={address.description} | ||||
|       secondary={address.address} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single Company instance | ||||
|  */ | ||||
| export function RenderCompany({ company }: { company: any }): ReactNode { | ||||
|   // TODO: Handle URL | ||||
|  | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       image={company.thumnbnail || company.image} | ||||
|       primary={company.name} | ||||
|       secondary={company.description} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single Contact instance | ||||
|  */ | ||||
| export function RenderContact({ contact }: { contact: any }): ReactNode { | ||||
|   return <RenderInlineModel primary={contact.name} />; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single SupplierPart instance | ||||
|  */ | ||||
| export function RenderSupplierPart({ | ||||
|   supplierpart | ||||
| }: { | ||||
|   supplierpart: any; | ||||
| }): ReactNode { | ||||
|   // TODO: Handle image | ||||
|   // TODO: handle URL | ||||
|  | ||||
|   let supplier = supplierpart.supplier_detail ?? {}; | ||||
|   let part = supplierpart.part_detail ?? {}; | ||||
|  | ||||
|   let text = supplierpart.SKU; | ||||
|  | ||||
|   if (supplier.name) { | ||||
|     text = `${supplier.name} | ${text}`; | ||||
|   } | ||||
|  | ||||
|   return <RenderInlineModel primary={text} secondary={part.full_name} />; | ||||
| } | ||||
							
								
								
									
										98
									
								
								src/frontend/src/components/render/Instance.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/frontend/src/components/render/Instance.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Alert } from '@mantine/core'; | ||||
| import { Group, Text } from '@mantine/core'; | ||||
| import { ReactNode } from 'react'; | ||||
|  | ||||
| import { Thumbnail } from '../items/Thumbnail'; | ||||
| import { | ||||
|   RenderAddress, | ||||
|   RenderCompany, | ||||
|   RenderContact, | ||||
|   RenderSupplierPart | ||||
| } from './Company'; | ||||
| import { | ||||
|   RenderPurchaseOrder, | ||||
|   RenderReturnOrder, | ||||
|   RenderSalesOrder, | ||||
|   RenderSalesOrderShipment | ||||
| } from './Order'; | ||||
| import { RenderPart, RenderPartCategory } from './Part'; | ||||
| import { RenderStockLocation } from './Stock'; | ||||
| import { RenderOwner, RenderUser } from './User'; | ||||
|  | ||||
| // import { ApiFormFieldType } from "../forms/fields/ApiFormField"; | ||||
|  | ||||
| /** | ||||
|  * Render an instance of a database model, depending on the provided data | ||||
|  */ | ||||
| export function RenderInstance({ | ||||
|   model, | ||||
|   instance | ||||
| }: { | ||||
|   model: string; | ||||
|   instance: any; | ||||
| }): ReactNode { | ||||
|   switch (model) { | ||||
|     case 'address': | ||||
|       return <RenderAddress address={instance} />; | ||||
|     case 'company': | ||||
|       return <RenderCompany company={instance} />; | ||||
|     case 'contact': | ||||
|       return <RenderContact contact={instance} />; | ||||
|     case 'owner': | ||||
|       return <RenderOwner owner={instance} />; | ||||
|     case 'part': | ||||
|       return <RenderPart part={instance} />; | ||||
|     case 'partcategory': | ||||
|       return <RenderPartCategory category={instance} />; | ||||
|     case 'purchaseorder': | ||||
|       return <RenderPurchaseOrder order={instance} />; | ||||
|     case 'returnorder': | ||||
|       return <RenderReturnOrder order={instance} />; | ||||
|     case 'salesoder': | ||||
|       return <RenderSalesOrder order={instance} />; | ||||
|     case 'salesordershipment': | ||||
|       return <RenderSalesOrderShipment shipment={instance} />; | ||||
|     case 'stocklocation': | ||||
|       return <RenderStockLocation location={instance} />; | ||||
|     case 'supplierpart': | ||||
|       return <RenderSupplierPart supplierpart={instance} />; | ||||
|     case 'user': | ||||
|       return <RenderUser user={instance} />; | ||||
|     default: | ||||
|       // Unknown model | ||||
|       return ( | ||||
|         <Alert color="red" title={t`Unknown model: ${model}`}> | ||||
|           <></> | ||||
|         </Alert> | ||||
|       ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Helper function for rendering an inline model in a consistent style | ||||
|  */ | ||||
| export function RenderInlineModel({ | ||||
|   primary, | ||||
|   secondary, | ||||
|   image, | ||||
|   labels, | ||||
|   url | ||||
| }: { | ||||
|   primary: string; | ||||
|   secondary?: string; | ||||
|   image?: string; | ||||
|   labels?: string[]; | ||||
|   url?: string; | ||||
| }): ReactNode { | ||||
|   // TODO: Handle labels | ||||
|   // TODO: Handle URL | ||||
|  | ||||
|   return ( | ||||
|     <Group spacing="xs"> | ||||
|       {image && Thumbnail({ src: image, size: 18 })} | ||||
|       <Text size="sm">{primary}</Text> | ||||
|       {secondary && <Text size="xs">{secondary}</Text>} | ||||
|     </Group> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										70
									
								
								src/frontend/src/components/render/Order.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/frontend/src/components/render/Order.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { ReactNode } from 'react'; | ||||
|  | ||||
| import { RenderInlineModel } from './Instance'; | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single PurchaseOrder instance | ||||
|  */ | ||||
| export function RenderPurchaseOrder({ order }: { order: any }): ReactNode { | ||||
|   let supplier = order.supplier_detail || {}; | ||||
|  | ||||
|   // TODO: Handle URL | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={order.reference} | ||||
|       secondary={order.description} | ||||
|       image={supplier.thumnbnail || supplier.image} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single ReturnOrder instance | ||||
|  */ | ||||
| export function RenderReturnOrder({ order }: { order: any }): ReactNode { | ||||
|   let customer = order.customer_detail || {}; | ||||
|  | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={order.reference} | ||||
|       secondary={order.description} | ||||
|       image={customer.thumnbnail || customer.image} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single SalesOrder instance | ||||
|  */ | ||||
| export function RenderSalesOrder({ order }: { order: any }): ReactNode { | ||||
|   let customer = order.customer_detail || {}; | ||||
|  | ||||
|   // TODO: Handle URL | ||||
|  | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={order.reference} | ||||
|       secondary={order.description} | ||||
|       image={customer.thumnbnail || customer.image} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single SalesOrderAllocation instance | ||||
|  */ | ||||
| export function RenderSalesOrderShipment({ | ||||
|   shipment | ||||
| }: { | ||||
|   shipment: any; | ||||
| }): ReactNode { | ||||
|   let order = shipment.sales_order_detail || {}; | ||||
|  | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={order.reference} | ||||
|       secondary={t`Shipment` + ` ${shipment.description}`} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										32
									
								
								src/frontend/src/components/render/Part.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/frontend/src/components/render/Part.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import { ReactNode } from 'react'; | ||||
|  | ||||
| import { RenderInlineModel } from './Instance'; | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single Part instance | ||||
|  */ | ||||
| export function RenderPart({ part }: { part: any }): ReactNode { | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={part.name} | ||||
|       secondary={part.description} | ||||
|       image={part.thumnbnail || part.image} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a PartCategory instance | ||||
|  */ | ||||
| export function RenderPartCategory({ category }: { category: any }): ReactNode { | ||||
|   // TODO: Handle URL | ||||
|  | ||||
|   let lvl = '-'.repeat(category.level || 0); | ||||
|  | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={`${lvl} ${category.name}`} | ||||
|       secondary={category.description} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/frontend/src/components/render/Stock.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/frontend/src/components/render/Stock.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { ReactNode } from 'react'; | ||||
|  | ||||
| import { RenderInlineModel } from './Instance'; | ||||
|  | ||||
| /** | ||||
|  * Inline rendering of a single StockLocation instance | ||||
|  */ | ||||
| export function RenderStockLocation({ | ||||
|   location | ||||
| }: { | ||||
|   location: any; | ||||
| }): ReactNode { | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={location.name} | ||||
|       secondary={location.description} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										18
									
								
								src/frontend/src/components/render/User.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/frontend/src/components/render/User.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| import { ReactNode } from 'react'; | ||||
|  | ||||
| import { RenderInlineModel } from './Instance'; | ||||
|  | ||||
| export function RenderOwner({ owner }: { owner: any }): ReactNode { | ||||
|   // TODO: Icon based on user / group status? | ||||
|  | ||||
|   return <RenderInlineModel primary={owner.name} />; | ||||
| } | ||||
|  | ||||
| export function RenderUser({ user }: { user: any }): ReactNode { | ||||
|   return ( | ||||
|     <RenderInlineModel | ||||
|       primary={user.username} | ||||
|       secondary={`${user.first_name} ${user.last_name}`} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -12,4 +12,7 @@ export type TableColumn = { | ||||
|   filter?: any; // A custom filter function | ||||
|   filtering?: boolean; // Whether the column is filterable | ||||
|   width?: number; // The width of the column | ||||
|   noWrap?: boolean; // Whether the column should wrap | ||||
|   ellipsis?: boolean; // Whether the column should be ellipsized | ||||
|   textAlignment?: 'left' | 'center' | 'right'; // The text alignment of the column | ||||
| }; | ||||
|   | ||||
							
								
								
									
										48
									
								
								src/frontend/src/components/tables/RowActions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/frontend/src/components/tables/RowActions.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { ActionIcon } from '@mantine/core'; | ||||
| import { Menu } from '@mantine/core'; | ||||
| import { IconDots } from '@tabler/icons-react'; | ||||
| import { ReactNode } from 'react'; | ||||
|  | ||||
| // Type definition for a table row action | ||||
| export type RowAction = { | ||||
|   title: string; | ||||
|   onClick: () => void; | ||||
|   tooltip?: string; | ||||
|   icon?: ReactNode; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Component for displaying actions for a row in a table. | ||||
|  * Displays a simple dropdown menu with a list of actions. | ||||
|  */ | ||||
| export function RowActions({ | ||||
|   title, | ||||
|   actions | ||||
| }: { | ||||
|   title?: string; | ||||
|   actions: RowAction[]; | ||||
| }): ReactNode { | ||||
|   return ( | ||||
|     <Menu> | ||||
|       <Menu.Target> | ||||
|         <ActionIcon variant="subtle" color="gray"> | ||||
|           <IconDots /> | ||||
|         </ActionIcon> | ||||
|       </Menu.Target> | ||||
|       <Menu.Dropdown> | ||||
|         <Menu.Label>{title || t`Actions`}</Menu.Label> | ||||
|         {actions.map((action, idx) => ( | ||||
|           <Menu.Item | ||||
|             key={idx} | ||||
|             onClick={action.onClick} | ||||
|             icon={action.icon} | ||||
|             title={action.tooltip || action.title} | ||||
|           > | ||||
|             {action.title} | ||||
|           </Menu.Item> | ||||
|         ))} | ||||
|       </Menu.Dropdown> | ||||
|     </Menu> | ||||
|   ); | ||||
| } | ||||
| @@ -1,13 +1,16 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Text } from '@mantine/core'; | ||||
| import { IconEdit, IconTrash } from '@tabler/icons-react'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| import { editPart } from '../../../functions/forms/PartForms'; | ||||
| import { notYetImplemented } from '../../../functions/notifications'; | ||||
| import { shortenString } from '../../../functions/tables'; | ||||
| import { ThumbnailHoverCard } from '../../items/Thumbnail'; | ||||
| import { TableColumn } from '../Column'; | ||||
| import { TableFilter } from '../Filter'; | ||||
| import { InvenTreeTable } from '../InvenTreeTable'; | ||||
| import { RowActions } from '../RowActions'; | ||||
|  | ||||
| /** | ||||
|  * Construct a list of columns for the part table | ||||
| @@ -17,6 +20,7 @@ function partTableColumns(): TableColumn[] { | ||||
|     { | ||||
|       accessor: 'name', | ||||
|       sortable: true, | ||||
|       noWrap: true, | ||||
|       title: t`Part`, | ||||
|       render: function (record: any) { | ||||
|         // TODO - Link to the part detail page | ||||
| @@ -78,6 +82,38 @@ function partTableColumns(): TableColumn[] { | ||||
|       accessor: 'link', | ||||
|       title: t`Link`, | ||||
|       switchable: true | ||||
|     }, | ||||
|     { | ||||
|       accessor: 'actions', | ||||
|       title: '', | ||||
|       switchable: false, | ||||
|       render: function (record: any) { | ||||
|         return ( | ||||
|           <RowActions | ||||
|             title={`Part Actions`} | ||||
|             actions={[ | ||||
|               { | ||||
|                 title: t`Edit`, | ||||
|                 icon: <IconEdit color="blue" />, | ||||
|                 onClick: () => | ||||
|                   editPart({ | ||||
|                     part_id: record.pk, | ||||
|                     callback: () => { | ||||
|                       // TODO: Reload the table, somehow? | ||||
|                       // TODO: Insert / update a single row in the table? | ||||
|                       // TODO: We need to have a hook back into the table | ||||
|                     } | ||||
|                   }) | ||||
|               }, | ||||
|               { | ||||
|                 title: t`Delete`, | ||||
|                 onClick: notYetImplemented, | ||||
|                 icon: <IconTrash color="red" /> | ||||
|               } | ||||
|             ]} | ||||
|           /> | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|   ]; | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import { ActionButton } from '../../items/ActionButton'; | ||||
| import { ThumbnailHoverCard } from '../../items/Thumbnail'; | ||||
| import { TableColumn } from '../Column'; | ||||
| import { TableFilter } from '../Filter'; | ||||
| import { RowActions } from '../RowActions'; | ||||
| import { InvenTreeTable } from './../InvenTreeTable'; | ||||
|  | ||||
| /** | ||||
| @@ -77,26 +78,26 @@ function stockItemTableColumns(): TableColumn[] { | ||||
|     // TODO: notes | ||||
|     { | ||||
|       accessor: 'actions', | ||||
|       title: t`Actions`, | ||||
|       title: '', | ||||
|       sortable: false, | ||||
|       switchable: false, | ||||
|       render: function (record: any) { | ||||
|         return ( | ||||
|           <Group position="right" spacing={5} noWrap={true}> | ||||
|             {/* {EditButton(setEditing, editing)} */} | ||||
|             {/* {DeleteButton()} */} | ||||
|             <ActionButton | ||||
|               color="green" | ||||
|               icon={<IconEdit />} | ||||
|               tooltip="Edit stock item" | ||||
|               onClick={() => notYetImplemented()} | ||||
|             /> | ||||
|             <ActionButton | ||||
|               color="red" | ||||
|               tooltip="Delete stock item" | ||||
|               icon={<IconTrash />} | ||||
|               onClick={() => notYetImplemented()} | ||||
|             /> | ||||
|           </Group> | ||||
|           <RowActions | ||||
|             title={t`Stock Actions`} | ||||
|             actions={[ | ||||
|               { | ||||
|                 title: t`Edit`, | ||||
|                 icon: <IconEdit color="blue" />, | ||||
|                 onClick: notYetImplemented | ||||
|               }, | ||||
|               { | ||||
|                 title: t`Delete`, | ||||
|                 icon: <IconTrash color="red" />, | ||||
|                 onClick: notYetImplemented | ||||
|               } | ||||
|             ]} | ||||
|           /> | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|   | ||||
| @@ -8,7 +8,9 @@ import { | ||||
| import { useColorScheme, useLocalStorage } from '@mantine/hooks'; | ||||
| import { ModalsProvider } from '@mantine/modals'; | ||||
| import { Notifications } from '@mantine/notifications'; | ||||
| import { QueryClientProvider } from '@tanstack/react-query'; | ||||
|  | ||||
| import { queryClient } from '../App'; | ||||
| import { QrCodeModal } from '../components/modals/QrCodeModal'; | ||||
| import { useLocalState } from '../states/LocalState'; | ||||
|  | ||||
| @@ -58,12 +60,14 @@ export function ThemeContext({ children }: { children: JSX.Element }) { | ||||
|     > | ||||
|       <MantineProvider theme={myTheme} withGlobalStyles withNormalizeCSS> | ||||
|         <Notifications /> | ||||
|         <ModalsProvider | ||||
|           labels={{ confirm: t`Submit`, cancel: t`Cancel` }} | ||||
|           modals={{ qr: QrCodeModal }} | ||||
|         > | ||||
|           {children} | ||||
|         </ModalsProvider> | ||||
|         <QueryClientProvider client={queryClient}> | ||||
|           <ModalsProvider | ||||
|             labels={{ confirm: t`Submit`, cancel: t`Cancel` }} | ||||
|             modals={{ qr: QrCodeModal }} | ||||
|           > | ||||
|             {children} | ||||
|           </ModalsProvider> | ||||
|         </QueryClientProvider> | ||||
|       </MantineProvider> | ||||
|     </ColorSchemeProvider> | ||||
|   ); | ||||
|   | ||||
							
								
								
									
										182
									
								
								src/frontend/src/functions/forms.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								src/frontend/src/functions/forms.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,182 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { modals } from '@mantine/modals'; | ||||
| import { notifications } from '@mantine/notifications'; | ||||
| import { AxiosResponse } from 'axios'; | ||||
|  | ||||
| import { api } from '../App'; | ||||
| import { ApiForm, ApiFormProps } from '../components/forms/ApiForm'; | ||||
| import { ApiFormFieldType } from '../components/forms/fields/ApiFormField'; | ||||
| import { invalidResponse, permissionDenied } from './notifications'; | ||||
| import { generateUniqueId } from './uid'; | ||||
|  | ||||
| /** | ||||
|  * Construct an API url from the provided ApiFormProps object | ||||
|  */ | ||||
| export function constructFormUrl(props: ApiFormProps): string { | ||||
|   let url = props.url; | ||||
|  | ||||
|   if (!url.endsWith('/')) { | ||||
|     url += '/'; | ||||
|   } | ||||
|  | ||||
|   if (props.pk && props.pk > 0) { | ||||
|     url += `${props.pk}/`; | ||||
|   } | ||||
|  | ||||
|   return url; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Extract the available fields (for a given method) from the response object | ||||
|  * | ||||
|  * @returns - A list of field definitions, or null if there was an error | ||||
|  */ | ||||
| export function extractAvailableFields( | ||||
|   response: AxiosResponse, | ||||
|   method?: string | ||||
| ): Record<string, ApiFormFieldType> | null { | ||||
|   // OPTIONS request *must* return 200 status | ||||
|   if (response.status != 200) { | ||||
|     invalidResponse(response.status); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   let actions: any = response.data?.actions ?? null; | ||||
|  | ||||
|   if (!method) { | ||||
|     notifications.show({ | ||||
|       title: t`Form Error`, | ||||
|       message: t`Form method not provided`, | ||||
|       color: 'red' | ||||
|     }); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   if (!actions) { | ||||
|     notifications.show({ | ||||
|       title: t`Form Error`, | ||||
|       message: t`Response did not contain action data`, | ||||
|       color: 'red' | ||||
|     }); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   method = method.toUpperCase(); | ||||
|  | ||||
|   if (!(method in actions)) { | ||||
|     // Missing method - this means user does not have appropriate permission | ||||
|     permissionDenied(); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   let fields: Record<string, ApiFormFieldType> = {}; | ||||
|  | ||||
|   for (const fieldName in actions[method]) { | ||||
|     const field = actions[method][fieldName]; | ||||
|     fields[fieldName] = { | ||||
|       ...field, | ||||
|       name: fieldName, | ||||
|       fieldType: field.type, | ||||
|       description: field.help_text, | ||||
|       value: field.value ?? field.default | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   return fields; | ||||
| } | ||||
|  | ||||
| /* | ||||
|  * Construct and open a modal form | ||||
|  * @param title : | ||||
|  */ | ||||
| export function openModalApiForm(props: ApiFormProps) { | ||||
|   // method property *must* be supplied | ||||
|   if (!props.method) { | ||||
|     notifications.show({ | ||||
|       title: t`Invalid Form`, | ||||
|       message: t`method parameter not supplied`, | ||||
|       color: 'red' | ||||
|     }); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   let url = constructFormUrl(props); | ||||
|  | ||||
|   // Make OPTIONS request first | ||||
|   api | ||||
|     .options(url) | ||||
|     .then((response) => { | ||||
|       // Extract available fields from the OPTIONS response (and handle any errors) | ||||
|       let fields: Record<string, ApiFormFieldType> | null = | ||||
|         extractAvailableFields(response, props.method); | ||||
|  | ||||
|       if (fields == null) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Generate a random modal ID for controller | ||||
|       let modalId: string = `modal-${props.title}-` + generateUniqueId(); | ||||
|  | ||||
|       modals.open({ | ||||
|         title: props.title, | ||||
|         modalId: modalId, | ||||
|         onClose: () => { | ||||
|           props.onClose ? props.onClose() : null; | ||||
|         }, | ||||
|         children: ( | ||||
|           <ApiForm modalId={modalId} props={props} fieldDefinitions={fields} /> | ||||
|         ) | ||||
|       }); | ||||
|     }) | ||||
|     .catch((error) => { | ||||
|       console.log('Error:', error); | ||||
|       if (error.response) { | ||||
|         invalidResponse(error.response.status); | ||||
|       } else { | ||||
|         notifications.show({ | ||||
|           title: t`Form Error`, | ||||
|           message: error.message, | ||||
|           color: 'red' | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Opens a modal form to create a new model instance | ||||
|  */ | ||||
| export function openCreateApiForm(props: ApiFormProps) { | ||||
|   let createProps: ApiFormProps = { | ||||
|     ...props, | ||||
|     method: 'POST' | ||||
|   }; | ||||
|  | ||||
|   openModalApiForm(createProps); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Open a modal form to edit a model instance | ||||
|  */ | ||||
| export function openEditApiForm(props: ApiFormProps) { | ||||
|   let editProps: ApiFormProps = { | ||||
|     ...props, | ||||
|     fetchInitialData: props.fetchInitialData ?? true, | ||||
|     method: 'PUT' | ||||
|   }; | ||||
|  | ||||
|   openModalApiForm(editProps); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Open a modal form to delete a model instancel | ||||
|  */ | ||||
| export function openDeleteApiForm(props: ApiFormProps) { | ||||
|   let deleteProps: ApiFormProps = { | ||||
|     ...props, | ||||
|     method: 'DELETE', | ||||
|     submitText: t`Delete`, | ||||
|     submitColor: 'red' | ||||
|   }; | ||||
|  | ||||
|   openModalApiForm(deleteProps); | ||||
| } | ||||
							
								
								
									
										126
									
								
								src/frontend/src/functions/forms/PartForms.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								src/frontend/src/functions/forms/PartForms.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,126 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
|  | ||||
| import { | ||||
|   ApiFormFieldSet, | ||||
|   ApiFormFieldType | ||||
| } from '../../components/forms/fields/ApiFormField'; | ||||
| import { openCreateApiForm, openEditApiForm } from '../forms'; | ||||
|  | ||||
| /** | ||||
|  * Construct a set of fields for creating / editing a Part instance | ||||
|  */ | ||||
| export function partFields({ | ||||
|   editing = false, | ||||
|   category_id | ||||
| }: { | ||||
|   editing?: boolean; | ||||
|   category_id?: number; | ||||
| }): ApiFormFieldSet { | ||||
|   let fields: ApiFormFieldSet = { | ||||
|     category: { | ||||
|       filters: { | ||||
|         strucural: false | ||||
|       } | ||||
|     }, | ||||
|     name: {}, | ||||
|     IPN: {}, | ||||
|     revision: {}, | ||||
|     description: {}, | ||||
|     variant_of: {}, | ||||
|     keywords: {}, | ||||
|     units: {}, | ||||
|     link: {}, | ||||
|     default_location: { | ||||
|       filters: { | ||||
|         structural: false | ||||
|       } | ||||
|     }, | ||||
|     default_expiry: {}, | ||||
|     minimum_stock: {}, | ||||
|     responsible: {}, | ||||
|     component: {}, | ||||
|     assembly: {}, | ||||
|     is_template: {}, | ||||
|     trackable: {}, | ||||
|     purchaseable: {}, | ||||
|     salable: {}, | ||||
|     virtual: {}, | ||||
|     active: {} | ||||
|   }; | ||||
|  | ||||
|   if (category_id != null) { | ||||
|     // TODO: Set the value of the category field | ||||
|   } | ||||
|  | ||||
|   if (!editing) { | ||||
|     // TODO: Hide 'active' field | ||||
|   } | ||||
|  | ||||
|   // TODO: pop 'expiry' field if expiry not enabled | ||||
|   delete fields['default_expiry']; | ||||
|  | ||||
|   // TODO: pop 'revision' field if PART_ENABLE_REVISION is False | ||||
|   delete fields['revision']; | ||||
|  | ||||
|   // TODO: handle part duplications | ||||
|  | ||||
|   return fields; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Launch a dialog to create a new Part instance | ||||
|  */ | ||||
| export function createPart() { | ||||
|   openCreateApiForm({ | ||||
|     name: 'part-create', | ||||
|     title: t`Create Part`, | ||||
|     url: '/part/', | ||||
|     successMessage: t`Part created`, | ||||
|     fields: partFields({}) | ||||
|   }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Launch a dialog to edit an existing Part instance | ||||
|  * @param part The ID of the part to edit | ||||
|  */ | ||||
| export function editPart({ | ||||
|   part_id, | ||||
|   callback | ||||
| }: { | ||||
|   part_id: number; | ||||
|   callback?: () => void; | ||||
| }) { | ||||
|   openEditApiForm({ | ||||
|     name: 'part-edit', | ||||
|     title: t`Edit Part`, | ||||
|     url: '/part/', | ||||
|     pk: part_id, | ||||
|     successMessage: t`Part updated`, | ||||
|     fields: partFields({ editing: true }) | ||||
|   }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Construct a set of fields for creating / editing a PartCategory instance | ||||
|  */ | ||||
| export function partCategoryFields({}: {}): ApiFormFieldSet { | ||||
|   let fields: ApiFormFieldSet = { | ||||
|     parent: { | ||||
|       description: t`Parent part category`, | ||||
|       required: false | ||||
|     }, | ||||
|     name: {}, | ||||
|     description: {}, | ||||
|     default_location: { | ||||
|       filters: { | ||||
|         structural: false | ||||
|       } | ||||
|     }, | ||||
|     default_keywords: {}, | ||||
|     structural: {}, | ||||
|     icon: {} | ||||
|   }; | ||||
|  | ||||
|   return fields; | ||||
| } | ||||
							
								
								
									
										106
									
								
								src/frontend/src/functions/forms/StockForms.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/frontend/src/functions/forms/StockForms.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
|  | ||||
| import { | ||||
|   ApiFormChangeCallback, | ||||
|   ApiFormFieldSet, | ||||
|   ApiFormFieldType | ||||
| } from '../../components/forms/fields/ApiFormField'; | ||||
| import { openCreateApiForm, openEditApiForm } from '../forms'; | ||||
|  | ||||
| /** | ||||
|  * Construct a set of fields for creating / editing a StockItem instance | ||||
|  */ | ||||
| export function stockFields({}: {}): ApiFormFieldSet { | ||||
|   let fields: ApiFormFieldSet = { | ||||
|     part: { | ||||
|       onValueChange: (change: ApiFormChangeCallback) => { | ||||
|         // TODO: implement this | ||||
|         console.log('part changed: ', change.value); | ||||
|       } | ||||
|     }, | ||||
|     supplier_part: { | ||||
|       // TODO: icon | ||||
|       // TODO: implement adjustFilters | ||||
|       filters: { | ||||
|         part_detail: true, | ||||
|         supplier_detail: true | ||||
|       } | ||||
|     }, | ||||
|     use_pack_size: { | ||||
|       description: t`Add given quantity as packs instead of individual items` | ||||
|     }, | ||||
|     location: { | ||||
|       filters: { | ||||
|         structural: false | ||||
|       } | ||||
|       // TODO: icon | ||||
|     }, | ||||
|     quantity: { | ||||
|       description: t`Enter initial quantity for this stock item` | ||||
|     }, | ||||
|     serial_numbers: { | ||||
|       // TODO: icon | ||||
|       fieldType: 'string', | ||||
|       label: t`Serial Numbers`, | ||||
|       description: t`Enter serial numbers for new stock (or leave blank)`, | ||||
|       required: false | ||||
|     }, | ||||
|     serial: { | ||||
|       // 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 | ||||
|  | ||||
|   return fields; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Launch a form to create a new StockItem instance | ||||
|  */ | ||||
| export function createStockItem() { | ||||
|   openCreateApiForm({ | ||||
|     name: 'stockitem-create', | ||||
|     url: '/stock/', | ||||
|     fields: stockFields({}), | ||||
|     title: t`Create Stock Item` | ||||
|   }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Launch a form to edit an existing StockItem instance | ||||
|  * @param item : primary key of the StockItem to edit | ||||
|  */ | ||||
| export function editStockItem(item: number) { | ||||
|   openEditApiForm({ | ||||
|     name: 'stockitem-edit', | ||||
|     url: '/stock/', | ||||
|     pk: item, | ||||
|     fields: stockFields({}), | ||||
|     title: t`Edit Stock Item` | ||||
|   }); | ||||
| } | ||||
| @@ -11,3 +11,26 @@ export function notYetImplemented() { | ||||
|     color: 'red' | ||||
|   }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Show a notification that the user does not have permission to perform the action | ||||
|  */ | ||||
| export function permissionDenied() { | ||||
|   notifications.show({ | ||||
|     title: t`Permission denied`, | ||||
|     message: t`You do not have permission to perform this action`, | ||||
|     color: 'red' | ||||
|   }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Display a notification on an invalid return code | ||||
|  */ | ||||
| export function invalidResponse(returnCode: number) { | ||||
|   // TODO: Specific return code messages | ||||
|   notifications.show({ | ||||
|     title: t`Invalid Return Code`, | ||||
|     message: t`Server returned status ${returnCode}`, | ||||
|     color: 'red' | ||||
|   }); | ||||
| } | ||||
|   | ||||
							
								
								
									
										15
									
								
								src/frontend/src/functions/uid.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/frontend/src/functions/uid.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| // dec2hex :: Integer -> String | ||||
| // i.e. 0-255 -> '00'-'ff' | ||||
| function dec2hex(dec: number) { | ||||
|   return dec.toString(16).padStart(2, '0'); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Generate a unique ID string with the specified number of values | ||||
|  */ | ||||
| export function generateUniqueId(length: number = 8): string { | ||||
|   let arr = new Uint8Array(length / 2); | ||||
|   window.crypto.getRandomValues(arr); | ||||
|  | ||||
|   return Array.from(arr, dec2hex).join(''); | ||||
| } | ||||
| @@ -1,8 +1,83 @@ | ||||
| import { Trans } from '@lingui/macro'; | ||||
| import { Button } from '@mantine/core'; | ||||
| import { Group, Text } from '@mantine/core'; | ||||
| import { Accordion } from '@mantine/core'; | ||||
| import { ReactNode } from 'react'; | ||||
|  | ||||
| import { ApiFormProps } from '../../components/forms/ApiForm'; | ||||
| import { ApiFormChangeCallback } from '../../components/forms/fields/ApiFormField'; | ||||
| import { PlaceholderPill } from '../../components/items/Placeholder'; | ||||
| import { StylishText } from '../../components/items/StylishText'; | ||||
| import { openCreateApiForm, openEditApiForm } from '../../functions/forms'; | ||||
| import { | ||||
|   createPart, | ||||
|   editPart, | ||||
|   partCategoryFields | ||||
| } from '../../functions/forms/PartForms'; | ||||
| import { createStockItem } from '../../functions/forms/StockForms'; | ||||
|  | ||||
| // Generate some example forms using the modal API forms interface | ||||
| function ApiFormsPlayground() { | ||||
|   let fields = partCategoryFields({}); | ||||
|  | ||||
|   const editCategoryForm: ApiFormProps = { | ||||
|     name: 'partcategory', | ||||
|     url: '/part/category/', | ||||
|     pk: 2, | ||||
|     title: 'Edit Category', | ||||
|     fields: fields | ||||
|   }; | ||||
|  | ||||
|   const createAttachmentForm: ApiFormProps = { | ||||
|     name: 'createattachment', | ||||
|     url: '/part/attachment/', | ||||
|     title: 'Create Attachment', | ||||
|     successMessage: 'Attachment uploaded', | ||||
|     fields: { | ||||
|       part: { | ||||
|         value: 1 | ||||
|       }, | ||||
|       attachment: {}, | ||||
|       comment: {} | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Group> | ||||
|         <Button onClick={() => createPart()}>Create New Part</Button> | ||||
|         <Button onClick={() => editPart({ part_id: 1 })}>Edit Part</Button> | ||||
|         <Button onClick={() => createStockItem()}>Create Stock Item</Button> | ||||
|         <Button onClick={() => openEditApiForm(editCategoryForm)}> | ||||
|           Edit Category | ||||
|         </Button> | ||||
|         <Button onClick={() => openCreateApiForm(createAttachmentForm)}> | ||||
|           Create Attachment | ||||
|         </Button> | ||||
|       </Group> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** Construct a simple accordion group with title and content */ | ||||
| function PlaygroundArea({ | ||||
|   title, | ||||
|   content | ||||
| }: { | ||||
|   title: string; | ||||
|   content: ReactNode; | ||||
| }) { | ||||
|   return ( | ||||
|     <> | ||||
|       <Accordion.Item value={`accordion-playground-{title}`}> | ||||
|         <Accordion.Control> | ||||
|           <Text>{title}</Text> | ||||
|         </Accordion.Control> | ||||
|         <Accordion.Panel>{content}</Accordion.Panel> | ||||
|       </Accordion.Item> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default function Playground() { | ||||
|   return ( | ||||
| @@ -18,6 +93,12 @@ export default function Playground() { | ||||
|           This page is a showcase for the possibilities of Platform UI. | ||||
|         </Trans> | ||||
|       </Text> | ||||
|       <Accordion defaultValue=""> | ||||
|         <PlaygroundArea | ||||
|           title="API Forms" | ||||
|           content={<ApiFormsPlayground />} | ||||
|         ></PlaygroundArea> | ||||
|       </Accordion> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -315,7 +315,7 @@ | ||||
|     "@babel/plugin-transform-modules-commonjs" "^7.22.15" | ||||
|     "@babel/plugin-transform-typescript" "^7.22.15" | ||||
|  | ||||
| "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": | ||||
| "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": | ||||
|   version "7.22.15" | ||||
|   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8" | ||||
|   integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA== | ||||
| @@ -373,7 +373,7 @@ | ||||
|     source-map "^0.5.7" | ||||
|     stylis "4.2.0" | ||||
|  | ||||
| "@emotion/cache@^11.11.0": | ||||
| "@emotion/cache@^11.11.0", "@emotion/cache@^11.4.0": | ||||
|   version "11.11.0" | ||||
|   resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff" | ||||
|   integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ== | ||||
| @@ -394,7 +394,7 @@ | ||||
|   resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" | ||||
|   integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== | ||||
|  | ||||
| "@emotion/react@^11.11.1": | ||||
| "@emotion/react@^11.11.1", "@emotion/react@^11.8.1": | ||||
|   version "11.11.1" | ||||
|   resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.11.1.tgz#b2c36afac95b184f73b08da8c214fdf861fa4157" | ||||
|   integrity sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA== | ||||
| @@ -671,7 +671,7 @@ | ||||
|   dependencies: | ||||
|     "@floating-ui/utils" "^0.1.1" | ||||
|  | ||||
| "@floating-ui/dom@^1.2.1": | ||||
| "@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.2.1": | ||||
|   version "1.5.1" | ||||
|   resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.1.tgz#88b70defd002fe851f17b4a25efb2d3c04d7a8d7" | ||||
|   integrity sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw== | ||||
| @@ -1266,6 +1266,13 @@ | ||||
|     "@types/history" "^4.7.11" | ||||
|     "@types/react" "*" | ||||
|  | ||||
| "@types/react-transition-group@^4.4.0": | ||||
|   version "4.4.6" | ||||
|   resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.6.tgz#18187bcda5281f8e10dfc48f0943e2fdf4f75e2e" | ||||
|   integrity sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew== | ||||
|   dependencies: | ||||
|     "@types/react" "*" | ||||
|  | ||||
| "@types/react@*", "@types/react@^18.2.21": | ||||
|   version "18.2.21" | ||||
|   resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9" | ||||
| @@ -2139,6 +2146,11 @@ mantine-datatable@^2.9.13: | ||||
|   resolved "https://registry.yarnpkg.com/mantine-datatable/-/mantine-datatable-2.9.13.tgz#2c94a8f3b596216b794f1c7881acc20150ab1186" | ||||
|   integrity sha512-k0Q+FKC3kx7IiNJxeLP2PXJHVxuL704U5OVvtVYP/rexlPW8tqZud3WIZDuqfDCkZ83VYoszSTzauCssW+7mLw== | ||||
|  | ||||
| memoize-one@^6.0.0: | ||||
|   version "6.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" | ||||
|   integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== | ||||
|  | ||||
| micromatch@4.0.2: | ||||
|   version "4.0.2" | ||||
|   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" | ||||
| @@ -2346,7 +2358,7 @@ pretty-format@^29.6.3: | ||||
|     ansi-styles "^5.0.0" | ||||
|     react-is "^18.0.0" | ||||
|  | ||||
| prop-types@15.x, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: | ||||
| prop-types@15.x, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: | ||||
|   version "15.8.1" | ||||
|   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" | ||||
|   integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== | ||||
| @@ -2470,6 +2482,21 @@ react-router@6.15.0: | ||||
|   dependencies: | ||||
|     "@remix-run/router" "1.8.0" | ||||
|  | ||||
| react-select@^5.7.4: | ||||
|   version "5.7.4" | ||||
|   resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.4.tgz#d8cad96e7bc9d6c8e2709bdda8f4363c5dd7ea7d" | ||||
|   integrity sha512-NhuE56X+p9QDFh4BgeygHFIvJJszO1i1KSkg/JPcIJrbovyRtI+GuOEa4XzFCEpZRAEoEI8u/cAHK+jG/PgUzQ== | ||||
|   dependencies: | ||||
|     "@babel/runtime" "^7.12.0" | ||||
|     "@emotion/cache" "^11.4.0" | ||||
|     "@emotion/react" "^11.8.1" | ||||
|     "@floating-ui/dom" "^1.0.1" | ||||
|     "@types/react-transition-group" "^4.4.0" | ||||
|     memoize-one "^6.0.0" | ||||
|     prop-types "^15.6.0" | ||||
|     react-transition-group "^4.3.0" | ||||
|     use-isomorphic-layout-effect "^1.1.2" | ||||
|  | ||||
| react-style-singleton@^2.2.1: | ||||
|   version "2.2.1" | ||||
|   resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" | ||||
| @@ -2498,6 +2525,16 @@ react-transition-group@4.4.2: | ||||
|     loose-envify "^1.4.0" | ||||
|     prop-types "^15.6.2" | ||||
|  | ||||
| react-transition-group@^4.3.0: | ||||
|   version "4.4.5" | ||||
|   resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" | ||||
|   integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== | ||||
|   dependencies: | ||||
|     "@babel/runtime" "^7.5.5" | ||||
|     dom-helpers "^5.0.1" | ||||
|     loose-envify "^1.4.0" | ||||
|     prop-types "^15.6.2" | ||||
|  | ||||
| react@^18.2.0: | ||||
|   version "18.2.0" | ||||
|   resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" | ||||
| @@ -2739,7 +2776,7 @@ use-composed-ref@^1.3.0: | ||||
|   resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" | ||||
|   integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== | ||||
|  | ||||
| use-isomorphic-layout-effect@^1.1.1: | ||||
| use-isomorphic-layout-effect@^1.1.1, use-isomorphic-layout-effect@^1.1.2: | ||||
|   version "1.1.2" | ||||
|   resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" | ||||
|   integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== | ||||
|   | ||||
		Reference in New Issue
	
	Block a user