2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-02 21:38:48 +00:00

PUI general improvements (#5947)

* First draft for refactoring the api forms including modals

* Fix merging errors

* Fix deepsource

* Fix jsdoc

* trigger: deepsource

* Try to improve performance by not passing the whole definition down

* First draft for switching to react-hook-form

* Fix warning log in console with i18n when locale is not loaded

* Fix: deepsource

* Fixed RelatedModelField initial value loading and disable submit if form is not 'dirty'

* Make field state hookable to state

* Added nested object field to PUI form framework

* Fix ts errors while integrating the new forms api into a few places

* Fix: deepsource

* Fix some values were not present in the submit data if the field is hidden

* Handle error while loading locales

* Fix: deepsource

* Added few general improvements

* Fix missig key prop

* Fix storage deprecation warnings
This commit is contained in:
Lukas 2023-11-20 22:24:00 +01:00 committed by GitHub
parent 333e2ce993
commit 264dc9d27a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 117 additions and 69 deletions

View File

@ -15,8 +15,6 @@ export function ButtonMenu({
label?: string; label?: string;
tooltip?: string; tooltip?: string;
}) { }) {
let idx = 0;
return ( return (
<Menu shadow="xs"> <Menu shadow="xs">
<Menu.Target> <Menu.Target>
@ -26,8 +24,8 @@ export function ButtonMenu({
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{label && <Menu.Label>{label}</Menu.Label>} {label && <Menu.Label>{label}</Menu.Label>}
{actions.map((action) => ( {actions.map((action, i) => (
<Menu.Item key={idx++}>{action}</Menu.Item> <Menu.Item key={i}>{action}</Menu.Item>
))} ))}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>

View File

@ -29,6 +29,7 @@ import {
mapFields mapFields
} from '../../functions/forms'; } from '../../functions/forms';
import { invalidResponse } from '../../functions/notifications'; import { invalidResponse } from '../../functions/notifications';
import { PathParams } from '../../states/ApiState';
import { import {
ApiFormField, ApiFormField,
ApiFormFieldSet, ApiFormFieldSet,
@ -46,6 +47,7 @@ export interface ApiFormAction {
* Properties for the ApiForm component * Properties for the ApiForm component
* @param url : The API endpoint to fetch the form data from * @param url : The API endpoint to fetch the form data from
* @param pk : Optional primary-key value when editing an existing object * @param pk : Optional primary-key value when editing an existing object
* @param pathParams : Optional path params for the url
* @param method : Optional HTTP method to use when submitting the form (default: GET) * @param method : Optional HTTP method to use when submitting the form (default: GET)
* @param fields : The fields to render in the form * @param fields : The fields to render in the form
* @param submitText : Optional custom text to display on the submit button (default: Submit)4 * @param submitText : Optional custom text to display on the submit button (default: Submit)4
@ -60,6 +62,7 @@ export interface ApiFormAction {
export interface ApiFormProps { export interface ApiFormProps {
url: ApiPaths; url: ApiPaths;
pk?: number | string | undefined; pk?: number | string | undefined;
pathParams?: PathParams;
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
fields?: ApiFormFieldSet; fields?: ApiFormFieldSet;
submitText?: string; submitText?: string;
@ -92,13 +95,20 @@ export function OptionsApiForm({
const id = useId(pId); const id = useId(pId);
const url = useMemo( const url = useMemo(
() => constructFormUrl(props.url, props.pk), () => constructFormUrl(props.url, props.pk, props.pathParams),
[props.url, props.pk] [props.url, props.pk, props.pathParams]
); );
const { data } = useQuery({ const { data } = useQuery({
enabled: true, enabled: true,
queryKey: ['form-options-data', id, props.method, props.url, props.pk], queryKey: [
'form-options-data',
id,
props.method,
props.url,
props.pk,
props.pathParams
],
queryFn: () => queryFn: () =>
api.options(url).then((res) => { api.options(url).then((res) => {
let fields: Record<string, ApiFormFieldType> | null = {}; let fields: Record<string, ApiFormFieldType> | null = {};
@ -171,14 +181,21 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
// Cache URL // Cache URL
const url = useMemo( const url = useMemo(
() => constructFormUrl(props.url, props.pk), () => constructFormUrl(props.url, props.pk, props.pathParams),
[props.url, props.pk] [props.url, props.pk, props.pathParams]
); );
// Query manager for retrieving initial data from the server // Query manager for retrieving initial data from the server
const initialDataQuery = useQuery({ const initialDataQuery = useQuery({
enabled: false, enabled: false,
queryKey: ['form-initial-data', id, props.method, props.url, props.pk], queryKey: [
'form-initial-data',
id,
props.method,
props.url,
props.pk,
props.pathParams
],
queryFn: async () => { queryFn: async () => {
return api return api
.get(url) .get(url)
@ -223,7 +240,14 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
// Fetch initial data if the fetchInitialData property is set // Fetch initial data if the fetchInitialData property is set
if (props.fetchInitialData) { if (props.fetchInitialData) {
queryClient.removeQueries({ queryClient.removeQueries({
queryKey: ['form-initial-data', id, props.method, props.url, props.pk] queryKey: [
'form-initial-data',
id,
props.method,
props.url,
props.pk,
props.pathParams
]
}); });
initialDataQuery.refetch(); initialDataQuery.refetch();
} }

View File

@ -50,10 +50,9 @@ export function ActionDropdown({
<Menu.Dropdown> <Menu.Dropdown>
{actions.map((action) => {actions.map((action) =>
action.disabled ? null : ( action.disabled ? null : (
<Tooltip label={action.tooltip} key={`tooltip-${action.name}`}> <Tooltip label={action.tooltip} key={action.name}>
<Menu.Item <Menu.Item
icon={action.icon} icon={action.icon}
key={action.name}
onClick={() => { onClick={() => {
if (action.onClick != undefined) { if (action.onClick != undefined) {
action.onClick(); action.onClick();

View File

@ -38,7 +38,7 @@ export function BreadcrumbList({
{breadcrumbs.map((breadcrumb, index) => { {breadcrumbs.map((breadcrumb, index) => {
return ( return (
<Anchor <Anchor
key={`breadcrumb-${index}`} key={index}
onClick={() => breadcrumb.url && navigate(breadcrumb.url)} onClick={() => breadcrumb.url && navigate(breadcrumb.url)}
> >
<Text size="sm">{breadcrumb.name}</Text> <Text size="sm">{breadcrumb.name}</Text>

View File

@ -88,7 +88,7 @@ export function NotificationDrawer({
</Alert> </Alert>
)} )}
{notificationQuery.data?.results?.map((notification: any) => ( {notificationQuery.data?.results?.map((notification: any) => (
<Group position="apart"> <Group position="apart" key={notification.pk}>
<Stack spacing="3"> <Stack spacing="3">
<Text size="sm">{notification.target?.name ?? 'target'}</Text> <Text size="sm">{notification.target?.name ?? 'target'}</Text>
<Text size="xs">{notification.age_human ?? 'name'}</Text> <Text size="xs">{notification.age_human ?? 'name'}</Text>

View File

@ -1,5 +1,5 @@
import { Group, Paper, Space, Stack, Text } from '@mantine/core'; import { Group, Paper, Space, Stack, Text } from '@mantine/core';
import { ReactNode } from 'react'; import { Fragment, ReactNode } from 'react';
import { ApiImage } from '../images/ApiImage'; import { ApiImage } from '../images/ApiImage';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
@ -58,8 +58,10 @@ export function PageDetail({
{detail} {detail}
<Space /> <Space />
{actions && ( {actions && (
<Group key="page-actions" spacing={5} position="right"> <Group spacing={5} position="right">
{actions} {actions.map((action, idx) => (
<Fragment key={idx}>{action}</Fragment>
))}
</Group> </Group>
)} )}
</Group> </Group>

View File

@ -90,15 +90,14 @@ export function PanelGroup({
> >
<Tabs.List position="left"> <Tabs.List position="left">
{panels.map( {panels.map(
(panel, idx) => (panel) =>
!panel.hidden && ( !panel.hidden && (
<Tooltip <Tooltip
label={panel.label} label={panel.label}
key={`panel-tab-tooltip-${panel.name}`} key={panel.name}
disabled={expanded} disabled={expanded}
> >
<Tabs.Tab <Tabs.Tab
key={`panel-tab-${panel.name}`}
p="xs" p="xs"
value={panel.name} value={panel.name}
icon={panel.icon} icon={panel.icon}
@ -125,10 +124,10 @@ export function PanelGroup({
)} )}
</Tabs.List> </Tabs.List>
{panels.map( {panels.map(
(panel, idx) => (panel) =>
!panel.hidden && ( !panel.hidden && (
<Tabs.Panel <Tabs.Panel
key={idx} key={panel.name}
value={panel.name} value={panel.name}
p="sm" p="sm"
style={{ style={{

View File

@ -90,12 +90,11 @@ function QueryResultGroup({
<Divider /> <Divider />
<Stack> <Stack>
{query.results.results.map((result: any) => ( {query.results.results.map((result: any) => (
<Anchor onClick={() => onResultClick(query.model, result.pk)}> <Anchor
<RenderInstance onClick={() => onResultClick(query.model, result.pk)}
key={`${query.model}-${result.pk}`} key={result.pk}
instance={result} >
model={query.model} <RenderInstance instance={result} model={query.model} />
/>
</Anchor> </Anchor>
))} ))}
</Stack> </Stack>
@ -395,8 +394,9 @@ export function SearchDrawer({
)} )}
{!searchQuery.isFetching && !searchQuery.isError && ( {!searchQuery.isFetching && !searchQuery.isError && (
<Stack spacing="md"> <Stack spacing="md">
{queryResults.map((query) => ( {queryResults.map((query, idx) => (
<QueryResultGroup <QueryResultGroup
key={idx}
query={query} query={query}
onRemove={(query) => removeResults(query)} onRemove={(query) => removeResults(query)}
onResultClick={(query, pk) => onResultClick(query, pk)} onResultClick={(query, pk) => onResultClick(query, pk)}

View File

@ -23,7 +23,10 @@ function SettingValue({
// Callback function when a boolean value is changed // Callback function when a boolean value is changed
function onToggle(value: boolean) { function onToggle(value: boolean) {
api api
.patch(apiUrl(settingsState.endpoint, setting.key), { value: value }) .patch(
apiUrl(settingsState.endpoint, setting.key, settingsState.pathParams),
{ value: value }
)
.then(() => { .then(() => {
showNotification({ showNotification({
title: t`Setting updated`, title: t`Setting updated`,
@ -53,6 +56,7 @@ function SettingValue({
openModalApiForm({ openModalApiForm({
url: settingsState.endpoint, url: settingsState.endpoint,
pk: setting.key, pk: setting.key,
pathParams: settingsState.pathParams,
method: 'PATCH', method: 'PATCH',
title: t`Edit Setting`, title: t`Edit Setting`,
ignorePermissionCheck: true, ignorePermissionCheck: true,

View File

@ -1,5 +1,5 @@
import { Stack, Text } from '@mantine/core'; import { Stack, Text, useMantineTheme } from '@mantine/core';
import { useEffect } from 'react'; import { useEffect, useMemo } from 'react';
import { import {
SettingsStateProps, SettingsStateProps,
@ -16,21 +16,36 @@ export function SettingList({
keys keys
}: { }: {
settingsState: SettingsStateProps; settingsState: SettingsStateProps;
keys: string[]; keys?: string[];
}) { }) {
useEffect(() => { useEffect(() => {
settingsState.fetchSettings(); settingsState.fetchSettings();
}, []); }, []);
const allKeys = useMemo(
() => settingsState?.settings?.map((s) => s.key),
[settingsState?.settings]
);
const theme = useMantineTheme();
return ( return (
<> <>
<Stack spacing="xs"> <Stack spacing="xs">
{keys.map((key) => { {(keys || allKeys).map((key, i) => {
const setting = settingsState?.settings?.find( const setting = settingsState?.settings?.find(
(s: any) => s.key === key (s: any) => s.key === key
); );
const style: Record<string, string> = { paddingLeft: '8px' };
if (i % 2 === 0)
style['backgroundColor'] =
theme.colorScheme === 'light'
? theme.colors.gray[1]
: theme.colors.gray[9];
return ( return (
<div key={key}> <div key={key} style={style}>
{setting ? ( {setting ? (
<SettingItem settingsState={settingsState} setting={setting} /> <SettingItem settingsState={settingsState} setting={setting} />
) : ( ) : (

View File

@ -1,14 +1,14 @@
/** /**
* Interface for the table column definition * Interface for the table column definition
*/ */
export type TableColumn = { export type TableColumn<T = any> = {
accessor: string; // The key in the record to access accessor: string; // The key in the record to access
ordering?: string; // The key in the record to sort by (defaults to accessor) ordering?: string; // The key in the record to sort by (defaults to accessor)
title: string; // The title of the column title: string; // The title of the column
sortable?: boolean; // Whether the column is sortable sortable?: boolean; // Whether the column is sortable
switchable?: boolean; // Whether the column is switchable switchable?: boolean; // Whether the column is switchable
hidden?: boolean; // Whether the column is hidden hidden?: boolean; // Whether the column is hidden
render?: (record: any) => any; // A custom render function render?: (record: T) => any; // A custom render function
filter?: any; // A custom filter function filter?: any; // A custom filter function
filtering?: boolean; // Whether the column is filterable filtering?: boolean; // Whether the column is filterable
width?: number; // The width of the column width?: number; // The width of the column

View File

@ -6,7 +6,7 @@ import { IconFilter, IconRefresh } from '@tabler/icons-react';
import { IconBarcode, IconPrinter } from '@tabler/icons-react'; import { IconBarcode, IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { DataTable, DataTableSortStatus } from 'mantine-datatable'; import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useMemo, useState } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import { api } from '../../App'; import { api } from '../../App';
import { ButtonMenu } from '../buttons/ButtonMenu'; import { ButtonMenu } from '../buttons/ButtonMenu';
@ -44,7 +44,7 @@ const defaultPageSize: number = 25;
* @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions * @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions
* @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked * @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
*/ */
export type InvenTreeTableProps = { export type InvenTreeTableProps<T = any> = {
params?: any; params?: any;
defaultSortColumn?: string; defaultSortColumn?: string;
noRecordsText?: string; noRecordsText?: string;
@ -57,12 +57,12 @@ export type InvenTreeTableProps = {
pageSize?: number; pageSize?: number;
barcodeActions?: any[]; barcodeActions?: any[];
customFilters?: TableFilter[]; customFilters?: TableFilter[];
customActionGroups?: any[]; customActionGroups?: React.ReactNode[];
printingActions?: any[]; printingActions?: any[];
idAccessor?: string; idAccessor?: string;
dataFormatter?: (data: any) => any; dataFormatter?: (data: T) => any;
rowActions?: (record: any) => RowAction[]; rowActions?: (record: T) => RowAction[];
onRowClick?: (record: any, index: number, event: any) => void; onRowClick?: (record: T, index: number, event: any) => void;
}; };
/** /**
@ -90,7 +90,7 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
/** /**
* Table Component which extends DataTable with custom InvenTree functionality * Table Component which extends DataTable with custom InvenTree functionality
*/ */
export function InvenTreeTable({ export function InvenTreeTable<T = any>({
url, url,
tableKey, tableKey,
columns, columns,
@ -98,8 +98,8 @@ export function InvenTreeTable({
}: { }: {
url: string; url: string;
tableKey: string; tableKey: string;
columns: TableColumn[]; columns: TableColumn<T>[];
props: InvenTreeTableProps; props: InvenTreeTableProps<T>;
}) { }) {
// Use the first part of the table key as the table name // Use the first part of the table key as the table name
const tableName: string = useMemo(() => { const tableName: string = useMemo(() => {
@ -107,7 +107,7 @@ export function InvenTreeTable({
}, []); }, []);
// Build table properties based on provided props (and default props) // Build table properties based on provided props (and default props)
const tableProps: InvenTreeTableProps = useMemo(() => { const tableProps: InvenTreeTableProps<T> = useMemo(() => {
return { return {
...defaultInvenTreeTableProps, ...defaultInvenTreeTableProps,
...props ...props
@ -432,9 +432,9 @@ export function InvenTreeTable({
<Stack spacing="sm"> <Stack spacing="sm">
<Group position="apart"> <Group position="apart">
<Group position="left" key="custom-actions" spacing={5}> <Group position="left" key="custom-actions" spacing={5}>
{tableProps.customActionGroups?.map( {tableProps.customActionGroups?.map((group, idx) => (
(group: any, idx: number) => group <Fragment key={idx}>{group}</Fragment>
)} ))}
{(tableProps.barcodeActions?.length ?? 0 > 0) && ( {(tableProps.barcodeActions?.length ?? 0 > 0) && (
<ButtonMenu <ButtonMenu
key="barcode-actions" key="barcode-actions"

View File

@ -150,8 +150,8 @@ export function RowActions({
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Group position="right" spacing="xs" p={8}> <Group position="right" spacing="xs" p={8}>
{visibleActions.map((action, _idx) => ( {visibleActions.map((action) => (
<RowActionIcon {...action} /> <RowActionIcon key={action.title} {...action} />
))} ))}
</Group> </Group>
</Menu.Dropdown> </Menu.Dropdown>

View File

@ -11,15 +11,19 @@ import {
} from '../components/forms/fields/ApiFormField'; } from '../components/forms/fields/ApiFormField';
import { StylishText } from '../components/items/StylishText'; import { StylishText } from '../components/items/StylishText';
import { ApiPaths } from '../enums/ApiEndpoints'; import { ApiPaths } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState'; import { PathParams, apiUrl } from '../states/ApiState';
import { invalidResponse, permissionDenied } from './notifications'; import { invalidResponse, permissionDenied } from './notifications';
import { generateUniqueId } from './uid'; import { generateUniqueId } from './uid';
/** /**
* Construct an API url from the provided ApiFormProps object * Construct an API url from the provided ApiFormProps object
*/ */
export function constructFormUrl(url: ApiPaths, pk?: string | number): string { export function constructFormUrl(
return apiUrl(url, pk); url: ApiPaths,
pk?: string | number,
pathParams?: PathParams
): string {
return apiUrl(url, pk, pathParams);
} }
/** /**
@ -208,7 +212,7 @@ export function openModalApiForm(props: OpenApiFormProps) {
modals.close(modalId); modals.close(modalId);
}; };
let url = constructFormUrl(props.url, props.pk); let url = constructFormUrl(props.url, props.pk, props.pathParams);
// Make OPTIONS request first // Make OPTIONS request first
api api

View File

@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../App'; import { api } from '../App';
import { StatusCodeListInterface } from '../components/render/StatusRenderer'; import { StatusCodeListInterface } from '../components/render/StatusRenderer';
@ -15,7 +15,7 @@ interface ServerApiStateProps {
server: ServerAPIProps; server: ServerAPIProps;
setServer: (newServer: ServerAPIProps) => void; setServer: (newServer: ServerAPIProps) => void;
fetchServerApiState: () => void; fetchServerApiState: () => void;
status: StatusLookup | undefined; status?: StatusLookup;
} }
export const useServerApiState = create<ServerApiStateProps>()( export const useServerApiState = create<ServerApiStateProps>()(
@ -44,7 +44,7 @@ export const useServerApiState = create<ServerApiStateProps>()(
}), }),
{ {
name: 'server-api-state', name: 'server-api-state',
getStorage: () => sessionStorage storage: createJSONStorage(() => sessionStorage)
} }
) )
); );
@ -189,13 +189,15 @@ export function apiEndpoint(path: ApiPaths): string {
} }
} }
export type PathParams = Record<string, string | number>;
/** /**
* Construct an API URL with an endpoint and (optional) pk value * Construct an API URL with an endpoint and (optional) pk value
*/ */
export function apiUrl( export function apiUrl(
path: ApiPaths, path: ApiPaths,
pk?: any, pk?: any,
data?: Record<string, string | number> pathParams?: PathParams
): string { ): string {
let _url = apiEndpoint(path); let _url = apiEndpoint(path);
@ -208,9 +210,9 @@ export function apiUrl(
_url += `${pk}/`; _url += `${pk}/`;
} }
if (_url && data) { if (_url && pathParams) {
for (const key in data) { for (const key in pathParams) {
_url = _url.replace(`:${key}`, `${data[key]}`); _url = _url.replace(`:${key}`, `${pathParams[key]}`);
} }
} }

View File

@ -1,11 +1,11 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { createJSONStorage, persist } from 'zustand/middleware';
import { setApiDefaults } from '../App'; import { setApiDefaults } from '../App';
interface SessionStateProps { interface SessionStateProps {
token: string | undefined; token?: string;
setToken: (newToken: string | undefined) => void; setToken: (newToken?: string) => void;
} }
export const useSessionState = create<SessionStateProps>()( export const useSessionState = create<SessionStateProps>()(
@ -19,7 +19,7 @@ export const useSessionState = create<SessionStateProps>()(
}), }),
{ {
name: 'session-state', name: 'session-state',
getStorage: () => sessionStorage storage: createJSONStorage(() => sessionStorage)
} }
) )
); );

View File

@ -6,7 +6,7 @@ import { create } from 'zustand';
import { api } from '../App'; import { api } from '../App';
import { ApiPaths } from '../enums/ApiEndpoints'; import { ApiPaths } from '../enums/ApiEndpoints';
import { isTrue } from '../functions/conversion'; import { isTrue } from '../functions/conversion';
import { apiUrl } from './ApiState'; import { PathParams, apiUrl } from './ApiState';
import { Setting, SettingsLookup } from './states'; import { Setting, SettingsLookup } from './states';
export interface SettingsStateProps { export interface SettingsStateProps {
@ -14,6 +14,7 @@ export interface SettingsStateProps {
lookup: SettingsLookup; lookup: SettingsLookup;
fetchSettings: () => void; fetchSettings: () => void;
endpoint: ApiPaths; endpoint: ApiPaths;
pathParams?: PathParams;
getSetting: (key: string, default_value?: string) => string; // Return a raw setting value getSetting: (key: string, default_value?: string) => string; // Return a raw setting value
isSet: (key: string, default_value?: boolean) => boolean; // Check a "boolean" setting isSet: (key: string, default_value?: boolean) => boolean; // Check a "boolean" setting
} }