mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
1e55fc8b6d
commit
baa9f3660b
@ -38,6 +38,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-grid-layout": "^1.3.4",
|
"react-grid-layout": "^1.3.4",
|
||||||
"react-router-dom": "^6.15.0",
|
"react-router-dom": "^6.15.0",
|
||||||
|
"react-select": "^5.7.4",
|
||||||
"zustand": "^4.4.1"
|
"zustand": "^4.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -9,6 +9,7 @@ import { useSessionState } from './states/SessionState';
|
|||||||
|
|
||||||
// API
|
// API
|
||||||
export const api = axios.create({});
|
export const api = axios.create({});
|
||||||
|
|
||||||
export function setApiDefaults() {
|
export function setApiDefaults() {
|
||||||
const host = useLocalState.getState().host;
|
const host = useLocalState.getState().host;
|
||||||
const token = useSessionState.getState().token;
|
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 { Group } from '@mantine/core';
|
||||||
import { Text } from '@mantine/core';
|
import { Text } from '@mantine/core';
|
||||||
|
|
||||||
|
import { api } from '../../App';
|
||||||
|
|
||||||
export function Thumbnail({
|
export function Thumbnail({
|
||||||
src,
|
src,
|
||||||
alt = t`Thumbnail`,
|
alt = t`Thumbnail`,
|
||||||
@ -12,11 +14,11 @@ export function Thumbnail({
|
|||||||
alt?: string;
|
alt?: string;
|
||||||
size?: number;
|
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: 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 (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={url}
|
src={url}
|
||||||
|
@ -25,9 +25,10 @@ import {
|
|||||||
IconX
|
IconX
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
|
import { RenderInstance } from '../render/Instance';
|
||||||
|
|
||||||
// Define type for handling individual search queries
|
// Define type for handling individual search queries
|
||||||
type SearchQuery = {
|
type SearchQuery = {
|
||||||
@ -36,7 +37,6 @@ type SearchQuery = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
parameters: any;
|
parameters: any;
|
||||||
results?: any;
|
results?: any;
|
||||||
render: (result: any) => JSX.Element;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Placeholder function for permissions checks (will be replaced with a proper implementation)
|
// Placeholder function for permissions checks (will be replaced with a proper implementation)
|
||||||
@ -49,12 +49,6 @@ function settingsCheck(setting: string) {
|
|||||||
return true;
|
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
|
* Build a list of search queries based on user permissions
|
||||||
*/
|
*/
|
||||||
@ -64,7 +58,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
name: 'part',
|
name: 'part',
|
||||||
title: t`Parts`,
|
title: t`Parts`,
|
||||||
parameters: {},
|
parameters: {},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('part.view') &&
|
permissionCheck('part.view') &&
|
||||||
settingsCheck('SEARCH_PREVIEW_SHOW_PARTS')
|
settingsCheck('SEARCH_PREVIEW_SHOW_PARTS')
|
||||||
@ -77,7 +70,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
supplier_detail: true,
|
supplier_detail: true,
|
||||||
manufacturer_detail: true
|
manufacturer_detail: true
|
||||||
},
|
},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('part.view') &&
|
permissionCheck('part.view') &&
|
||||||
permissionCheck('purchase_order.view') &&
|
permissionCheck('purchase_order.view') &&
|
||||||
@ -91,7 +83,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
supplier_detail: true,
|
supplier_detail: true,
|
||||||
manufacturer_detail: true
|
manufacturer_detail: true
|
||||||
},
|
},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('part.view') &&
|
permissionCheck('part.view') &&
|
||||||
permissionCheck('purchase_order.view') &&
|
permissionCheck('purchase_order.view') &&
|
||||||
@ -101,7 +92,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
name: 'partcategory',
|
name: 'partcategory',
|
||||||
title: t`Part Categories`,
|
title: t`Part Categories`,
|
||||||
parameters: {},
|
parameters: {},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('part_category.view') &&
|
permissionCheck('part_category.view') &&
|
||||||
settingsCheck('SEARCH_PREVIEW_SHOW_CATEGORIES')
|
settingsCheck('SEARCH_PREVIEW_SHOW_CATEGORIES')
|
||||||
@ -113,7 +103,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
part_detail: true,
|
part_detail: true,
|
||||||
location_detail: true
|
location_detail: true
|
||||||
},
|
},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('stock.view') &&
|
permissionCheck('stock.view') &&
|
||||||
settingsCheck('SEARCH_PREVIEW_SHOW_STOCK')
|
settingsCheck('SEARCH_PREVIEW_SHOW_STOCK')
|
||||||
@ -122,7 +111,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
name: 'stocklocation',
|
name: 'stocklocation',
|
||||||
title: t`Stock Locations`,
|
title: t`Stock Locations`,
|
||||||
parameters: {},
|
parameters: {},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('stock_location.view') &&
|
permissionCheck('stock_location.view') &&
|
||||||
settingsCheck('SEARCH_PREVIEW_SHOW_LOCATIONS')
|
settingsCheck('SEARCH_PREVIEW_SHOW_LOCATIONS')
|
||||||
@ -133,7 +121,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
parameters: {
|
parameters: {
|
||||||
part_detail: true
|
part_detail: true
|
||||||
},
|
},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('build.view') &&
|
permissionCheck('build.view') &&
|
||||||
settingsCheck('SEARCH_PREVIEW_SHOW_BUILD_ORDERS')
|
settingsCheck('SEARCH_PREVIEW_SHOW_BUILD_ORDERS')
|
||||||
@ -142,7 +129,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
name: 'company',
|
name: 'company',
|
||||||
title: t`Companies`,
|
title: t`Companies`,
|
||||||
parameters: {},
|
parameters: {},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
(permissionCheck('sales_order.view') ||
|
(permissionCheck('sales_order.view') ||
|
||||||
permissionCheck('purchase_order.view')) &&
|
permissionCheck('purchase_order.view')) &&
|
||||||
@ -154,7 +140,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
parameters: {
|
parameters: {
|
||||||
supplier_detail: true
|
supplier_detail: true
|
||||||
},
|
},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('purchase_order.view') &&
|
permissionCheck('purchase_order.view') &&
|
||||||
settingsCheck(`SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS`)
|
settingsCheck(`SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS`)
|
||||||
@ -165,7 +150,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
parameters: {
|
parameters: {
|
||||||
customer_detail: true
|
customer_detail: true
|
||||||
},
|
},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('sales_order.view') &&
|
permissionCheck('sales_order.view') &&
|
||||||
settingsCheck(`SEARCH_PREVIEW_SHOW_SALES_ORDERS`)
|
settingsCheck(`SEARCH_PREVIEW_SHOW_SALES_ORDERS`)
|
||||||
@ -176,7 +160,6 @@ function buildSearchQueries(): SearchQuery[] {
|
|||||||
parameters: {
|
parameters: {
|
||||||
customer_detail: true
|
customer_detail: true
|
||||||
},
|
},
|
||||||
render: renderResult,
|
|
||||||
enabled:
|
enabled:
|
||||||
permissionCheck('return_order.view') &&
|
permissionCheck('return_order.view') &&
|
||||||
settingsCheck(`SEARCH_PREVIEW_SHOW_RETURN_ORDERS`)
|
settingsCheck(`SEARCH_PREVIEW_SHOW_RETURN_ORDERS`)
|
||||||
@ -222,7 +205,9 @@ function QueryResultGroup({
|
|||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Stack>
|
<Stack>
|
||||||
{query.results.results.map((result: any) => query.render(result))}
|
{query.results.results.map((result: any) => (
|
||||||
|
<RenderInstance instance={result} model={query.name} />
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Space />
|
<Space />
|
||||||
</Stack>
|
</Stack>
|
||||||
@ -255,7 +240,7 @@ export function SearchDrawer({
|
|||||||
// Re-fetch data whenever the search term is updated
|
// Re-fetch data whenever the search term is updated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: Implement search functionality
|
// TODO: Implement search functionality
|
||||||
refetch();
|
searchQuery.refetch();
|
||||||
}, [searchText]);
|
}, [searchText]);
|
||||||
|
|
||||||
// Function for performing the actual search query
|
// Function for performing the actual search query
|
||||||
@ -278,8 +263,14 @@ export function SearchDrawer({
|
|||||||
params[query.name] = query.parameters;
|
params[query.name] = query.parameters;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel any pending search queries
|
||||||
|
getAbortController().abort();
|
||||||
|
|
||||||
return api
|
return api
|
||||||
.post(`/search/`, params)
|
.post(`/search/`, {
|
||||||
|
params: params,
|
||||||
|
signal: getAbortController().signal
|
||||||
|
})
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
return response.data;
|
return response.data;
|
||||||
})
|
})
|
||||||
@ -290,7 +281,7 @@ export function SearchDrawer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Search query manager
|
// Search query manager
|
||||||
const { data, isError, isFetching, isLoading, refetch } = useQuery(
|
const searchQuery = useQuery(
|
||||||
['search', searchText, searchRegex, searchWhole],
|
['search', searchText, searchRegex, searchWhole],
|
||||||
performSearch,
|
performSearch,
|
||||||
{
|
{
|
||||||
@ -303,13 +294,15 @@ export function SearchDrawer({
|
|||||||
|
|
||||||
// Update query results whenever the search results change
|
// Update query results whenever the search results change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (searchQuery.data) {
|
||||||
let queries = searchQueries.filter((query) => query.name in 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);
|
let query = queries.find((q) => q.name == key);
|
||||||
if (query) {
|
if (query) {
|
||||||
query.results = data[key];
|
query.results = searchQuery.data[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,7 +313,17 @@ export function SearchDrawer({
|
|||||||
} else {
|
} else {
|
||||||
setQueryResults([]);
|
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
|
// Callback to remove a set of results from the list
|
||||||
function removeResults(query: string) {
|
function removeResults(query: string) {
|
||||||
@ -359,7 +362,7 @@ export function SearchDrawer({
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
radius="xs"
|
radius="xs"
|
||||||
onClick={() => refetch()}
|
onClick={() => searchQuery.refetch()}
|
||||||
>
|
>
|
||||||
<IconRefresh />
|
<IconRefresh />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@ -396,12 +399,12 @@ export function SearchDrawer({
|
|||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isFetching && (
|
{searchQuery.isFetching && (
|
||||||
<Center>
|
<Center>
|
||||||
<Loader />
|
<Loader />
|
||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
{!isFetching && !isError && (
|
{!searchQuery.isFetching && !searchQuery.isError && (
|
||||||
<Stack spacing="md">
|
<Stack spacing="md">
|
||||||
{queryResults.map((query) => (
|
{queryResults.map((query) => (
|
||||||
<QueryResultGroup
|
<QueryResultGroup
|
||||||
@ -411,7 +414,7 @@ export function SearchDrawer({
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
{isError && (
|
{searchQuery.isError && (
|
||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
@ -422,7 +425,10 @@ export function SearchDrawer({
|
|||||||
<Trans>An error occurred during search query</Trans>
|
<Trans>An error occurred during search query</Trans>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{searchText && !isFetching && !isError && queryResults.length == 0 && (
|
{searchText &&
|
||||||
|
!searchQuery.isFetching &&
|
||||||
|
!searchQuery.isError &&
|
||||||
|
queryResults.length == 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
color="blue"
|
color="blue"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
|
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
|
filter?: any; // A custom filter function
|
||||||
filtering?: boolean; // Whether the column is filterable
|
filtering?: boolean; // Whether the column is filterable
|
||||||
width?: number; // The width of the column
|
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 { t } from '@lingui/macro';
|
||||||
import { Text } from '@mantine/core';
|
import { Text } from '@mantine/core';
|
||||||
|
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { editPart } from '../../../functions/forms/PartForms';
|
||||||
import { notYetImplemented } from '../../../functions/notifications';
|
import { notYetImplemented } from '../../../functions/notifications';
|
||||||
import { shortenString } from '../../../functions/tables';
|
import { shortenString } from '../../../functions/tables';
|
||||||
import { ThumbnailHoverCard } from '../../items/Thumbnail';
|
import { ThumbnailHoverCard } from '../../items/Thumbnail';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { TableFilter } from '../Filter';
|
import { TableFilter } from '../Filter';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { RowActions } from '../RowActions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a list of columns for the part table
|
* Construct a list of columns for the part table
|
||||||
@ -17,6 +20,7 @@ function partTableColumns(): TableColumn[] {
|
|||||||
{
|
{
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
noWrap: true,
|
||||||
title: t`Part`,
|
title: t`Part`,
|
||||||
render: function (record: any) {
|
render: function (record: any) {
|
||||||
// TODO - Link to the part detail page
|
// TODO - Link to the part detail page
|
||||||
@ -78,6 +82,38 @@ function partTableColumns(): TableColumn[] {
|
|||||||
accessor: 'link',
|
accessor: 'link',
|
||||||
title: t`Link`,
|
title: t`Link`,
|
||||||
switchable: true
|
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 { ThumbnailHoverCard } from '../../items/Thumbnail';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { TableFilter } from '../Filter';
|
import { TableFilter } from '../Filter';
|
||||||
|
import { RowActions } from '../RowActions';
|
||||||
import { InvenTreeTable } from './../InvenTreeTable';
|
import { InvenTreeTable } from './../InvenTreeTable';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -77,26 +78,26 @@ function stockItemTableColumns(): TableColumn[] {
|
|||||||
// TODO: notes
|
// TODO: notes
|
||||||
{
|
{
|
||||||
accessor: 'actions',
|
accessor: 'actions',
|
||||||
title: t`Actions`,
|
title: '',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
switchable: false,
|
||||||
render: function (record: any) {
|
render: function (record: any) {
|
||||||
return (
|
return (
|
||||||
<Group position="right" spacing={5} noWrap={true}>
|
<RowActions
|
||||||
{/* {EditButton(setEditing, editing)} */}
|
title={t`Stock Actions`}
|
||||||
{/* {DeleteButton()} */}
|
actions={[
|
||||||
<ActionButton
|
{
|
||||||
color="green"
|
title: t`Edit`,
|
||||||
icon={<IconEdit />}
|
icon: <IconEdit color="blue" />,
|
||||||
tooltip="Edit stock item"
|
onClick: notYetImplemented
|
||||||
onClick={() => notYetImplemented()}
|
},
|
||||||
|
{
|
||||||
|
title: t`Delete`,
|
||||||
|
icon: <IconTrash color="red" />,
|
||||||
|
onClick: notYetImplemented
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
|
||||||
color="red"
|
|
||||||
tooltip="Delete stock item"
|
|
||||||
icon={<IconTrash />}
|
|
||||||
onClick={() => notYetImplemented()}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,9 @@ import {
|
|||||||
import { useColorScheme, useLocalStorage } from '@mantine/hooks';
|
import { useColorScheme, useLocalStorage } from '@mantine/hooks';
|
||||||
import { ModalsProvider } from '@mantine/modals';
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
import { Notifications } from '@mantine/notifications';
|
import { Notifications } from '@mantine/notifications';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { queryClient } from '../App';
|
||||||
import { QrCodeModal } from '../components/modals/QrCodeModal';
|
import { QrCodeModal } from '../components/modals/QrCodeModal';
|
||||||
import { useLocalState } from '../states/LocalState';
|
import { useLocalState } from '../states/LocalState';
|
||||||
|
|
||||||
@ -58,12 +60,14 @@ export function ThemeContext({ children }: { children: JSX.Element }) {
|
|||||||
>
|
>
|
||||||
<MantineProvider theme={myTheme} withGlobalStyles withNormalizeCSS>
|
<MantineProvider theme={myTheme} withGlobalStyles withNormalizeCSS>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<ModalsProvider
|
<ModalsProvider
|
||||||
labels={{ confirm: t`Submit`, cancel: t`Cancel` }}
|
labels={{ confirm: t`Submit`, cancel: t`Cancel` }}
|
||||||
modals={{ qr: QrCodeModal }}
|
modals={{ qr: QrCodeModal }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</ColorSchemeProvider>
|
</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'
|
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 { Trans } from '@lingui/macro';
|
||||||
|
import { Button } from '@mantine/core';
|
||||||
import { Group, Text } 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 { PlaceholderPill } from '../../components/items/Placeholder';
|
||||||
import { StylishText } from '../../components/items/StylishText';
|
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() {
|
export default function Playground() {
|
||||||
return (
|
return (
|
||||||
@ -18,6 +93,12 @@ export default function Playground() {
|
|||||||
This page is a showcase for the possibilities of Platform UI.
|
This page is a showcase for the possibilities of Platform UI.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</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-modules-commonjs" "^7.22.15"
|
||||||
"@babel/plugin-transform-typescript" "^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"
|
version "7.22.15"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
|
||||||
integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
|
integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
|
||||||
@ -373,7 +373,7 @@
|
|||||||
source-map "^0.5.7"
|
source-map "^0.5.7"
|
||||||
stylis "4.2.0"
|
stylis "4.2.0"
|
||||||
|
|
||||||
"@emotion/cache@^11.11.0":
|
"@emotion/cache@^11.11.0", "@emotion/cache@^11.4.0":
|
||||||
version "11.11.0"
|
version "11.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff"
|
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff"
|
||||||
integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==
|
integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==
|
||||||
@ -394,7 +394,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17"
|
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17"
|
||||||
integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==
|
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"
|
version "11.11.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.11.1.tgz#b2c36afac95b184f73b08da8c214fdf861fa4157"
|
resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.11.1.tgz#b2c36afac95b184f73b08da8c214fdf861fa4157"
|
||||||
integrity sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==
|
integrity sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==
|
||||||
@ -671,7 +671,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/utils" "^0.1.1"
|
"@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"
|
version "1.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.1.tgz#88b70defd002fe851f17b4a25efb2d3c04d7a8d7"
|
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.1.tgz#88b70defd002fe851f17b4a25efb2d3c04d7a8d7"
|
||||||
integrity sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==
|
integrity sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==
|
||||||
@ -1266,6 +1266,13 @@
|
|||||||
"@types/history" "^4.7.11"
|
"@types/history" "^4.7.11"
|
||||||
"@types/react" "*"
|
"@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":
|
"@types/react@*", "@types/react@^18.2.21":
|
||||||
version "18.2.21"
|
version "18.2.21"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9"
|
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"
|
resolved "https://registry.yarnpkg.com/mantine-datatable/-/mantine-datatable-2.9.13.tgz#2c94a8f3b596216b794f1c7881acc20150ab1186"
|
||||||
integrity sha512-k0Q+FKC3kx7IiNJxeLP2PXJHVxuL704U5OVvtVYP/rexlPW8tqZud3WIZDuqfDCkZ83VYoszSTzauCssW+7mLw==
|
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:
|
micromatch@4.0.2:
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259"
|
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"
|
ansi-styles "^5.0.0"
|
||||||
react-is "^18.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"
|
version "15.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
@ -2470,6 +2482,21 @@ react-router@6.15.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@remix-run/router" "1.8.0"
|
"@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:
|
react-style-singleton@^2.2.1:
|
||||||
version "2.2.1"
|
version "2.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
|
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"
|
loose-envify "^1.4.0"
|
||||||
prop-types "^15.6.2"
|
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:
|
react@^18.2.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
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"
|
resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
|
||||||
integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
|
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"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
|
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==
|
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
|
||||||
|
Loading…
Reference in New Issue
Block a user