feat(user-interface): introduce user interface page (#1226)
This commit is contained in:
@@ -8,7 +8,11 @@ type Params = {
|
||||
};
|
||||
};
|
||||
|
||||
const updateConfig = async (_parent: unknown, params: Params, context: Context) => {
|
||||
const updateConfig = async (
|
||||
_parent: unknown,
|
||||
params: Params,
|
||||
context: Context
|
||||
) => {
|
||||
context.currentUser.can('update', 'Config');
|
||||
|
||||
const config = params.input;
|
||||
@@ -18,22 +22,26 @@ const updateConfig = async (_parent: unknown, params: Params, context: Context)
|
||||
for (const key of configKeys) {
|
||||
const newValue = config[key];
|
||||
|
||||
const entryUpdate = Config
|
||||
.query()
|
||||
.insert({
|
||||
key,
|
||||
value: {
|
||||
data: newValue
|
||||
}
|
||||
})
|
||||
.onConflict('key')
|
||||
.merge({
|
||||
value: {
|
||||
data: newValue
|
||||
}
|
||||
});
|
||||
if (newValue) {
|
||||
const entryUpdate = Config.query()
|
||||
.insert({
|
||||
key,
|
||||
value: {
|
||||
data: newValue,
|
||||
},
|
||||
})
|
||||
.onConflict('key')
|
||||
.merge({
|
||||
value: {
|
||||
data: newValue,
|
||||
},
|
||||
});
|
||||
|
||||
updates.push(entryUpdate);
|
||||
updates.push(entryUpdate);
|
||||
} else {
|
||||
const entryUpdate = Config.query().findOne({ key }).delete();
|
||||
updates.push(entryUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(updates);
|
||||
|
@@ -30,6 +30,7 @@
|
||||
"graphql": "^15.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^2.3.1",
|
||||
"mui-color-input": "^2.0.0",
|
||||
"notistack": "^2.0.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
|
@@ -6,6 +6,7 @@ import CreateUser from 'pages/CreateUser';
|
||||
import Roles from 'pages/Roles/index.ee';
|
||||
import CreateRole from 'pages/CreateRole/index.ee';
|
||||
import EditRole from 'pages/EditRole/index.ee';
|
||||
import UserInterface from 'pages/UserInterface';
|
||||
|
||||
import * as URLS from 'config/urls';
|
||||
import Can from 'components/Can';
|
||||
@@ -79,6 +80,17 @@ export default (
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={URLS.USER_INTERFACE}
|
||||
element={
|
||||
<Can I="update" a="Config">
|
||||
<AdminSettingsLayout>
|
||||
<UserInterface />
|
||||
</AdminSettingsLayout>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={URLS.ADMIN_SETTINGS}
|
||||
element={<Navigate to={URLS.USERS} replace />}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||
import GroupIcon from '@mui/icons-material/Group';
|
||||
import GroupsIcon from '@mui/icons-material/Groups';
|
||||
import BrushIcon from '@mui/icons-material/Brush';
|
||||
import Box from '@mui/material/Box';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
@@ -18,25 +19,43 @@ type SettingsLayoutProps = {
|
||||
};
|
||||
|
||||
type DrawerLink = {
|
||||
Icon: SvgIconComponent,
|
||||
primary: string,
|
||||
to: string,
|
||||
}
|
||||
Icon: SvgIconComponent;
|
||||
primary: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
function createDrawerLinks({ canReadRole, canReadUser }: { canReadRole: boolean; canReadUser: boolean; }) {
|
||||
function createDrawerLinks({
|
||||
canReadRole,
|
||||
canReadUser,
|
||||
canUpdateConfig,
|
||||
}: {
|
||||
canReadRole: boolean;
|
||||
canReadUser: boolean;
|
||||
canUpdateConfig: boolean;
|
||||
}) {
|
||||
const items = [
|
||||
canReadUser ? {
|
||||
Icon: GroupIcon,
|
||||
primary: 'adminSettingsDrawer.users',
|
||||
to: URLS.USERS,
|
||||
} : null,
|
||||
canReadRole ? {
|
||||
Icon: GroupsIcon,
|
||||
primary: 'adminSettingsDrawer.roles',
|
||||
to: URLS.ROLES,
|
||||
} : null
|
||||
]
|
||||
.filter(Boolean) as DrawerLink[];
|
||||
canReadUser
|
||||
? {
|
||||
Icon: GroupIcon,
|
||||
primary: 'adminSettingsDrawer.users',
|
||||
to: URLS.USERS,
|
||||
}
|
||||
: null,
|
||||
canReadRole
|
||||
? {
|
||||
Icon: GroupsIcon,
|
||||
primary: 'adminSettingsDrawer.roles',
|
||||
to: URLS.ROLES,
|
||||
}
|
||||
: null,
|
||||
canUpdateConfig
|
||||
? {
|
||||
Icon: BrushIcon,
|
||||
primary: 'adminSettingsDrawer.userInterface',
|
||||
to: URLS.USER_INTERFACE,
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean) as DrawerLink[];
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -62,6 +81,7 @@ export default function SettingsLayout({
|
||||
const drawerLinks = createDrawerLinks({
|
||||
canReadUser: currentUserAbility.can('read', 'User'),
|
||||
canReadRole: currentUserAbility.can('read', 'Role'),
|
||||
canUpdateConfig: currentUserAbility.can('update', 'Config'),
|
||||
});
|
||||
|
||||
return (
|
||||
|
41
packages/web/src/components/ColorInput/index.tsx
Normal file
41
packages/web/src/components/ColorInput/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { MuiColorInput, MuiColorInputProps } from 'mui-color-input';
|
||||
|
||||
type ColorInputProps = {
|
||||
shouldUnregister?: boolean;
|
||||
name: string;
|
||||
'data-test'?: string;
|
||||
} & Partial<MuiColorInputProps>;
|
||||
|
||||
export default function ColorInput(props: ColorInputProps): React.ReactElement {
|
||||
const { control } = useFormContext();
|
||||
const {
|
||||
required,
|
||||
name,
|
||||
shouldUnregister = false,
|
||||
disabled = false,
|
||||
'data-test': dataTest,
|
||||
...textFieldProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
rules={{ required }}
|
||||
name={name}
|
||||
control={control}
|
||||
shouldUnregister={shouldUnregister}
|
||||
render={({ field }) => (
|
||||
<MuiColorInput
|
||||
format="hex"
|
||||
{...textFieldProps}
|
||||
{...field}
|
||||
disabled={disabled}
|
||||
inputProps={{
|
||||
'data-test': dataTest,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -3,12 +3,12 @@ import appConfig from './app';
|
||||
export const CONNECTIONS = '/connections';
|
||||
export const EXECUTIONS = '/executions';
|
||||
export const EXECUTION_PATTERN = '/executions/:executionId';
|
||||
export const EXECUTION = (executionId: string) =>
|
||||
`/executions/${executionId}`;
|
||||
export const EXECUTION = (executionId: string) => `/executions/${executionId}`;
|
||||
|
||||
export const LOGIN = '/login';
|
||||
export const LOGIN_CALLBACK = `${LOGIN}/callback`;
|
||||
export const SSO_LOGIN = (issuer: string) => `${appConfig.apiUrl}/login/saml/${issuer}`;
|
||||
export const SSO_LOGIN = (issuer: string) =>
|
||||
`${appConfig.apiUrl}/login/saml/${issuer}`;
|
||||
export const SIGNUP = '/sign-up';
|
||||
export const FORGOT_PASSWORD = '/forgot-password';
|
||||
export const RESET_PASSWORD = '/reset-password';
|
||||
@@ -17,18 +17,19 @@ export const APPS = '/apps';
|
||||
export const NEW_APP_CONNECTION = '/apps/new';
|
||||
export const APP = (appKey: string) => `/app/${appKey}`;
|
||||
export const APP_PATTERN = '/app/:appKey';
|
||||
export const APP_CONNECTIONS = (appKey: string) =>
|
||||
`/app/${appKey}/connections`;
|
||||
export const APP_CONNECTIONS = (appKey: string) => `/app/${appKey}/connections`;
|
||||
export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections';
|
||||
export const APP_ADD_CONNECTION = (appKey: string, shared = false) =>
|
||||
`/app/${appKey}/connections/add?shared=${shared}`;
|
||||
export const APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID = (appKey: string, appAuthClientId: string) =>
|
||||
`/app/${appKey}/connections/add?appAuthClientId=${appAuthClientId}`;
|
||||
export const APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID = (
|
||||
appKey: string,
|
||||
appAuthClientId: string
|
||||
) => `/app/${appKey}/connections/add?appAuthClientId=${appAuthClientId}`;
|
||||
export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add';
|
||||
export const APP_RECONNECT_CONNECTION = (
|
||||
appKey: string,
|
||||
connectionId: string,
|
||||
appAuthClientId?: string,
|
||||
appAuthClientId?: string
|
||||
) => {
|
||||
const path = `/app/${appKey}/connections/${connectionId}/reconnect`;
|
||||
|
||||
@@ -96,6 +97,7 @@ export const ROLES = `${ADMIN_SETTINGS}/roles`;
|
||||
export const ROLE = (roleId: string) => `${ROLES}/${roleId}`;
|
||||
export const ROLE_PATTERN = `${ROLES}/:roleId`;
|
||||
export const CREATE_ROLE = `${ROLES}/create`;
|
||||
export const USER_INTERFACE = `${ADMIN_SETTINGS}/user-interface`;
|
||||
|
||||
export const DASHBOARD = FLOWS;
|
||||
|
||||
|
18
packages/web/src/helpers/nestObject.ts
Normal file
18
packages/web/src/helpers/nestObject.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IJSONObject } from '@automatisch/types';
|
||||
import set from 'lodash/set';
|
||||
|
||||
export default function nestObject<T = IJSONObject>(
|
||||
config: IJSONObject | undefined
|
||||
): Partial<T> {
|
||||
if (!config) return {};
|
||||
const result = {};
|
||||
|
||||
for (const key in config) {
|
||||
if (Object.prototype.hasOwnProperty.call(config, key)) {
|
||||
const value = config[key];
|
||||
set(result, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@@ -15,6 +15,7 @@
|
||||
"settingsDrawer.billingAndUsage": "Billing and usage",
|
||||
"adminSettingsDrawer.users": "Users",
|
||||
"adminSettingsDrawer.roles": "Roles",
|
||||
"adminSettingsDrawer.userInterface": "User Interface",
|
||||
"adminSettingsDrawer.goBack": "Go to the dashboard",
|
||||
"app.connectionCount": "{count} connections",
|
||||
"app.flowCount": "{count} flows",
|
||||
@@ -213,5 +214,12 @@
|
||||
"permissionSettings.cancel": "Cancel",
|
||||
"permissionSettings.apply": "Apply",
|
||||
"permissionSettings.title": "Conditions",
|
||||
"appAuthClientsDialog.title": "Choose your authentication client"
|
||||
"appAuthClientsDialog.title": "Choose your authentication client",
|
||||
"userInterfacePage.title": "User Interface",
|
||||
"userInterfacePage.successfullyUpdated": "User interface has been updated.",
|
||||
"userInterfacePage.mainColor": "Primary main color",
|
||||
"userInterfacePage.darkColor": "Primary dark color",
|
||||
"userInterfacePage.lightColor": "Primary light color",
|
||||
"userInterfacePage.svgData": "Logo SVG code",
|
||||
"userInterfacePage.submit": "Update"
|
||||
}
|
||||
|
130
packages/web/src/pages/UserInterface/index.tsx
Normal file
130
packages/web/src/pages/UserInterface/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import * as React from 'react';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import Container from '@mui/material/Container';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
import { UPDATE_CONFIG } from 'graphql/mutations/update-config.ee';
|
||||
import useConfig from 'hooks/useConfig';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import Form from 'components/Form';
|
||||
import TextField from 'components/TextField';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import ColorInput from 'components/ColorInput';
|
||||
import nestObject from 'helpers/nestObject';
|
||||
import { Skeleton } from '@mui/material';
|
||||
|
||||
type UserInterface = {
|
||||
palette: {
|
||||
primary: {
|
||||
dark: string;
|
||||
light: string;
|
||||
main: string;
|
||||
};
|
||||
};
|
||||
logo: {
|
||||
svgData: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function UserInterface(): React.ReactElement {
|
||||
const formatMessage = useFormatMessage();
|
||||
const [updateConfig, { loading }] = useMutation(UPDATE_CONFIG, {
|
||||
refetchQueries: ['GetConfig'],
|
||||
});
|
||||
const { config, loading: configLoading } = useConfig([
|
||||
'palette.primary.main',
|
||||
'palette.primary.light',
|
||||
'palette.primary.dark',
|
||||
'logo.svgData',
|
||||
]);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const handleUserInterfaceUpdate = async (uiData: Partial<UserInterface>) => {
|
||||
try {
|
||||
await updateConfig({
|
||||
variables: {
|
||||
input: {
|
||||
'palette.primary.main': uiData?.palette?.primary.main,
|
||||
'palette.primary.dark': uiData?.palette?.primary.dark,
|
||||
'palette.primary.light': uiData?.palette?.primary.light,
|
||||
'logo.svgData': uiData?.logo?.svgData,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
enqueueSnackbar(formatMessage('userInterfacePage.successfullyUpdated'), {
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error('Failed while updating!');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
<Grid container item xs={12} sm={9} md={8} lg={6}>
|
||||
<Grid item xs={12} sx={{ mb: [2, 5] }}>
|
||||
<PageTitle>{formatMessage('userInterfacePage.title')}</PageTitle>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
|
||||
{configLoading && (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Skeleton variant="rounded" height={55} />
|
||||
<Skeleton variant="rounded" height={55} />
|
||||
<Skeleton variant="rounded" height={55} />
|
||||
<Skeleton variant="rounded" height={85} />
|
||||
<Skeleton variant="rounded" height={45} />
|
||||
</Stack>
|
||||
)}
|
||||
{!configLoading && (
|
||||
<Form
|
||||
onSubmit={handleUserInterfaceUpdate}
|
||||
defaultValues={nestObject<UserInterface>(config)}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
<ColorInput
|
||||
name="palette.primary.main"
|
||||
label={formatMessage('userInterfacePage.mainColor')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<ColorInput
|
||||
name="palette.primary.dark"
|
||||
label={formatMessage('userInterfacePage.darkColor')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<ColorInput
|
||||
name="palette.primary.light"
|
||||
label={formatMessage('userInterfacePage.lightColor')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name="logo.svgData"
|
||||
label={formatMessage('userInterfacePage.svgData')}
|
||||
multiline
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={loading}
|
||||
>
|
||||
{formatMessage('userInterfacePage.submit')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Form>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
12
yarn.lock
12
yarn.lock
@@ -1469,6 +1469,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.0.0.tgz#a9583a75c3f150667771f30b60d9f059473e62c4"
|
||||
integrity sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg==
|
||||
|
||||
"@ctrl/tinycolor@^3.6.0":
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.0.tgz#53fa5fe9c34faee89469e48f91d51a3766108bc8"
|
||||
integrity sha512-/Z3l6pXthq0JvMYdUFyX9j0MaCltlIn6mfh9jLyQwg5aPKxkyNa0PTHtU1AlFXLNk55ZuAeJRcpvq+tmLfKmaQ==
|
||||
|
||||
"@dabh/diagnostics@^2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31"
|
||||
@@ -12375,6 +12380,13 @@ msgpackr@^1.6.2:
|
||||
optionalDependencies:
|
||||
msgpackr-extract "^2.1.2"
|
||||
|
||||
mui-color-input@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mui-color-input/-/mui-color-input-2.0.0.tgz#49c8df63d3d18f1a1817572c0efc15bd970b35a2"
|
||||
integrity sha512-Xw6OGsZVbtlZEAUVgJ08Lyv4u0YDQH+aTMJhhWm2fRin+1T+0IrVFyBtbSjJjrH4aBkkQPMCm75//7qO9zncLw==
|
||||
dependencies:
|
||||
"@ctrl/tinycolor" "^3.6.0"
|
||||
|
||||
multer@1.4.5-lts.1:
|
||||
version "1.4.5-lts.1"
|
||||
resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac"
|
||||
|
Reference in New Issue
Block a user