mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
f034d86c3f
commit
15f58b965e
@ -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
|
||||
|
||||
|
@ -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'),
|
||||
]))
|
||||
|
@ -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())
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
34
src/frontend/src/components/items/InfoItem.tsx
Normal file
34
src/frontend/src/components/items/InfoItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
58
src/frontend/src/components/nav/DetailDrawer.tsx
Normal file
58
src/frontend/src/components/nav/DetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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'
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -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'
|
||||
|
@ -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) => {
|
||||
|
@ -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(() => {
|
||||
|
@ -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;
|
||||
});
|
||||
},
|
||||
|
@ -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>}
|
||||
>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
75
src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
Normal file
75
src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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: (
|
||||
|
@ -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 />}>
|
||||
|
@ -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:
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -72,7 +72,8 @@ export enum SettingType {
|
||||
Boolean = 'boolean',
|
||||
Integer = 'integer',
|
||||
String = 'string',
|
||||
Choice = 'choice'
|
||||
Choice = 'choice',
|
||||
Model = 'related field'
|
||||
}
|
||||
|
||||
export interface PluginProps {
|
||||
|
Loading…
Reference in New Issue
Block a user