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
|
||||||
INVENTREE_API_VERSION = 157
|
INVENTREE_API_VERSION = 158
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v158 -> 2023-11-21 : https://github.com/inventree/InvenTree/pull/5953
|
||||||
|
- Adds API endpoint for listing all settings of a particular plugin
|
||||||
|
- Adds API endpoint for registry status (errors)
|
||||||
|
|
||||||
v157 -> 2023-12-02 : https://github.com/inventree/InvenTree/pull/6021
|
v157 -> 2023-12-02 : https://github.com/inventree/InvenTree/pull/6021
|
||||||
- Add write-only "existing_image" field to Part API serializer
|
- Add write-only "existing_image" field to Part API serializer
|
||||||
|
|
||||||
|
@ -3,9 +3,11 @@
|
|||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import permissions, status
|
from rest_framework import permissions, status
|
||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
import plugin.serializers as PluginSerializers
|
import plugin.serializers as PluginSerializers
|
||||||
from common.api import GlobalSettingsPermissions
|
from common.api import GlobalSettingsPermissions
|
||||||
@ -15,6 +17,7 @@ from InvenTree.helpers import str2bool
|
|||||||
from InvenTree.mixins import (CreateAPI, ListAPI, RetrieveUpdateAPI,
|
from InvenTree.mixins import (CreateAPI, ListAPI, RetrieveUpdateAPI,
|
||||||
RetrieveUpdateDestroyAPI, UpdateAPI)
|
RetrieveUpdateDestroyAPI, UpdateAPI)
|
||||||
from InvenTree.permissions import IsSuperuser
|
from InvenTree.permissions import IsSuperuser
|
||||||
|
from plugin import registry
|
||||||
from plugin.base.action.api import ActionPluginView
|
from plugin.base.action.api import ActionPluginView
|
||||||
from plugin.base.barcodes.api import barcode_api_urls
|
from plugin.base.barcodes.api import barcode_api_urls
|
||||||
from plugin.base.locate.api import LocatePluginView
|
from plugin.base.locate.api import LocatePluginView
|
||||||
@ -251,7 +254,35 @@ def check_plugin(plugin_slug: str, plugin_pk: int) -> InvenTreePlugin:
|
|||||||
if not plugin_cgf.active:
|
if not plugin_cgf.active:
|
||||||
raise NotFound(detail=f"Plugin '{ref}' is not active")
|
raise NotFound(detail=f"Plugin '{ref}' is not active")
|
||||||
|
|
||||||
return plugin_cgf.plugin
|
plugin = plugin_cgf.plugin
|
||||||
|
|
||||||
|
if not plugin:
|
||||||
|
raise NotFound(detail=f"Plugin '{ref}' not installed")
|
||||||
|
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
class PluginAllSettingList(APIView):
|
||||||
|
"""List endpoint for all plugin settings for a specific plugin.
|
||||||
|
|
||||||
|
- GET: return all settings for a plugin config
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [GlobalSettingsPermissions]
|
||||||
|
|
||||||
|
@extend_schema(responses={200: PluginSerializers.PluginSettingSerializer(many=True)})
|
||||||
|
def get(self, request, pk):
|
||||||
|
"""Get all settings for a plugin config."""
|
||||||
|
|
||||||
|
# look up the plugin
|
||||||
|
plugin = check_plugin(None, pk)
|
||||||
|
|
||||||
|
settings = getattr(plugin, 'settings', {})
|
||||||
|
|
||||||
|
settings_dict = PluginSetting.all_settings(settings_definition=settings, plugin=plugin.plugin_config())
|
||||||
|
|
||||||
|
results = PluginSerializers.PluginSettingSerializer(list(settings_dict.values()), many=True).data
|
||||||
|
return Response(results)
|
||||||
|
|
||||||
|
|
||||||
class PluginSettingDetail(RetrieveUpdateAPI):
|
class PluginSettingDetail(RetrieveUpdateAPI):
|
||||||
@ -287,6 +318,37 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryStatusView(APIView):
|
||||||
|
"""Status API endpoint for the plugin registry.
|
||||||
|
|
||||||
|
- GET: Provide status data for the plugin registry
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsSuperuser, ]
|
||||||
|
|
||||||
|
serializer_class = PluginSerializers.PluginRegistryStatusSerializer
|
||||||
|
|
||||||
|
@extend_schema(responses={200: PluginSerializers.PluginRegistryStatusSerializer()})
|
||||||
|
def get(self, request):
|
||||||
|
"""Show registry status information."""
|
||||||
|
error_list = []
|
||||||
|
|
||||||
|
for stage, errors in registry.errors.items():
|
||||||
|
for error_detail in errors:
|
||||||
|
for name, message in error_detail.items():
|
||||||
|
error_list.append({
|
||||||
|
"stage": stage,
|
||||||
|
"name": name,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
result = PluginSerializers.PluginRegistryStatusSerializer({
|
||||||
|
"registry_errors": error_list,
|
||||||
|
}).data
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
|
|
||||||
plugin_api_urls = [
|
plugin_api_urls = [
|
||||||
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
||||||
re_path(r'^barcode/', include(barcode_api_urls)),
|
re_path(r'^barcode/', include(barcode_api_urls)),
|
||||||
@ -300,7 +362,10 @@ plugin_api_urls = [
|
|||||||
|
|
||||||
# Detail views for a single PluginConfig item
|
# Detail views for a single PluginConfig item
|
||||||
path(r'<int:pk>/', include([
|
path(r'<int:pk>/', include([
|
||||||
re_path(r'^settings/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'),
|
re_path(r"^settings/", include([
|
||||||
|
re_path(r'^(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'),
|
||||||
|
re_path(r"^.*$", PluginAllSettingList.as_view(), name="api-plugin-settings"),
|
||||||
|
])),
|
||||||
re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'),
|
re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'),
|
||||||
re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
|
re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
|
||||||
])),
|
])),
|
||||||
@ -312,6 +377,9 @@ plugin_api_urls = [
|
|||||||
re_path(r'^install/', PluginInstall.as_view(), name='api-plugin-install'),
|
re_path(r'^install/', PluginInstall.as_view(), name='api-plugin-install'),
|
||||||
re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-activate'),
|
re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-activate'),
|
||||||
|
|
||||||
|
# Registry status
|
||||||
|
re_path(r"^status/", RegistryStatusView.as_view(), name="api-plugin-registry-status"),
|
||||||
|
|
||||||
# Anything else
|
# Anything else
|
||||||
re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
|
re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
|
||||||
]))
|
]))
|
||||||
|
@ -174,3 +174,17 @@ class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
|
|||||||
EXTRA_FIELDS = ['method', ]
|
EXTRA_FIELDS = ['method', ]
|
||||||
|
|
||||||
method = serializers.CharField(read_only=True)
|
method = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRegistryErrorSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for a plugin registry error."""
|
||||||
|
|
||||||
|
stage = serializers.CharField()
|
||||||
|
name = serializers.CharField()
|
||||||
|
message = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRegistryStatusSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for plugin registry status."""
|
||||||
|
|
||||||
|
registry_errors = serializers.ListField(child=PluginRegistryErrorSerializer())
|
||||||
|
@ -179,7 +179,12 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
|||||||
criteriaMode: 'all',
|
criteriaMode: 'all',
|
||||||
defaultValues
|
defaultValues
|
||||||
});
|
});
|
||||||
const { isValid, isDirty, isLoading: isFormLoading } = form.formState;
|
const {
|
||||||
|
isValid,
|
||||||
|
isDirty,
|
||||||
|
isLoading: isFormLoading,
|
||||||
|
isSubmitting
|
||||||
|
} = form.formState;
|
||||||
|
|
||||||
// Cache URL
|
// Cache URL
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
@ -351,8 +356,8 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isLoading = useMemo(
|
const isLoading = useMemo(
|
||||||
() => isFormLoading || initialDataQuery.isFetching,
|
() => isFormLoading || initialDataQuery.isFetching || isSubmitting,
|
||||||
[isFormLoading, initialDataQuery.isFetching]
|
[isFormLoading, initialDataQuery.isFetching, isSubmitting]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
|
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
|
||||||
@ -361,7 +366,6 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Divider />
|
|
||||||
<Stack spacing="sm">
|
<Stack spacing="sm">
|
||||||
<LoadingOverlay visible={isLoading} />
|
<LoadingOverlay visible={isLoading} />
|
||||||
{(!isValid || nonFieldErrors.length > 0) && (
|
{(!isValid || nonFieldErrors.length > 0) && (
|
||||||
@ -424,3 +428,62 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CreateApiForm({
|
||||||
|
id,
|
||||||
|
props
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
props: ApiFormProps;
|
||||||
|
}) {
|
||||||
|
const createProps = useMemo<ApiFormProps>(
|
||||||
|
() => ({
|
||||||
|
...props,
|
||||||
|
method: 'POST'
|
||||||
|
}),
|
||||||
|
[props]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <OptionsApiForm props={createProps} id={id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditApiForm({
|
||||||
|
id,
|
||||||
|
props
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
props: ApiFormProps;
|
||||||
|
}) {
|
||||||
|
const editProps = useMemo<ApiFormProps>(
|
||||||
|
() => ({
|
||||||
|
...props,
|
||||||
|
fetchInitialData: props.fetchInitialData ?? true,
|
||||||
|
submitText: t`Update` ?? props.submitText,
|
||||||
|
method: 'PUT'
|
||||||
|
}),
|
||||||
|
[props]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <OptionsApiForm props={editProps} id={id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteApiForm({
|
||||||
|
id,
|
||||||
|
props
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
props: ApiFormProps;
|
||||||
|
}) {
|
||||||
|
const deleteProps = useMemo<ApiFormProps>(
|
||||||
|
() => ({
|
||||||
|
...props,
|
||||||
|
method: 'DELETE',
|
||||||
|
submitText: t`Delete`,
|
||||||
|
submitColor: 'red',
|
||||||
|
fields: {}
|
||||||
|
}),
|
||||||
|
[props]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <OptionsApiForm props={deleteProps} id={id} />;
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { Input } from '@mantine/core';
|
|||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
import { useId } from '@mantine/hooks';
|
import { useId } from '@mantine/hooks';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
|
|
||||||
@ -35,12 +35,17 @@ export function RelatedModelField({
|
|||||||
// Keep track of the primary key value for this field
|
// Keep track of the primary key value for this field
|
||||||
const [pk, setPk] = useState<number | null>(null);
|
const [pk, setPk] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [offset, setOffset] = useState<number>(0);
|
||||||
|
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const dataRef = useRef<any[]>([]);
|
||||||
|
|
||||||
// If an initial value is provided, load from the API
|
// If an initial value is provided, load from the API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If the value is unchanged, do nothing
|
// If the value is unchanged, do nothing
|
||||||
if (field.value === pk) return;
|
if (field.value === pk) return;
|
||||||
|
|
||||||
if (field.value !== null) {
|
if (field.value !== null && field.value !== undefined) {
|
||||||
const url = `${definition.api_url}${field.value}/`;
|
const url = `${definition.api_url}${field.value}/`;
|
||||||
|
|
||||||
api.get(url).then((response) => {
|
api.get(url).then((response) => {
|
||||||
@ -53,6 +58,7 @@ export function RelatedModelField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
setData([value]);
|
setData([value]);
|
||||||
|
dataRef.current = [value];
|
||||||
setPk(data.pk);
|
setPk(data.pk);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -61,14 +67,16 @@ export function RelatedModelField({
|
|||||||
}
|
}
|
||||||
}, [definition.api_url, field.value]);
|
}, [definition.api_url, field.value]);
|
||||||
|
|
||||||
const [offset, setOffset] = useState<number>(0);
|
|
||||||
|
|
||||||
const [data, setData] = useState<any[]>([]);
|
|
||||||
|
|
||||||
// Search input query
|
// Search input query
|
||||||
const [value, setValue] = useState<string>('');
|
const [value, setValue] = useState<string>('');
|
||||||
const [searchText, cancelSearchText] = useDebouncedValue(value, 250);
|
const [searchText, cancelSearchText] = useDebouncedValue(value, 250);
|
||||||
|
|
||||||
|
// reset current data on search value change
|
||||||
|
useEffect(() => {
|
||||||
|
dataRef.current = [];
|
||||||
|
setData([]);
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
const selectQuery = useQuery({
|
const selectQuery = useQuery({
|
||||||
enabled: !definition.disabled && !!definition.api_url && !definition.hidden,
|
enabled: !definition.disabled && !!definition.api_url && !definition.hidden,
|
||||||
queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText],
|
queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText],
|
||||||
@ -95,7 +103,9 @@ export function RelatedModelField({
|
|||||||
params: params
|
params: params
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const values: any[] = [...data];
|
// current values need to be accessed via a ref, otherwise "data" has old values here
|
||||||
|
// and this results in no overriding the data which means the current value cannot be displayed
|
||||||
|
const values: any[] = [...dataRef.current];
|
||||||
const alreadyPresentPks = values.map((x) => x.value);
|
const alreadyPresentPks = values.map((x) => x.value);
|
||||||
|
|
||||||
const results = response.data?.results ?? response.data ?? [];
|
const results = response.data?.results ?? response.data ?? [];
|
||||||
@ -111,6 +121,7 @@ export function RelatedModelField({
|
|||||||
});
|
});
|
||||||
|
|
||||||
setData(values);
|
setData(values);
|
||||||
|
dataRef.current = values;
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
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 {
|
import {
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconPlugConnected,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserBolt,
|
IconUserBolt,
|
||||||
IconUserCog
|
IconUserCog
|
||||||
@ -59,15 +58,6 @@ export function MainMenu() {
|
|||||||
<Trans>System Settings</Trans>
|
<Trans>System Settings</Trans>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{userState.user?.is_staff && (
|
|
||||||
<Menu.Item
|
|
||||||
icon={<IconPlugConnected />}
|
|
||||||
component={Link}
|
|
||||||
to="/settings/plugin"
|
|
||||||
>
|
|
||||||
<Trans>Plugins</Trans>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
@ -12,6 +12,7 @@ import { Group, Stack, Text } from '@mantine/core';
|
|||||||
import { IconBellCheck, IconBellPlus } from '@tabler/icons-react';
|
import { IconBellCheck, IconBellPlus } from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
import { ApiPaths } from '../../enums/ApiEndpoints';
|
import { ApiPaths } from '../../enums/ApiEndpoints';
|
||||||
@ -71,7 +72,7 @@ export function NotificationDrawer({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose();
|
onClose();
|
||||||
navigate('/notifications/');
|
navigate('/notifications/unread');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconBellPlus />
|
<IconBellPlus />
|
||||||
@ -90,7 +91,18 @@ export function NotificationDrawer({
|
|||||||
{notificationQuery.data?.results?.map((notification: any) => (
|
{notificationQuery.data?.results?.map((notification: any) => (
|
||||||
<Group position="apart" key={notification.pk}>
|
<Group position="apart" key={notification.pk}>
|
||||||
<Stack spacing="3">
|
<Stack spacing="3">
|
||||||
|
{notification?.target?.link ? (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
component={Link}
|
||||||
|
to={notification?.target?.link}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{notification.target?.name ?? 'target'}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
<Text size="sm">{notification.target?.name ?? 'target'}</Text>
|
<Text size="sm">{notification.target?.name ?? 'target'}</Text>
|
||||||
|
)}
|
||||||
<Text size="xs">{notification.age_human ?? 'name'}</Text>
|
<Text size="xs">{notification.age_human ?? 'name'}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Space />
|
<Space />
|
||||||
|
@ -6,14 +6,21 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
|
||||||
import {
|
import {
|
||||||
IconLayoutSidebarLeftCollapse,
|
IconLayoutSidebarLeftCollapse,
|
||||||
IconLayoutSidebarRightCollapse
|
IconLayoutSidebarRightCollapse
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode, useMemo } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useNavigate,
|
||||||
|
useParams
|
||||||
|
} from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useLocalState } from '../../states/LocalState';
|
||||||
import { PlaceholderPanel } from '../items/Placeholder';
|
import { PlaceholderPanel } from '../items/Placeholder';
|
||||||
import { StylishText } from '../items/StylishText';
|
import { StylishText } from '../items/StylishText';
|
||||||
|
|
||||||
@ -29,48 +36,48 @@ export type PanelType = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export type PanelProps = {
|
||||||
*
|
|
||||||
* @param panels : PanelDefinition[] - The list of panels to display
|
|
||||||
* @param activePanel : string - The name of the currently active panel (defaults to the first panel)
|
|
||||||
* @param setActivePanel : (panel: string) => void - Function to set the active panel
|
|
||||||
* @param onPanelChange : (panel: string) => void - Callback when the active panel changes
|
|
||||||
* @param collabsible : boolean - If true, the panel group can be collapsed (defaults to true)
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function PanelGroup({
|
|
||||||
pageKey,
|
|
||||||
panels,
|
|
||||||
selectedPanel,
|
|
||||||
onPanelChange,
|
|
||||||
collabsible = true
|
|
||||||
}: {
|
|
||||||
pageKey: string;
|
pageKey: string;
|
||||||
panels: PanelType[];
|
panels: PanelType[];
|
||||||
selectedPanel?: string;
|
selectedPanel?: string;
|
||||||
onPanelChange?: (panel: string) => void;
|
onPanelChange?: (panel: string) => void;
|
||||||
collabsible?: boolean;
|
collapsible?: boolean;
|
||||||
}): ReactNode {
|
};
|
||||||
const [activePanel, setActivePanel] = useLocalStorage<string>({
|
|
||||||
key: `panel-group-active-panel-${pageKey}`,
|
function BasePanelGroup({
|
||||||
defaultValue: selectedPanel || panels.length > 0 ? panels[0].name : ''
|
pageKey,
|
||||||
});
|
panels,
|
||||||
|
onPanelChange,
|
||||||
|
selectedPanel,
|
||||||
|
collapsible = true
|
||||||
|
}: PanelProps): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { panel } = useParams();
|
||||||
|
|
||||||
|
const activePanels = useMemo(
|
||||||
|
() => panels.filter((panel) => !panel.hidden && !panel.disabled),
|
||||||
|
[panels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setLastUsedPanel = useLocalState((state) =>
|
||||||
|
state.setLastUsedPanel(pageKey)
|
||||||
|
);
|
||||||
|
|
||||||
// Update the active panel when the selected panel changes
|
|
||||||
// If the selected panel is not available, default to the first available panel
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let activePanelNames = panels
|
if (panel) {
|
||||||
.filter((panel) => !panel.hidden && !panel.disabled)
|
setLastUsedPanel(panel);
|
||||||
.map((panel) => panel.name);
|
|
||||||
|
|
||||||
if (!activePanelNames.includes(activePanel)) {
|
|
||||||
setActivePanel(activePanelNames.length > 0 ? activePanelNames[0] : '');
|
|
||||||
}
|
}
|
||||||
}, [panels]);
|
// panel is intentionally no dependency as this should only run on initial render
|
||||||
|
}, [setLastUsedPanel]);
|
||||||
|
|
||||||
// Callback when the active panel changes
|
// Callback when the active panel changes
|
||||||
function handlePanelChange(panel: string) {
|
function handlePanelChange(panel: string) {
|
||||||
setActivePanel(panel);
|
if (activePanels.findIndex((p) => p.name === panel) === -1) {
|
||||||
|
setLastUsedPanel('');
|
||||||
|
return navigate('../');
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(`../${panel}`);
|
||||||
|
|
||||||
// Optionally call external callback hook
|
// Optionally call external callback hook
|
||||||
if (onPanelChange) {
|
if (onPanelChange) {
|
||||||
@ -78,12 +85,27 @@ export function PanelGroup({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if the selected panel state changes update the current panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPanel && selectedPanel !== panel) {
|
||||||
|
handlePanelChange(selectedPanel);
|
||||||
|
}
|
||||||
|
}, [selectedPanel, panel]);
|
||||||
|
|
||||||
|
// Update the active panel when panels changes and the active is no longer available
|
||||||
|
useEffect(() => {
|
||||||
|
if (activePanels.findIndex((p) => p.name === panel) === -1) {
|
||||||
|
setLastUsedPanel('');
|
||||||
|
return navigate('../');
|
||||||
|
}
|
||||||
|
}, [activePanels, panel]);
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState<boolean>(true);
|
const [expanded, setExpanded] = useState<boolean>(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p="sm" radius="xs" shadow="xs">
|
<Paper p="sm" radius="xs" shadow="xs">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activePanel}
|
value={panel}
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
onTabChange={handlePanelChange}
|
onTabChange={handlePanelChange}
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
@ -108,7 +130,7 @@ export function PanelGroup({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{collabsible && (
|
{collapsible && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: '10px'
|
paddingLeft: '10px'
|
||||||
@ -147,3 +169,38 @@ export function PanelGroup({
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IndexPanelComponent({ pageKey, selectedPanel, panels }: PanelProps) {
|
||||||
|
const lastUsedPanel = useLocalState((state) => {
|
||||||
|
const panelName =
|
||||||
|
selectedPanel || state.lastUsedPanels[pageKey] || panels[0]?.name;
|
||||||
|
|
||||||
|
if (
|
||||||
|
panels.findIndex(
|
||||||
|
(p) => p.name === panelName && !p.disabled && !p.hidden
|
||||||
|
) === -1
|
||||||
|
) {
|
||||||
|
return panels[0]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return panelName;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Navigate to={lastUsedPanel} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a panel group. The current panel will be appended to the current url.
|
||||||
|
* The last opened panel will be stored in local storage and opened if no panel is provided via url param
|
||||||
|
* @param panels - The list of panels to display
|
||||||
|
* @param onPanelChange - Callback when the active panel changes
|
||||||
|
* @param collapsible - If true, the panel group can be collapsed (defaults to true)
|
||||||
|
*/
|
||||||
|
export function PanelGroup(props: PanelProps) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<IndexPanelComponent {...props} />} />
|
||||||
|
<Route path="/:panel/*" element={<BasePanelGroup {...props} />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,14 +1,25 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button, Group, Space, Stack, Switch, Text } from '@mantine/core';
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Space,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
useMantineTheme
|
||||||
|
} from '@mantine/core';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { IconEdit } from '@tabler/icons-react';
|
import { IconEdit } from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { openModalApiForm } from '../../functions/forms';
|
import { openModalApiForm } from '../../functions/forms';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { SettingsStateProps } from '../../states/SettingsState';
|
import { SettingsStateProps } from '../../states/SettingsState';
|
||||||
import { Setting, SettingType } from '../../states/states';
|
import { Setting, SettingType } from '../../states/states';
|
||||||
|
import { ApiFormFieldType } from '../forms/fields/ApiFormField';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a single setting value
|
* Render a single setting value
|
||||||
@ -47,10 +58,27 @@ function SettingValue({
|
|||||||
|
|
||||||
// Callback function to open the edit dialog (for non-boolean settings)
|
// Callback function to open the edit dialog (for non-boolean settings)
|
||||||
function onEditButton() {
|
function onEditButton() {
|
||||||
let field_type = setting?.type ?? 'string';
|
const fieldDefinition: ApiFormFieldType = {
|
||||||
|
value: setting?.value ?? '',
|
||||||
|
field_type: setting?.type ?? 'string',
|
||||||
|
label: setting?.name,
|
||||||
|
description: setting?.description
|
||||||
|
};
|
||||||
|
|
||||||
if (setting?.choices && setting?.choices?.length > 0) {
|
// Match related field
|
||||||
field_type = SettingType.Choice;
|
if (
|
||||||
|
fieldDefinition.field_type === SettingType.Model &&
|
||||||
|
setting.api_url &&
|
||||||
|
setting.model_name
|
||||||
|
) {
|
||||||
|
fieldDefinition.api_url = setting.api_url;
|
||||||
|
|
||||||
|
// TODO: improve this model matching mechanism
|
||||||
|
fieldDefinition.model = setting.model_name.split('.')[1] as ModelType;
|
||||||
|
} else if (setting.choices?.length > 0) {
|
||||||
|
// Match choices
|
||||||
|
fieldDefinition.field_type = SettingType.Choice;
|
||||||
|
fieldDefinition.choices = setting?.choices || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
openModalApiForm({
|
openModalApiForm({
|
||||||
@ -61,13 +89,7 @@ function SettingValue({
|
|||||||
title: t`Edit Setting`,
|
title: t`Edit Setting`,
|
||||||
ignorePermissionCheck: true,
|
ignorePermissionCheck: true,
|
||||||
fields: {
|
fields: {
|
||||||
value: {
|
value: fieldDefinition
|
||||||
value: setting?.value ?? '',
|
|
||||||
field_type: field_type,
|
|
||||||
choices: setting?.choices || [],
|
|
||||||
label: setting?.name,
|
|
||||||
description: setting?.description
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onFormSuccess() {
|
onFormSuccess() {
|
||||||
showNotification({
|
showNotification({
|
||||||
@ -131,13 +153,25 @@ function SettingValue({
|
|||||||
*/
|
*/
|
||||||
export function SettingItem({
|
export function SettingItem({
|
||||||
settingsState,
|
settingsState,
|
||||||
setting
|
setting,
|
||||||
|
shaded
|
||||||
}: {
|
}: {
|
||||||
settingsState: SettingsStateProps;
|
settingsState: SettingsStateProps;
|
||||||
setting: Setting;
|
setting: Setting;
|
||||||
|
shaded: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
|
const style: Record<string, string> = { paddingLeft: '8px' };
|
||||||
|
if (shaded) {
|
||||||
|
style['backgroundColor'] =
|
||||||
|
theme.colorScheme === 'light'
|
||||||
|
? theme.colors.gray[1]
|
||||||
|
: theme.colors.gray[9];
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Paper style={style}>
|
||||||
<Group position="apart" p="10">
|
<Group position="apart" p="10">
|
||||||
<Stack spacing="2">
|
<Stack spacing="2">
|
||||||
<Text>{setting.name}</Text>
|
<Text>{setting.name}</Text>
|
||||||
@ -145,6 +179,6 @@ export function SettingItem({
|
|||||||
</Stack>
|
</Stack>
|
||||||
<SettingValue settingsState={settingsState} setting={setting} />
|
<SettingValue settingsState={settingsState} setting={setting} />
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { Stack, Text, useMantineTheme } from '@mantine/core';
|
import { Stack, Text } from '@mantine/core';
|
||||||
import { useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useStore } from 'zustand';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SettingsStateProps,
|
SettingsStateProps,
|
||||||
|
createPluginSettingsState,
|
||||||
useGlobalSettingsState,
|
useGlobalSettingsState,
|
||||||
useUserSettingsState
|
useUserSettingsState
|
||||||
} from '../../states/SettingsState';
|
} from '../../states/SettingsState';
|
||||||
@ -27,8 +29,6 @@ export function SettingList({
|
|||||||
[settingsState?.settings]
|
[settingsState?.settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const theme = useMantineTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
@ -37,23 +37,20 @@ export function SettingList({
|
|||||||
(s: any) => s.key === key
|
(s: any) => s.key === key
|
||||||
);
|
);
|
||||||
|
|
||||||
const style: Record<string, string> = { paddingLeft: '8px' };
|
|
||||||
if (i % 2 === 0)
|
|
||||||
style['backgroundColor'] =
|
|
||||||
theme.colorScheme === 'light'
|
|
||||||
? theme.colors.gray[1]
|
|
||||||
: theme.colors.gray[9];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} style={style}>
|
<React.Fragment key={key}>
|
||||||
{setting ? (
|
{setting ? (
|
||||||
<SettingItem settingsState={settingsState} setting={setting} />
|
<SettingItem
|
||||||
|
settingsState={settingsState}
|
||||||
|
setting={setting}
|
||||||
|
shaded={i % 2 === 0}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" italic color="red">
|
<Text size="sm" italic color="red">
|
||||||
Setting {key} not found
|
Setting {key} not found
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
@ -72,3 +69,12 @@ export function GlobalSettingList({ keys }: { keys: string[] }) {
|
|||||||
|
|
||||||
return <SettingList settingsState={globalSettings} keys={keys} />;
|
return <SettingList settingsState={globalSettings} keys={keys} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PluginSettingList({ pluginPk }: { pluginPk: string }) {
|
||||||
|
const pluginSettingsStore = useRef(
|
||||||
|
createPluginSettingsState({ plugin: pluginPk })
|
||||||
|
).current;
|
||||||
|
const pluginSettings = useStore(pluginSettingsStore);
|
||||||
|
|
||||||
|
return <SettingList settingsState={pluginSettings} />;
|
||||||
|
}
|
||||||
|
@ -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 { Trans, t } from '@lingui/macro';
|
||||||
import { Alert, Group, Stack, Text, Tooltip } from '@mantine/core';
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
LoadingOverlay,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
Tooltip
|
||||||
|
} from '@mantine/core';
|
||||||
import { modals } from '@mantine/modals';
|
import { modals } from '@mantine/modals';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconCircleCheck,
|
IconCircleCheck,
|
||||||
IconCircleX,
|
IconCircleX,
|
||||||
IconHelpCircle
|
IconHelpCircle,
|
||||||
|
IconPlaylistAdd,
|
||||||
|
IconRefresh
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { IconDots } from '@tabler/icons-react';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { api } from '../../../App';
|
import { api } from '../../../App';
|
||||||
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||||
|
import { openEditApiForm } from '../../../functions/forms';
|
||||||
|
import { useCreateApiFormModal } from '../../../hooks/UseForm';
|
||||||
|
import { useInstance } from '../../../hooks/UseInstance';
|
||||||
import { useTable } from '../../../hooks/UseTable';
|
import { useTable } from '../../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../../states/ApiState';
|
import { apiUrl, useServerApiState } from '../../../states/ApiState';
|
||||||
|
import { ActionButton } from '../../buttons/ActionButton';
|
||||||
|
import { ActionDropdown, EditItemAction } from '../../items/ActionDropdown';
|
||||||
|
import { InfoItem } from '../../items/InfoItem';
|
||||||
import { StylishText } from '../../items/StylishText';
|
import { StylishText } from '../../items/StylishText';
|
||||||
|
import { DetailDrawer } from '../../nav/DetailDrawer';
|
||||||
|
import { PluginSettingList } from '../../settings/SettingList';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
|
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
|
||||||
import { RowAction } from '../RowActions';
|
import { RowAction } from '../RowActions';
|
||||||
|
|
||||||
|
export interface PluginI {
|
||||||
|
pk: number;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
is_builtin: boolean;
|
||||||
|
is_sample: boolean;
|
||||||
|
is_installed: boolean;
|
||||||
|
meta: {
|
||||||
|
author: string | null;
|
||||||
|
description: string | null;
|
||||||
|
human_name: string | null;
|
||||||
|
license: string | null;
|
||||||
|
package_path: string | null;
|
||||||
|
pub_date: string | null;
|
||||||
|
settings_url: string | null;
|
||||||
|
slug: string | null;
|
||||||
|
version: string | null;
|
||||||
|
website: string | null;
|
||||||
|
};
|
||||||
|
mixins: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
key: string;
|
||||||
|
human_name: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginDrawer({
|
||||||
|
id,
|
||||||
|
refreshTable
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
refreshTable: () => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
instance: plugin,
|
||||||
|
refreshInstance,
|
||||||
|
instanceQuery: { isFetching, error }
|
||||||
|
} = useInstance<PluginI>({
|
||||||
|
endpoint: ApiPaths.plugin_list,
|
||||||
|
pk: id,
|
||||||
|
throwError: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
refreshTable();
|
||||||
|
refreshInstance();
|
||||||
|
}, [refreshTable, refreshInstance]);
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return <LoadingOverlay visible={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{(error as any)?.response?.status === 404 ? (
|
||||||
|
<Trans>Plugin with id {id} not found</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>An error occurred while fetching plugin details</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={'xs'}>
|
||||||
|
<Group position="apart">
|
||||||
|
<Box></Box>
|
||||||
|
|
||||||
|
<Group spacing={'xs'}>
|
||||||
|
{plugin && PluginIcon(plugin)}
|
||||||
|
<Title order={4}>{plugin?.meta.human_name || plugin?.name}</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ActionDropdown
|
||||||
|
tooltip={t`Plugin Actions`}
|
||||||
|
icon={<IconDots />}
|
||||||
|
actions={[
|
||||||
|
EditItemAction({
|
||||||
|
tooltip: t`Edit plugin`,
|
||||||
|
onClick: () => {
|
||||||
|
openEditApiForm({
|
||||||
|
title: t`Edit plugin`,
|
||||||
|
url: ApiPaths.plugin_list,
|
||||||
|
pk: id,
|
||||||
|
fields: {
|
||||||
|
active: {}
|
||||||
|
},
|
||||||
|
onClose: refetch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: t`Reload`,
|
||||||
|
tooltip: t`Reload`,
|
||||||
|
icon: <IconRefresh />,
|
||||||
|
onClick: refreshInstance
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<LoadingOverlay visible={isFetching} overlayOpacity={0} />
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack spacing="md">
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans>Plugin information</Trans>
|
||||||
|
</Title>
|
||||||
|
<Stack pos="relative" spacing="xs">
|
||||||
|
<InfoItem type="text" name={t`Name`} value={plugin?.name} />
|
||||||
|
<InfoItem
|
||||||
|
type="text"
|
||||||
|
name={t`Description`}
|
||||||
|
value={plugin?.meta.description}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
type="text"
|
||||||
|
name={t`Author`}
|
||||||
|
value={plugin?.meta.author}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
type="text"
|
||||||
|
name={t`Date`}
|
||||||
|
value={plugin?.meta.pub_date}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
type="text"
|
||||||
|
name={t`Version`}
|
||||||
|
value={plugin?.meta.version}
|
||||||
|
/>
|
||||||
|
<InfoItem type="boolean" name={t`Active`} value={plugin?.active} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack spacing="md">
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans>Package information</Trans>
|
||||||
|
</Title>
|
||||||
|
<Stack pos="relative" spacing="xs">
|
||||||
|
<InfoItem
|
||||||
|
type="text"
|
||||||
|
name={t`Installation path`}
|
||||||
|
value={plugin?.meta.package_path}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
type="boolean"
|
||||||
|
name={t`Builtin`}
|
||||||
|
value={plugin?.is_builtin}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{plugin && plugin.active && (
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack spacing="md">
|
||||||
|
<Title order={4}>
|
||||||
|
<Trans>Plugin settings</Trans>
|
||||||
|
</Title>
|
||||||
|
<PluginSettingList pluginPk={id} />
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct an indicator icon for a single plugin
|
* Construct an indicator icon for a single plugin
|
||||||
*/
|
*/
|
||||||
function PluginIcon(plugin: any) {
|
function PluginIcon(plugin: PluginI) {
|
||||||
if (plugin.is_installed) {
|
if (plugin.is_installed) {
|
||||||
if (plugin.active) {
|
if (plugin.active) {
|
||||||
return (
|
return (
|
||||||
@ -50,6 +245,11 @@ function PluginIcon(plugin: any) {
|
|||||||
*/
|
*/
|
||||||
export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
||||||
const table = useTable('plugin');
|
const table = useTable('plugin');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const pluginsEnabled = useServerApiState(
|
||||||
|
(state) => state.server.plugins_enabled
|
||||||
|
);
|
||||||
|
|
||||||
const pluginTableColumns: TableColumn[] = useMemo(
|
const pluginTableColumns: TableColumn[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@ -200,7 +400,58 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installPluginModal = useCreateApiFormModal({
|
||||||
|
title: t`Install plugin`,
|
||||||
|
url: ApiPaths.plugin_install,
|
||||||
|
fields: {
|
||||||
|
packagename: {},
|
||||||
|
url: {},
|
||||||
|
confirm: {}
|
||||||
|
},
|
||||||
|
closeOnClickOutside: false,
|
||||||
|
submitText: t`Install`,
|
||||||
|
successMessage: undefined,
|
||||||
|
onFormSuccess: (data) => {
|
||||||
|
notifications.show({
|
||||||
|
title: t`Plugin installed successfully`,
|
||||||
|
message: data.result,
|
||||||
|
autoClose: 30000,
|
||||||
|
color: 'green'
|
||||||
|
});
|
||||||
|
|
||||||
|
table.refreshTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom table actions
|
||||||
|
const tableActions = useMemo(() => {
|
||||||
|
let actions = [];
|
||||||
|
|
||||||
|
if (pluginsEnabled) {
|
||||||
|
actions.push(
|
||||||
|
<ActionButton
|
||||||
|
color="green"
|
||||||
|
icon={<IconPlaylistAdd />}
|
||||||
|
tooltip={t`Install Plugin`}
|
||||||
|
onClick={() => installPluginModal.open()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{installPluginModal.modal}
|
||||||
|
<DetailDrawer
|
||||||
|
title={t`Plugin detail`}
|
||||||
|
size={'lg'}
|
||||||
|
renderContent={(id) => {
|
||||||
|
if (!id) return false;
|
||||||
|
return <PluginDrawer id={id} refreshTable={table.refreshTable} />;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiPaths.plugin_list)}
|
url={apiUrl(ApiPaths.plugin_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
@ -212,6 +463,8 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
...props.params
|
...props.params
|
||||||
},
|
},
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
|
onRowClick: (plugin) => navigate(`${plugin.pk}/`),
|
||||||
|
customActionGroups: tableActions,
|
||||||
customFilters: [
|
customFilters: [
|
||||||
{
|
{
|
||||||
name: 'active',
|
name: 'active',
|
||||||
@ -236,5 +489,6 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,98 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { Trans, t } from '@lingui/macro';
|
||||||
|
import { Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||||
import {
|
import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
|
||||||
openCreateApiForm,
|
import { useInstance } from '../../../hooks/UseInstance';
|
||||||
openDeleteApiForm,
|
|
||||||
openEditApiForm
|
|
||||||
} from '../../../functions/forms';
|
|
||||||
import { useTable } from '../../../hooks/UseTable';
|
import { useTable } from '../../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../../states/ApiState';
|
import { apiUrl } from '../../../states/ApiState';
|
||||||
import { AddItemButton } from '../../buttons/AddItemButton';
|
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||||
|
import { EditApiForm } from '../../forms/ApiForm';
|
||||||
|
import { PlaceholderPill } from '../../items/Placeholder';
|
||||||
|
import { DetailDrawer } from '../../nav/DetailDrawer';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||||
|
|
||||||
|
export interface GroupDetailI {
|
||||||
|
pk: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupDrawer({
|
||||||
|
id,
|
||||||
|
refreshTable
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
refreshTable: () => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
refreshInstance,
|
||||||
|
instanceQuery: { isFetching, error }
|
||||||
|
} = useInstance({
|
||||||
|
endpoint: ApiPaths.group_list,
|
||||||
|
pk: id,
|
||||||
|
throwError: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return <LoadingOverlay visible={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{(error as any)?.response?.status === 404 ? (
|
||||||
|
<Trans>Group with id {id} not found</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>An error occurred while fetching group details</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<EditApiForm
|
||||||
|
props={{
|
||||||
|
url: ApiPaths.group_list,
|
||||||
|
pk: id,
|
||||||
|
fields: {
|
||||||
|
name: {}
|
||||||
|
},
|
||||||
|
onFormSuccess: () => {
|
||||||
|
refreshTable();
|
||||||
|
refreshInstance();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
id={`group-detail-drawer-${id}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title order={5}>
|
||||||
|
<Trans>Permission set</Trans>
|
||||||
|
</Title>
|
||||||
|
<Group>
|
||||||
|
<PlaceholderPill />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table for displaying list of groups
|
* Table for displaying list of groups
|
||||||
*/
|
*/
|
||||||
export function GroupTable() {
|
export function GroupTable() {
|
||||||
const table = useTable('groups');
|
const table = useTable('groups');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const columns: TableColumn[] = useMemo(() => {
|
const openDetailDrawer = useCallback(
|
||||||
|
(pk: number) => navigate(`group-${pk}/`),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: TableColumn<GroupDetailI>[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
@ -30,21 +102,10 @@ export function GroupTable() {
|
|||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const rowActions = useCallback((record: any): RowAction[] => {
|
const rowActions = useCallback((record: GroupDetailI): RowAction[] => {
|
||||||
return [
|
return [
|
||||||
RowEditAction({
|
RowEditAction({
|
||||||
onClick: () => {
|
onClick: () => openDetailDrawer(record.pk)
|
||||||
openEditApiForm({
|
|
||||||
url: ApiPaths.group_list,
|
|
||||||
pk: record.pk,
|
|
||||||
title: t`Edit group`,
|
|
||||||
fields: {
|
|
||||||
name: {}
|
|
||||||
},
|
|
||||||
onFormSuccess: table.refreshTable,
|
|
||||||
successMessage: t`Group updated`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@ -86,14 +147,29 @@ export function GroupTable() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<DetailDrawer
|
||||||
|
title={t`Edit group`}
|
||||||
|
renderContent={(id) => {
|
||||||
|
if (!id || !id.startsWith('group-')) return false;
|
||||||
|
return (
|
||||||
|
<GroupDrawer
|
||||||
|
id={id.replace('group-', '')}
|
||||||
|
refreshTable={table.refreshTable}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiPaths.group_list)}
|
url={apiUrl(ApiPaths.group_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
props={{
|
props={{
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
customActionGroups: tableActions
|
customActionGroups: tableActions,
|
||||||
|
onRowClick: (record) => openDetailDrawer(record.pk)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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 { Trans, t } from '@lingui/macro';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { Alert, List, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||||
import {
|
import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
|
||||||
openCreateApiForm,
|
import { useInstance } from '../../../hooks/UseInstance';
|
||||||
openDeleteApiForm,
|
|
||||||
openEditApiForm
|
|
||||||
} from '../../../functions/forms';
|
|
||||||
import { useTable } from '../../../hooks/UseTable';
|
import { useTable } from '../../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../../states/ApiState';
|
import { apiUrl } from '../../../states/ApiState';
|
||||||
|
import { useUserState } from '../../../states/UserState';
|
||||||
import { AddItemButton } from '../../buttons/AddItemButton';
|
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||||
|
import { EditApiForm } from '../../forms/ApiForm';
|
||||||
|
import { DetailDrawer } from '../../nav/DetailDrawer';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { BooleanColumn } from '../ColumnRenderers';
|
import { BooleanColumn } from '../ColumnRenderers';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||||
import { UserDrawer } from './UserDrawer';
|
import { GroupDetailI } from './GroupTable';
|
||||||
|
|
||||||
interface GroupDetailI {
|
|
||||||
pk: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserDetailI {
|
export interface UserDetailI {
|
||||||
pk: number;
|
pk: number;
|
||||||
@ -34,13 +32,122 @@ export interface UserDetailI {
|
|||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UserDrawer({
|
||||||
|
id,
|
||||||
|
refreshTable
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
refreshTable: () => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
instance: userDetail,
|
||||||
|
refreshInstance,
|
||||||
|
instanceQuery: { isFetching, error }
|
||||||
|
} = useInstance<UserDetailI>({
|
||||||
|
endpoint: ApiPaths.user_list,
|
||||||
|
pk: id,
|
||||||
|
throwError: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUserPk = useUserState((s) => s.user?.pk);
|
||||||
|
const isCurrentUser = useMemo(
|
||||||
|
() => currentUserPk === parseInt(id, 10),
|
||||||
|
[currentUserPk, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return <LoadingOverlay visible={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{(error as any)?.response?.status === 404 ? (
|
||||||
|
<Trans>User with id {id} not found</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>An error occurred while fetching user details</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<EditApiForm
|
||||||
|
props={{
|
||||||
|
url: ApiPaths.user_list,
|
||||||
|
pk: id,
|
||||||
|
fields: {
|
||||||
|
username: {},
|
||||||
|
first_name: {},
|
||||||
|
last_name: {},
|
||||||
|
email: {},
|
||||||
|
is_active: {
|
||||||
|
label: t`Is Active`,
|
||||||
|
description: t`Designates whether this user should be treated as active. Unselect this instead of deleting accounts.`,
|
||||||
|
disabled: isCurrentUser
|
||||||
|
},
|
||||||
|
is_staff: {
|
||||||
|
label: t`Is Staff`,
|
||||||
|
description: t`Designates whether the user can log into the django admin site.`,
|
||||||
|
disabled: isCurrentUser
|
||||||
|
},
|
||||||
|
is_superuser: {
|
||||||
|
label: t`Is Superuser`,
|
||||||
|
description: t`Designates that this user has all permissions without explicitly assigning them.`,
|
||||||
|
disabled: isCurrentUser
|
||||||
|
}
|
||||||
|
},
|
||||||
|
postFormContent: isCurrentUser ? (
|
||||||
|
<Alert
|
||||||
|
title={<Trans>Info</Trans>}
|
||||||
|
color="blue"
|
||||||
|
icon={<IconInfoCircle />}
|
||||||
|
>
|
||||||
|
<Trans>
|
||||||
|
You cannot edit the rights for the currently logged-in user.
|
||||||
|
</Trans>
|
||||||
|
</Alert>
|
||||||
|
) : undefined,
|
||||||
|
onFormSuccess: () => {
|
||||||
|
refreshTable();
|
||||||
|
refreshInstance();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
id={`user-detail-drawer-${id}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title order={5}>
|
||||||
|
<Trans>Groups</Trans>
|
||||||
|
</Title>
|
||||||
|
<Text ml={'md'}>
|
||||||
|
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
|
||||||
|
<List>
|
||||||
|
{userDetail?.groups?.map((group) => (
|
||||||
|
<List.Item key={group.pk}>
|
||||||
|
<Link to={`../group-${group.pk}`}>{group.name}</Link>
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
) : (
|
||||||
|
<Trans>No groups</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table for displaying list of users
|
* Table for displaying list of users
|
||||||
*/
|
*/
|
||||||
export function UserTable() {
|
export function UserTable() {
|
||||||
const table = useTable('users');
|
const table = useTable('users');
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const navigate = useNavigate();
|
||||||
const [userDetail, setUserDetail] = useState<UserDetailI>();
|
|
||||||
|
const openDetailDrawer = useCallback(
|
||||||
|
(pk: number) => navigate(`user-${pk}/`),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const columns: TableColumn[] = useMemo(() => {
|
const columns: TableColumn[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@ -92,20 +199,7 @@ export function UserTable() {
|
|||||||
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
|
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
|
||||||
return [
|
return [
|
||||||
RowEditAction({
|
RowEditAction({
|
||||||
onClick: () => {
|
onClick: () => openDetailDrawer(record.pk)
|
||||||
openEditApiForm({
|
|
||||||
url: ApiPaths.user_list,
|
|
||||||
pk: record.pk,
|
|
||||||
title: t`Edit user`,
|
|
||||||
fields: {
|
|
||||||
email: {},
|
|
||||||
first_name: {},
|
|
||||||
last_name: {}
|
|
||||||
},
|
|
||||||
onFormSuccess: table.refreshTable,
|
|
||||||
successMessage: t`User updated`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@ -149,11 +243,17 @@ export function UserTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<DetailDrawer
|
||||||
|
title={t`Edit user`}
|
||||||
|
renderContent={(id) => {
|
||||||
|
if (!id || !id.startsWith('user-')) return false;
|
||||||
|
return (
|
||||||
<UserDrawer
|
<UserDrawer
|
||||||
opened={opened}
|
id={id.replace('user-', '')}
|
||||||
close={close}
|
|
||||||
refreshTable={table.refreshTable}
|
refreshTable={table.refreshTable}
|
||||||
userDetail={userDetail}
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiPaths.user_list)}
|
url={apiUrl(ApiPaths.user_list)}
|
||||||
@ -162,10 +262,7 @@ export function UserTable() {
|
|||||||
props={{
|
props={{
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
customActionGroups: tableActions,
|
customActionGroups: tableActions,
|
||||||
onRowClick: (record: any) => {
|
onRowClick: (record) => openDetailDrawer(record.pk)
|
||||||
setUserDetail(record);
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -84,6 +84,9 @@ export enum ApiPaths {
|
|||||||
|
|
||||||
// Plugin URLs
|
// Plugin URLs
|
||||||
plugin_list = 'api-plugin-list',
|
plugin_list = 'api-plugin-list',
|
||||||
|
plugin_setting_list = 'api-plugin-settings',
|
||||||
|
plugin_install = 'api-plugin-install',
|
||||||
|
plugin_registry_status = 'api-plugin-registry-status',
|
||||||
|
|
||||||
project_code_list = 'api-project-code-list',
|
project_code_list = 'api-project-code-list',
|
||||||
custom_unit_list = 'api-custom-unit-list'
|
custom_unit_list = 'api-custom-unit-list'
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { Divider, Stack } from '@mantine/core';
|
||||||
import { modals } from '@mantine/modals';
|
import { modals } from '@mantine/modals';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
@ -248,7 +249,12 @@ export function openModalApiForm(props: OpenApiFormProps) {
|
|||||||
onClose: () => {
|
onClose: () => {
|
||||||
props.onClose ? props.onClose() : null;
|
props.onClose ? props.onClose() : null;
|
||||||
},
|
},
|
||||||
children: <ApiForm id={modalId} props={props} />
|
children: (
|
||||||
|
<Stack spacing={'xs'}>
|
||||||
|
<Divider />
|
||||||
|
<ApiForm id={modalId} props={props} />
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { Divider, Stack } from '@mantine/core';
|
||||||
import { useId } from '@mantine/hooks';
|
import { useId } from '@mantine/hooks';
|
||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
@ -18,6 +19,7 @@ export interface ApiFormModalProps extends ApiFormProps {
|
|||||||
cancelColor?: string;
|
cancelColor?: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
|
closeOnClickOutside?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -56,8 +58,14 @@ export function useApiFormModal(props: ApiFormModalProps) {
|
|||||||
title: formProps.title,
|
title: formProps.title,
|
||||||
onOpen: formProps.onOpen,
|
onOpen: formProps.onOpen,
|
||||||
onClose: formProps.onClose,
|
onClose: formProps.onClose,
|
||||||
|
closeOnClickOutside: formProps.closeOnClickOutside,
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
children: <OptionsApiForm props={formProps} id={id} />
|
children: (
|
||||||
|
<Stack spacing={'xs'}>
|
||||||
|
<Divider />
|
||||||
|
<OptionsApiForm props={formProps} id={id} />
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -3,7 +3,7 @@ import { useCallback, useState } from 'react';
|
|||||||
|
|
||||||
import { api } from '../App';
|
import { api } from '../App';
|
||||||
import { ApiPaths } from '../enums/ApiEndpoints';
|
import { ApiPaths } from '../enums/ApiEndpoints';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { PathParams, apiUrl } from '../states/ApiState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for loading a single instance of an instance from the API
|
* Custom hook for loading a single instance of an instance from the API
|
||||||
@ -14,26 +14,30 @@ import { apiUrl } from '../states/ApiState';
|
|||||||
* To use this hook:
|
* To use this hook:
|
||||||
* const { instance, refreshInstance } = useInstance(url: string, pk: number)
|
* const { instance, refreshInstance } = useInstance(url: string, pk: number)
|
||||||
*/
|
*/
|
||||||
export function useInstance({
|
export function useInstance<T = any>({
|
||||||
endpoint,
|
endpoint,
|
||||||
pk,
|
pk,
|
||||||
params = {},
|
params = {},
|
||||||
defaultValue = {},
|
defaultValue = {},
|
||||||
|
pathParams,
|
||||||
hasPrimaryKey = true,
|
hasPrimaryKey = true,
|
||||||
refetchOnMount = true,
|
refetchOnMount = true,
|
||||||
refetchOnWindowFocus = false
|
refetchOnWindowFocus = false,
|
||||||
|
throwError = false
|
||||||
}: {
|
}: {
|
||||||
endpoint: ApiPaths;
|
endpoint: ApiPaths;
|
||||||
pk?: string | undefined;
|
pk?: string | undefined;
|
||||||
hasPrimaryKey?: boolean;
|
hasPrimaryKey?: boolean;
|
||||||
params?: any;
|
params?: any;
|
||||||
|
pathParams?: PathParams;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
refetchOnMount?: boolean;
|
refetchOnMount?: boolean;
|
||||||
refetchOnWindowFocus?: boolean;
|
refetchOnWindowFocus?: boolean;
|
||||||
|
throwError?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [instance, setInstance] = useState<any>(defaultValue);
|
const [instance, setInstance] = useState<T | undefined>(defaultValue);
|
||||||
|
|
||||||
const instanceQuery = useQuery({
|
const instanceQuery = useQuery<T>({
|
||||||
queryKey: ['instance', endpoint, pk, params],
|
queryKey: ['instance', endpoint, pk, params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (hasPrimaryKey) {
|
if (hasPrimaryKey) {
|
||||||
@ -43,7 +47,7 @@ export function useInstance({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = apiUrl(endpoint, pk);
|
const url = apiUrl(endpoint, pk, pathParams);
|
||||||
|
|
||||||
return api
|
return api
|
||||||
.get(url, {
|
.get(url, {
|
||||||
@ -62,6 +66,9 @@ export function useInstance({
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setInstance(defaultValue);
|
setInstance(defaultValue);
|
||||||
console.error(`Error fetching instance ${url}:`, error);
|
console.error(`Error fetching instance ${url}:`, error);
|
||||||
|
|
||||||
|
if (throwError) throw error;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -10,6 +10,7 @@ export interface UseModalProps {
|
|||||||
size?: MantineNumberSize;
|
size?: MantineNumberSize;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
closeOnClickOutside?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useModal(props: UseModalProps) {
|
export function useModal(props: UseModalProps) {
|
||||||
@ -34,6 +35,7 @@ export function useModal(props: UseModalProps) {
|
|||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
|
closeOnClickOutside={props.closeOnClickOutside}
|
||||||
size={props.size ?? 'xl'}
|
size={props.size ?? 'xl'}
|
||||||
title={<StylishText size="xl">{props.title}</StylishText>}
|
title={<StylishText size="xl">{props.title}</StylishText>}
|
||||||
>
|
>
|
||||||
|
@ -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(() => {
|
const notificationPanels = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'notifications-unread',
|
name: 'unread',
|
||||||
label: t`Notifications`,
|
label: t`Notifications`,
|
||||||
icon: <IconBellExclamation size="18" />,
|
icon: <IconBellExclamation size="18" />,
|
||||||
content: (
|
content: (
|
||||||
@ -52,7 +52,7 @@ export default function NotificationsPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'notifications-history',
|
name: 'history',
|
||||||
label: t`History`,
|
label: t`History`,
|
||||||
icon: <IconBellCheck size="18" />,
|
icon: <IconBellCheck size="18" />,
|
||||||
content: (
|
content: (
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import { Loadable } from './functions/loading';
|
import { Loadable } from './functions/loading';
|
||||||
|
|
||||||
@ -89,11 +89,8 @@ export const SystemSettings = Loadable(
|
|||||||
lazy(() => import('./pages/Index/Settings/SystemSettings'))
|
lazy(() => import('./pages/Index/Settings/SystemSettings'))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const PluginSettings = Loadable(
|
|
||||||
lazy(() => import('./pages/Index/Settings/PluginSettings'))
|
|
||||||
);
|
|
||||||
export const AdminCenter = Loadable(
|
export const AdminCenter = Loadable(
|
||||||
lazy(() => import('./pages/Index/Settings/AdminCenter'))
|
lazy(() => import('./pages/Index/Settings/AdminCenter/Index'))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
|
export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
|
||||||
@ -112,42 +109,43 @@ export const routes = (
|
|||||||
<Route index element={<Home />} />,
|
<Route index element={<Home />} />,
|
||||||
<Route path="home/" element={<Home />} />,
|
<Route path="home/" element={<Home />} />,
|
||||||
<Route path="dashboard/" element={<Dashboard />} />,
|
<Route path="dashboard/" element={<Dashboard />} />,
|
||||||
<Route path="notifications/" element={<Notifications />} />,
|
<Route path="notifications/*" element={<Notifications />} />,
|
||||||
<Route path="playground/" element={<Playground />} />,
|
<Route path="playground/" element={<Playground />} />,
|
||||||
<Route path="scan/" element={<Scan />} />,
|
<Route path="scan/" element={<Scan />} />,
|
||||||
<Route path="settings/">
|
<Route path="settings/">
|
||||||
<Route index element={<AdminCenter />} />
|
<Route index element={<Navigate to="admin/" />} />
|
||||||
<Route path="admin/" element={<AdminCenter />} />
|
<Route path="admin/*" element={<AdminCenter />} />
|
||||||
<Route path="system/" element={<SystemSettings />} />
|
<Route path="system/*" element={<SystemSettings />} />
|
||||||
<Route path="user/" element={<UserSettings />} />
|
<Route path="user/*" element={<UserSettings />} />
|
||||||
<Route path="plugin/" element={<PluginSettings />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="part/">
|
<Route path="part/">
|
||||||
<Route index element={<CategoryDetail />} />
|
<Route index element={<Navigate to="category/" />} />
|
||||||
<Route path="category/:id" element={<CategoryDetail />} />
|
<Route path="category/:id?/*" element={<CategoryDetail />} />
|
||||||
<Route path=":id/" element={<PartDetail />} />
|
<Route path=":id/*" element={<PartDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="stock/">
|
<Route path="stock/">
|
||||||
<Route index element={<LocationDetail />} />
|
<Route index element={<Navigate to="location/" />} />
|
||||||
<Route path="location/:id" element={<LocationDetail />} />
|
<Route path="location/:id?/*" element={<LocationDetail />} />
|
||||||
<Route path="item/:id/" element={<StockDetail />} />
|
<Route path="item/:id/*" element={<StockDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="build/">
|
<Route path="build/">
|
||||||
<Route index element={<BuildIndex />} />
|
<Route index element={<BuildIndex />} />
|
||||||
<Route path=":id/" element={<BuildDetail />} />
|
<Route path=":id/" element={<BuildDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="purchasing/">
|
<Route path="purchasing/">
|
||||||
<Route index element={<PurchasingIndex />} />
|
<Route index element={<Navigate to="index/" />} />
|
||||||
<Route path="purchase-order/:id/" element={<PurchaseOrderDetail />} />
|
<Route path="index/*" element={<PurchasingIndex />} />
|
||||||
<Route path="supplier/:id/" element={<SupplierDetail />} />
|
<Route path="purchase-order/:id/*" element={<PurchaseOrderDetail />} />
|
||||||
<Route path="manufacturer/:id/" element={<ManufacturerDetail />} />
|
<Route path="supplier/:id/*" element={<SupplierDetail />} />
|
||||||
|
<Route path="manufacturer/:id/*" element={<ManufacturerDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="company/:id/" element={<CompanyDetail />} />
|
<Route path="company/:id/*" element={<CompanyDetail />} />
|
||||||
<Route path="sales/">
|
<Route path="sales/">
|
||||||
<Route index element={<SalesIndex />} />
|
<Route index element={<Navigate to="index/" />} />
|
||||||
<Route path="sales-order/:id/" element={<SalesOrderDetail />} />
|
<Route path="index/*" element={<SalesIndex />} />
|
||||||
<Route path="return-order/:id/" element={<ReturnOrderDetail />} />
|
<Route path="sales-order/:id/*" element={<SalesOrderDetail />} />
|
||||||
<Route path="customer/:id/" element={<CustomerDetail />} />
|
<Route path="return-order/:id/*" element={<ReturnOrderDetail />} />
|
||||||
|
<Route path="customer/:id/*" element={<CustomerDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/" errorElement={<ErrorPage />}>
|
<Route path="/" errorElement={<ErrorPage />}>
|
||||||
|
@ -181,6 +181,12 @@ export function apiEndpoint(path: ApiPaths): string {
|
|||||||
return 'order/ro/attachment/';
|
return 'order/ro/attachment/';
|
||||||
case ApiPaths.plugin_list:
|
case ApiPaths.plugin_list:
|
||||||
return 'plugins/';
|
return 'plugins/';
|
||||||
|
case ApiPaths.plugin_setting_list:
|
||||||
|
return 'plugins/:plugin/settings/';
|
||||||
|
case ApiPaths.plugin_registry_status:
|
||||||
|
return 'plugins/status/';
|
||||||
|
case ApiPaths.plugin_install:
|
||||||
|
return 'plugins/install/';
|
||||||
case ApiPaths.project_code_list:
|
case ApiPaths.project_code_list:
|
||||||
return 'project-code/';
|
return 'project-code/';
|
||||||
case ApiPaths.custom_unit_list:
|
case ApiPaths.custom_unit_list:
|
||||||
|
@ -22,11 +22,13 @@ interface LocalStateProps {
|
|||||||
blackColor: string;
|
blackColor: string;
|
||||||
radius: MantineNumberSize;
|
radius: MantineNumberSize;
|
||||||
loader: LoaderType;
|
loader: LoaderType;
|
||||||
|
lastUsedPanels: Record<string, string>;
|
||||||
|
setLastUsedPanel: (panelKey: string) => (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLocalState = create<LocalStateProps>()(
|
export const useLocalState = create<LocalStateProps>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
autoupdate: false,
|
autoupdate: false,
|
||||||
toggleAutoupdate: () =>
|
toggleAutoupdate: () =>
|
||||||
set((state) => ({ autoupdate: !state.autoupdate })),
|
set((state) => ({ autoupdate: !state.autoupdate })),
|
||||||
@ -43,7 +45,17 @@ export const useLocalState = create<LocalStateProps>()(
|
|||||||
whiteColor: '#fff',
|
whiteColor: '#fff',
|
||||||
blackColor: '#000',
|
blackColor: '#000',
|
||||||
radius: 'xs',
|
radius: 'xs',
|
||||||
loader: 'oval'
|
loader: 'oval',
|
||||||
|
// panels
|
||||||
|
lastUsedPanels: {},
|
||||||
|
setLastUsedPanel: (panelKey) => (value) => {
|
||||||
|
const currentValue = get().lastUsedPanels[panelKey];
|
||||||
|
if (currentValue !== value) {
|
||||||
|
set({
|
||||||
|
lastUsedPanels: { ...get().lastUsedPanels, [panelKey]: value }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'session-settings'
|
name: 'session-settings'
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* State management for remote (server side) settings
|
* State management for remote (server side) settings
|
||||||
*/
|
*/
|
||||||
import { create } from 'zustand';
|
import { create, createStore } from 'zustand';
|
||||||
|
|
||||||
import { api } from '../App';
|
import { api } from '../App';
|
||||||
import { ApiPaths } from '../enums/ApiEndpoints';
|
import { ApiPaths } from '../enums/ApiEndpoints';
|
||||||
@ -79,6 +79,50 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State management for plugin settings
|
||||||
|
*/
|
||||||
|
interface CreatePluginSettingStateProps {
|
||||||
|
plugin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPluginSettingsState = ({
|
||||||
|
plugin
|
||||||
|
}: CreatePluginSettingStateProps) => {
|
||||||
|
const pathParams: PathParams = { plugin };
|
||||||
|
|
||||||
|
return createStore<SettingsStateProps>()((set, get) => ({
|
||||||
|
settings: [],
|
||||||
|
lookup: {},
|
||||||
|
endpoint: ApiPaths.plugin_setting_list,
|
||||||
|
pathParams,
|
||||||
|
fetchSettings: async () => {
|
||||||
|
await api
|
||||||
|
.get(apiUrl(ApiPaths.plugin_setting_list, undefined, { plugin }))
|
||||||
|
.then((response) => {
|
||||||
|
const settings = response.data;
|
||||||
|
set({
|
||||||
|
settings,
|
||||||
|
lookup: generate_lookup(settings)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
`Error fetching plugin settings for plugin ${plugin}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getSetting: (key: string, default_value?: string) => {
|
||||||
|
return get().lookup[key] ?? default_value ?? '';
|
||||||
|
},
|
||||||
|
isSet: (key: string, default_value?: boolean) => {
|
||||||
|
let value = get().lookup[key] ?? default_value ?? 'false';
|
||||||
|
return isTrue(value);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
return a lookup dictionary for the value of the provided Setting list
|
return a lookup dictionary for the value of the provided Setting list
|
||||||
*/
|
*/
|
||||||
|
@ -72,7 +72,8 @@ export enum SettingType {
|
|||||||
Boolean = 'boolean',
|
Boolean = 'boolean',
|
||||||
Integer = 'integer',
|
Integer = 'integer',
|
||||||
String = 'string',
|
String = 'string',
|
||||||
Choice = 'choice'
|
Choice = 'choice',
|
||||||
|
Model = 'related field'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginProps {
|
export interface PluginProps {
|
||||||
|
Loading…
Reference in New Issue
Block a user