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