mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
333e2ce993
commit
264dc9d27a
@ -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>
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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={{
|
||||
|
@ -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)}
|
||||
|
@ -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,
|
||||
|
@ -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} />
|
||||
) : (
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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]}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
)
|
||||
);
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user