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:
Oliver 2023-09-08 21:24:06 +10:00 committed by GitHub
parent 1e55fc8b6d
commit baa9f3660b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1976 additions and 75 deletions

View File

@ -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": {

View File

@ -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;

View 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>
);
}

View 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>;

View 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)}
/>
);
}

View 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>
);
}

View File

@ -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}

View File

@ -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>
);
}

View 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} />;
}

View 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>
);
}

View 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}`}
/>
);
}

View 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}
/>
);
}

View 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}
/>
);
}

View 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}`}
/>
);
}

View File

@ -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
};

View 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>
);
}

View File

@ -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" />
}
]}
/>
);
}
}
];
}

View File

@ -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
}
]}
/>
);
}
}

View File

@ -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>
);

View 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);
}

View 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;
}

View 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`
});
}

View File

@ -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'
});
}

View 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('');
}

View File

@ -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>
</>
);
}

View File

@ -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==