diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 75fd2018b3..ca9c6efb85 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index ebfb501924..6eeb69b5c5 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -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'/', include([ - re_path(r'^settings/(?P\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'), + re_path(r"^settings/", include([ + re_path(r'^(?P\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'), ])) diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index 49b9a6996e..7d24b11d42 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -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()) diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index df3a30b7d3..417b704c46 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -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>(() => { @@ -361,7 +366,6 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { return ( - {(!isValid || nonFieldErrors.length > 0) && ( @@ -424,3 +428,62 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { ); } + +export function CreateApiForm({ + id, + props +}: { + id?: string; + props: ApiFormProps; +}) { + const createProps = useMemo( + () => ({ + ...props, + method: 'POST' + }), + [props] + ); + + return ; +} + +export function EditApiForm({ + id, + props +}: { + id?: string; + props: ApiFormProps; +}) { + const editProps = useMemo( + () => ({ + ...props, + fetchInitialData: props.fetchInitialData ?? true, + submitText: t`Update` ?? props.submitText, + method: 'PUT' + }), + [props] + ); + + return ; +} + +export function DeleteApiForm({ + id, + props +}: { + id?: string; + props: ApiFormProps; +}) { + const deleteProps = useMemo( + () => ({ + ...props, + method: 'DELETE', + submitText: t`Delete`, + submitColor: 'red', + fields: {} + }), + [props] + ); + + return ; +} diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index 5ac5de9678..52df2308f1 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -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(null); + const [offset, setOffset] = useState(0); + + const [data, setData] = useState([]); + const dataRef = useRef([]); + // 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(0); - - const [data, setData] = useState([]); - // Search input query const [value, setValue] = useState(''); 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) => { diff --git a/src/frontend/src/components/items/InfoItem.tsx b/src/frontend/src/components/items/InfoItem.tsx new file mode 100644 index 0000000000..e77a302bb9 --- /dev/null +++ b/src/frontend/src/components/items/InfoItem.tsx @@ -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 ( + + + {name}: + + + {children} + {value !== undefined && type === 'text' ? ( + {value || None} + ) : type === 'boolean' ? ( + + ) : ( + '' + )} + + + ); +} diff --git a/src/frontend/src/components/nav/DetailDrawer.tsx b/src/frontend/src/components/nav/DetailDrawer.tsx new file mode 100644 index 0000000000..fcb1918036 --- /dev/null +++ b/src/frontend/src/components/nav/DetailDrawer.tsx @@ -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 ( + navigate('../')} + position={position} + size={size} + title={ + + {title} + + } + overlayProps={{ opacity: 0.5, blur: 4 }} + > + + + {content} + + + ); +} + +export function DetailDrawer(props: DrawerProps) { + return ( + + } /> + + ); +} diff --git a/src/frontend/src/components/nav/MainMenu.tsx b/src/frontend/src/components/nav/MainMenu.tsx index 68e2c8b0c6..6ad8ce5a4d 100644 --- a/src/frontend/src/components/nav/MainMenu.tsx +++ b/src/frontend/src/components/nav/MainMenu.tsx @@ -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() { System Settings )} - {userState.user?.is_staff && ( - } - component={Link} - to="/settings/plugin" - > - Plugins - - )} { onClose(); - navigate('/notifications/'); + navigate('/notifications/unread'); }} > @@ -90,7 +91,18 @@ export function NotificationDrawer({ {notificationQuery.data?.results?.map((notification: any) => ( - {notification.target?.name ?? 'target'} + {notification?.target?.link ? ( + + {notification.target?.name ?? 'target'} + + ) : ( + {notification.target?.name ?? 'target'} + )} {notification.age_human ?? 'name'} diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx index 3f2929b232..a56d9bb5ef 100644 --- a/src/frontend/src/components/nav/PanelGroup.tsx +++ b/src/frontend/src/components/nav/PanelGroup.tsx @@ -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({ - 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(true); return ( ) )} - {collabsible && ( + {collapsible && ( ); } + +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 ; +} + +/** + * 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 ( + + } /> + } /> + + ); +} diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index 5b91b9e255..0e15fb2bff 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -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 = { paddingLeft: '8px' }; + if (shaded) { + style['backgroundColor'] = + theme.colorScheme === 'light' + ? theme.colors.gray[1] + : theme.colors.gray[9]; + } + return ( - <> + {setting.name} @@ -145,6 +179,6 @@ export function SettingItem({ - + ); } diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index 628d4c7728..fff899846a 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -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 ( <> @@ -37,23 +37,20 @@ export function SettingList({ (s: any) => s.key === key ); - const style: Record = { paddingLeft: '8px' }; - if (i % 2 === 0) - style['backgroundColor'] = - theme.colorScheme === 'light' - ? theme.colors.gray[1] - : theme.colors.gray[9]; - return ( -
+ {setting ? ( - + ) : ( Setting {key} not found )} -
+ ); })}
@@ -72,3 +69,12 @@ export function GlobalSettingList({ keys }: { keys: string[] }) { return ; } + +export function PluginSettingList({ pluginPk }: { pluginPk: string }) { + const pluginSettingsStore = useRef( + createPluginSettingsState({ plugin: pluginPk }) + ).current; + const pluginSettings = useStore(pluginSettingsStore); + + return ; +} diff --git a/src/frontend/src/components/tables/plugin/PluginErrorTable.tsx b/src/frontend/src/components/tables/plugin/PluginErrorTable.tsx new file mode 100644 index 0000000000..4e2f1fc4b6 --- /dev/null +++ b/src/frontend/src/components/tables/plugin/PluginErrorTable.tsx @@ -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[] = + useMemo( + () => [ + { + accessor: 'stage', + title: t`Stage` + }, + { + accessor: 'name', + title: t`Name` + }, + { + accessor: 'message', + title: t`Message`, + render: (row) => {row.message} + } + ], + [] + ); + + return ( + + data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })), + idAccessor: 'id', + enableDownload: false, + enableFilters: false, + enableSearch: false, + params: { + ...props.params + } + }} + /> + ); +} diff --git a/src/frontend/src/components/tables/plugin/PluginListTable.tsx b/src/frontend/src/components/tables/plugin/PluginListTable.tsx index c6740ecffc..c9662413b0 100644 --- a/src/frontend/src/components/tables/plugin/PluginListTable.tsx +++ b/src/frontend/src/components/tables/plugin/PluginListTable.tsx @@ -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({ + endpoint: ApiPaths.plugin_list, + pk: id, + throwError: true + }); + + const refetch = useCallback(() => { + refreshTable(); + refreshInstance(); + }, [refreshTable, refreshInstance]); + + if (isFetching) { + return ; + } + + if (error) { + return ( + + {(error as any)?.response?.status === 404 ? ( + Plugin with id {id} not found + ) : ( + An error occurred while fetching plugin details + )} + + ); + } + + return ( + + + + + + {plugin && PluginIcon(plugin)} + {plugin?.meta.human_name || plugin?.name} + + + } + 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: , + onClick: refreshInstance + } + ]} + /> + + + + + + + + <Trans>Plugin information</Trans> + + + + + + + + + + + + + + + + <Trans>Package information</Trans> + + + + + + + + + {plugin && plugin.active && ( + + + + <Trans>Plugin settings</Trans> + + + + + )} + + ); +} + /** * 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( + } + tooltip={t`Install Plugin`} + onClick={() => installPluginModal.open()} + /> + ); + } + + return actions; + }, []); + return ( - + {installPluginModal.modal} + { + if (!id) return false; + return ; + }} + /> + + 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' + } + ] + }} + /> + ); } diff --git a/src/frontend/src/components/tables/settings/GroupTable.tsx b/src/frontend/src/components/tables/settings/GroupTable.tsx index 5b35cd0968..9baba7b65e 100644 --- a/src/frontend/src/components/tables/settings/GroupTable.tsx +++ b/src/frontend/src/components/tables/settings/GroupTable.tsx @@ -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 ; + } + + if (error) { + return ( + + {(error as any)?.response?.status === 404 ? ( + Group with id {id} not found + ) : ( + An error occurred while fetching group details + )} + + ); + } + + return ( + + { + refreshTable(); + refreshInstance(); + } + }} + id={`group-detail-drawer-${id}`} + /> + + + <Trans>Permission set</Trans> + + + + + + ); +} + /** * 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[] = 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 ( - + <> + { + if (!id || !id.startsWith('group-')) return false; + return ( + + ); + }} + /> + openDetailDrawer(record.pk) + }} + /> + ); } diff --git a/src/frontend/src/components/tables/settings/UserDrawer.tsx b/src/frontend/src/components/tables/settings/UserDrawer.tsx deleted file mode 100644 index 92bff34da1..0000000000 --- a/src/frontend/src/components/tables/settings/UserDrawer.tsx +++ /dev/null @@ -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(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: - }); - 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: - }); - 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 ( - - - - - <Trans>Details</Trans> - - - - {userDetail ? ( - - - - - - - - Rights - - - - - Active - - - Staff - - - Superuser - - - - - ) : ( - - )} - - - <Trans>Groups</Trans> - - - {userDetail && userDetail.groups.length == 0 ? ( - No groups - ) : ( - - {userDetail && - userDetail.groups.map((message) => ( - {message.name} - ))} - - )} - - - - ); -} diff --git a/src/frontend/src/components/tables/settings/UserTable.tsx b/src/frontend/src/components/tables/settings/UserTable.tsx index 5058d5c0f9..68a0f79fec 100644 --- a/src/frontend/src/components/tables/settings/UserTable.tsx +++ b/src/frontend/src/components/tables/settings/UserTable.tsx @@ -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({ + 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 ; + } + + if (error) { + return ( + + {(error as any)?.response?.status === 404 ? ( + User with id {id} not found + ) : ( + An error occurred while fetching user details + )} + + ); + } + + return ( + + Info} + color="blue" + icon={} + > + + You cannot edit the rights for the currently logged-in user. + + + ) : undefined, + onFormSuccess: () => { + refreshTable(); + refreshInstance(); + } + }} + id={`user-detail-drawer-${id}`} + /> + + + <Trans>Groups</Trans> + + + {userDetail?.groups && userDetail?.groups?.length > 0 ? ( + + {userDetail?.groups?.map((group) => ( + + {group.name} + + ))} + + ) : ( + No groups + )} + + + ); +} + /** * Table for displaying list of users */ export function UserTable() { const table = useTable('users'); - const [opened, { open, close }] = useDisclosure(false); - const [userDetail, setUserDetail] = useState(); + 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 ( <> - { + if (!id || !id.startsWith('user-')) return false; + return ( + + ); + }} /> { - setUserDetail(record); - open(); - } + onRowClick: (record) => openDetailDrawer(record.pk) }} /> diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 10ec3b4cb2..6495758455 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -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' diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx index 2fbdad6c01..39c38108ae 100644 --- a/src/frontend/src/functions/forms.tsx +++ b/src/frontend/src/functions/forms.tsx @@ -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: + children: ( + + + + + ) }); }) .catch((error) => { diff --git a/src/frontend/src/hooks/UseForm.tsx b/src/frontend/src/hooks/UseForm.tsx index 8cf0de9e80..248d294c74 100644 --- a/src/frontend/src/hooks/UseForm.tsx +++ b/src/frontend/src/hooks/UseForm.tsx @@ -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: + children: ( + + + + + ) }); useEffect(() => { diff --git a/src/frontend/src/hooks/UseInstance.tsx b/src/frontend/src/hooks/UseInstance.tsx index fdf16af13e..b15603f28c 100644 --- a/src/frontend/src/hooks/UseInstance.tsx +++ b/src/frontend/src/hooks/UseInstance.tsx @@ -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({ 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(defaultValue); + const [instance, setInstance] = useState(defaultValue); - const instanceQuery = useQuery({ + const instanceQuery = useQuery({ 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; }); }, diff --git a/src/frontend/src/hooks/UseModal.tsx b/src/frontend/src/hooks/UseModal.tsx index 3eb7331738..d8377f749e 100644 --- a/src/frontend/src/hooks/UseModal.tsx +++ b/src/frontend/src/hooks/UseModal.tsx @@ -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) { {props.title}} > diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter.tsx deleted file mode 100644 index 75311714d5..0000000000 --- a/src/frontend/src/pages/Index/Settings/AdminCenter.tsx +++ /dev/null @@ -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: ( - - - <Trans>Users</Trans> - - - - <Trans>Groups</Trans> - - - - - - Settings - - - - - Select settings relevant for user lifecycle. More available - in - - - - System settings - - - - - - ) - } - ]; - }, []); - - const QuickAction = () => ( - - - <Trans>Quick Actions</Trans> - - - - - Add a new user - - - - - - - - - - - - - ); - - return ( - <> - - - - - - - ); -} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx new file mode 100644 index 0000000000..9e1c443b37 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -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: , + content: + }, + { + name: 'plugin', + label: t`Plugins`, + icon: , + content: + } + ]; + }, []); + + const QuickAction = () => ( + + + <Trans>Quick Actions</Trans> + + + + + Add a new user + + + + + + + + + + + + + ); + + return ( + + + + + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx new file mode 100644 index 0000000000..638cc099fa --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx @@ -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 ( + + {!pluginsEnabled && ( + Info} + icon={} + color="blue" + > + + External plugins are not enabled for this InvenTree installation. + + + )} + + + + + + <Trans>Plugin Error Stack</Trans> + + + + + + + <Trans>Plugin Settings</Trans> + + } + color="yellow" + title={Warning} + > + + Changing the settings below require you to immediately restart the + server. Do not change this while under active usage. + + + + + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx new file mode 100644 index 0000000000..c6d1406a4e --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx @@ -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 ( + + + <Trans>Users</Trans> + + + + + <Trans>Groups</Trans> + + + + + + + + Settings + + + + + Select settings relevant for user lifecycle. More available in + + + + System settings + + + + + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/PluginSettings.tsx b/src/frontend/src/pages/Index/Settings/PluginSettings.tsx deleted file mode 100644 index ba564cb3b2..0000000000 --- a/src/frontend/src/pages/Index/Settings/PluginSettings.tsx +++ /dev/null @@ -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: , - content: ( - - - - ) - } - ]; - }, []); - - return ( - <> - - - - - - - ); -} diff --git a/src/frontend/src/pages/Notifications.tsx b/src/frontend/src/pages/Notifications.tsx index 282f723b2a..6a643af30b 100644 --- a/src/frontend/src/pages/Notifications.tsx +++ b/src/frontend/src/pages/Notifications.tsx @@ -24,7 +24,7 @@ export default function NotificationsPage() { const notificationPanels = useMemo(() => { return [ { - name: 'notifications-unread', + name: 'unread', label: t`Notifications`, icon: , content: ( @@ -52,7 +52,7 @@ export default function NotificationsPage() { ) }, { - name: 'notifications-history', + name: 'history', label: t`History`, icon: , content: ( diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 7342229306..5c768a9024 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -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 = ( } />, } />, } />, - } />, + } />, } />, } />, - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> - } /> - } /> - } /> + } /> + } /> + } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> - } /> + } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> }> diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index d673b8e38c..1a834ba2d3 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -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: diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx index 0ce28d45a4..1eda3fcceb 100644 --- a/src/frontend/src/states/LocalState.tsx +++ b/src/frontend/src/states/LocalState.tsx @@ -22,11 +22,13 @@ interface LocalStateProps { blackColor: string; radius: MantineNumberSize; loader: LoaderType; + lastUsedPanels: Record; + setLastUsedPanel: (panelKey: string) => (value: string) => void; } export const useLocalState = create()( persist( - (set) => ({ + (set, get) => ({ autoupdate: false, toggleAutoupdate: () => set((state) => ({ autoupdate: !state.autoupdate })), @@ -43,7 +45,17 @@ export const useLocalState = create()( 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' diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index 42da0e9f28..77288be087 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -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((set, get) => ({ } })); +/** + * State management for plugin settings + */ +interface CreatePluginSettingStateProps { + plugin: string; +} + +export const createPluginSettingsState = ({ + plugin +}: CreatePluginSettingStateProps) => { + const pathParams: PathParams = { plugin }; + + return createStore()((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 */ diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index cb890104bd..103bd807d0 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -72,7 +72,8 @@ export enum SettingType { Boolean = 'boolean', Integer = 'integer', String = 'string', - Choice = 'choice' + Choice = 'choice', + Model = 'related field' } export interface PluginProps {