2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[PUI] User settings panel updates (#7944)

* Simplify user theme settings

* Cleanup

* Fix permission on user list endpoint

* Update AccountDetailPanel to use modal form

* Update components

* UI updates

* Implement default colors

* Display more user details (read only)

* Add specific "MeUserSerializer"

- Prevent certain attributes from being adjusted

* Add <YesNoUndefinedButton>

* Allow role checks to be bypassed for a given view

- Override the 'get_permission_model' attribute with None

* Enable 'GET' metadata

- Required for extracting field information even if we only have 'read' permissions
- e.g. getting table columns for users without write perms
- use 'GET' action when reading table cols

* Add info on new user account

* Fix boolean expression wrapper

* Ruff fixes

* Adjust icon

* Update unit test

* Bummp API version

* Table layout fix
This commit is contained in:
Oliver 2024-09-14 12:30:46 +10:00 committed by GitHub
parent a5ab4a30ea
commit 7fbc1fba72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 311 additions and 155 deletions

View File

@ -16,6 +16,7 @@ The demo instance has a number of user accounts which you can use to explore the
| Username | Password | Staff Access | Enabled | Description | | Username | Password | Staff Access | Enabled | Description |
| -------- | -------- | ------------ | ------- | ----------- | | -------- | -------- | ------------ | ------- | ----------- |
| noaccess | youshallnotpass | No | Yes | Can login, but has no permissions |
| allaccess | nolimits | No | Yes | View / create / edit all pages and items | | allaccess | nolimits | No | Yes | View / create / edit all pages and items |
| reader | readonly | No | Yes | Can view all pages but cannot create, edit or delete database records | | reader | readonly | No | Yes | Can view all pages but cannot create, edit or delete database records |
| engineer | partsonly | No | Yes | Can manage parts, view stock, but no access to purchase orders or sales orders | | engineer | partsonly | No | Yes | Can manage parts, view stock, but no access to purchase orders or sales orders |

View File

@ -1,14 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 252 INVENTREE_API_VERSION = 253
"""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 = """
v252 - 2024-09-30 : https://github.com/inventree/InvenTree/pull/8035 v253 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7944
- Adjustments for user API endpoints
v252 - 2024-09-13 : https://github.com/inventree/InvenTree/pull/8040
- Add endpoint for listing all known units - Add endpoint for listing all known units
v251 - 2024-09-06 : https://github.com/inventree/InvenTree/pull/8018 v251 - 2024-09-06 : https://github.com/inventree/InvenTree/pull/8018

View File

@ -2,9 +2,13 @@
import logging import logging
from rest_framework import serializers from django.core.exceptions import PermissionDenied
from django.http import Http404
from rest_framework import exceptions, serializers
from rest_framework.fields import empty from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
import common.models import common.models
@ -29,6 +33,40 @@ class InvenTreeMetadata(SimpleMetadata):
so we can perform lookup for ForeignKey related fields. so we can perform lookup for ForeignKey related fields.
""" """
def determine_actions(self, request, view):
"""Determine the 'actions' available to the user for the given view.
Note that this differs from the standard DRF implementation,
in that we also allow annotation for the 'GET' method.
This allows the client to determine what fields are available,
even if they are only for a read (GET) operation.
See SimpleMetadata.determine_actions for more information.
"""
actions = {}
for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods):
view.request = clone_request(request, method)
try:
# Test global permissions
if hasattr(view, 'check_permissions'):
view.check_permissions(view.request)
# Test object permissions
if method == 'PUT' and hasattr(view, 'get_object'):
view.get_object()
except (exceptions.APIException, PermissionDenied, Http404):
pass
else:
# If user has appropriate permissions for the view, include
# appropriate metadata about the fields that should be supplied.
serializer = view.get_serializer()
actions[method] = self.get_serializer_info(serializer)
finally:
view.request = request
return actions
def determine_metadata(self, request, view): def determine_metadata(self, request, view):
"""Overwrite the metadata to adapt to the request user.""" """Overwrite the metadata to adapt to the request user."""
self.request = request self.request = request
@ -81,6 +119,7 @@ class InvenTreeMetadata(SimpleMetadata):
# Map the request method to a permission type # Map the request method to a permission type
rolemap = { rolemap = {
'GET': 'view',
'POST': 'add', 'POST': 'add',
'PUT': 'change', 'PUT': 'change',
'PATCH': 'change', 'PATCH': 'change',
@ -102,10 +141,6 @@ class InvenTreeMetadata(SimpleMetadata):
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'): if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
actions['DELETE'] = {} actions['DELETE'] = {}
# Add a 'VIEW' action if we are allowed to view
if 'GET' in view.allowed_methods and check(user, table, 'view'):
actions['GET'] = {}
metadata['actions'] = actions metadata['actions'] = actions
except AttributeError: except AttributeError:

View File

@ -79,6 +79,9 @@ class RolePermission(permissions.BasePermission):
# Extract the model name associated with this request # Extract the model name associated with this request
model = get_model_for_view(view) model = get_model_for_view(view)
if model is None:
return True
app_label = model._meta.app_label app_label = model._meta.app_label
model_name = model._meta.model_name model_name = model._meta.model_name
@ -99,6 +102,17 @@ class IsSuperuser(permissions.IsAdminUser):
return bool(request.user and request.user.is_superuser) return bool(request.user and request.user.is_superuser)
class IsSuperuserOrReadOnly(permissions.IsAdminUser):
"""Allow read-only access to any user, but write access is restricted to superuser users."""
def has_permission(self, request, view):
"""Check if the user is a superuser."""
return bool(
(request.user and request.user.is_superuser)
or request.method in permissions.SAFE_METHODS
)
class IsStaffOrReadOnly(permissions.IsAdminUser): class IsStaffOrReadOnly(permissions.IsAdminUser):
"""Allows read-only access to any user, but write access is restricted to staff users.""" """Allows read-only access to any user, but write access is restricted to staff users."""

View File

@ -403,18 +403,21 @@ class UserSerializer(InvenTreeModelSerializer):
read_only_fields = ['username', 'email'] read_only_fields = ['username', 'email']
username = serializers.CharField(label=_('Username'), help_text=_('Username')) username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField( first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
) )
last_name = serializers.CharField( last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
) )
email = serializers.EmailField( email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
) )
class ExendedUserSerializer(UserSerializer): class ExtendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info.""" """Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer from users.serializers import GroupSerializer
@ -437,9 +440,11 @@ class ExendedUserSerializer(UserSerializer):
is_staff = serializers.BooleanField( is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions') label=_('Staff'), help_text=_('Does this user have staff permissions')
) )
is_superuser = serializers.BooleanField( is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser') label=_('Superuser'), help_text=_('Is this user a superuser')
) )
is_active = serializers.BooleanField( is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active') label=_('Active'), help_text=_('Is this user account active')
) )
@ -464,9 +469,33 @@ class ExendedUserSerializer(UserSerializer):
return super().validate(attrs) return super().validate(attrs)
class UserCreateSerializer(ExendedUserSerializer): class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options.
Extends the ExtendedUserSerializer.Meta options,
but ensures that certain fields are read-only.
"""
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
'is_staff',
'is_superuser',
]
class UserCreateSerializer(ExtendedUserSerializer):
"""Serializer for creating a new User.""" """Serializer for creating a new User."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options for the UserCreateSerializer."""
# Prevent creation of users with superuser or staff permissions
read_only_fields = ['groups', 'is_staff', 'is_superuser']
def validate(self, attrs): def validate(self, attrs):
"""Expanded valiadation for auth.""" """Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser # Check that the user trying to create a new user is a superuser

View File

@ -24,6 +24,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
import InvenTree.helpers import InvenTree.helpers
import InvenTree.permissions
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ( from InvenTree.mixins import (
@ -33,7 +34,11 @@ from InvenTree.mixins import (
RetrieveUpdateAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI, RetrieveUpdateDestroyAPI,
) )
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer from InvenTree.serializers import (
ExtendedUserSerializer,
MeUserSerializer,
UserCreateSerializer,
)
from InvenTree.settings import FRONTEND_URL_BASE from InvenTree.settings import FRONTEND_URL_BASE
from users.models import ApiToken, Owner from users.models import ApiToken, Owner
from users.serializers import ( from users.serializers import (
@ -134,24 +139,38 @@ class UserDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single user.""" """Detail endpoint for a single user."""
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = ExendedUserSerializer serializer_class = ExtendedUserSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
class MeUserDetail(RetrieveUpdateAPI, UserDetail): class MeUserDetail(RetrieveUpdateAPI, UserDetail):
"""Detail endpoint for current user.""" """Detail endpoint for current user."""
serializer_class = MeUserSerializer
rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}
def get_object(self): def get_object(self):
"""Always return the current user object.""" """Always return the current user object."""
return self.request.user return self.request.user
def get_permission_model(self):
"""Return the model for the permission check.
Note that for this endpoint, the current user can *always* edit their own details.
"""
return None
class UserList(ListCreateAPI): class UserList(ListCreateAPI):
"""List endpoint for detail on all users.""" """List endpoint for detail on all users."""
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserCreateSerializer serializer_class = UserCreateSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.IsSuperuserOrReadOnly,
]
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
search_fields = ['first_name', 'last_name', 'username'] search_fields = ['first_name', 'last_name', 'username']

View File

@ -17,7 +17,10 @@ class UserAPITests(InvenTreeAPITestCase):
self.assignRole('admin.add') self.assignRole('admin.add')
response = self.options(reverse('api-user-list'), expected_code=200) response = self.options(reverse('api-user-list'), expected_code=200)
fields = response.data['actions']['POST'] # User is *not* a superuser, so user account API is read-only
self.assertNotIn('POST', response.data['actions'])
fields = response.data['actions']['GET']
# Check some of the field values # Check some of the field values
self.assertEqual(fields['username']['label'], 'Username') self.assertEqual(fields['username']['label'], 'Username')

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Badge } from '@mantine/core'; import { Badge, Skeleton } from '@mantine/core';
import { isTrue } from '../../functions/conversion'; import { isTrue } from '../../functions/conversion';
@ -32,3 +32,11 @@ export function PassFailButton({
export function YesNoButton({ value }: { value: any }) { export function YesNoButton({ value }: { value: any }) {
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />; return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
} }
export function YesNoUndefinedButton({ value }: { value?: boolean }) {
if (value === undefined) {
return <Skeleton height={15} width={32} />;
} else {
return <YesNoButton value={value} />;
}
}

View File

@ -1,12 +1,14 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { Button, Group, Stack, Text, TextInput, Title } from '@mantine/core'; import { Group, Stack, Table, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { IconKey, IconUser } from '@tabler/icons-react';
import { useToggle } from '@mantine/hooks'; import { useMemo } from 'react';
import { api } from '../../../../App'; import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton';
import { EditButton } from '../../../../components/buttons/EditButton'; import { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField';
import { ActionDropdown } from '../../../../components/items/ActionDropdown';
import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
import { apiUrl } from '../../../../states/ApiState'; import { notYetImplemented } from '../../../../functions/notifications';
import { useEditApiFormModal } from '../../../../hooks/UseForm';
import { useUserState } from '../../../../states/UserState'; import { useUserState } from '../../../../states/UserState';
export function AccountDetailPanel() { export function AccountDetailPanel() {
@ -14,66 +16,89 @@ export function AccountDetailPanel() {
state.user, state.user,
state.fetchUserState state.fetchUserState
]); ]);
const form = useForm({ initialValues: user });
const [editing, setEditing] = useToggle([false, true] as const); const userFields: ApiFormFieldSet = useMemo(() => {
function SaveData(values: any) { return {
// copy values over to break form rendering link first_name: {},
const urlVals = { ...values }; last_name: {}
urlVals.is_active = true; };
// send }, []);
api
.put(apiUrl(ApiEndpoints.user_me), urlVals) const editUser = useEditApiFormModal({
.then((res) => { title: t`Edit User Information`,
if (res.status === 200) { url: ApiEndpoints.user_me,
setEditing(); onFormSuccess: fetchUserState,
fetchUserState(); fields: userFields,
} successMessage: t`User details updated`
})
.catch(() => {
console.error('ERR: Error saving user data');
}); });
}
return ( return (
<form onSubmit={form.onSubmit((values) => SaveData(values))}> <>
<Group> {editUser.modal}
<Title order={3}>
<Trans>Account Details</Trans>
</Title>
<EditButton setEditing={setEditing} editing={editing} />
</Group>
<Group>
{editing ? (
<Stack gap="xs"> <Stack gap="xs">
<TextInput <Group justify="space-between">
label="first name" <Title order={3}>
placeholder={t`First name`} <Trans>User Details</Trans>
{...form.getInputProps('first_name')} </Title>
<ActionDropdown
tooltip={t`User Actions`}
icon={<IconUser />}
actions={[
{
name: t`Edit User`,
icon: <IconUser />,
tooltip: t`Edit User Information`,
onClick: editUser.open
},
{
name: t`Set Password`,
icon: <IconKey />,
tooltip: t`Set User Password`,
onClick: notYetImplemented
}
]}
/> />
<TextInput
label="Last name"
placeholder={t`Last name`}
{...form.getInputProps('last_name')}
/>
<Group justify="right" mt="md">
<Button type="submit">
<Trans>Submit</Trans>
</Button>
</Group> </Group>
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Trans>Username</Trans>
</Table.Td>
<Table.Td>{user?.username}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>First Name</Trans>
</Table.Td>
<Table.Td>{user?.first_name}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Last Name</Trans>
</Table.Td>
<Table.Td>{user?.last_name}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Staff Access</Trans>
</Table.Td>
<Table.Td>
<YesNoUndefinedButton value={user?.is_staff} />
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Superuser</Trans>
</Table.Td>
<Table.Td>
<YesNoUndefinedButton value={user?.is_superuser} />
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Stack> </Stack>
) : ( </>
<Stack gap="0">
<Text>
<Trans>First name: </Trans>
{form.values.first_name}
</Text>
<Text>
<Trans>Last name: </Trans>
{form.values.last_name}
</Text>
</Stack>
)}
</Group>
</form>
); );
} }

View File

@ -1,51 +0,0 @@
import { Trans } from '@lingui/macro';
import { Button, Container, Group, Table, Title } from '@mantine/core';
import { ColorToggle } from '../../../../components/items/ColorToggle';
import { LanguageSelect } from '../../../../components/items/LanguageSelect';
import { IS_DEV } from '../../../../main';
import { useLocalState } from '../../../../states/LocalState';
export function DisplaySettingsPanel({ height }: { height: number }) {
function enablePseudoLang(): void {
useLocalState.setState({ language: 'pseudo-LOCALE' });
}
return (
<Container w="100%" mih={height} p={0}>
<Title order={3}>
<Trans>Display Settings</Trans>
</Title>
<Table>
<tbody>
<tr>
<td>
<Trans>Color Mode</Trans>
</td>
<td>
<Group>
<ColorToggle />
</Group>
</td>
</tr>
<tr>
<td>
<Trans>Language</Trans>
</td>
<td>
{' '}
<Group>
<LanguageSelect width={200} />
{IS_DEV && (
<Button onClick={enablePseudoLang} variant="light">
<Trans>Use pseudo language</Trans>
</Button>
)}
</Group>
</td>
</tr>
</tbody>
</Table>
</Container>
);
}

View File

@ -1,7 +1,6 @@
import { Container, Grid, SimpleGrid } from '@mantine/core'; import { Container, Grid, SimpleGrid } from '@mantine/core';
import { AccountDetailPanel } from './AccountDetailPanel'; import { AccountDetailPanel } from './AccountDetailPanel';
import { DisplaySettingsPanel } from './DisplaySettingsPanel';
import { UserTheme } from './UserThemePanel'; import { UserTheme } from './UserThemePanel';
export function AccountContent() { export function AccountContent() {
@ -18,9 +17,6 @@ export function AccountContent() {
<Grid.Col> <Grid.Col>
<UserTheme height={SECONDARY_COL_HEIGHT} /> <UserTheme height={SECONDARY_COL_HEIGHT} />
</Grid.Col> </Grid.Col>
<Grid.Col>
<DisplaySettingsPanel height={SECONDARY_COL_HEIGHT} />
</Grid.Col>
</Grid> </Grid>
</SimpleGrid> </SimpleGrid>
</div> </div>

View File

@ -1,5 +1,7 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { import {
ActionIcon,
Button,
ColorInput, ColorInput,
ColorPicker, ColorPicker,
Container, Container,
@ -9,13 +11,18 @@ import {
Select, Select,
Slider, Slider,
Table, Table,
Title Title,
useMantineTheme
} from '@mantine/core'; } from '@mantine/core';
import { IconReload, IconRestore } from '@tabler/icons-react';
import { useState } from 'react'; import { useState } from 'react';
import { ColorToggle } from '../../../../components/items/ColorToggle';
import { LanguageSelect } from '../../../../components/items/LanguageSelect';
import { SizeMarks } from '../../../../defaults/defaults'; import { SizeMarks } from '../../../../defaults/defaults';
import { notYetImplemented } from '../../../../functions/notifications';
import { IS_DEV } from '../../../../main';
import { useLocalState } from '../../../../states/LocalState'; import { useLocalState } from '../../../../states/LocalState';
import { theme } from '../../../../theme';
function getLkp(color: string) { function getLkp(color: string) {
return { [DEFAULT_THEME.colors[color][6]]: color }; return { [DEFAULT_THEME.colors[color][6]]: color };
@ -26,18 +33,24 @@ const LOOKUP = Object.assign(
); );
export function UserTheme({ height }: { height: number }) { export function UserTheme({ height }: { height: number }) {
// primary color const theme = useMantineTheme();
function changePrimary(color: string) {
useLocalState.setState({ primaryColor: LOOKUP[color] }); const [themeLoader, setThemeLoader] = useLocalState((state) => [
} state.loader,
state.setLoader
]);
// white color // white color
const [whiteColor, setWhiteColor] = useState(theme.white); const [whiteColor, setWhiteColor] = useState(theme.white);
function changeWhite(color: string) { function changeWhite(color: string) {
useLocalState.setState({ whiteColor: color }); useLocalState.setState({ whiteColor: color });
setWhiteColor(color); setWhiteColor(color);
} }
// black color // black color
const [blackColor, setBlackColor] = useState(theme.black); const [blackColor, setBlackColor] = useState(theme.black);
function changeBlack(color: string) { function changeBlack(color: string) {
useLocalState.setState({ blackColor: color }); useLocalState.setState({ blackColor: color });
setBlackColor(color); setBlackColor(color);
@ -48,6 +61,7 @@ export function UserTheme({ height }: { height: number }) {
if (obj) return obj; if (obj) return obj;
return SizeMarks[0]; return SizeMarks[0];
} }
function getDefaultRadius() { function getDefaultRadius() {
const obj = SizeMarks.find( const obj = SizeMarks.find(
(mark) => mark.label === useLocalState.getState().radius (mark) => mark.label === useLocalState.getState().radius
@ -60,16 +74,23 @@ export function UserTheme({ height }: { height: number }) {
setRadius(value); setRadius(value);
useLocalState.setState({ radius: getMark(value).label }); useLocalState.setState({ radius: getMark(value).label });
} }
// loader
// Set theme primary color
function changePrimary(color: string) {
useLocalState.setState({ primaryColor: LOOKUP[color] });
}
function enablePseudoLang(): void {
useLocalState.setState({ language: 'pseudo-LOCALE' });
}
// Custom loading indicator
const loaderDate = [ const loaderDate = [
{ value: 'bars', label: t`bars` }, { value: 'bars', label: t`Bars` },
{ value: 'oval', label: t`oval` }, { value: 'oval', label: t`Oval` },
{ value: 'dots', label: t`dots` } { value: 'dots', label: t`Dots` }
]; ];
const [themeLoader, setThemeLoader] = useLocalState((state) => [
state.loader,
state.setLoader
]);
function changeLoader(value: string | null) { function changeLoader(value: string | null) {
if (value === null) return; if (value === null) return;
setThemeLoader(value); setThemeLoader(value);
@ -78,13 +99,39 @@ export function UserTheme({ height }: { height: number }) {
return ( return (
<Container w="100%" mih={height} p={0}> <Container w="100%" mih={height} p={0}>
<Title order={3}> <Title order={3}>
<Trans>Theme</Trans> <Trans>Display Settings</Trans>
</Title> </Title>
<Table> <Table>
<Table.Tbody> <Table.Tbody>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Primary color</Trans> <Trans>Language</Trans>
</Table.Td>
<Table.Td>
<LanguageSelect width={200} />
</Table.Td>
<Table.Td>
{IS_DEV && (
<Button onClick={enablePseudoLang} variant="light">
<Trans>Use pseudo language</Trans>
</Button>
)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Color Mode</Trans>
</Table.Td>
<Table.Td>
<Group justify="left">
<ColorToggle />
</Group>
</Table.Td>
<Table.Td></Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Highlight color</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<ColorPicker <ColorPicker
@ -94,6 +141,11 @@ export function UserTheme({ height }: { height: number }) {
swatches={Object.keys(LOOKUP)} swatches={Object.keys(LOOKUP)}
/> />
</Table.Td> </Table.Td>
<Table.Td>
<Button color={theme.primaryColor} variant="light">
<Trans>Example</Trans>
</Button>
</Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
@ -102,6 +154,14 @@ export function UserTheme({ height }: { height: number }) {
<Table.Td> <Table.Td>
<ColorInput value={whiteColor} onChange={changeWhite} /> <ColorInput value={whiteColor} onChange={changeWhite} />
</Table.Td> </Table.Td>
<Table.Td>
<ActionIcon
variant="default"
onClick={() => changeWhite('#FFFFFF')}
>
<IconRestore />
</ActionIcon>
</Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
@ -110,6 +170,14 @@ export function UserTheme({ height }: { height: number }) {
<Table.Td> <Table.Td>
<ColorInput value={blackColor} onChange={changeBlack} /> <ColorInput value={blackColor} onChange={changeBlack} />
</Table.Td> </Table.Td>
<Table.Td>
<ActionIcon
variant="default"
onClick={() => changeBlack('#000000')}
>
<IconRestore />
</ActionIcon>
</Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
@ -132,13 +200,17 @@ export function UserTheme({ height }: { height: number }) {
<Trans>Loader</Trans> <Trans>Loader</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group align="center"> <Group justify="left">
<Select <Select
data={loaderDate} data={loaderDate}
value={themeLoader} value={themeLoader}
onChange={changeLoader} onChange={changeLoader}
/> />
<Loader type={themeLoader} mah={18} /> </Group>
</Table.Td>
<Table.Td>
<Group justify="left">
<Loader type={themeLoader} mah={16} size="sm" />
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>

View File

@ -153,7 +153,8 @@ export function InvenTreeTable<T = any>({
getTableColumnNames, getTableColumnNames,
setTableColumnNames, setTableColumnNames,
getTableSorting, getTableSorting,
setTableSorting setTableSorting,
loader
} = useLocalState(); } = useLocalState();
const [fieldNames, setFieldNames] = useState<Record<string, string>>({}); const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
@ -192,8 +193,9 @@ export function InvenTreeTable<T = any>({
// Extract field information from the API // Extract field information from the API
let names: Record<string, string> = {}; let names: Record<string, string> = {};
let fields: ApiFormFieldSet = let fields: ApiFormFieldSet =
extractAvailableFields(response, 'POST', true) || {}; extractAvailableFields(response, 'GET', true) || {};
// Extract flattened map of fields // Extract flattened map of fields
mapFields(fields, (path, field) => { mapFields(fields, (path, field) => {
@ -720,7 +722,7 @@ export function InvenTreeTable<T = any>({
withColumnBorders withColumnBorders
striped striped
highlightOnHover highlightOnHover
loaderType="dots" loaderType={loader}
pinLastColumn={tableProps.rowActions != undefined} pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableProps.idAccessor} idAccessor={tableProps.idAccessor}
minHeight={300} minHeight={300}