[React] Settings interface (#5679)

* Add some dummy pages for settings

* Add some placeholder pages for settings

* Refactor 'useInstance' hook

- Allow use without a pk

* Make response status code available

* Cleanup user settings page

* Fill out (empty) panels for system settings

* Update URLs

* Add more user information to "me" API endpoint

* Implement global user context

- User information available globally
- Add placeholder page for plugin settings

* remove debug entry

* Add first-pass implementation of plugins table

* Add icon to plugin table

* plugin table improvements

* Add SettingsContext component

- Allows list of settings to be drilled down through props
- Also provides a way of reloading the settings list

* Update settings page

* Update settings detail API endpoints

- No longer case sensitive

* Display boolean values, and allow them to be edited

- Still some work to be done here
- Need to pass the base URL down through useContext

* Allow editing of non-boolean settings too

- Required some adjustment to existing forms interface

* Fix rendering of choice field within modal

* Display correct value for choice fields

* Expose settings units to API

* Updates

- Display units for setting (if available)
- Rename fieldType to field_type
- React does not like snakeCase props

* Improve form handling

* Add global server settings keys

* Add table for project codes

* Use cache to ensure that settings are not rebuilt too often

* Update api version notes

* Add username helper function to user state

* Remove SettingsContext

- Replace with global state manager
- Does not currently refresh properly

* Remove UserContext

* Update BaseContext

* Cleanup to match master

- Integrated many improvements from current master

* Get settings items working again

- Now integrates nicely with userSettingsState and globalSettingsState

* Improve generation of user name

* Handle user settings too

* url endpoint fix

* Add new table for custom unit management

* Update global settings
This commit is contained in:
Oliver 2023-10-17 10:28:46 +11:00 committed by GitHub
parent f409cd6894
commit 8bc750bc06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1141 additions and 39 deletions

View File

@ -43,7 +43,7 @@ import { ApiFormField, ApiFormFieldSet } from './fields/ApiFormField';
export interface ApiFormProps {
name: string;
url: ApiPaths;
pk?: number | string;
pk?: number | string | undefined;
title: string;
fields?: ApiFormFieldSet;
cancelText?: string;
@ -51,6 +51,7 @@ export interface ApiFormProps {
submitColor?: string;
cancelColor?: string;
fetchInitialData?: boolean;
ignorePermissionCheck?: boolean;
method?: string;
preFormContent?: JSX.Element | (() => JSX.Element);
postFormContent?: JSX.Element | (() => JSX.Element);

View File

@ -3,7 +3,7 @@ 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 { useQuery } from '@tanstack/react-query';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import Select from 'react-select';

View File

@ -75,10 +75,10 @@ export function Header() {
<NavTabs />
</Group>
<Group>
<ScanButton />
<ActionIcon onClick={openSearchDrawer}>
<IconSearch />
</ActionIcon>
<ScanButton />
<ActionIcon onClick={openNotificationDrawer}>
<Indicator
radius="lg"

View File

@ -2,10 +2,11 @@ import { Trans } from '@lingui/macro';
import { Group, Menu, Skeleton, Text, UnstyledButton } from '@mantine/core';
import {
IconChevronDown,
IconHeart,
IconLogout,
IconPlugConnected,
IconSettings,
IconUserCircle
IconUserCircle,
IconUserCog
} from '@tabler/icons-react';
import { Link } from 'react-router-dom';
@ -16,17 +17,16 @@ import { PlaceholderPill } from '../items/Placeholder';
export function MainMenu() {
const { classes, theme } = InvenTreeStyle();
const [username] = useUserState((state) => [
state.user?.name ?? state.user?.username
]);
const userState = useUserState();
return (
<Menu width={260} position="bottom-end">
<Menu.Target>
<UnstyledButton className={classes.layoutHeaderUser}>
<Group spacing={7}>
<Text weight={500} size="sm" sx={{ lineHeight: 1 }} mr={3}>
{username ? (
username
{userState.username() ? (
userState.username()
) : (
<Skeleton height={20} width={40} radius={theme.defaultRadius} />
)}
@ -46,6 +46,25 @@ export function MainMenu() {
<Menu.Item icon={<IconSettings />} component={Link} to="/profile/user">
<Trans>Account settings</Trans>
</Menu.Item>
<Menu.Item icon={<IconUserCog />} component={Link} to="/settings/user">
<Trans>Account settings</Trans>
</Menu.Item>
{userState.user?.is_staff && (
<Menu.Item icon={<IconSettings />} component={Link} to="/settings/">
<Trans>System Settings</Trans>
</Menu.Item>
)}
{userState.user?.is_staff && (
<Menu.Item
icon={<IconPlugConnected />}
component={Link}
to="/settings/plugin"
>
<Trans>Plugins</Trans>
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
icon={<IconLogout />}
onClick={() => {

View File

@ -14,6 +14,7 @@ import {
import { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { PlaceholderPanel } from '../items/Placeholder';
import { StylishText } from '../items/StylishText';
/**
@ -23,7 +24,7 @@ export type PanelType = {
name: string;
label: string;
icon?: ReactNode;
content: ReactNode;
content?: ReactNode;
hidden?: boolean;
disabled?: boolean;
};
@ -119,7 +120,7 @@ export function PanelGroup({
<Stack spacing="md">
<StylishText size="lg">{panel.label}</StylishText>
<Divider />
{panel.content}
{panel.content ?? <PlaceholderPanel />}
</Stack>
</Tabs.Panel>
)

View File

@ -0,0 +1,147 @@
import { t } from '@lingui/macro';
import { Button, Group, Space, Stack, Switch, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEdit } from '@tabler/icons-react';
import { useMemo } from 'react';
import { api } from '../../App';
import { openModalApiForm } from '../../functions/forms';
import { ApiPaths, apiUrl } from '../../states/ApiState';
import { SettingsStateProps } from '../../states/SettingsState';
import { Setting } from '../../states/states';
/**
* Render a single setting value
*/
function SettingValue({
settingsState,
setting
}: {
settingsState: SettingsStateProps;
setting: Setting;
}) {
// Callback function when a boolean value is changed
function onToggle(value: boolean) {
api
.patch(apiUrl(settingsState.endpoint, setting.key), { value: value })
.then(() => {
showNotification({
title: t`Setting updated`,
message: t`${setting?.name} updated successfully`,
color: 'green'
});
settingsState.fetchSettings();
})
.catch((error) => {
console.log('Error editing setting', error);
showNotification({
title: t`Error editing setting`,
message: error.message,
color: 'red'
});
});
}
// Callback function to open the edit dialog (for non-boolean settings)
function onEditButton() {
let field_type: string = setting?.type ?? 'string';
if (setting?.choices && setting?.choices?.length > 0) {
field_type = 'choice';
}
openModalApiForm({
name: 'setting-edit',
url: settingsState.endpoint,
pk: setting.key,
method: 'PATCH',
title: t`Edit Setting`,
ignorePermissionCheck: true,
fields: {
value: {
value: setting?.value ?? '',
field_type: field_type,
choices: setting?.choices || [],
label: setting?.name,
description: setting?.description
}
},
onFormSuccess() {
showNotification({
title: t`Setting updated`,
message: t`${setting?.name} updated successfully`,
color: 'green'
});
settingsState.fetchSettings();
}
});
}
// Determine the text to display for the setting value
const valueText: string = useMemo(() => {
let value = setting.value;
// If the setting has a choice, display the choice label
if (setting?.choices && setting?.choices?.length > 0) {
const choice = setting.choices.find((c) => c.value == setting.value);
value = choice?.display_name || setting.value;
}
if (setting?.units) {
value = `${value} ${setting.units}`;
}
return value;
}, [setting]);
switch (setting?.type || 'string') {
case 'boolean':
return (
<Switch
size="sm"
radius="lg"
checked={setting.value.toLowerCase() == 'true'}
onChange={(event) => onToggle(event.currentTarget.checked)}
style={{
paddingRight: '20px'
}}
/>
);
default:
return valueText ? (
<Group spacing="xs" position="right">
<Space />
<Button variant="subtle" onClick={onEditButton}>
{valueText}
</Button>
</Group>
) : (
<Button variant="subtle" onClick={onEditButton}>
<IconEdit />
</Button>
);
}
}
/**
* Display a single setting item, and allow editing of the value
*/
export function SettingItem({
settingsState,
setting
}: {
settingsState: SettingsStateProps;
setting: Setting;
}) {
return (
<>
<Group position="apart" p="10">
<Stack spacing="2">
<Text>{setting.name}</Text>
<Text size="xs">{setting.description}</Text>
</Stack>
<SettingValue settingsState={settingsState} setting={setting} />
</Group>
</>
);
}

View File

@ -0,0 +1,59 @@
import { LoadingOverlay, Stack, Text } from '@mantine/core';
import { useEffect } from 'react';
import {
SettingsStateProps,
useGlobalSettingsState,
useUserSettingsState
} from '../../states/SettingsState';
import { SettingItem } from './SettingItem';
/**
* Display a list of setting items, based on a list of provided keys
*/
export function SettingList({
settingsState,
keys
}: {
settingsState: SettingsStateProps;
keys: string[];
}) {
useEffect(() => {
settingsState.fetchSettings();
}, []);
return (
<>
<Stack spacing="xs">
{keys.map((key) => {
const setting = settingsState?.settings?.find(
(s: any) => s.key === key
);
return (
<div key={key}>
{setting ? (
<SettingItem settingsState={settingsState} setting={setting} />
) : (
<Text size="sm" italic color="red">
Setting {key} not found
</Text>
)}
</div>
);
})}
</Stack>
</>
);
}
export function UserSettingList({ keys }: { keys: string[] }) {
const userSettings = useUserSettingsState();
return <SettingList settingsState={userSettings} keys={keys} />;
}
export function GlobalSettingList({ keys }: { keys: string[] }) {
const globalSettings = useGlobalSettingsState();
return <SettingList settingsState={globalSettings} keys={keys} />;
}

View File

@ -188,8 +188,8 @@ function partTableFilters(): TableFilter[] {
* @returns
*/
export function PartListTable({ props }: { props: InvenTreeTableProps }) {
let tableColumns = useMemo(() => partTableColumns(), []);
let tableFilters = useMemo(() => partTableFilters(), []);
const tableColumns = useMemo(() => partTableColumns(), []);
const tableFilters = useMemo(() => partTableFilters(), []);
const { tableKey, refreshTable } = useTableRefresh('part');

View File

@ -0,0 +1,160 @@
import { t } from '@lingui/macro';
import { Group, Text, Tooltip } from '@mantine/core';
import {
IconCircleCheck,
IconCircleX,
IconHelpCircle
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { notYetImplemented } from '../../../functions/notifications';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
/**
* Construct an indicator icon for a single plugin
*/
function PluginIcon(plugin: any) {
if (plugin.is_installed) {
if (plugin.active) {
return (
<Tooltip label={t`Plugin is active`}>
<IconCircleCheck color="green" />
</Tooltip>
);
} else {
return (
<Tooltip label={t`Plugin is inactive`}>
<IconCircleX color="red" />
</Tooltip>
);
}
} else {
return (
<Tooltip label={t`Plugin is not installed`}>
<IconHelpCircle />
</Tooltip>
);
}
}
/**
* Table displaying list of available plugins
*/
export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
const { tableKey, refreshTable } = useTableRefresh('plugin');
const pluginTableColumns: TableColumn[] = useMemo(
() => [
{
accessor: 'name',
title: t`Plugin`,
sortable: true,
render: function (record: any) {
// TODO: Add link to plugin detail page
// TODO: Add custom badges
return (
<Group position="left">
<PluginIcon {...record} />
<Text>{record.name}</Text>
</Group>
);
}
},
{
accessor: 'meta.description',
title: t`Description`,
sortable: false,
switchable: true,
render: function (record: any) {
if (record.active) {
return record.meta.description;
} else {
return <Text italic>{t`Description not available`}</Text>;
}
}
},
{
accessor: 'meta.version',
title: t`Version`,
sortable: false,
switchable: true
// TODO: Display date information if available
},
{
accessor: 'meta.author',
title: 'Author',
sortable: false,
switchable: true
}
],
[]
);
// Determine available actions for a given plugin
function rowActions(record: any): RowAction[] {
let actions: RowAction[] = [];
if (!record.is_builtin && record.is_installed) {
if (record.active) {
actions.push({
title: t`Deactivate`,
color: 'red',
onClick: () => {
notYetImplemented();
}
});
} else {
actions.push({
title: t`Activate`,
onClick: () => {
notYetImplemented();
}
});
}
}
return actions;
}
return (
<InvenTreeTable
url={apiUrl(ApiPaths.plugin_list)}
tableKey={tableKey}
columns={pluginTableColumns}
props={{
...props,
enableDownload: false,
params: {
...props.params
},
rowActions: rowActions,
customFilters: [
{
name: 'active',
label: t`Active`,
type: 'boolean'
},
{
name: 'builtin',
label: t`Builtin`,
type: 'boolean'
},
{
name: 'sample',
label: t`Sample`,
type: 'boolean'
},
{
name: 'installed',
label: t`Installed`,
type: 'boolean'
}
]
}}
/>
);
}

View File

@ -0,0 +1,125 @@
import { t } from '@lingui/macro';
import { ActionIcon, Text, Tooltip } from '@mantine/core';
import { IconCirclePlus } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
/**
* Table for displaying list of custom physical units
*/
export function CustomUnitsTable() {
const { tableKey, refreshTable } = useTableRefresh('custom-units');
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
title: t`Name`,
switchable: false,
sortable: true
},
{
accessor: 'definition',
title: t`Definition`,
switchable: false,
sortable: false
},
{
accessor: 'symbol',
title: t`Symbol`,
switchable: false,
sortable: true
}
];
}, []);
const rowActions = useCallback((record: any): RowAction[] => {
return [
{
title: t`Edit`,
onClick: () => {
openEditApiForm({
name: 'edit-custom-unit',
url: ApiPaths.custom_unit_list,
pk: record.pk,
title: t`Edit custom unit`,
fields: {
name: {},
definition: {},
symbol: {}
},
onFormSuccess: refreshTable,
successMessage: t`Custom unit updated`
});
}
},
{
title: t`Delete`,
onClick: () => {
openDeleteApiForm({
name: 'delete-custom-unit',
url: ApiPaths.custom_unit_list,
pk: record.pk,
title: t`Delete custom unit`,
successMessage: t`Custom unit deleted`,
onFormSuccess: refreshTable,
preFormContent: (
<Text>{t`Are you sure you want to remove this custom unit?`}</Text>
)
});
}
}
];
}, []);
const addCustomUnit = useCallback(() => {
openCreateApiForm({
name: 'add-custom-unit',
url: ApiPaths.custom_unit_list,
title: t`Add custom unit`,
fields: {
name: {},
definition: {},
symbol: {}
},
successMessage: t`Custom unit created`,
onFormSuccess: refreshTable
});
}, []);
const tableActions = useMemo(() => {
let actions = [];
actions.push(
<Tooltip label={t`Add custom unit`}>
<ActionIcon radius="sm" onClick={addCustomUnit}>
<IconCirclePlus color="green" />
</ActionIcon>
</Tooltip>
);
return actions;
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.custom_unit_list)}
tableKey={tableKey}
columns={columns}
props={{
rowActions: rowActions,
customActionGroups: tableActions
}}
/>
);
}

View File

@ -0,0 +1,118 @@
import { t } from '@lingui/macro';
import { ActionIcon, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconCirclePlus } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { notYetImplemented } from '../../../functions/notifications';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
/**
* Table for displaying list of project codes
*/
export function ProjectCodeTable() {
const { tableKey, refreshTable } = useTableRefresh('project-code');
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'code',
sortable: true,
title: t`Project Code`
},
{
accessor: 'description',
sortable: false,
title: t`Description`
}
];
}, []);
const rowActions = useCallback((record: any): RowAction[] => {
return [
{
title: t`Edit`,
onClick: () => {
openEditApiForm({
name: 'edit-project-code',
url: ApiPaths.project_code_list,
pk: record.pk,
title: t`Edit project code`,
fields: {
code: {},
description: {}
},
onFormSuccess: refreshTable,
successMessage: t`Project code updated`
});
}
},
{
title: t`Delete`,
color: 'red',
onClick: () => {
openDeleteApiForm({
name: 'delete-project-code',
url: ApiPaths.project_code_list,
pk: record.pk,
title: t`Delete project code`,
successMessage: t`Project code deleted`,
onFormSuccess: refreshTable,
preFormContent: (
<Text>{t`Are you sure you want to remove this project code?`}</Text>
)
});
}
}
];
}, []);
const addProjectCode = useCallback(() => {
openCreateApiForm({
name: 'add-project-code',
url: ApiPaths.project_code_list,
title: t`Add project code`,
fields: {
code: {},
description: {}
},
onFormSuccess: refreshTable,
successMessage: t`Added project code`
});
}, []);
const tableActions = useMemo(() => {
let actions = [];
actions.push(
<Tooltip label={t`Add project code`}>
<ActionIcon radius="sm" onClick={addProjectCode}>
<IconCirclePlus color="green" />
</ActionIcon>
</Tooltip>
);
return actions;
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.project_code_list)}
tableKey={tableKey}
columns={columns}
props={{
rowActions: rowActions,
customActionGroups: tableActions
}}
/>
);
}

View File

@ -1,10 +1,15 @@
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../App';
import { LanguageContext } from './LanguageContext';
import { ThemeContext } from './ThemeContext';
export const BaseContext = ({ children }: { children: any }) => {
return (
<LanguageContext>
<ThemeContext>{children}</ThemeContext>
</LanguageContext>
<QueryClientProvider client={queryClient}>
<LanguageContext>
<ThemeContext>{children}</ThemeContext>
</LanguageContext>
</QueryClientProvider>
);
};

View File

@ -8,9 +8,7 @@ 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';
@ -60,14 +58,12 @@ export function ThemeContext({ children }: { children: JSX.Element }) {
>
<MantineProvider theme={myTheme} withGlobalStyles withNormalizeCSS>
<Notifications />
<QueryClientProvider client={queryClient}>
<ModalsProvider
labels={{ confirm: t`Submit`, cancel: t`Cancel` }}
modals={{ qr: QrCodeModal }}
>
{children}
</ModalsProvider>
</QueryClientProvider>
<ModalsProvider
labels={{ confirm: t`Submit`, cancel: t`Cancel` }}
modals={{ qr: QrCodeModal }}
>
{children}
</ModalsProvider>
</MantineProvider>
</ColorSchemeProvider>
);

View File

@ -98,11 +98,15 @@ export function openModalApiForm(props: ApiFormProps) {
.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;
let fields: Record<string, ApiFormFieldType> | null = {};
if (!props.ignorePermissionCheck) {
fields = extractAvailableFields(response, props.method);
if (fields == null) {
return;
}
}
// Generate a random modal ID for controller

View File

@ -51,7 +51,7 @@ const vals: LayoutItemType[] = [
];
export default function Home() {
const [username] = useUserState((state) => [state.user?.name]);
const [username] = useUserState((state) => [state.username()]);
return (
<>
<Title order={1}>

View File

@ -0,0 +1,52 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Stack } from '@mantine/core';
import { IconPlugConnected } from '@tabler/icons-react';
import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PluginListTable } from '../../components/tables/plugin/PluginListTable';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths, apiUrl } from '../../states/ApiState';
/**
* Plugins settings page
*/
export default function PluginSettings() {
// Query manager for global plugin settings
const {
instance: settings,
refreshInstance: reloadSettings,
instanceQuery: settingsQuery
} = useInstance({
endpoint: ApiPaths.settings_global_list,
hasPrimaryKey: false,
refetchOnMount: true,
defaultValue: []
});
const pluginPanels: PanelType[] = useMemo(() => {
return [
{
name: 'plugins',
label: t`Plugins`,
icon: <IconPlugConnected />,
content: (
<Stack spacing="xs">
<PluginListTable props={{}} />
</Stack>
)
}
];
}, []);
return (
<>
<Stack spacing="xs">
<LoadingOverlay visible={settingsQuery.isFetching} />
<PageDetail title={t`Plugin Settings`} />
<PanelGroup pageKey="plugin-settings" panels={pluginPanels} />
</Stack>
</>
);
}

View File

@ -0,0 +1,261 @@
import { t } from '@lingui/macro';
import { Divider, Stack } from '@mantine/core';
import {
IconBellCog,
IconCategory,
IconClipboardCheck,
IconCurrencyDollar,
IconFileAnalytics,
IconFingerprint,
IconList,
IconListDetails,
IconPackages,
IconQrcode,
IconScale,
IconServerCog,
IconShoppingCart,
IconSitemap,
IconTag,
IconTools,
IconTruckDelivery
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { GlobalSettingList } from '../../components/settings/SettingList';
import { CustomUnitsTable } from '../../components/tables/settings/CustomUnitsTable';
import { ProjectCodeTable } from '../../components/tables/settings/ProjectCodeTable';
/**
* System settings page
*/
export default function SystemSettings() {
const systemSettingsPanels: PanelType[] = useMemo(() => {
return [
{
name: 'server',
label: t`Server`,
icon: <IconServerCog />,
content: (
<GlobalSettingList
keys={[
'INVENTREE_BASE_URL',
'INVENTREE_COMPANY_NAME',
'INVENTREE_INSTANCE',
'INVENTREE_INSTANCE_TITLE',
'INVENTREE_RESTRICT_ABOUT',
'INVENTREE_UPDATE_CHECK_INTERVAL',
'INVENTREE_DOWNLOAD_FROM_URL',
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE',
'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT',
'INVENTREE_REQUIRE_CONFIRM',
'INVENTREE_TREE_DEPTH',
'INVENTREE_BACKUP_ENABLE',
'INVENTREE_BACKUP_DAYS',
'INVENTREE_DELETE_TASKS_DAYS',
'INVENTREE_DELETE_ERRORS_DAYS',
'INVENTREE_DELETE_NOTIFICATIONS_DAYS'
]}
/>
)
},
{
name: 'login',
label: t`Login`,
icon: <IconFingerprint />,
content: (
<GlobalSettingList
keys={[
'LOGIN_ENABLE_PWD_FORGOT',
'LOGIN_MAIL_REQUIRED',
'LOGIN_ENFORCE_MFA',
'LOGIN_ENABLE_REG',
'LOGIN_SIGNUP_MAIL_TWICE',
'LOGIN_SIGNUP_PWD_TWICE',
'SIGNUP_GROUP',
'LOGIN_SIGNUP_MAIL_RESTRICTION',
'LOGIN_ENABLE_SSO',
'LOGIN_ENABLE_SSO_REG',
'LOGIN_SIGNUP_SSO_AUTO'
]}
/>
)
},
{
name: 'barcode',
label: t`Barcodes`,
icon: <IconQrcode />,
content: (
<GlobalSettingList
keys={[
'BARCODE_ENABLE',
'BARCODE_INPUT_DELAY',
'BARCODE_WEBCAM_SUPPORT'
]}
/>
)
},
{
name: 'projectcodes',
label: t`Project Codes`,
icon: <IconListDetails />,
content: (
<Stack spacing="xs">
<GlobalSettingList keys={['PROJECT_CODES_ENABLED']} />
<Divider />
<ProjectCodeTable />
</Stack>
)
},
{
name: 'physicalunits',
label: t`Physical Units`,
icon: <IconScale />,
content: <CustomUnitsTable />
},
{
name: 'notifications',
label: t`Notifications`,
icon: <IconBellCog />
},
{
name: 'pricing',
label: t`Pricing`,
icon: <IconCurrencyDollar />
},
{
name: 'labels',
label: t`Labels`,
icon: <IconTag />,
content: <GlobalSettingList keys={['LABEL_ENABLE', 'LABEL_DPI']} />
},
{
name: 'reporting',
label: t`Reporting`,
icon: <IconFileAnalytics />,
content: (
<GlobalSettingList
keys={[
'REPORT_ENABLE',
'REPORT_DEFAULT_PAGE_SIZE',
'REPORT_DEBUG_MODE',
'REPORT_ENABLE_TEST_REPORT',
'REPORT_ATTACH_TEST_REPORT'
]}
/>
)
},
{
name: 'categories',
label: t`Part Categories`,
icon: <IconSitemap />
},
{
name: 'parts',
label: t`Parts`,
icon: <IconCategory />,
content: (
<GlobalSettingList
keys={[
'PART_ENABLE_REVISION',
'PART_IPN_REGEX',
'PART_ALLOW_DUPLICATE_IPN',
'PART_ALLOW_EDIT_IPN',
'PART_NAME_FORMAT',
'PART_SHOW_RELATED',
'PART_CREATE_INITIAL',
'PART_CREATE_SUPPLIER', // TODO: Break here
'PART_TEMPLATE',
'PART_ASSEMBLY',
'PART_COMPONENT',
'PART_TRACKABLE',
'PART_PURCHASEABLE',
'PART_SALABLE',
'PART_VIRTUAL', // TODO: Break here
'PART_COPY_BOM',
'PART_COPY_PARAMETERS',
'PART_COPY_TESTS',
'PART_CATEGORY_PARAMETERS',
'PART_CATEGORY_DEFAULT_ICON' // TODO: Move to part category settings page
]}
/>
)
},
{
name: 'parameters',
label: t`Part Parameters`,
icon: <IconList />
},
{
name: 'stock',
label: t`Stock`,
icon: <IconPackages />,
content: (
<GlobalSettingList
keys={[
'SERIAL_NUMBER_GLOBALLY_UNIQUE',
'SERIAL_NUMBER_AUTOFILL',
'STOCK_DELETE_DEPLETED_DEFAULT',
'STOCK_BATCH_CODE_TEMPLATE',
'STOCK_ENABLE_EXPIRY',
'STOCK_STALE_DAYS',
'STOCK_ALLOW_EXPIRED_SALE',
'STOCK_ALLOW_EXPIRED_BUILD',
'STOCK_OWNERSHIP_CONTROL',
'STOCK_LOCATION_DEFAULT_ICON',
'STOCK_SHOW_INSTALLED_ITEMS'
]}
/>
)
},
{
name: 'stocktake',
label: t`Stocktake`,
icon: <IconClipboardCheck />
},
{
name: 'buildorders',
label: t`Build Orders`,
icon: <IconTools />,
content: <GlobalSettingList keys={['BUILDORDER_REFERENCE_PATTERN']} />
},
{
name: 'purchaseorders',
label: t`Purchase Orders`,
icon: <IconShoppingCart />,
content: (
<GlobalSettingList
keys={[
'PURCHASEORDER_REFERENCE_PATTERN',
'PURCHASEORDER_EDIT_COMPLETED_ORDERS'
]}
/>
)
},
{
name: 'salesorders',
label: t`Sales Orders`,
icon: <IconTruckDelivery />,
content: (
<GlobalSettingList
keys={[
'SALESORDER_REFERENCE_PATTERN',
'SALESORDER_DEFAULT_SHIPMENT',
'SALESORDER_EDIT_COMPLETED_ORDERS'
]}
/>
)
}
];
}, []);
return (
<>
<Stack spacing="xs">
<PageDetail title={t`System Settings`} />
<PanelGroup pageKey="system-settings" panels={systemSettingsPanels} />
</Stack>
</>
);
}

View File

@ -0,0 +1,110 @@
import { t } from '@lingui/macro';
import { Stack, Text } from '@mantine/core';
import {
IconBellCog,
IconDeviceDesktop,
IconDeviceDesktopAnalytics,
IconFileAnalytics,
IconSearch,
IconUserCircle
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { UserSettingList } from '../../components/settings/SettingList';
/**
* User settings page
*/
export default function UserSettings() {
const userSettingsPanels: PanelType[] = useMemo(() => {
return [
{
name: 'account',
label: t`Account`,
icon: <IconUserCircle />
},
{
name: 'dashboard',
label: t`Dashboard`,
icon: <IconDeviceDesktopAnalytics />
},
{
name: 'display',
label: t`Display Options`,
icon: <IconDeviceDesktop />,
content: (
<UserSettingList
keys={[
'STICKY_HEADER',
'DATE_DISPLAY_FORMAT',
'FORMS_CLOSE_USING_ESCAPE',
'PART_SHOW_QUANTITY_IN_FORMS',
'DISPLAY_SCHEDULE_TAB',
'DISPLAY_STOCKTAKE_TAB',
'TABLE_STRING_MAX_LENGTH'
]}
/>
)
},
{
name: 'search',
label: t`Search`,
icon: <IconSearch />,
content: (
<UserSettingList
keys={[
'SEARCH_WHOLE',
'SEARCH_REGEX',
'SEARCH_PREVIEW_RESULTS',
'SEARCH_PREVIEW_SHOW_PARTS',
'SEARCH_HIDE_INACTIVE_PARTS',
'SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS',
'SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS',
'SEARCH_PREVIEW_SHOW_CATEGORIES',
'SEARCH_PREVIEW_SHOW_STOCK',
'SEARCH_PREVIEW_HIDE_UNAVAILABLE_STOCK',
'SEARCH_PREVIEW_SHOW_LOCATIONS',
'SEARCH_PREVIEW_SHOW_COMPANIES',
'SEARCH_PREVIEW_SHOW_BUILD_ORDERS',
'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS',
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS',
'SEARCH_PREVIEW_SHOW_SALES_ORDERS',
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS',
'SEARCH_PREVIEW_SHOW_RETURN_ORDERS',
'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS'
]}
/>
)
},
{
name: 'notifications',
label: t`Notifications`,
icon: <IconBellCog />
},
{
name: 'reporting',
label: t`Reporting`,
icon: <IconFileAnalytics />,
content: (
<UserSettingList
keys={['REPORT_INLINE', 'LABEL_INLINE', 'LABEL_DEFAULT_PRINTER']}
/>
)
}
];
}, []);
return (
<>
<Stack spacing="xs">
<PageDetail
title={t`User Settings`}
detail={<Text>TODO: Filler</Text>}
/>
<PanelGroup pageKey="user-settings" panels={userSettingsPanels} />
</Stack>
</>
);
}

View File

@ -49,6 +49,18 @@ export const Profile = Loadable(
lazy(() => import('./pages/Index/Profile/Profile'))
);
export const UserSettings = Loadable(
lazy(() => import('./pages/Index/UserSettings'))
);
export const SystemSettings = Loadable(
lazy(() => import('./pages/Index/SystemSettings'))
);
export const PluginSettings = Loadable(
lazy(() => import('./pages/Index/PluginSettings'))
);
export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
@ -68,6 +80,11 @@ export const routes = (
<Route path="notifications/" element={<Notifications />} />,
<Route path="playground/" element={<Playground />} />,
<Route path="scan/" element={<Scan />} />,
<Route path="settings/">
<Route index element={<SystemSettings />} />
<Route path="user/" element={<UserSettings />} />
<Route path="plugin/" element={<PluginSettings />} />
</Route>
<Route path="part/">
<Route index element={<CategoryDetail />} />
<Route path="category/:id" element={<CategoryDetail />} />

View File

@ -65,7 +65,13 @@ export enum ApiPaths {
purchase_order_list = 'api-purchase-order-list',
// Sales Order URLs
sales_order_list = 'api-sales-order-list'
sales_order_list = 'api-sales-order-list',
// Plugin URLs
plugin_list = 'api-plugin-list',
project_code_list = 'api-project-code-list',
custom_unit_list = 'api-custom-unit-list'
}
/**
@ -137,7 +143,12 @@ export function apiEndpoint(path: ApiPaths): string {
return 'order/po/';
case ApiPaths.sales_order_list:
return 'order/so/';
case ApiPaths.plugin_list:
return 'plugins/';
case ApiPaths.project_code_list:
return 'project-code/';
case ApiPaths.custom_unit_list:
return 'units/';
default:
return '';
}

View File

@ -7,9 +7,10 @@ import { api } from '../App';
import { ApiPaths, apiUrl } from './ApiState';
import { Setting } from './states';
interface SettingsStateProps {
export interface SettingsStateProps {
settings: Setting[];
fetchSettings: () => void;
endpoint: ApiPaths;
}
/**
@ -18,6 +19,7 @@ interface SettingsStateProps {
export const useGlobalSettingsState = create<SettingsStateProps>(
(set, get) => ({
settings: [],
endpoint: ApiPaths.settings_global_list,
fetchSettings: async () => {
await api
.get(apiUrl(ApiPaths.settings_global_list))
@ -36,6 +38,7 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
*/
export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
settings: [],
endpoint: ApiPaths.settings_user_list,
fetchSettings: async () => {
await api
.get(apiUrl(ApiPaths.settings_user_list))

View File

@ -6,6 +6,7 @@ import { UserProps } from './states';
interface UserStateProps {
user: UserProps | undefined;
username: () => string;
setUser: (newUser: UserProps) => void;
fetchUserState: () => void;
}
@ -15,6 +16,15 @@ interface UserStateProps {
*/
export const useUserState = create<UserStateProps>((set, get) => ({
user: undefined,
username: () => {
const user: UserProps = get().user as UserProps;
if (user.first_name || user.last_name) {
return `${user.first_name} ${user.last_name}`.trim();
} else {
return user.username;
}
},
setUser: (newUser: UserProps) => set({ user: newUser }),
fetchUserState: async () => {
// Fetch user data
@ -22,7 +32,8 @@ export const useUserState = create<UserStateProps>((set, get) => ({
.get(apiUrl(ApiPaths.user_me))
.then((response) => {
const user: UserProps = {
name: `${response.data.first_name} ${response.data.last_name}`,
first_name: response.data?.first_name ?? '',
last_name: response.data?.last_name ?? '',
email: response.data.email,
username: response.data.username
};

View File

@ -9,9 +9,10 @@ export interface HostList {
// Type interface fully defining the current user
export interface UserProps {
name: string;
email: string;
username: string;
first_name: string;
last_name: string;
email: string;
is_staff?: boolean;
is_superuser?: boolean;
roles?: Record<string, string[]>;
@ -61,7 +62,8 @@ export enum SettingTyp {
export enum SettingType {
Boolean = 'boolean',
Integer = 'integer',
String = 'string'
String = 'string',
Choice = 'choice'
}
export interface PluginProps {