diff --git a/src/frontend/src/components/buttons/ButtonMenu.tsx b/src/frontend/src/components/buttons/ButtonMenu.tsx index c30b146633..47192ad20f 100644 --- a/src/frontend/src/components/buttons/ButtonMenu.tsx +++ b/src/frontend/src/components/buttons/ButtonMenu.tsx @@ -15,8 +15,6 @@ export function ButtonMenu({ label?: string; tooltip?: string; }) { - let idx = 0; - return ( @@ -26,8 +24,8 @@ export function ButtonMenu({ {label && {label}} - {actions.map((action) => ( - {action} + {actions.map((action, i) => ( + {action} ))} diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index bde9e0a2ff..0231f8c649 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -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 | 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(); } diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index 1590b9e4b3..32854f5e53 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -50,10 +50,9 @@ export function ActionDropdown({ {actions.map((action) => action.disabled ? null : ( - + { if (action.onClick != undefined) { action.onClick(); diff --git a/src/frontend/src/components/nav/BreadcrumbList.tsx b/src/frontend/src/components/nav/BreadcrumbList.tsx index 831f76c15e..b76c20be21 100644 --- a/src/frontend/src/components/nav/BreadcrumbList.tsx +++ b/src/frontend/src/components/nav/BreadcrumbList.tsx @@ -38,7 +38,7 @@ export function BreadcrumbList({ {breadcrumbs.map((breadcrumb, index) => { return ( breadcrumb.url && navigate(breadcrumb.url)} > {breadcrumb.name} diff --git a/src/frontend/src/components/nav/NotificationDrawer.tsx b/src/frontend/src/components/nav/NotificationDrawer.tsx index 895cc11eef..90030070f4 100644 --- a/src/frontend/src/components/nav/NotificationDrawer.tsx +++ b/src/frontend/src/components/nav/NotificationDrawer.tsx @@ -88,7 +88,7 @@ export function NotificationDrawer({ )} {notificationQuery.data?.results?.map((notification: any) => ( - + {notification.target?.name ?? 'target'} {notification.age_human ?? 'name'} diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx index 5f997482f2..efaf1f3b33 100644 --- a/src/frontend/src/components/nav/PageDetail.tsx +++ b/src/frontend/src/components/nav/PageDetail.tsx @@ -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} {actions && ( - - {actions} + + {actions.map((action, idx) => ( + {action} + ))} )} diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx index 9a155b9a77..3f2929b232 100644 --- a/src/frontend/src/components/nav/PanelGroup.tsx +++ b/src/frontend/src/components/nav/PanelGroup.tsx @@ -90,15 +90,14 @@ export function PanelGroup({ > {panels.map( - (panel, idx) => + (panel) => !panel.hidden && ( {panels.map( - (panel, idx) => + (panel) => !panel.hidden && ( {query.results.results.map((result: any) => ( - onResultClick(query.model, result.pk)}> - + onResultClick(query.model, result.pk)} + key={result.pk} + > + ))} @@ -395,8 +394,9 @@ export function SearchDrawer({ )} {!searchQuery.isFetching && !searchQuery.isError && ( - {queryResults.map((query) => ( + {queryResults.map((query, idx) => ( removeResults(query)} onResultClick={(query, pk) => onResultClick(query, pk)} diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index 52b0d02547..5b91b9e255 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -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, diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index 157a3ab698..628d4c7728 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -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 ( <> - {keys.map((key) => { + {(keys || allKeys).map((key, i) => { const setting = settingsState?.settings?.find( (s: any) => s.key === key ); + + const style: Record = { paddingLeft: '8px' }; + if (i % 2 === 0) + style['backgroundColor'] = + theme.colorScheme === 'light' + ? theme.colors.gray[1] + : theme.colors.gray[9]; + return ( -
+
{setting ? ( ) : ( diff --git a/src/frontend/src/components/tables/Column.tsx b/src/frontend/src/components/tables/Column.tsx index 460386c2dc..4917689b99 100644 --- a/src/frontend/src/components/tables/Column.tsx +++ b/src/frontend/src/components/tables/Column.tsx @@ -1,14 +1,14 @@ /** * Interface for the table column definition */ -export type TableColumn = { +export type TableColumn = { 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 diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 51cd0ae6eb..1ec524293f 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -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 = { 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({ url, tableKey, columns, @@ -98,8 +98,8 @@ export function InvenTreeTable({ }: { url: string; tableKey: string; - columns: TableColumn[]; - props: InvenTreeTableProps; + columns: TableColumn[]; + props: InvenTreeTableProps; }) { // 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 = useMemo(() => { return { ...defaultInvenTreeTableProps, ...props @@ -432,9 +432,9 @@ export function InvenTreeTable({ - {tableProps.customActionGroups?.map( - (group: any, idx: number) => group - )} + {tableProps.customActionGroups?.map((group, idx) => ( + {group} + ))} {(tableProps.barcodeActions?.length ?? 0 > 0) && ( - {visibleActions.map((action, _idx) => ( - + {visibleActions.map((action) => ( + ))} diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx index 031b076721..127f910165 100644 --- a/src/frontend/src/functions/forms.tsx +++ b/src/frontend/src/functions/forms.tsx @@ -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 diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index 4a4b5ef0ae..6713f9e23d 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -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()( @@ -44,7 +44,7 @@ export const useServerApiState = create()( }), { name: 'server-api-state', - getStorage: () => sessionStorage + storage: createJSONStorage(() => sessionStorage) } ) ); @@ -189,13 +189,15 @@ export function apiEndpoint(path: ApiPaths): string { } } +export type PathParams = Record; + /** * Construct an API URL with an endpoint and (optional) pk value */ export function apiUrl( path: ApiPaths, pk?: any, - data?: Record + 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]}`); } } diff --git a/src/frontend/src/states/SessionState.tsx b/src/frontend/src/states/SessionState.tsx index d49a223910..54f1e58b9e 100644 --- a/src/frontend/src/states/SessionState.tsx +++ b/src/frontend/src/states/SessionState.tsx @@ -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()( @@ -19,7 +19,7 @@ export const useSessionState = create()( }), { name: 'session-state', - getStorage: () => sessionStorage + storage: createJSONStorage(() => sessionStorage) } ) ); diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index 6270446117..42da0e9f28 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -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 }