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

View File

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

View File

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

View File

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

View File

@ -88,7 +88,7 @@ export function NotificationDrawer({
</Alert>
)}
{notificationQuery.data?.results?.map((notification: any) => (
<Group position="apart">
<Group position="apart" key={notification.pk}>
<Stack spacing="3">
<Text size="sm">{notification.target?.name ?? 'target'}</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 { ReactNode } from 'react';
import { Fragment, ReactNode } from 'react';
import { ApiImage } from '../images/ApiImage';
import { StylishText } from '../items/StylishText';
@ -58,8 +58,10 @@ export function PageDetail({
{detail}
<Space />
{actions && (
<Group key="page-actions" spacing={5} position="right">
{actions}
<Group spacing={5} position="right">
{actions.map((action, idx) => (
<Fragment key={idx}>{action}</Fragment>
))}
</Group>
)}
</Group>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Stack, Text } from '@mantine/core';
import { useEffect } from 'react';
import { Stack, Text, useMantineTheme } from '@mantine/core';
import { useEffect, useMemo } from 'react';
import {
SettingsStateProps,
@ -16,21 +16,36 @@ export function SettingList({
keys
}: {
settingsState: SettingsStateProps;
keys: string[];
keys?: string[];
}) {
useEffect(() => {
settingsState.fetchSettings();
}, []);
const allKeys = useMemo(
() => settingsState?.settings?.map((s) => s.key),
[settingsState?.settings]
);
const theme = useMantineTheme();
return (
<>
<Stack spacing="xs">
{keys.map((key) => {
{(keys || allKeys).map((key, i) => {
const setting = settingsState?.settings?.find(
(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 (
<div key={key}>
<div key={key} style={style}>
{setting ? (
<SettingItem settingsState={settingsState} setting={setting} />
) : (

View File

@ -1,14 +1,14 @@
/**
* Interface for the table column definition
*/
export type TableColumn = {
export type TableColumn<T = any> = {
accessor: string; // The key in the record to access
ordering?: string; // The key in the record to sort by (defaults to accessor)
title: string; // The title of the column
sortable?: boolean; // Whether the column is sortable
switchable?: boolean; // Whether the column is switchable
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
filtering?: boolean; // Whether the column is filterable
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 { useQuery } from '@tanstack/react-query';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
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 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;
defaultSortColumn?: string;
noRecordsText?: string;
@ -57,12 +57,12 @@ export type InvenTreeTableProps = {
pageSize?: number;
barcodeActions?: any[];
customFilters?: TableFilter[];
customActionGroups?: any[];
customActionGroups?: React.ReactNode[];
printingActions?: any[];
idAccessor?: string;
dataFormatter?: (data: any) => any;
rowActions?: (record: any) => RowAction[];
onRowClick?: (record: any, index: number, event: any) => void;
dataFormatter?: (data: T) => any;
rowActions?: (record: T) => RowAction[];
onRowClick?: (record: T, index: number, event: any) => void;
};
/**
@ -90,7 +90,7 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
/**
* Table Component which extends DataTable with custom InvenTree functionality
*/
export function InvenTreeTable({
export function InvenTreeTable<T = any>({
url,
tableKey,
columns,
@ -98,8 +98,8 @@ export function InvenTreeTable({
}: {
url: string;
tableKey: string;
columns: TableColumn[];
props: InvenTreeTableProps;
columns: TableColumn<T>[];
props: InvenTreeTableProps<T>;
}) {
// Use the first part of the table key as the table name
const tableName: string = useMemo(() => {
@ -107,7 +107,7 @@ export function InvenTreeTable({
}, []);
// Build table properties based on provided props (and default props)
const tableProps: InvenTreeTableProps = useMemo(() => {
const tableProps: InvenTreeTableProps<T> = useMemo(() => {
return {
...defaultInvenTreeTableProps,
...props
@ -432,9 +432,9 @@ export function InvenTreeTable({
<Stack spacing="sm">
<Group position="apart">
<Group position="left" key="custom-actions" spacing={5}>
{tableProps.customActionGroups?.map(
(group: any, idx: number) => group
)}
{tableProps.customActionGroups?.map((group, idx) => (
<Fragment key={idx}>{group}</Fragment>
))}
{(tableProps.barcodeActions?.length ?? 0 > 0) && (
<ButtonMenu
key="barcode-actions"

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../App';
import { StatusCodeListInterface } from '../components/render/StatusRenderer';
@ -15,7 +15,7 @@ interface ServerApiStateProps {
server: ServerAPIProps;
setServer: (newServer: ServerAPIProps) => void;
fetchServerApiState: () => void;
status: StatusLookup | undefined;
status?: StatusLookup;
}
export const useServerApiState = create<ServerApiStateProps>()(
@ -44,7 +44,7 @@ export const useServerApiState = create<ServerApiStateProps>()(
}),
{
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
*/
export function apiUrl(
path: ApiPaths,
pk?: any,
data?: Record<string, string | number>
pathParams?: PathParams
): string {
let _url = apiEndpoint(path);
@ -208,9 +210,9 @@ export function apiUrl(
_url += `${pk}/`;
}
if (_url && data) {
for (const key in data) {
_url = _url.replace(`:${key}`, `${data[key]}`);
if (_url && pathParams) {
for (const key in pathParams) {
_url = _url.replace(`:${key}`, `${pathParams[key]}`);
}
}

View File

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

View File

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