PUI: navigation/admin center improvements (#5953)

* 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

* Save panel state in url params

* Improved admin center

* Delete unused file

* Fix: deepsource

* Improve user drawer with descriptions

* Fix api bug with plugin settings and added links to notification entries

* Make plugin related settings work

* Added a lot more plugin actions

* Move InfoItem into own component

* Use Paper for setting item to make have some border radius according to theme

* Fix: deepsource

* Proposal: unify system settings and admin center to one central place

* Fix: deepsource

* Dont add install plugin if plugins are not enabled on this instance

* Fix switch links

* Revert: 'Proposal: unify system settings and admin center to one central place'

* Fix related model settings field

* Fix key error in plugin error table

* Make plugin panels loadables

* Remove user/group edit modal and open the detail drawer instead
This commit is contained in:
Lukas 2023-12-04 13:09:53 +01:00 committed by GitHub
parent f034d86c3f
commit 15f58b965e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1349 additions and 618 deletions

View File

@ -2,11 +2,15 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 157 INVENTREE_API_VERSION = 158
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v158 -> 2023-11-21 : https://github.com/inventree/InvenTree/pull/5953
- Adds API endpoint for listing all settings of a particular plugin
- Adds API endpoint for registry status (errors)
v157 -> 2023-12-02 : https://github.com/inventree/InvenTree/pull/6021 v157 -> 2023-12-02 : https://github.com/inventree/InvenTree/pull/6021
- Add write-only "existing_image" field to Part API serializer - Add write-only "existing_image" field to Part API serializer

View File

@ -3,9 +3,11 @@
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, status from rest_framework import permissions, status
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
import plugin.serializers as PluginSerializers import plugin.serializers as PluginSerializers
from common.api import GlobalSettingsPermissions from common.api import GlobalSettingsPermissions
@ -15,6 +17,7 @@ from InvenTree.helpers import str2bool
from InvenTree.mixins import (CreateAPI, ListAPI, RetrieveUpdateAPI, from InvenTree.mixins import (CreateAPI, ListAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI, UpdateAPI) RetrieveUpdateDestroyAPI, UpdateAPI)
from InvenTree.permissions import IsSuperuser from InvenTree.permissions import IsSuperuser
from plugin import registry
from plugin.base.action.api import ActionPluginView from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView from plugin.base.locate.api import LocatePluginView
@ -251,7 +254,35 @@ def check_plugin(plugin_slug: str, plugin_pk: int) -> InvenTreePlugin:
if not plugin_cgf.active: if not plugin_cgf.active:
raise NotFound(detail=f"Plugin '{ref}' is not active") raise NotFound(detail=f"Plugin '{ref}' is not active")
return plugin_cgf.plugin plugin = plugin_cgf.plugin
if not plugin:
raise NotFound(detail=f"Plugin '{ref}' not installed")
return plugin
class PluginAllSettingList(APIView):
"""List endpoint for all plugin settings for a specific plugin.
- GET: return all settings for a plugin config
"""
permission_classes = [GlobalSettingsPermissions]
@extend_schema(responses={200: PluginSerializers.PluginSettingSerializer(many=True)})
def get(self, request, pk):
"""Get all settings for a plugin config."""
# look up the plugin
plugin = check_plugin(None, pk)
settings = getattr(plugin, 'settings', {})
settings_dict = PluginSetting.all_settings(settings_definition=settings, plugin=plugin.plugin_config())
results = PluginSerializers.PluginSettingSerializer(list(settings_dict.values()), many=True).data
return Response(results)
class PluginSettingDetail(RetrieveUpdateAPI): class PluginSettingDetail(RetrieveUpdateAPI):
@ -287,6 +318,37 @@ class PluginSettingDetail(RetrieveUpdateAPI):
] ]
class RegistryStatusView(APIView):
"""Status API endpoint for the plugin registry.
- GET: Provide status data for the plugin registry
"""
permission_classes = [IsSuperuser, ]
serializer_class = PluginSerializers.PluginRegistryStatusSerializer
@extend_schema(responses={200: PluginSerializers.PluginRegistryStatusSerializer()})
def get(self, request):
"""Show registry status information."""
error_list = []
for stage, errors in registry.errors.items():
for error_detail in errors:
for name, message in error_detail.items():
error_list.append({
"stage": stage,
"name": name,
"message": message,
})
result = PluginSerializers.PluginRegistryStatusSerializer({
"registry_errors": error_list,
}).data
return Response(result)
plugin_api_urls = [ plugin_api_urls = [
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'), re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
re_path(r'^barcode/', include(barcode_api_urls)), re_path(r'^barcode/', include(barcode_api_urls)),
@ -300,7 +362,10 @@ plugin_api_urls = [
# Detail views for a single PluginConfig item # Detail views for a single PluginConfig item
path(r'<int:pk>/', include([ path(r'<int:pk>/', include([
re_path(r'^settings/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'), re_path(r"^settings/", include([
re_path(r'^(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'),
re_path(r"^.*$", PluginAllSettingList.as_view(), name="api-plugin-settings"),
])),
re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'), re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'),
re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
])), ])),
@ -312,6 +377,9 @@ plugin_api_urls = [
re_path(r'^install/', PluginInstall.as_view(), name='api-plugin-install'), re_path(r'^install/', PluginInstall.as_view(), name='api-plugin-install'),
re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-activate'), re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-activate'),
# Registry status
re_path(r"^status/", RegistryStatusView.as_view(), name="api-plugin-registry-status"),
# Anything else # Anything else
re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'), re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
])) ]))

View File

@ -174,3 +174,17 @@ class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
EXTRA_FIELDS = ['method', ] EXTRA_FIELDS = ['method', ]
method = serializers.CharField(read_only=True) method = serializers.CharField(read_only=True)
class PluginRegistryErrorSerializer(serializers.Serializer):
"""Serializer for a plugin registry error."""
stage = serializers.CharField()
name = serializers.CharField()
message = serializers.CharField()
class PluginRegistryStatusSerializer(serializers.Serializer):
"""Serializer for plugin registry status."""
registry_errors = serializers.ListField(child=PluginRegistryErrorSerializer())

View File

@ -179,7 +179,12 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
criteriaMode: 'all', criteriaMode: 'all',
defaultValues defaultValues
}); });
const { isValid, isDirty, isLoading: isFormLoading } = form.formState; const {
isValid,
isDirty,
isLoading: isFormLoading,
isSubmitting
} = form.formState;
// Cache URL // Cache URL
const url = useMemo( const url = useMemo(
@ -351,8 +356,8 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
}; };
const isLoading = useMemo( const isLoading = useMemo(
() => isFormLoading || initialDataQuery.isFetching, () => isFormLoading || initialDataQuery.isFetching || isSubmitting,
[isFormLoading, initialDataQuery.isFetching] [isFormLoading, initialDataQuery.isFetching, isSubmitting]
); );
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => { const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
@ -361,7 +366,6 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
return ( return (
<Stack> <Stack>
<Divider />
<Stack spacing="sm"> <Stack spacing="sm">
<LoadingOverlay visible={isLoading} /> <LoadingOverlay visible={isLoading} />
{(!isValid || nonFieldErrors.length > 0) && ( {(!isValid || nonFieldErrors.length > 0) && (
@ -424,3 +428,62 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
</Stack> </Stack>
); );
} }
export function CreateApiForm({
id,
props
}: {
id?: string;
props: ApiFormProps;
}) {
const createProps = useMemo<ApiFormProps>(
() => ({
...props,
method: 'POST'
}),
[props]
);
return <OptionsApiForm props={createProps} id={id} />;
}
export function EditApiForm({
id,
props
}: {
id?: string;
props: ApiFormProps;
}) {
const editProps = useMemo<ApiFormProps>(
() => ({
...props,
fetchInitialData: props.fetchInitialData ?? true,
submitText: t`Update` ?? props.submitText,
method: 'PUT'
}),
[props]
);
return <OptionsApiForm props={editProps} id={id} />;
}
export function DeleteApiForm({
id,
props
}: {
id?: string;
props: ApiFormProps;
}) {
const deleteProps = useMemo<ApiFormProps>(
() => ({
...props,
method: 'DELETE',
submitText: t`Delete`,
submitColor: 'red',
fields: {}
}),
[props]
);
return <OptionsApiForm props={deleteProps} id={id} />;
}

View File

@ -3,7 +3,7 @@ import { Input } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue } from '@mantine/hooks';
import { useId } from '@mantine/hooks'; import { useId } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FieldValues, UseControllerReturn } from 'react-hook-form'; import { FieldValues, UseControllerReturn } from 'react-hook-form';
import Select from 'react-select'; import Select from 'react-select';
@ -35,12 +35,17 @@ export function RelatedModelField({
// Keep track of the primary key value for this field // Keep track of the primary key value for this field
const [pk, setPk] = useState<number | null>(null); const [pk, setPk] = useState<number | null>(null);
const [offset, setOffset] = useState<number>(0);
const [data, setData] = useState<any[]>([]);
const dataRef = useRef<any[]>([]);
// If an initial value is provided, load from the API // If an initial value is provided, load from the API
useEffect(() => { useEffect(() => {
// If the value is unchanged, do nothing // If the value is unchanged, do nothing
if (field.value === pk) return; if (field.value === pk) return;
if (field.value !== null) { if (field.value !== null && field.value !== undefined) {
const url = `${definition.api_url}${field.value}/`; const url = `${definition.api_url}${field.value}/`;
api.get(url).then((response) => { api.get(url).then((response) => {
@ -53,6 +58,7 @@ export function RelatedModelField({
}; };
setData([value]); setData([value]);
dataRef.current = [value];
setPk(data.pk); setPk(data.pk);
} }
}); });
@ -61,14 +67,16 @@ export function RelatedModelField({
} }
}, [definition.api_url, field.value]); }, [definition.api_url, field.value]);
const [offset, setOffset] = useState<number>(0);
const [data, setData] = useState<any[]>([]);
// Search input query // Search input query
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const [searchText, cancelSearchText] = useDebouncedValue(value, 250); const [searchText, cancelSearchText] = useDebouncedValue(value, 250);
// reset current data on search value change
useEffect(() => {
dataRef.current = [];
setData([]);
}, [searchText]);
const selectQuery = useQuery({ const selectQuery = useQuery({
enabled: !definition.disabled && !!definition.api_url && !definition.hidden, enabled: !definition.disabled && !!definition.api_url && !definition.hidden,
queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText], queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText],
@ -95,7 +103,9 @@ export function RelatedModelField({
params: params params: params
}) })
.then((response) => { .then((response) => {
const values: any[] = [...data]; // current values need to be accessed via a ref, otherwise "data" has old values here
// and this results in no overriding the data which means the current value cannot be displayed
const values: any[] = [...dataRef.current];
const alreadyPresentPks = values.map((x) => x.value); const alreadyPresentPks = values.map((x) => x.value);
const results = response.data?.results ?? response.data ?? []; const results = response.data?.results ?? response.data ?? [];
@ -111,6 +121,7 @@ export function RelatedModelField({
}); });
setData(values); setData(values);
dataRef.current = values;
return response; return response;
}) })
.catch((error) => { .catch((error) => {

View File

@ -0,0 +1,34 @@
import { Trans } from '@lingui/macro';
import { Flex, Group, Text } from '@mantine/core';
import { YesNoButton } from './YesNoButton';
export function InfoItem({
name,
children,
type,
value
}: {
name: string;
children?: React.ReactNode;
type?: 'text' | 'boolean';
value?: any;
}) {
return (
<Group position="apart">
<Text fz="sm" fw={700}>
{name}:
</Text>
<Flex>
{children}
{value !== undefined && type === 'text' ? (
<Text>{value || <Trans>None</Trans>}</Text>
) : type === 'boolean' ? (
<YesNoButton value={value || false} />
) : (
''
)}
</Flex>
</Group>
);
}

View File

@ -0,0 +1,58 @@
import { Divider, Drawer, MantineNumberSize, Stack, Text } from '@mantine/core';
import { useMemo } from 'react';
import { Route, Routes, useNavigate, useParams } from 'react-router-dom';
/**
* @param title - drawer title
* @param position - drawer position
* @param renderContent - function used to render the drawer content
* @param urlPrefix - set an additional url segment, useful when multiple drawers are rendered on one page (e.g. "user/")
*/
export interface DrawerProps {
title: string;
position?: 'right' | 'left';
renderContent: (id?: string) => React.ReactNode;
urlPrefix?: string;
size?: MantineNumberSize;
}
function DetailDrawerComponent({
title,
position = 'right',
size,
renderContent
}: DrawerProps) {
const navigate = useNavigate();
const { id } = useParams();
const content = renderContent(id);
const opened = useMemo(() => !!id && !!content, [id, content]);
return (
<Drawer
opened={opened}
onClose={() => navigate('../')}
position={position}
size={size}
title={
<Text size="xl" fw={600} variant="gradient">
{title}
</Text>
}
overlayProps={{ opacity: 0.5, blur: 4 }}
>
<Stack spacing={'xs'}>
<Divider />
{content}
</Stack>
</Drawer>
);
}
export function DetailDrawer(props: DrawerProps) {
return (
<Routes>
<Route path=":id?/" element={<DetailDrawerComponent {...props} />} />
</Routes>
);
}

View File

@ -3,7 +3,6 @@ import { Group, Menu, Skeleton, Text, UnstyledButton } from '@mantine/core';
import { import {
IconChevronDown, IconChevronDown,
IconLogout, IconLogout,
IconPlugConnected,
IconSettings, IconSettings,
IconUserBolt, IconUserBolt,
IconUserCog IconUserCog
@ -59,15 +58,6 @@ export function MainMenu() {
<Trans>System Settings</Trans> <Trans>System Settings</Trans>
</Menu.Item> </Menu.Item>
)} )}
{userState.user?.is_staff && (
<Menu.Item
icon={<IconPlugConnected />}
component={Link}
to="/settings/plugin"
>
<Trans>Plugins</Trans>
</Menu.Item>
)}
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item

View File

@ -12,6 +12,7 @@ import { Group, Stack, Text } from '@mantine/core';
import { IconBellCheck, IconBellPlus } from '@tabler/icons-react'; import { IconBellCheck, IconBellPlus } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { ApiPaths } from '../../enums/ApiEndpoints'; import { ApiPaths } from '../../enums/ApiEndpoints';
@ -71,7 +72,7 @@ export function NotificationDrawer({
<ActionIcon <ActionIcon
onClick={() => { onClick={() => {
onClose(); onClose();
navigate('/notifications/'); navigate('/notifications/unread');
}} }}
> >
<IconBellPlus /> <IconBellPlus />
@ -90,7 +91,18 @@ export function NotificationDrawer({
{notificationQuery.data?.results?.map((notification: any) => ( {notificationQuery.data?.results?.map((notification: any) => (
<Group position="apart" key={notification.pk}> <Group position="apart" key={notification.pk}>
<Stack spacing="3"> <Stack spacing="3">
{notification?.target?.link ? (
<Text
size="sm"
component={Link}
to={notification?.target?.link}
target="_blank"
>
{notification.target?.name ?? 'target'}
</Text>
) : (
<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>
</Stack> </Stack>
<Space /> <Space />

View File

@ -6,14 +6,21 @@ import {
Tabs, Tabs,
Tooltip Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { import {
IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse IconLayoutSidebarRightCollapse
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { ReactNode } from 'react'; import { ReactNode, useMemo } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import {
Navigate,
Route,
Routes,
useNavigate,
useParams
} from 'react-router-dom';
import { useLocalState } from '../../states/LocalState';
import { PlaceholderPanel } from '../items/Placeholder'; import { PlaceholderPanel } from '../items/Placeholder';
import { StylishText } from '../items/StylishText'; import { StylishText } from '../items/StylishText';
@ -29,48 +36,48 @@ export type PanelType = {
disabled?: boolean; disabled?: boolean;
}; };
/** export type PanelProps = {
*
* @param panels : PanelDefinition[] - The list of panels to display
* @param activePanel : string - The name of the currently active panel (defaults to the first panel)
* @param setActivePanel : (panel: string) => void - Function to set the active panel
* @param onPanelChange : (panel: string) => void - Callback when the active panel changes
* @param collabsible : boolean - If true, the panel group can be collapsed (defaults to true)
* @returns
*/
export function PanelGroup({
pageKey,
panels,
selectedPanel,
onPanelChange,
collabsible = true
}: {
pageKey: string; pageKey: string;
panels: PanelType[]; panels: PanelType[];
selectedPanel?: string; selectedPanel?: string;
onPanelChange?: (panel: string) => void; onPanelChange?: (panel: string) => void;
collabsible?: boolean; collapsible?: boolean;
}): ReactNode { };
const [activePanel, setActivePanel] = useLocalStorage<string>({
key: `panel-group-active-panel-${pageKey}`, function BasePanelGroup({
defaultValue: selectedPanel || panels.length > 0 ? panels[0].name : '' pageKey,
}); panels,
onPanelChange,
selectedPanel,
collapsible = true
}: PanelProps): ReactNode {
const navigate = useNavigate();
const { panel } = useParams();
const activePanels = useMemo(
() => panels.filter((panel) => !panel.hidden && !panel.disabled),
[panels]
);
const setLastUsedPanel = useLocalState((state) =>
state.setLastUsedPanel(pageKey)
);
// Update the active panel when the selected panel changes
// If the selected panel is not available, default to the first available panel
useEffect(() => { useEffect(() => {
let activePanelNames = panels if (panel) {
.filter((panel) => !panel.hidden && !panel.disabled) setLastUsedPanel(panel);
.map((panel) => panel.name);
if (!activePanelNames.includes(activePanel)) {
setActivePanel(activePanelNames.length > 0 ? activePanelNames[0] : '');
} }
}, [panels]); // panel is intentionally no dependency as this should only run on initial render
}, [setLastUsedPanel]);
// Callback when the active panel changes // Callback when the active panel changes
function handlePanelChange(panel: string) { function handlePanelChange(panel: string) {
setActivePanel(panel); if (activePanels.findIndex((p) => p.name === panel) === -1) {
setLastUsedPanel('');
return navigate('../');
}
navigate(`../${panel}`);
// Optionally call external callback hook // Optionally call external callback hook
if (onPanelChange) { if (onPanelChange) {
@ -78,12 +85,27 @@ export function PanelGroup({
} }
} }
// if the selected panel state changes update the current panel
useEffect(() => {
if (selectedPanel && selectedPanel !== panel) {
handlePanelChange(selectedPanel);
}
}, [selectedPanel, panel]);
// Update the active panel when panels changes and the active is no longer available
useEffect(() => {
if (activePanels.findIndex((p) => p.name === panel) === -1) {
setLastUsedPanel('');
return navigate('../');
}
}, [activePanels, panel]);
const [expanded, setExpanded] = useState<boolean>(true); const [expanded, setExpanded] = useState<boolean>(true);
return ( return (
<Paper p="sm" radius="xs" shadow="xs"> <Paper p="sm" radius="xs" shadow="xs">
<Tabs <Tabs
value={activePanel} value={panel}
orientation="vertical" orientation="vertical"
onTabChange={handlePanelChange} onTabChange={handlePanelChange}
keepMounted={false} keepMounted={false}
@ -108,7 +130,7 @@ export function PanelGroup({
</Tooltip> </Tooltip>
) )
)} )}
{collabsible && ( {collapsible && (
<ActionIcon <ActionIcon
style={{ style={{
paddingLeft: '10px' paddingLeft: '10px'
@ -147,3 +169,38 @@ export function PanelGroup({
</Paper> </Paper>
); );
} }
function IndexPanelComponent({ pageKey, selectedPanel, panels }: PanelProps) {
const lastUsedPanel = useLocalState((state) => {
const panelName =
selectedPanel || state.lastUsedPanels[pageKey] || panels[0]?.name;
if (
panels.findIndex(
(p) => p.name === panelName && !p.disabled && !p.hidden
) === -1
) {
return panels[0]?.name;
}
return panelName;
});
return <Navigate to={lastUsedPanel} replace />;
}
/**
* Render a panel group. The current panel will be appended to the current url.
* The last opened panel will be stored in local storage and opened if no panel is provided via url param
* @param panels - The list of panels to display
* @param onPanelChange - Callback when the active panel changes
* @param collapsible - If true, the panel group can be collapsed (defaults to true)
*/
export function PanelGroup(props: PanelProps) {
return (
<Routes>
<Route index element={<IndexPanelComponent {...props} />} />
<Route path="/:panel/*" element={<BasePanelGroup {...props} />} />
</Routes>
);
}

View File

@ -1,14 +1,25 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button, Group, Space, Stack, Switch, Text } from '@mantine/core'; import {
Button,
Group,
Paper,
Space,
Stack,
Switch,
Text,
useMantineTheme
} from '@mantine/core';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconEdit } from '@tabler/icons-react'; import { IconEdit } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { api } from '../../App'; import { api } from '../../App';
import { ModelType } from '../../enums/ModelType';
import { openModalApiForm } from '../../functions/forms'; import { openModalApiForm } from '../../functions/forms';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { SettingsStateProps } from '../../states/SettingsState'; import { SettingsStateProps } from '../../states/SettingsState';
import { Setting, SettingType } from '../../states/states'; import { Setting, SettingType } from '../../states/states';
import { ApiFormFieldType } from '../forms/fields/ApiFormField';
/** /**
* Render a single setting value * Render a single setting value
@ -47,10 +58,27 @@ function SettingValue({
// Callback function to open the edit dialog (for non-boolean settings) // Callback function to open the edit dialog (for non-boolean settings)
function onEditButton() { function onEditButton() {
let field_type = setting?.type ?? 'string'; const fieldDefinition: ApiFormFieldType = {
value: setting?.value ?? '',
field_type: setting?.type ?? 'string',
label: setting?.name,
description: setting?.description
};
if (setting?.choices && setting?.choices?.length > 0) { // Match related field
field_type = SettingType.Choice; if (
fieldDefinition.field_type === SettingType.Model &&
setting.api_url &&
setting.model_name
) {
fieldDefinition.api_url = setting.api_url;
// TODO: improve this model matching mechanism
fieldDefinition.model = setting.model_name.split('.')[1] as ModelType;
} else if (setting.choices?.length > 0) {
// Match choices
fieldDefinition.field_type = SettingType.Choice;
fieldDefinition.choices = setting?.choices || [];
} }
openModalApiForm({ openModalApiForm({
@ -61,13 +89,7 @@ function SettingValue({
title: t`Edit Setting`, title: t`Edit Setting`,
ignorePermissionCheck: true, ignorePermissionCheck: true,
fields: { fields: {
value: { value: fieldDefinition
value: setting?.value ?? '',
field_type: field_type,
choices: setting?.choices || [],
label: setting?.name,
description: setting?.description
}
}, },
onFormSuccess() { onFormSuccess() {
showNotification({ showNotification({
@ -131,13 +153,25 @@ function SettingValue({
*/ */
export function SettingItem({ export function SettingItem({
settingsState, settingsState,
setting setting,
shaded
}: { }: {
settingsState: SettingsStateProps; settingsState: SettingsStateProps;
setting: Setting; setting: Setting;
shaded: boolean;
}) { }) {
const theme = useMantineTheme();
const style: Record<string, string> = { paddingLeft: '8px' };
if (shaded) {
style['backgroundColor'] =
theme.colorScheme === 'light'
? theme.colors.gray[1]
: theme.colors.gray[9];
}
return ( return (
<> <Paper style={style}>
<Group position="apart" p="10"> <Group position="apart" p="10">
<Stack spacing="2"> <Stack spacing="2">
<Text>{setting.name}</Text> <Text>{setting.name}</Text>
@ -145,6 +179,6 @@ export function SettingItem({
</Stack> </Stack>
<SettingValue settingsState={settingsState} setting={setting} /> <SettingValue settingsState={settingsState} setting={setting} />
</Group> </Group>
</> </Paper>
); );
} }

View File

@ -1,8 +1,10 @@
import { Stack, Text, useMantineTheme } from '@mantine/core'; import { Stack, Text } from '@mantine/core';
import { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { useStore } from 'zustand';
import { import {
SettingsStateProps, SettingsStateProps,
createPluginSettingsState,
useGlobalSettingsState, useGlobalSettingsState,
useUserSettingsState useUserSettingsState
} from '../../states/SettingsState'; } from '../../states/SettingsState';
@ -27,8 +29,6 @@ export function SettingList({
[settingsState?.settings] [settingsState?.settings]
); );
const theme = useMantineTheme();
return ( return (
<> <>
<Stack spacing="xs"> <Stack spacing="xs">
@ -37,23 +37,20 @@ export function SettingList({
(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} style={style}> <React.Fragment key={key}>
{setting ? ( {setting ? (
<SettingItem settingsState={settingsState} setting={setting} /> <SettingItem
settingsState={settingsState}
setting={setting}
shaded={i % 2 === 0}
/>
) : ( ) : (
<Text size="sm" italic color="red"> <Text size="sm" italic color="red">
Setting {key} not found Setting {key} not found
</Text> </Text>
)} )}
</div> </React.Fragment>
); );
})} })}
</Stack> </Stack>
@ -72,3 +69,12 @@ export function GlobalSettingList({ keys }: { keys: string[] }) {
return <SettingList settingsState={globalSettings} keys={keys} />; return <SettingList settingsState={globalSettings} keys={keys} />;
} }
export function PluginSettingList({ pluginPk }: { pluginPk: string }) {
const pluginSettingsStore = useRef(
createPluginSettingsState({ plugin: pluginPk })
).current;
const pluginSettings = useStore(pluginSettingsStore);
return <SettingList settingsState={pluginSettings} />;
}

View File

@ -0,0 +1,63 @@
import { t } from '@lingui/macro';
import { Code } from '@mantine/core';
import { useMemo } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
export interface PluginRegistryErrorI {
id: number;
stage: string;
name: string;
message: string;
}
/**
* Table displaying list of plugin registry errors
*/
export function PluginErrorTable({ props }: { props: InvenTreeTableProps }) {
const table = useTable('registryErrors');
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
useMemo(
() => [
{
accessor: 'stage',
title: t`Stage`
},
{
accessor: 'name',
title: t`Name`
},
{
accessor: 'message',
title: t`Message`,
render: (row) => <Code>{row.message}</Code>
}
],
[]
);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.plugin_registry_status)}
tableState={table}
columns={registryErrorTableColumns}
props={{
...props,
dataFormatter: (data: any) =>
data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })),
idAccessor: 'id',
enableDownload: false,
enableFilters: false,
enableSearch: false,
params: {
...props.params
}
}}
/>
);
}

View File

@ -1,27 +1,222 @@
import { t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { Alert, Group, Stack, Text, Tooltip } from '@mantine/core'; import {
Alert,
Box,
Card,
Group,
LoadingOverlay,
Stack,
Text,
Title,
Tooltip
} from '@mantine/core';
import { modals } from '@mantine/modals'; import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { import {
IconCircleCheck, IconCircleCheck,
IconCircleX, IconCircleX,
IconHelpCircle IconHelpCircle,
IconPlaylistAdd,
IconRefresh
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { IconDots } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../../App'; import { api } from '../../../App';
import { ApiPaths } from '../../../enums/ApiEndpoints'; import { ApiPaths } from '../../../enums/ApiEndpoints';
import { openEditApiForm } from '../../../functions/forms';
import { useCreateApiFormModal } from '../../../hooks/UseForm';
import { useInstance } from '../../../hooks/UseInstance';
import { useTable } from '../../../hooks/UseTable'; import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState'; import { apiUrl, useServerApiState } from '../../../states/ApiState';
import { ActionButton } from '../../buttons/ActionButton';
import { ActionDropdown, EditItemAction } from '../../items/ActionDropdown';
import { InfoItem } from '../../items/InfoItem';
import { StylishText } from '../../items/StylishText'; import { StylishText } from '../../items/StylishText';
import { DetailDrawer } from '../../nav/DetailDrawer';
import { PluginSettingList } from '../../settings/SettingList';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
import { RowAction } from '../RowActions'; import { RowAction } from '../RowActions';
export interface PluginI {
pk: number;
key: string;
name: string;
active: boolean;
is_builtin: boolean;
is_sample: boolean;
is_installed: boolean;
meta: {
author: string | null;
description: string | null;
human_name: string | null;
license: string | null;
package_path: string | null;
pub_date: string | null;
settings_url: string | null;
slug: string | null;
version: string | null;
website: string | null;
};
mixins: Record<
string,
{
key: string;
human_name: string;
}
>;
}
export function PluginDrawer({
id,
refreshTable
}: {
id: string;
refreshTable: () => void;
}) {
const {
instance: plugin,
refreshInstance,
instanceQuery: { isFetching, error }
} = useInstance<PluginI>({
endpoint: ApiPaths.plugin_list,
pk: id,
throwError: true
});
const refetch = useCallback(() => {
refreshTable();
refreshInstance();
}, [refreshTable, refreshInstance]);
if (isFetching) {
return <LoadingOverlay visible={true} />;
}
if (error) {
return (
<Text>
{(error as any)?.response?.status === 404 ? (
<Trans>Plugin with id {id} not found</Trans>
) : (
<Trans>An error occurred while fetching plugin details</Trans>
)}
</Text>
);
}
return (
<Stack spacing={'xs'}>
<Group position="apart">
<Box></Box>
<Group spacing={'xs'}>
{plugin && PluginIcon(plugin)}
<Title order={4}>{plugin?.meta.human_name || plugin?.name}</Title>
</Group>
<ActionDropdown
tooltip={t`Plugin Actions`}
icon={<IconDots />}
actions={[
EditItemAction({
tooltip: t`Edit plugin`,
onClick: () => {
openEditApiForm({
title: t`Edit plugin`,
url: ApiPaths.plugin_list,
pk: id,
fields: {
active: {}
},
onClose: refetch
});
}
}),
{
name: t`Reload`,
tooltip: t`Reload`,
icon: <IconRefresh />,
onClick: refreshInstance
}
]}
/>
</Group>
<LoadingOverlay visible={isFetching} overlayOpacity={0} />
<Card withBorder>
<Stack spacing="md">
<Title order={4}>
<Trans>Plugin information</Trans>
</Title>
<Stack pos="relative" spacing="xs">
<InfoItem type="text" name={t`Name`} value={plugin?.name} />
<InfoItem
type="text"
name={t`Description`}
value={plugin?.meta.description}
/>
<InfoItem
type="text"
name={t`Author`}
value={plugin?.meta.author}
/>
<InfoItem
type="text"
name={t`Date`}
value={plugin?.meta.pub_date}
/>
<InfoItem
type="text"
name={t`Version`}
value={plugin?.meta.version}
/>
<InfoItem type="boolean" name={t`Active`} value={plugin?.active} />
</Stack>
</Stack>
</Card>
<Card withBorder>
<Stack spacing="md">
<Title order={4}>
<Trans>Package information</Trans>
</Title>
<Stack pos="relative" spacing="xs">
<InfoItem
type="text"
name={t`Installation path`}
value={plugin?.meta.package_path}
/>
<InfoItem
type="boolean"
name={t`Builtin`}
value={plugin?.is_builtin}
/>
</Stack>
</Stack>
</Card>
{plugin && plugin.active && (
<Card withBorder>
<Stack spacing="md">
<Title order={4}>
<Trans>Plugin settings</Trans>
</Title>
<PluginSettingList pluginPk={id} />
</Stack>
</Card>
)}
</Stack>
);
}
/** /**
* Construct an indicator icon for a single plugin * Construct an indicator icon for a single plugin
*/ */
function PluginIcon(plugin: any) { function PluginIcon(plugin: PluginI) {
if (plugin.is_installed) { if (plugin.is_installed) {
if (plugin.active) { if (plugin.active) {
return ( return (
@ -50,6 +245,11 @@ function PluginIcon(plugin: any) {
*/ */
export function PluginListTable({ props }: { props: InvenTreeTableProps }) { export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
const table = useTable('plugin'); const table = useTable('plugin');
const navigate = useNavigate();
const pluginsEnabled = useServerApiState(
(state) => state.server.plugins_enabled
);
const pluginTableColumns: TableColumn[] = useMemo( const pluginTableColumns: TableColumn[] = useMemo(
() => [ () => [
@ -200,7 +400,58 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
return actions; return actions;
} }
const installPluginModal = useCreateApiFormModal({
title: t`Install plugin`,
url: ApiPaths.plugin_install,
fields: {
packagename: {},
url: {},
confirm: {}
},
closeOnClickOutside: false,
submitText: t`Install`,
successMessage: undefined,
onFormSuccess: (data) => {
notifications.show({
title: t`Plugin installed successfully`,
message: data.result,
autoClose: 30000,
color: 'green'
});
table.refreshTable();
}
});
// Custom table actions
const tableActions = useMemo(() => {
let actions = [];
if (pluginsEnabled) {
actions.push(
<ActionButton
color="green"
icon={<IconPlaylistAdd />}
tooltip={t`Install Plugin`}
onClick={() => installPluginModal.open()}
/>
);
}
return actions;
}, []);
return ( return (
<>
{installPluginModal.modal}
<DetailDrawer
title={t`Plugin detail`}
size={'lg'}
renderContent={(id) => {
if (!id) return false;
return <PluginDrawer id={id} refreshTable={table.refreshTable} />;
}}
/>
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiPaths.plugin_list)} url={apiUrl(ApiPaths.plugin_list)}
tableState={table} tableState={table}
@ -212,6 +463,8 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
...props.params ...props.params
}, },
rowActions: rowActions, rowActions: rowActions,
onRowClick: (plugin) => navigate(`${plugin.pk}/`),
customActionGroups: tableActions,
customFilters: [ customFilters: [
{ {
name: 'active', name: 'active',
@ -236,5 +489,6 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
] ]
}} }}
/> />
</>
); );
} }

View File

@ -1,26 +1,98 @@
import { t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ApiPaths } from '../../../enums/ApiEndpoints'; import { ApiPaths } from '../../../enums/ApiEndpoints';
import { import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
openCreateApiForm, import { useInstance } from '../../../hooks/UseInstance';
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTable } from '../../../hooks/UseTable'; import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState'; import { apiUrl } from '../../../states/ApiState';
import { AddItemButton } from '../../buttons/AddItemButton'; import { AddItemButton } from '../../buttons/AddItemButton';
import { EditApiForm } from '../../forms/ApiForm';
import { PlaceholderPill } from '../../items/Placeholder';
import { DetailDrawer } from '../../nav/DetailDrawer';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
export interface GroupDetailI {
pk: number;
name: string;
}
export function GroupDrawer({
id,
refreshTable
}: {
id: string;
refreshTable: () => void;
}) {
const {
refreshInstance,
instanceQuery: { isFetching, error }
} = useInstance({
endpoint: ApiPaths.group_list,
pk: id,
throwError: true
});
if (isFetching) {
return <LoadingOverlay visible={true} />;
}
if (error) {
return (
<Text>
{(error as any)?.response?.status === 404 ? (
<Trans>Group with id {id} not found</Trans>
) : (
<Trans>An error occurred while fetching group details</Trans>
)}
</Text>
);
}
return (
<Stack>
<EditApiForm
props={{
url: ApiPaths.group_list,
pk: id,
fields: {
name: {}
},
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`group-detail-drawer-${id}`}
/>
<Title order={5}>
<Trans>Permission set</Trans>
</Title>
<Group>
<PlaceholderPill />
</Group>
</Stack>
);
}
/** /**
* Table for displaying list of groups * Table for displaying list of groups
*/ */
export function GroupTable() { export function GroupTable() {
const table = useTable('groups'); const table = useTable('groups');
const navigate = useNavigate();
const columns: TableColumn[] = useMemo(() => { const openDetailDrawer = useCallback(
(pk: number) => navigate(`group-${pk}/`),
[]
);
const columns: TableColumn<GroupDetailI>[] = useMemo(() => {
return [ return [
{ {
accessor: 'name', accessor: 'name',
@ -30,21 +102,10 @@ export function GroupTable() {
]; ];
}, []); }, []);
const rowActions = useCallback((record: any): RowAction[] => { const rowActions = useCallback((record: GroupDetailI): RowAction[] => {
return [ return [
RowEditAction({ RowEditAction({
onClick: () => { onClick: () => openDetailDrawer(record.pk)
openEditApiForm({
url: ApiPaths.group_list,
pk: record.pk,
title: t`Edit group`,
fields: {
name: {}
},
onFormSuccess: table.refreshTable,
successMessage: t`Group updated`
});
}
}), }),
RowDeleteAction({ RowDeleteAction({
onClick: () => { onClick: () => {
@ -86,14 +147,29 @@ export function GroupTable() {
}, []); }, []);
return ( return (
<>
<DetailDrawer
title={t`Edit group`}
renderContent={(id) => {
if (!id || !id.startsWith('group-')) return false;
return (
<GroupDrawer
id={id.replace('group-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiPaths.group_list)} url={apiUrl(ApiPaths.group_list)}
tableState={table} tableState={table}
columns={columns} columns={columns}
props={{ props={{
rowActions: rowActions, rowActions: rowActions,
customActionGroups: tableActions customActionGroups: tableActions,
onRowClick: (record) => openDetailDrawer(record.pk)
}} }}
/> />
</>
); );
} }

View File

@ -1,217 +0,0 @@
import { Trans, t } from '@lingui/macro';
import {
Chip,
Drawer,
Group,
List,
Loader,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { useToggle } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { api } from '../../../App';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import {
invalidResponse,
permissionDenied
} from '../../../functions/notifications';
import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { EditButton } from '../../items/EditButton';
import { UserDetailI } from './UserTable';
export function UserDrawer({
opened,
close,
refreshTable,
userDetail
}: {
opened: boolean;
close: () => void;
refreshTable: () => void;
userDetail: UserDetailI | undefined;
}) {
const [user] = useUserState((state) => [state.user]);
const [rightsValue, setRightsValue] = useState(['']);
const [locked, setLocked] = useState<boolean>(false);
const [userEditing, setUserEditing] = useToggle([false, true] as const);
// Set initial values
useEffect(() => {
if (!userDetail) return;
setLocked(true);
// rights
let new_rights = [];
if (userDetail.is_staff) {
new_rights.push('is_staff');
}
if (userDetail.is_active) {
new_rights.push('is_active');
}
if (userDetail.is_superuser) {
new_rights.push('is_superuser');
}
setRightsValue(new_rights);
setLocked(false);
}, [userDetail]);
// actions on role change
function changeRights(roles: [string]) {
if (!userDetail) return;
let data = {
is_staff: roles.includes('is_staff'),
is_superuser: roles.includes('is_superuser')
};
if (
data.is_staff != userDetail.is_staff ||
data.is_superuser != userDetail.is_superuser
) {
setPermission(userDetail.pk, data);
}
if (userDetail.is_active != roles.includes('is_active')) {
setActive(userDetail.pk, roles.includes('is_active'));
}
setRightsValue(roles);
}
function setPermission(pk: number, data: any) {
setLocked(true);
api
.patch(apiUrl(ApiPaths.user_list, pk), data)
.then(() => {
notifications.show({
title: t`User permission changed successfully`,
message: t`Some changes might only take effect after the user refreshes their login.`,
color: 'green',
icon: <IconCheck size="1rem" />
});
refreshTable();
})
.catch((error) => {
if (error.response.status === 403) {
permissionDenied();
} else {
console.log(error);
invalidResponse(error.response.status);
}
})
.finally(() => setLocked(false));
}
function setActive(pk: number, active: boolean) {
setLocked(true);
api
.patch(apiUrl(ApiPaths.user_list, pk), {
is_active: active
})
.then(() => {
notifications.show({
title: t`Changed user active status successfully`,
message: t`Set to ${active}`,
color: 'green',
icon: <IconCheck size="1rem" />
});
refreshTable();
})
.catch((error) => {
if (error.response.status === 403) {
permissionDenied();
} else {
console.log(error);
invalidResponse(error.response.status);
}
})
.finally(() => setLocked(false));
}
const userEditable = locked || !userEditing;
return (
<Drawer
opened={opened}
onClose={close}
position="right"
title={userDetail ? t`User details for ${userDetail.username}` : ''}
overlayProps={{ opacity: 0.5, blur: 4 }}
>
<Stack spacing={'xs'}>
<Group>
<Title order={5}>
<Trans>Details</Trans>
</Title>
<EditButton
editing={userEditing}
setEditing={setUserEditing}
disabled
/>
</Group>
{userDetail ? (
<Stack spacing={0} ml={'md'}>
<TextInput
label={t`Username`}
value={userDetail.username}
disabled={userEditable}
/>
<TextInput label={t`Email`} value={userDetail.email} disabled />
<TextInput
label={t`First Name`}
value={userDetail.first_name}
disabled={userEditable}
/>
<TextInput
label={t`Last Name`}
value={userDetail.last_name}
disabled={userEditable}
/>
<Text>
<Trans>Rights</Trans>
</Text>
<Chip.Group multiple value={rightsValue} onChange={changeRights}>
<Group spacing={0}>
<Chip value="is_active" disabled={locked || !user?.is_staff}>
<Trans>Active</Trans>
</Chip>
<Chip value="is_staff" disabled={locked || !user?.is_staff}>
<Trans>Staff</Trans>
</Chip>
<Chip
value="is_superuser"
disabled={locked || !(user?.is_staff && user?.is_superuser)}
>
<Trans>Superuser</Trans>
</Chip>
</Group>
</Chip.Group>
</Stack>
) : (
<Loader />
)}
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<Text ml={'md'}>
{userDetail && userDetail.groups.length == 0 ? (
<Trans>No groups</Trans>
) : (
<List>
{userDetail &&
userDetail.groups.map((message) => (
<List.Item key={message.name}>{message.name}</List.Item>
))}
</List>
)}
</Text>
</Stack>
</Drawer>
);
}

View File

@ -1,26 +1,24 @@
import { t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { useDisclosure } from '@mantine/hooks'; import { Alert, List, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react'; import { IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { ApiPaths } from '../../../enums/ApiEndpoints'; import { ApiPaths } from '../../../enums/ApiEndpoints';
import { import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
openCreateApiForm, import { useInstance } from '../../../hooks/UseInstance';
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTable } from '../../../hooks/UseTable'; import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState'; import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { AddItemButton } from '../../buttons/AddItemButton'; import { AddItemButton } from '../../buttons/AddItemButton';
import { EditApiForm } from '../../forms/ApiForm';
import { DetailDrawer } from '../../nav/DetailDrawer';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers'; import { BooleanColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import { UserDrawer } from './UserDrawer'; import { GroupDetailI } from './GroupTable';
interface GroupDetailI {
pk: number;
name: string;
}
export interface UserDetailI { export interface UserDetailI {
pk: number; pk: number;
@ -34,13 +32,122 @@ export interface UserDetailI {
is_superuser: boolean; is_superuser: boolean;
} }
export function UserDrawer({
id,
refreshTable
}: {
id: string;
refreshTable: () => void;
}) {
const {
instance: userDetail,
refreshInstance,
instanceQuery: { isFetching, error }
} = useInstance<UserDetailI>({
endpoint: ApiPaths.user_list,
pk: id,
throwError: true
});
const currentUserPk = useUserState((s) => s.user?.pk);
const isCurrentUser = useMemo(
() => currentUserPk === parseInt(id, 10),
[currentUserPk, id]
);
if (isFetching) {
return <LoadingOverlay visible={true} />;
}
if (error) {
return (
<Text>
{(error as any)?.response?.status === 404 ? (
<Trans>User with id {id} not found</Trans>
) : (
<Trans>An error occurred while fetching user details</Trans>
)}
</Text>
);
}
return (
<Stack>
<EditApiForm
props={{
url: ApiPaths.user_list,
pk: id,
fields: {
username: {},
first_name: {},
last_name: {},
email: {},
is_active: {
label: t`Is Active`,
description: t`Designates whether this user should be treated as active. Unselect this instead of deleting accounts.`,
disabled: isCurrentUser
},
is_staff: {
label: t`Is Staff`,
description: t`Designates whether the user can log into the django admin site.`,
disabled: isCurrentUser
},
is_superuser: {
label: t`Is Superuser`,
description: t`Designates that this user has all permissions without explicitly assigning them.`,
disabled: isCurrentUser
}
},
postFormContent: isCurrentUser ? (
<Alert
title={<Trans>Info</Trans>}
color="blue"
icon={<IconInfoCircle />}
>
<Trans>
You cannot edit the rights for the currently logged-in user.
</Trans>
</Alert>
) : undefined,
onFormSuccess: () => {
refreshTable();
refreshInstance();
}
}}
id={`user-detail-drawer-${id}`}
/>
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<Text ml={'md'}>
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
<List>
{userDetail?.groups?.map((group) => (
<List.Item key={group.pk}>
<Link to={`../group-${group.pk}`}>{group.name}</Link>
</List.Item>
))}
</List>
) : (
<Trans>No groups</Trans>
)}
</Text>
</Stack>
);
}
/** /**
* Table for displaying list of users * Table for displaying list of users
*/ */
export function UserTable() { export function UserTable() {
const table = useTable('users'); const table = useTable('users');
const [opened, { open, close }] = useDisclosure(false); const navigate = useNavigate();
const [userDetail, setUserDetail] = useState<UserDetailI>();
const openDetailDrawer = useCallback(
(pk: number) => navigate(`user-${pk}/`),
[]
);
const columns: TableColumn[] = useMemo(() => { const columns: TableColumn[] = useMemo(() => {
return [ return [
@ -92,20 +199,7 @@ export function UserTable() {
const rowActions = useCallback((record: UserDetailI): RowAction[] => { const rowActions = useCallback((record: UserDetailI): RowAction[] => {
return [ return [
RowEditAction({ RowEditAction({
onClick: () => { onClick: () => openDetailDrawer(record.pk)
openEditApiForm({
url: ApiPaths.user_list,
pk: record.pk,
title: t`Edit user`,
fields: {
email: {},
first_name: {},
last_name: {}
},
onFormSuccess: table.refreshTable,
successMessage: t`User updated`
});
}
}), }),
RowDeleteAction({ RowDeleteAction({
onClick: () => { onClick: () => {
@ -149,11 +243,17 @@ export function UserTable() {
return ( return (
<> <>
<DetailDrawer
title={t`Edit user`}
renderContent={(id) => {
if (!id || !id.startsWith('user-')) return false;
return (
<UserDrawer <UserDrawer
opened={opened} id={id.replace('user-', '')}
close={close}
refreshTable={table.refreshTable} refreshTable={table.refreshTable}
userDetail={userDetail} />
);
}}
/> />
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiPaths.user_list)} url={apiUrl(ApiPaths.user_list)}
@ -162,10 +262,7 @@ export function UserTable() {
props={{ props={{
rowActions: rowActions, rowActions: rowActions,
customActionGroups: tableActions, customActionGroups: tableActions,
onRowClick: (record: any) => { onRowClick: (record) => openDetailDrawer(record.pk)
setUserDetail(record);
open();
}
}} }}
/> />
</> </>

View File

@ -84,6 +84,9 @@ export enum ApiPaths {
// Plugin URLs // Plugin URLs
plugin_list = 'api-plugin-list', plugin_list = 'api-plugin-list',
plugin_setting_list = 'api-plugin-settings',
plugin_install = 'api-plugin-install',
plugin_registry_status = 'api-plugin-registry-status',
project_code_list = 'api-project-code-list', project_code_list = 'api-project-code-list',
custom_unit_list = 'api-custom-unit-list' custom_unit_list = 'api-custom-unit-list'

View File

@ -1,4 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Divider, Stack } from '@mantine/core';
import { modals } from '@mantine/modals'; import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
@ -248,7 +249,12 @@ export function openModalApiForm(props: OpenApiFormProps) {
onClose: () => { onClose: () => {
props.onClose ? props.onClose() : null; props.onClose ? props.onClose() : null;
}, },
children: <ApiForm id={modalId} props={props} /> children: (
<Stack spacing={'xs'}>
<Divider />
<ApiForm id={modalId} props={props} />
</Stack>
)
}); });
}) })
.catch((error) => { .catch((error) => {

View File

@ -1,4 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Divider, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks'; import { useId } from '@mantine/hooks';
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
@ -18,6 +19,7 @@ export interface ApiFormModalProps extends ApiFormProps {
cancelColor?: string; cancelColor?: string;
onClose?: () => void; onClose?: () => void;
onOpen?: () => void; onOpen?: () => void;
closeOnClickOutside?: boolean;
} }
/** /**
@ -56,8 +58,14 @@ export function useApiFormModal(props: ApiFormModalProps) {
title: formProps.title, title: formProps.title,
onOpen: formProps.onOpen, onOpen: formProps.onOpen,
onClose: formProps.onClose, onClose: formProps.onClose,
closeOnClickOutside: formProps.closeOnClickOutside,
size: 'xl', size: 'xl',
children: <OptionsApiForm props={formProps} id={id} /> children: (
<Stack spacing={'xs'}>
<Divider />
<OptionsApiForm props={formProps} id={id} />
</Stack>
)
}); });
useEffect(() => { useEffect(() => {

View File

@ -3,7 +3,7 @@ import { useCallback, useState } from 'react';
import { api } from '../App'; import { api } from '../App';
import { ApiPaths } from '../enums/ApiEndpoints'; import { ApiPaths } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState'; import { PathParams, apiUrl } from '../states/ApiState';
/** /**
* Custom hook for loading a single instance of an instance from the API * Custom hook for loading a single instance of an instance from the API
@ -14,26 +14,30 @@ import { apiUrl } from '../states/ApiState';
* To use this hook: * To use this hook:
* const { instance, refreshInstance } = useInstance(url: string, pk: number) * const { instance, refreshInstance } = useInstance(url: string, pk: number)
*/ */
export function useInstance({ export function useInstance<T = any>({
endpoint, endpoint,
pk, pk,
params = {}, params = {},
defaultValue = {}, defaultValue = {},
pathParams,
hasPrimaryKey = true, hasPrimaryKey = true,
refetchOnMount = true, refetchOnMount = true,
refetchOnWindowFocus = false refetchOnWindowFocus = false,
throwError = false
}: { }: {
endpoint: ApiPaths; endpoint: ApiPaths;
pk?: string | undefined; pk?: string | undefined;
hasPrimaryKey?: boolean; hasPrimaryKey?: boolean;
params?: any; params?: any;
pathParams?: PathParams;
defaultValue?: any; defaultValue?: any;
refetchOnMount?: boolean; refetchOnMount?: boolean;
refetchOnWindowFocus?: boolean; refetchOnWindowFocus?: boolean;
throwError?: boolean;
}) { }) {
const [instance, setInstance] = useState<any>(defaultValue); const [instance, setInstance] = useState<T | undefined>(defaultValue);
const instanceQuery = useQuery({ const instanceQuery = useQuery<T>({
queryKey: ['instance', endpoint, pk, params], queryKey: ['instance', endpoint, pk, params],
queryFn: async () => { queryFn: async () => {
if (hasPrimaryKey) { if (hasPrimaryKey) {
@ -43,7 +47,7 @@ export function useInstance({
} }
} }
let url = apiUrl(endpoint, pk); const url = apiUrl(endpoint, pk, pathParams);
return api return api
.get(url, { .get(url, {
@ -62,6 +66,9 @@ export function useInstance({
.catch((error) => { .catch((error) => {
setInstance(defaultValue); setInstance(defaultValue);
console.error(`Error fetching instance ${url}:`, error); console.error(`Error fetching instance ${url}:`, error);
if (throwError) throw error;
return null; return null;
}); });
}, },

View File

@ -10,6 +10,7 @@ export interface UseModalProps {
size?: MantineNumberSize; size?: MantineNumberSize;
onOpen?: () => void; onOpen?: () => void;
onClose?: () => void; onClose?: () => void;
closeOnClickOutside?: boolean;
} }
export function useModal(props: UseModalProps) { export function useModal(props: UseModalProps) {
@ -34,6 +35,7 @@ export function useModal(props: UseModalProps) {
<Modal <Modal
opened={opened} opened={opened}
onClose={close} onClose={close}
closeOnClickOutside={props.closeOnClickOutside}
size={props.size ?? 'xl'} size={props.size ?? 'xl'}
title={<StylishText size="xl">{props.title}</StylishText>} title={<StylishText size="xl">{props.title}</StylishText>}
> >

View File

@ -1,112 +0,0 @@
import { Trans, t } from '@lingui/macro';
import {
Anchor,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Title
} from '@mantine/core';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { PlaceholderPill } from '../../../components/items/Placeholder';
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { GlobalSettingList } from '../../../components/settings/SettingList';
import { GroupTable } from '../../../components/tables/settings/GroupTable';
import { UserTable } from '../../../components/tables/settings/UserTable';
/**
* System settings page
*/
export default function AdminCenter() {
const adminCenterPanels: PanelType[] = useMemo(() => {
return [
{
name: 'user',
label: t`User Management`,
content: (
<Stack spacing="xs">
<Title order={5}>
<Trans>Users</Trans>
</Title>
<UserTable />
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<GroupTable />
<Divider />
<Stack spacing={0}>
<Text>
<Trans>Settings</Trans>
</Text>
<Group>
<Text c="dimmed">
<Trans>
Select settings relevant for user lifecycle. More available
in
</Trans>
</Text>
<Anchor component={Link} to={'/settings/system'}>
<Trans>System settings</Trans>
</Anchor>
</Group>
</Stack>
<GlobalSettingList
keys={[
'LOGIN_ENABLE_REG',
'SIGNUP_GROUP',
'LOGIN_ENABLE_SSO_REG'
]}
/>
</Stack>
)
}
];
}, []);
const QuickAction = () => (
<Stack spacing={'xs'} ml={'sm'}>
<Title order={5}>
<Trans>Quick Actions</Trans>
</Title>
<SimpleGrid cols={3}>
<Paper shadow="xs" p="sm" withBorder>
<Text>
<Trans>Add a new user</Trans>
</Text>
</Paper>
<Paper shadow="xs" p="sm" withBorder>
<PlaceholderPill />
</Paper>
<Paper shadow="xs" p="sm" withBorder>
<PlaceholderPill />
</Paper>
</SimpleGrid>
</Stack>
);
return (
<>
<Stack spacing="xs">
<SettingsHeader
title={t`Admin Center`}
subtitle={t`Advanced Options`}
switch_link="/settings/system"
switch_text="System Settings"
/>
<QuickAction />
<PanelGroup
pageKey="admin-center"
panels={adminCenterPanels}
collabsible={false}
/>
</Stack>
</>
);
}

View File

@ -0,0 +1,75 @@
import { Trans, t } from '@lingui/macro';
import { Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { IconPlugConnected, IconUsersGroup } from '@tabler/icons-react';
import { lazy, useMemo } from 'react';
import { PlaceholderPill } from '../../../../components/items/Placeholder';
import { PanelGroup, PanelType } from '../../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
import { Loadable } from '../../../../functions/loading';
const UserManagementPanel = Loadable(
lazy(() => import('./UserManagementPanel'))
);
const PluginManagementPanel = Loadable(
lazy(() => import('./PluginManagementPanel'))
);
export default function AdminCenter() {
const adminCenterPanels: PanelType[] = useMemo(() => {
return [
{
name: 'user',
label: t`Users`,
icon: <IconUsersGroup />,
content: <UserManagementPanel />
},
{
name: 'plugin',
label: t`Plugins`,
icon: <IconPlugConnected />,
content: <PluginManagementPanel />
}
];
}, []);
const QuickAction = () => (
<Stack spacing={'xs'} ml={'sm'}>
<Title order={5}>
<Trans>Quick Actions</Trans>
</Title>
<SimpleGrid cols={3}>
<Paper shadow="xs" p="sm" withBorder>
<Text>
<Trans>Add a new user</Trans>
</Text>
</Paper>
<Paper shadow="xs" p="sm" withBorder>
<PlaceholderPill />
</Paper>
<Paper shadow="xs" p="sm" withBorder>
<PlaceholderPill />
</Paper>
</SimpleGrid>
</Stack>
);
return (
<Stack spacing="xs">
<SettingsHeader
title={t`Admin Center`}
subtitle={t`Advanced Options`}
switch_link="/settings/system"
switch_text="System Settings"
/>
<QuickAction />
<PanelGroup
pageKey="admin-center"
panels={adminCenterPanels}
collapsible={false}
/>
</Stack>
);
}

View File

@ -0,0 +1,65 @@
import { Trans } from '@lingui/macro';
import { Alert, Stack, Title } from '@mantine/core';
import { IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { PluginErrorTable } from '../../../../components/tables/plugin/PluginErrorTable';
import { PluginListTable } from '../../../../components/tables/plugin/PluginListTable';
import { useServerApiState } from '../../../../states/ApiState';
export default function PluginManagementPanel() {
const pluginsEnabled = useServerApiState(
(state) => state.server.plugins_enabled
);
return (
<Stack>
{!pluginsEnabled && (
<Alert
title={<Trans>Info</Trans>}
icon={<IconInfoCircle />}
color="blue"
>
<Trans>
External plugins are not enabled for this InvenTree installation.
</Trans>
</Alert>
)}
<PluginListTable props={{}} />
<Stack spacing={'xs'}>
<Title order={5}>
<Trans>Plugin Error Stack</Trans>
</Title>
<PluginErrorTable props={{}} />
</Stack>
<Stack spacing={'xs'}>
<Title order={5}>
<Trans>Plugin Settings</Trans>
</Title>
<Alert
icon={<IconAlertTriangle />}
color="yellow"
title={<Trans>Warning</Trans>}
>
<Trans>
Changing the settings below require you to immediately restart the
server. Do not change this while under active usage.
</Trans>
</Alert>
<GlobalSettingList
keys={[
'ENABLE_PLUGINS_SCHEDULE',
'ENABLE_PLUGINS_EVENTS',
'ENABLE_PLUGINS_URL',
'ENABLE_PLUGINS_NAVIGATION',
'ENABLE_PLUGINS_APP',
'PLUGIN_ON_STARTUP'
]}
/>
</Stack>
</Stack>
);
}

View File

@ -0,0 +1,44 @@
import { Trans } from '@lingui/macro';
import { Anchor, Divider, Group, Stack, Text, Title } from '@mantine/core';
import { Link } from 'react-router-dom';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { GroupTable } from '../../../../components/tables/settings/GroupTable';
import { UserTable } from '../../../../components/tables/settings/UserTable';
export default function UserManagementPanel() {
return (
<Stack spacing="xs">
<Title order={5}>
<Trans>Users</Trans>
</Title>
<UserTable />
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<GroupTable />
<Divider />
<Stack spacing={0}>
<Text>
<Trans>Settings</Trans>
</Text>
<Group>
<Text c="dimmed">
<Trans>
Select settings relevant for user lifecycle. More available in
</Trans>
</Text>
<Anchor component={Link} to={'/settings/system'}>
<Trans>System settings</Trans>
</Anchor>
</Group>
</Stack>
<GlobalSettingList
keys={['LOGIN_ENABLE_REG', 'SIGNUP_GROUP', 'LOGIN_ENABLE_SSO_REG']}
/>
</Stack>
);
}

View File

@ -1,52 +0,0 @@
import { t } from '@lingui/macro';
import { LoadingOverlay, Stack } from '@mantine/core';
import { IconPlugConnected } from '@tabler/icons-react';
import { useMemo } from 'react';
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { PluginListTable } from '../../../components/tables/plugin/PluginListTable';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import { useInstance } from '../../../hooks/UseInstance';
/**
* 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} />
<SettingsHeader title={t`Plugin Settings`} switch_condition={false} />
<PanelGroup pageKey="plugin-settings" panels={pluginPanels} />
</Stack>
</>
);
}

View File

@ -24,7 +24,7 @@ export default function NotificationsPage() {
const notificationPanels = useMemo(() => { const notificationPanels = useMemo(() => {
return [ return [
{ {
name: 'notifications-unread', name: 'unread',
label: t`Notifications`, label: t`Notifications`,
icon: <IconBellExclamation size="18" />, icon: <IconBellExclamation size="18" />,
content: ( content: (
@ -52,7 +52,7 @@ export default function NotificationsPage() {
) )
}, },
{ {
name: 'notifications-history', name: 'history',
label: t`History`, label: t`History`,
icon: <IconBellCheck size="18" />, icon: <IconBellCheck size="18" />,
content: ( content: (

View File

@ -1,5 +1,5 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { Loadable } from './functions/loading'; import { Loadable } from './functions/loading';
@ -89,11 +89,8 @@ export const SystemSettings = Loadable(
lazy(() => import('./pages/Index/Settings/SystemSettings')) lazy(() => import('./pages/Index/Settings/SystemSettings'))
); );
export const PluginSettings = Loadable(
lazy(() => import('./pages/Index/Settings/PluginSettings'))
);
export const AdminCenter = Loadable( export const AdminCenter = Loadable(
lazy(() => import('./pages/Index/Settings/AdminCenter')) lazy(() => import('./pages/Index/Settings/AdminCenter/Index'))
); );
export const NotFound = Loadable(lazy(() => import('./pages/NotFound'))); export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
@ -112,42 +109,43 @@ export const routes = (
<Route index element={<Home />} />, <Route index element={<Home />} />,
<Route path="home/" element={<Home />} />, <Route path="home/" element={<Home />} />,
<Route path="dashboard/" element={<Dashboard />} />, <Route path="dashboard/" element={<Dashboard />} />,
<Route path="notifications/" element={<Notifications />} />, <Route path="notifications/*" element={<Notifications />} />,
<Route path="playground/" element={<Playground />} />, <Route path="playground/" element={<Playground />} />,
<Route path="scan/" element={<Scan />} />, <Route path="scan/" element={<Scan />} />,
<Route path="settings/"> <Route path="settings/">
<Route index element={<AdminCenter />} /> <Route index element={<Navigate to="admin/" />} />
<Route path="admin/" element={<AdminCenter />} /> <Route path="admin/*" element={<AdminCenter />} />
<Route path="system/" element={<SystemSettings />} /> <Route path="system/*" element={<SystemSettings />} />
<Route path="user/" element={<UserSettings />} /> <Route path="user/*" element={<UserSettings />} />
<Route path="plugin/" element={<PluginSettings />} />
</Route> </Route>
<Route path="part/"> <Route path="part/">
<Route index element={<CategoryDetail />} /> <Route index element={<Navigate to="category/" />} />
<Route path="category/:id" element={<CategoryDetail />} /> <Route path="category/:id?/*" element={<CategoryDetail />} />
<Route path=":id/" element={<PartDetail />} /> <Route path=":id/*" element={<PartDetail />} />
</Route> </Route>
<Route path="stock/"> <Route path="stock/">
<Route index element={<LocationDetail />} /> <Route index element={<Navigate to="location/" />} />
<Route path="location/:id" element={<LocationDetail />} /> <Route path="location/:id?/*" element={<LocationDetail />} />
<Route path="item/:id/" element={<StockDetail />} /> <Route path="item/:id/*" element={<StockDetail />} />
</Route> </Route>
<Route path="build/"> <Route path="build/">
<Route index element={<BuildIndex />} /> <Route index element={<BuildIndex />} />
<Route path=":id/" element={<BuildDetail />} /> <Route path=":id/" element={<BuildDetail />} />
</Route> </Route>
<Route path="purchasing/"> <Route path="purchasing/">
<Route index element={<PurchasingIndex />} /> <Route index element={<Navigate to="index/" />} />
<Route path="purchase-order/:id/" element={<PurchaseOrderDetail />} /> <Route path="index/*" element={<PurchasingIndex />} />
<Route path="supplier/:id/" element={<SupplierDetail />} /> <Route path="purchase-order/:id/*" element={<PurchaseOrderDetail />} />
<Route path="manufacturer/:id/" element={<ManufacturerDetail />} /> <Route path="supplier/:id/*" element={<SupplierDetail />} />
<Route path="manufacturer/:id/*" element={<ManufacturerDetail />} />
</Route> </Route>
<Route path="company/:id/" element={<CompanyDetail />} /> <Route path="company/:id/*" element={<CompanyDetail />} />
<Route path="sales/"> <Route path="sales/">
<Route index element={<SalesIndex />} /> <Route index element={<Navigate to="index/" />} />
<Route path="sales-order/:id/" element={<SalesOrderDetail />} /> <Route path="index/*" element={<SalesIndex />} />
<Route path="return-order/:id/" element={<ReturnOrderDetail />} /> <Route path="sales-order/:id/*" element={<SalesOrderDetail />} />
<Route path="customer/:id/" element={<CustomerDetail />} /> <Route path="return-order/:id/*" element={<ReturnOrderDetail />} />
<Route path="customer/:id/*" element={<CustomerDetail />} />
</Route> </Route>
</Route> </Route>
<Route path="/" errorElement={<ErrorPage />}> <Route path="/" errorElement={<ErrorPage />}>

View File

@ -181,6 +181,12 @@ export function apiEndpoint(path: ApiPaths): string {
return 'order/ro/attachment/'; return 'order/ro/attachment/';
case ApiPaths.plugin_list: case ApiPaths.plugin_list:
return 'plugins/'; return 'plugins/';
case ApiPaths.plugin_setting_list:
return 'plugins/:plugin/settings/';
case ApiPaths.plugin_registry_status:
return 'plugins/status/';
case ApiPaths.plugin_install:
return 'plugins/install/';
case ApiPaths.project_code_list: case ApiPaths.project_code_list:
return 'project-code/'; return 'project-code/';
case ApiPaths.custom_unit_list: case ApiPaths.custom_unit_list:

View File

@ -22,11 +22,13 @@ interface LocalStateProps {
blackColor: string; blackColor: string;
radius: MantineNumberSize; radius: MantineNumberSize;
loader: LoaderType; loader: LoaderType;
lastUsedPanels: Record<string, string>;
setLastUsedPanel: (panelKey: string) => (value: string) => void;
} }
export const useLocalState = create<LocalStateProps>()( export const useLocalState = create<LocalStateProps>()(
persist( persist(
(set) => ({ (set, get) => ({
autoupdate: false, autoupdate: false,
toggleAutoupdate: () => toggleAutoupdate: () =>
set((state) => ({ autoupdate: !state.autoupdate })), set((state) => ({ autoupdate: !state.autoupdate })),
@ -43,7 +45,17 @@ export const useLocalState = create<LocalStateProps>()(
whiteColor: '#fff', whiteColor: '#fff',
blackColor: '#000', blackColor: '#000',
radius: 'xs', radius: 'xs',
loader: 'oval' loader: 'oval',
// panels
lastUsedPanels: {},
setLastUsedPanel: (panelKey) => (value) => {
const currentValue = get().lastUsedPanels[panelKey];
if (currentValue !== value) {
set({
lastUsedPanels: { ...get().lastUsedPanels, [panelKey]: value }
});
}
}
}), }),
{ {
name: 'session-settings' name: 'session-settings'

View File

@ -1,7 +1,7 @@
/** /**
* State management for remote (server side) settings * State management for remote (server side) settings
*/ */
import { create } from 'zustand'; import { create, createStore } from 'zustand';
import { api } from '../App'; import { api } from '../App';
import { ApiPaths } from '../enums/ApiEndpoints'; import { ApiPaths } from '../enums/ApiEndpoints';
@ -79,6 +79,50 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
} }
})); }));
/**
* State management for plugin settings
*/
interface CreatePluginSettingStateProps {
plugin: string;
}
export const createPluginSettingsState = ({
plugin
}: CreatePluginSettingStateProps) => {
const pathParams: PathParams = { plugin };
return createStore<SettingsStateProps>()((set, get) => ({
settings: [],
lookup: {},
endpoint: ApiPaths.plugin_setting_list,
pathParams,
fetchSettings: async () => {
await api
.get(apiUrl(ApiPaths.plugin_setting_list, undefined, { plugin }))
.then((response) => {
const settings = response.data;
set({
settings,
lookup: generate_lookup(settings)
});
})
.catch((error) => {
console.error(
`Error fetching plugin settings for plugin ${plugin}:`,
error
);
});
},
getSetting: (key: string, default_value?: string) => {
return get().lookup[key] ?? default_value ?? '';
},
isSet: (key: string, default_value?: boolean) => {
let value = get().lookup[key] ?? default_value ?? 'false';
return isTrue(value);
}
}));
};
/* /*
return a lookup dictionary for the value of the provided Setting list return a lookup dictionary for the value of the provided Setting list
*/ */

View File

@ -72,7 +72,8 @@ export enum SettingType {
Boolean = 'boolean', Boolean = 'boolean',
Integer = 'integer', Integer = 'integer',
String = 'string', String = 'string',
Choice = 'choice' Choice = 'choice',
Model = 'related field'
} }
export interface PluginProps { export interface PluginProps {