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 = 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."""
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
- 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_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework.views import APIView
import plugin.serializers as PluginSerializers
from common.api import GlobalSettingsPermissions
@ -15,6 +17,7 @@ from InvenTree.helpers import str2bool
from InvenTree.mixins import (CreateAPI, ListAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI, UpdateAPI)
from InvenTree.permissions import IsSuperuser
from plugin import registry
from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls
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:
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):
@ -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 = [
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
re_path(r'^barcode/', include(barcode_api_urls)),
@ -300,7 +362,10 @@ plugin_api_urls = [
# Detail views for a single PluginConfig item
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'^.*$', 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'^activate/', PluginActivate.as_view(), name='api-plugin-activate'),
# Registry status
re_path(r"^status/", RegistryStatusView.as_view(), name="api-plugin-registry-status"),
# Anything else
re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
]))

View File

@ -174,3 +174,17 @@ class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
EXTRA_FIELDS = ['method', ]
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',
defaultValues
});
const { isValid, isDirty, isLoading: isFormLoading } = form.formState;
const {
isValid,
isDirty,
isLoading: isFormLoading,
isSubmitting
} = form.formState;
// Cache URL
const url = useMemo(
@ -351,8 +356,8 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
};
const isLoading = useMemo(
() => isFormLoading || initialDataQuery.isFetching,
[isFormLoading, initialDataQuery.isFetching]
() => isFormLoading || initialDataQuery.isFetching || isSubmitting,
[isFormLoading, initialDataQuery.isFetching, isSubmitting]
);
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
@ -361,7 +366,6 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
return (
<Stack>
<Divider />
<Stack spacing="sm">
<LoadingOverlay visible={isLoading} />
{(!isValid || nonFieldErrors.length > 0) && (
@ -424,3 +428,62 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
</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 { useId } from '@mantine/hooks';
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 Select from 'react-select';
@ -35,12 +35,17 @@ export function RelatedModelField({
// Keep track of the primary key value for this field
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
useEffect(() => {
// If the value is unchanged, do nothing
if (field.value === pk) return;
if (field.value !== null) {
if (field.value !== null && field.value !== undefined) {
const url = `${definition.api_url}${field.value}/`;
api.get(url).then((response) => {
@ -53,6 +58,7 @@ export function RelatedModelField({
};
setData([value]);
dataRef.current = [value];
setPk(data.pk);
}
});
@ -61,14 +67,16 @@ export function RelatedModelField({
}
}, [definition.api_url, field.value]);
const [offset, setOffset] = useState<number>(0);
const [data, setData] = useState<any[]>([]);
// Search input query
const [value, setValue] = useState<string>('');
const [searchText, cancelSearchText] = useDebouncedValue(value, 250);
// reset current data on search value change
useEffect(() => {
dataRef.current = [];
setData([]);
}, [searchText]);
const selectQuery = useQuery({
enabled: !definition.disabled && !!definition.api_url && !definition.hidden,
queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText],
@ -95,7 +103,9 @@ export function RelatedModelField({
params: params
})
.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 results = response.data?.results ?? response.data ?? [];
@ -111,6 +121,7 @@ export function RelatedModelField({
});
setData(values);
dataRef.current = values;
return response;
})
.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 {
IconChevronDown,
IconLogout,
IconPlugConnected,
IconSettings,
IconUserBolt,
IconUserCog
@ -59,15 +58,6 @@ export function MainMenu() {
<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

View File

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

View File

@ -6,14 +6,21 @@ import {
Tabs,
Tooltip
} from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import {
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse
} from '@tabler/icons-react';
import { ReactNode } from 'react';
import { ReactNode, useMemo } 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 { StylishText } from '../items/StylishText';
@ -29,48 +36,48 @@ export type PanelType = {
disabled?: boolean;
};
/**
*
* @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
}: {
export type PanelProps = {
pageKey: string;
panels: PanelType[];
selectedPanel?: string;
onPanelChange?: (panel: string) => void;
collabsible?: boolean;
}): ReactNode {
const [activePanel, setActivePanel] = useLocalStorage<string>({
key: `panel-group-active-panel-${pageKey}`,
defaultValue: selectedPanel || panels.length > 0 ? panels[0].name : ''
});
collapsible?: boolean;
};
function BasePanelGroup({
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(() => {
let activePanelNames = panels
.filter((panel) => !panel.hidden && !panel.disabled)
.map((panel) => panel.name);
if (!activePanelNames.includes(activePanel)) {
setActivePanel(activePanelNames.length > 0 ? activePanelNames[0] : '');
if (panel) {
setLastUsedPanel(panel);
}
}, [panels]);
// panel is intentionally no dependency as this should only run on initial render
}, [setLastUsedPanel]);
// Callback when the active panel changes
function handlePanelChange(panel: string) {
setActivePanel(panel);
if (activePanels.findIndex((p) => p.name === panel) === -1) {
setLastUsedPanel('');
return navigate('../');
}
navigate(`../${panel}`);
// Optionally call external callback hook
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);
return (
<Paper p="sm" radius="xs" shadow="xs">
<Tabs
value={activePanel}
value={panel}
orientation="vertical"
onTabChange={handlePanelChange}
keepMounted={false}
@ -108,7 +130,7 @@ export function PanelGroup({
</Tooltip>
)
)}
{collabsible && (
{collapsible && (
<ActionIcon
style={{
paddingLeft: '10px'
@ -147,3 +169,38 @@ export function PanelGroup({
</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 { 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 { IconEdit } from '@tabler/icons-react';
import { useMemo } from 'react';
import { api } from '../../App';
import { ModelType } from '../../enums/ModelType';
import { openModalApiForm } from '../../functions/forms';
import { apiUrl } from '../../states/ApiState';
import { SettingsStateProps } from '../../states/SettingsState';
import { Setting, SettingType } from '../../states/states';
import { ApiFormFieldType } from '../forms/fields/ApiFormField';
/**
* Render a single setting value
@ -47,10 +58,27 @@ function SettingValue({
// Callback function to open the edit dialog (for non-boolean settings)
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) {
field_type = SettingType.Choice;
// Match related field
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({
@ -61,13 +89,7 @@ function SettingValue({
title: t`Edit Setting`,
ignorePermissionCheck: true,
fields: {
value: {
value: setting?.value ?? '',
field_type: field_type,
choices: setting?.choices || [],
label: setting?.name,
description: setting?.description
}
value: fieldDefinition
},
onFormSuccess() {
showNotification({
@ -131,13 +153,25 @@ function SettingValue({
*/
export function SettingItem({
settingsState,
setting
setting,
shaded
}: {
settingsState: SettingsStateProps;
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 (
<>
<Paper style={style}>
<Group position="apart" p="10">
<Stack spacing="2">
<Text>{setting.name}</Text>
@ -145,6 +179,6 @@ export function SettingItem({
</Stack>
<SettingValue settingsState={settingsState} setting={setting} />
</Group>
</>
</Paper>
);
}

View File

@ -1,8 +1,10 @@
import { Stack, Text, useMantineTheme } from '@mantine/core';
import { useEffect, useMemo } from 'react';
import { Stack, Text } from '@mantine/core';
import React, { useEffect, useMemo, useRef } from 'react';
import { useStore } from 'zustand';
import {
SettingsStateProps,
createPluginSettingsState,
useGlobalSettingsState,
useUserSettingsState
} from '../../states/SettingsState';
@ -27,8 +29,6 @@ export function SettingList({
[settingsState?.settings]
);
const theme = useMantineTheme();
return (
<>
<Stack spacing="xs">
@ -37,23 +37,20 @@ export function SettingList({
(s: any) => s.key === key
);
const style: Record<string, string> = { paddingLeft: '8px' };
if (i % 2 === 0)
style['backgroundColor'] =
theme.colorScheme === 'light'
? theme.colors.gray[1]
: theme.colors.gray[9];
return (
<div key={key} style={style}>
<React.Fragment key={key}>
{setting ? (
<SettingItem settingsState={settingsState} setting={setting} />
<SettingItem
settingsState={settingsState}
setting={setting}
shaded={i % 2 === 0}
/>
) : (
<Text size="sm" italic color="red">
Setting {key} not found
</Text>
)}
</div>
</React.Fragment>
);
})}
</Stack>
@ -72,3 +69,12 @@ export function GlobalSettingList({ keys }: { keys: string[] }) {
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 { Alert, Group, Stack, Text, Tooltip } from '@mantine/core';
import { Trans, t } from '@lingui/macro';
import {
Alert,
Box,
Card,
Group,
LoadingOverlay,
Stack,
Text,
Title,
Tooltip
} from '@mantine/core';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import {
IconCircleCheck,
IconCircleX,
IconHelpCircle
IconHelpCircle,
IconPlaylistAdd,
IconRefresh
} from '@tabler/icons-react';
import { IconDots } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../../App';
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 { 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 { DetailDrawer } from '../../nav/DetailDrawer';
import { PluginSettingList } from '../../settings/SettingList';
import { TableColumn } from '../Column';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
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
*/
function PluginIcon(plugin: any) {
function PluginIcon(plugin: PluginI) {
if (plugin.is_installed) {
if (plugin.active) {
return (
@ -50,6 +245,11 @@ function PluginIcon(plugin: any) {
*/
export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
const table = useTable('plugin');
const navigate = useNavigate();
const pluginsEnabled = useServerApiState(
(state) => state.server.plugins_enabled
);
const pluginTableColumns: TableColumn[] = useMemo(
() => [
@ -200,41 +400,95 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
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 (
<InvenTreeTable
url={apiUrl(ApiPaths.plugin_list)}
tableState={table}
columns={pluginTableColumns}
props={{
...props,
enableDownload: false,
params: {
...props.params
},
rowActions: rowActions,
customFilters: [
{
name: 'active',
label: t`Active`,
type: 'boolean'
<>
{installPluginModal.modal}
<DetailDrawer
title={t`Plugin detail`}
size={'lg'}
renderContent={(id) => {
if (!id) return false;
return <PluginDrawer id={id} refreshTable={table.refreshTable} />;
}}
/>
<InvenTreeTable
url={apiUrl(ApiPaths.plugin_list)}
tableState={table}
columns={pluginTableColumns}
props={{
...props,
enableDownload: false,
params: {
...props.params
},
{
name: 'builtin',
label: t`Builtin`,
type: 'boolean'
},
{
name: 'sample',
label: t`Sample`,
type: 'boolean'
},
{
name: 'installed',
label: t`Installed`,
type: 'boolean'
}
]
}}
/>
rowActions: rowActions,
onRowClick: (plugin) => navigate(`${plugin.pk}/`),
customActionGroups: tableActions,
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

@ -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 { useNavigate } from 'react-router-dom';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
import { useInstance } from '../../../hooks/UseInstance';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
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 { InvenTreeTable } from '../InvenTreeTable';
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
*/
export function GroupTable() {
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 [
{
accessor: 'name',
@ -30,21 +102,10 @@ export function GroupTable() {
];
}, []);
const rowActions = useCallback((record: any): RowAction[] => {
const rowActions = useCallback((record: GroupDetailI): RowAction[] => {
return [
RowEditAction({
onClick: () => {
openEditApiForm({
url: ApiPaths.group_list,
pk: record.pk,
title: t`Edit group`,
fields: {
name: {}
},
onFormSuccess: table.refreshTable,
successMessage: t`Group updated`
});
}
onClick: () => openDetailDrawer(record.pk)
}),
RowDeleteAction({
onClick: () => {
@ -86,14 +147,29 @@ export function GroupTable() {
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.group_list)}
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
customActionGroups: tableActions
}}
/>
<>
<DetailDrawer
title={t`Edit group`}
renderContent={(id) => {
if (!id || !id.startsWith('group-')) return false;
return (
<GroupDrawer
id={id.replace('group-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
<InvenTreeTable
url={apiUrl(ApiPaths.group_list)}
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
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 { useDisclosure } from '@mantine/hooks';
import { useCallback, useMemo, useState } from 'react';
import { Trans, t } from '@lingui/macro';
import { Alert, List, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
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 {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
import { useInstance } from '../../../hooks/UseInstance';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { AddItemButton } from '../../buttons/AddItemButton';
import { EditApiForm } from '../../forms/ApiForm';
import { DetailDrawer } from '../../nav/DetailDrawer';
import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import { UserDrawer } from './UserDrawer';
interface GroupDetailI {
pk: number;
name: string;
}
import { GroupDetailI } from './GroupTable';
export interface UserDetailI {
pk: number;
@ -34,13 +32,122 @@ export interface UserDetailI {
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
*/
export function UserTable() {
const table = useTable('users');
const [opened, { open, close }] = useDisclosure(false);
const [userDetail, setUserDetail] = useState<UserDetailI>();
const navigate = useNavigate();
const openDetailDrawer = useCallback(
(pk: number) => navigate(`user-${pk}/`),
[]
);
const columns: TableColumn[] = useMemo(() => {
return [
@ -92,20 +199,7 @@ export function UserTable() {
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
return [
RowEditAction({
onClick: () => {
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`
});
}
onClick: () => openDetailDrawer(record.pk)
}),
RowDeleteAction({
onClick: () => {
@ -149,11 +243,17 @@ export function UserTable() {
return (
<>
<UserDrawer
opened={opened}
close={close}
refreshTable={table.refreshTable}
userDetail={userDetail}
<DetailDrawer
title={t`Edit user`}
renderContent={(id) => {
if (!id || !id.startsWith('user-')) return false;
return (
<UserDrawer
id={id.replace('user-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
<InvenTreeTable
url={apiUrl(ApiPaths.user_list)}
@ -162,10 +262,7 @@ export function UserTable() {
props={{
rowActions: rowActions,
customActionGroups: tableActions,
onRowClick: (record: any) => {
setUserDetail(record);
open();
}
onRowClick: (record) => openDetailDrawer(record.pk)
}}
/>
</>

View File

@ -84,6 +84,9 @@ export enum ApiPaths {
// Plugin URLs
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',
custom_unit_list = 'api-custom-unit-list'

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ export interface UseModalProps {
size?: MantineNumberSize;
onOpen?: () => void;
onClose?: () => void;
closeOnClickOutside?: boolean;
}
export function useModal(props: UseModalProps) {
@ -34,6 +35,7 @@ export function useModal(props: UseModalProps) {
<Modal
opened={opened}
onClose={close}
closeOnClickOutside={props.closeOnClickOutside}
size={props.size ?? 'xl'}
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(() => {
return [
{
name: 'notifications-unread',
name: 'unread',
label: t`Notifications`,
icon: <IconBellExclamation size="18" />,
content: (
@ -52,7 +52,7 @@ export default function NotificationsPage() {
)
},
{
name: 'notifications-history',
name: 'history',
label: t`History`,
icon: <IconBellCheck size="18" />,
content: (

View File

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

View File

@ -181,6 +181,12 @@ export function apiEndpoint(path: ApiPaths): string {
return 'order/ro/attachment/';
case ApiPaths.plugin_list:
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:
return 'project-code/';
case ApiPaths.custom_unit_list:

View File

@ -22,11 +22,13 @@ interface LocalStateProps {
blackColor: string;
radius: MantineNumberSize;
loader: LoaderType;
lastUsedPanels: Record<string, string>;
setLastUsedPanel: (panelKey: string) => (value: string) => void;
}
export const useLocalState = create<LocalStateProps>()(
persist(
(set) => ({
(set, get) => ({
autoupdate: false,
toggleAutoupdate: () =>
set((state) => ({ autoupdate: !state.autoupdate })),
@ -43,7 +45,17 @@ export const useLocalState = create<LocalStateProps>()(
whiteColor: '#fff',
blackColor: '#000',
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'

View File

@ -1,7 +1,7 @@
/**
* State management for remote (server side) settings
*/
import { create } from 'zustand';
import { create, createStore } from 'zustand';
import { api } from '../App';
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
*/

View File

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