diff --git a/src/frontend/package.json b/src/frontend/package.json index d08815246846..d6097a9ce55e 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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": { diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 2f5b895ef220..8448509df228 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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; diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx new file mode 100644 index 000000000000..38efa98fc15d --- /dev/null +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -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([]); + + // 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(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 ( + + + + + {(Object.keys(form.errors).length > 0 || nonFieldErrors.length > 0) && ( + + {nonFieldErrors.length > 0 && ( + + {nonFieldErrors.map((message) => ( + {message} + ))} + + )} + + )} + {preFormElement} + + + {Object.entries(props.fields).map( + ([fieldName, field]) => + !field.hidden && ( + + ) + )} + + + {postFormElement} + + + + + + + + ); +} diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx new file mode 100644 index 000000000000..6442fe2c9a48 --- /dev/null +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -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>; +}; + +/* 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>; + fieldName: string; + field: ApiFormFieldType; + definitions: Record; +}) { + 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>; + fieldName: string; + field: ApiFormFieldType; + error: ReactNode; + definitions: Record; +}) { + 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 ( + + ); + case 'email': + case 'url': + case 'string': + return ( + onChange(event.currentTarget.value)} + rightSection={ + definition.value && !definition.required ? ( + onChange('')} /> + ) : null + } + /> + ); + case 'boolean': + return ( + onChange(event.currentTarget.checked)} + /> + ); + case 'date': + return ( + onChange(value)} + valueFormat="YYYY-MM-DD" + /> + ); + case 'integer': + case 'decimal': + case 'float': + case 'number': + return ( + onChange(value)} + /> + ); + case 'choice': + return ( + + ); + case 'file upload': + return ( + onChange(payload)} + /> + ); + default: + return ( + + Invalid field type for field '{fieldName}': '{definition.fieldType}' + + ); + } + } + + return ( + + {preFieldElement} + {buildField()} + {postFieldElement} + + ); +} + +export type ApiFormFieldSet = Record; diff --git a/src/frontend/src/components/forms/fields/ChoiceField.tsx b/src/frontend/src/components/forms/fields/ChoiceField.tsx new file mode 100644 index 000000000000..93e2df3b1e55 --- /dev/null +++ b/src/frontend/src/components/forms/fields/ChoiceField.tsx @@ -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>; + 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 ( + 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)} + /> + + ); +} diff --git a/src/frontend/src/components/items/Thumbnail.tsx b/src/frontend/src/components/items/Thumbnail.tsx index 548451cd1d92..4825926996a9 100644 --- a/src/frontend/src/components/items/Thumbnail.tsx +++ b/src/frontend/src/components/items/Thumbnail.tsx @@ -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 ( 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 Result here - ID = {`${result.pk}`}; -} - /* * 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({ - {query.results.results.map((result: any) => query.render(result))} + {query.results.results.map((result: any) => ( + + ))} @@ -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(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()} > @@ -396,12 +399,12 @@ export function SearchDrawer({ } > - {isFetching && ( + {searchQuery.isFetching && (
)} - {!isFetching && !isError && ( + {!searchQuery.isFetching && !searchQuery.isError && ( {queryResults.map((query) => ( )} - {isError && ( + {searchQuery.isError && ( An error occurred during search query )} - {searchText && !isFetching && !isError && queryResults.length == 0 && ( - } - > - No results available for search query - - )} + {searchText && + !searchQuery.isFetching && + !searchQuery.isError && + queryResults.length == 0 && ( + } + > + No results available for search query + + )} ); } diff --git a/src/frontend/src/components/render/Company.tsx b/src/frontend/src/components/render/Company.tsx new file mode 100644 index 000000000000..7b4689afa338 --- /dev/null +++ b/src/frontend/src/components/render/Company.tsx @@ -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 ( + + ); +} + +/** + * Inline rendering of a single Company instance + */ +export function RenderCompany({ company }: { company: any }): ReactNode { + // TODO: Handle URL + + return ( + + ); +} + +/** + * Inline rendering of a single Contact instance + */ +export function RenderContact({ contact }: { contact: any }): ReactNode { + return ; +} + +/** + * 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 ; +} diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx new file mode 100644 index 000000000000..c70ceba16225 --- /dev/null +++ b/src/frontend/src/components/render/Instance.tsx @@ -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 ; + case 'company': + return ; + case 'contact': + return ; + case 'owner': + return ; + case 'part': + return ; + case 'partcategory': + return ; + case 'purchaseorder': + return ; + case 'returnorder': + return ; + case 'salesoder': + return ; + case 'salesordershipment': + return ; + case 'stocklocation': + return ; + case 'supplierpart': + return ; + case 'user': + return ; + default: + // Unknown model + return ( + + <> + + ); + } +} + +/** + * 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 ( + + {image && Thumbnail({ src: image, size: 18 })} + {primary} + {secondary && {secondary}} + + ); +} diff --git a/src/frontend/src/components/render/Order.tsx b/src/frontend/src/components/render/Order.tsx new file mode 100644 index 000000000000..92157cf70be0 --- /dev/null +++ b/src/frontend/src/components/render/Order.tsx @@ -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 ( + + ); +} + +/** + * Inline rendering of a single ReturnOrder instance + */ +export function RenderReturnOrder({ order }: { order: any }): ReactNode { + let customer = order.customer_detail || {}; + + return ( + + ); +} + +/** + * Inline rendering of a single SalesOrder instance + */ +export function RenderSalesOrder({ order }: { order: any }): ReactNode { + let customer = order.customer_detail || {}; + + // TODO: Handle URL + + return ( + + ); +} + +/** + * Inline rendering of a single SalesOrderAllocation instance + */ +export function RenderSalesOrderShipment({ + shipment +}: { + shipment: any; +}): ReactNode { + let order = shipment.sales_order_detail || {}; + + return ( + + ); +} diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx new file mode 100644 index 000000000000..bd570085cce3 --- /dev/null +++ b/src/frontend/src/components/render/Part.tsx @@ -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 ( + + ); +} + +/** + * Inline rendering of a PartCategory instance + */ +export function RenderPartCategory({ category }: { category: any }): ReactNode { + // TODO: Handle URL + + let lvl = '-'.repeat(category.level || 0); + + return ( + + ); +} diff --git a/src/frontend/src/components/render/Stock.tsx b/src/frontend/src/components/render/Stock.tsx new file mode 100644 index 000000000000..f475610bf70f --- /dev/null +++ b/src/frontend/src/components/render/Stock.tsx @@ -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 ( + + ); +} diff --git a/src/frontend/src/components/render/User.tsx b/src/frontend/src/components/render/User.tsx new file mode 100644 index 000000000000..ca099c04e6d4 --- /dev/null +++ b/src/frontend/src/components/render/User.tsx @@ -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 ; +} + +export function RenderUser({ user }: { user: any }): ReactNode { + return ( + + ); +} diff --git a/src/frontend/src/components/tables/Column.tsx b/src/frontend/src/components/tables/Column.tsx index 364960bdadf3..620c5c14444a 100644 --- a/src/frontend/src/components/tables/Column.tsx +++ b/src/frontend/src/components/tables/Column.tsx @@ -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 }; diff --git a/src/frontend/src/components/tables/RowActions.tsx b/src/frontend/src/components/tables/RowActions.tsx new file mode 100644 index 000000000000..f65d7fd22cee --- /dev/null +++ b/src/frontend/src/components/tables/RowActions.tsx @@ -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 ( + + + + + + + + {title || t`Actions`} + {actions.map((action, idx) => ( + + {action.title} + + ))} + + + ); +} diff --git a/src/frontend/src/components/tables/part/PartTable.tsx b/src/frontend/src/components/tables/part/PartTable.tsx index f1aab2cdfeeb..f32a31880e2e 100644 --- a/src/frontend/src/components/tables/part/PartTable.tsx +++ b/src/frontend/src/components/tables/part/PartTable.tsx @@ -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 ( + , + 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: + } + ]} + /> + ); + } } ]; } diff --git a/src/frontend/src/components/tables/stock/StockItemTable.tsx b/src/frontend/src/components/tables/stock/StockItemTable.tsx index 8db343ced27e..c8d4f3baabf2 100644 --- a/src/frontend/src/components/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/components/tables/stock/StockItemTable.tsx @@ -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 ( - - {/* {EditButton(setEditing, editing)} */} - {/* {DeleteButton()} */} - } - tooltip="Edit stock item" - onClick={() => notYetImplemented()} - /> - } - onClick={() => notYetImplemented()} - /> - + , + onClick: notYetImplemented + }, + { + title: t`Delete`, + icon: , + onClick: notYetImplemented + } + ]} + /> ); } } diff --git a/src/frontend/src/contexts/ThemeContext.tsx b/src/frontend/src/contexts/ThemeContext.tsx index 66db20e48dff..1864b44c00e0 100644 --- a/src/frontend/src/contexts/ThemeContext.tsx +++ b/src/frontend/src/contexts/ThemeContext.tsx @@ -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 }) { > - - {children} - + + + {children} + + ); diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx new file mode 100644 index 000000000000..23babecec1e4 --- /dev/null +++ b/src/frontend/src/functions/forms.tsx @@ -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 | 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 = {}; + + 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 | 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: ( + + ) + }); + }) + .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); +} diff --git a/src/frontend/src/functions/forms/PartForms.tsx b/src/frontend/src/functions/forms/PartForms.tsx new file mode 100644 index 000000000000..eeb81cf40350 --- /dev/null +++ b/src/frontend/src/functions/forms/PartForms.tsx @@ -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; +} diff --git a/src/frontend/src/functions/forms/StockForms.tsx b/src/frontend/src/functions/forms/StockForms.tsx new file mode 100644 index 000000000000..106d4d00c31c --- /dev/null +++ b/src/frontend/src/functions/forms/StockForms.tsx @@ -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` + }); +} diff --git a/src/frontend/src/functions/notifications.tsx b/src/frontend/src/functions/notifications.tsx index 99c623b6c908..9682e8738c2b 100644 --- a/src/frontend/src/functions/notifications.tsx +++ b/src/frontend/src/functions/notifications.tsx @@ -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' + }); +} diff --git a/src/frontend/src/functions/uid.tsx b/src/frontend/src/functions/uid.tsx new file mode 100644 index 000000000000..1496e7f6901d --- /dev/null +++ b/src/frontend/src/functions/uid.tsx @@ -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(''); +} diff --git a/src/frontend/src/pages/Index/Playground.tsx b/src/frontend/src/pages/Index/Playground.tsx index ecfa4eef8bd5..5c3a7fdb37e6 100644 --- a/src/frontend/src/pages/Index/Playground.tsx +++ b/src/frontend/src/pages/Index/Playground.tsx @@ -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 ( + <> + + + + + + + + + ); +} + +/** Construct a simple accordion group with title and content */ +function PlaygroundArea({ + title, + content +}: { + title: string; + content: ReactNode; +}) { + return ( + <> + + + {title} + + {content} + + + ); +} export default function Playground() { return ( @@ -18,6 +93,12 @@ export default function Playground() { This page is a showcase for the possibilities of Platform UI. + + } + > + ); } diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 87b94e7f4e1c..d33aa4e832d0 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -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==