mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-23 01:16:27 +02:00
Merge branch 'main' into refactor/applications
This commit is contained in:
commit
f58c284f70
@ -76,6 +76,7 @@
|
|||||||
"react-dnd": "14.0.5",
|
"react-dnd": "14.0.5",
|
||||||
"react-dnd-html5-backend": "14.1.0",
|
"react-dnd-html5-backend": "14.1.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
|
"react-hooks-global-state": "^1.0.2",
|
||||||
"react-outside-click-handler": "1.3.0",
|
"react-outside-click-handler": "1.3.0",
|
||||||
"react-redux": "7.2.6",
|
"react-redux": "7.2.6",
|
||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "5.3.0",
|
||||||
|
@ -31,6 +31,7 @@ import Dialogue from '../../../common/Dialogue';
|
|||||||
import { CREATE_API_TOKEN_BUTTON } from '../../../../testIds';
|
import { CREATE_API_TOKEN_BUTTON } from '../../../../testIds';
|
||||||
import { Alert } from '@material-ui/lab';
|
import { Alert } from '@material-ui/lab';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
|
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
|
||||||
|
|
||||||
interface IApiToken {
|
interface IApiToken {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@ -41,16 +42,13 @@ interface IApiToken {
|
|||||||
environment: string;
|
environment: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IApiTokenList {
|
const ApiTokenList = () => {
|
||||||
location: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ApiTokenList = ({ location }: IApiTokenList) => {
|
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [delToken, setDeleteToken] = useState<IApiToken>();
|
const [delToken, setDeleteToken] = useState<IApiToken>();
|
||||||
|
const { locationSettings } = useLocationSettings()
|
||||||
const { setToastData } = useToast();
|
const { setToastData } = useToast();
|
||||||
const { tokens, loading, refetch, error } = useApiTokens();
|
const { tokens, loading, refetch, error } = useApiTokens();
|
||||||
const { deleteToken } = useApiTokensApi();
|
const { deleteToken } = useApiTokensApi();
|
||||||
@ -150,7 +148,7 @@ const ApiTokenList = ({ location }: IApiTokenList) => {
|
|||||||
>
|
>
|
||||||
{formatDateWithLocale(
|
{formatDateWithLocale(
|
||||||
item.createdAt,
|
item.createdAt,
|
||||||
location.locale
|
locationSettings.locale
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
|
@ -5,7 +5,7 @@ import AdminMenu from '../menu/AdminMenu';
|
|||||||
import usePermissions from '../../../hooks/usePermissions';
|
import usePermissions from '../../../hooks/usePermissions';
|
||||||
import ConditionallyRender from '../../common/ConditionallyRender';
|
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||||
|
|
||||||
const ApiPage = ({ history, location }) => {
|
const ApiPage = ({ history }) => {
|
||||||
const { isAdmin } = usePermissions();
|
const { isAdmin } = usePermissions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -14,7 +14,7 @@ const ApiPage = ({ history, location }) => {
|
|||||||
condition={isAdmin()}
|
condition={isAdmin()}
|
||||||
show={<AdminMenu history={history} />}
|
show={<AdminMenu history={history} />}
|
||||||
/>
|
/>
|
||||||
<ApiTokenList location={location} />
|
<ApiTokenList />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -22,7 +22,6 @@ const ApiPage = ({ history, location }) => {
|
|||||||
ApiPage.propTypes = {
|
ApiPage.propTypes = {
|
||||||
match: PropTypes.object.isRequired,
|
match: PropTypes.object.isRequired,
|
||||||
history: PropTypes.object.isRequired,
|
history: PropTypes.object.isRequired,
|
||||||
location: PropTypes.object.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ApiPage;
|
export default ApiPage;
|
||||||
|
@ -1,16 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import AdminMenu from '../menu/AdminMenu';
|
import AdminMenu from '../menu/AdminMenu';
|
||||||
import { Alert } from '@material-ui/lab';
|
import { Alert } from '@material-ui/lab';
|
||||||
import GoogleAuth from './google-auth-container';
|
|
||||||
import SamlAuth from './saml-auth-container';
|
|
||||||
import OidcAuth from './oidc-auth-container';
|
|
||||||
import PasswordAuthSettings from './PasswordAuthSettings';
|
|
||||||
import TabNav from '../../common/TabNav/TabNav';
|
import TabNav from '../../common/TabNav/TabNav';
|
||||||
import PageContent from '../../common/PageContent/PageContent';
|
import PageContent from '../../common/PageContent/PageContent';
|
||||||
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
|
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { OidcAuth } from './OidcAuth/OidcAuth';
|
||||||
|
import { SamlAuth } from './SamlAuth/SamlAuth';
|
||||||
|
import { PasswordAuth } from './PasswordAuth/PasswordAuth';
|
||||||
|
import { GoogleAuth } from './GoogleAuth/GoogleAuth';
|
||||||
|
|
||||||
|
export const AuthSettings = () => {
|
||||||
|
const { authenticationType } = useUiConfig().uiConfig;
|
||||||
|
|
||||||
function AdminAuthPage({ authenticationType, history }) {
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: 'OpenID Connect',
|
label: 'OpenID Connect',
|
||||||
@ -22,7 +24,7 @@ function AdminAuthPage({ authenticationType, history }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Password',
|
label: 'Password',
|
||||||
component: <PasswordAuthSettings />,
|
component: <PasswordAuth />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Google',
|
label: 'Google',
|
||||||
@ -32,7 +34,7 @@ function AdminAuthPage({ authenticationType, history }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminMenu history={history} />
|
<AdminMenu />
|
||||||
<PageContent headerContent="Single Sign-On">
|
<PageContent headerContent="Single Sign-On">
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={authenticationType === 'enterprise'}
|
condition={authenticationType === 'enterprise'}
|
||||||
@ -80,12 +82,4 @@ function AdminAuthPage({ authenticationType, history }) {
|
|||||||
</PageContent>
|
</PageContent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
AdminAuthPage.propTypes = {
|
|
||||||
match: PropTypes.object.isRequired,
|
|
||||||
history: PropTypes.object.isRequired,
|
|
||||||
authenticationType: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminAuthPage;
|
|
@ -1,5 +1,14 @@
|
|||||||
import React, { ChangeEvent, Fragment } from 'react';
|
import React, { ChangeEvent, Fragment } from 'react';
|
||||||
import { FormControl, Grid, MenuItem, Switch, TextField, Select, InputLabel, FormControlLabel } from '@material-ui/core';
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
Grid,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
} from '@material-ui/core';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data?: {
|
data?: {
|
||||||
@ -7,25 +16,29 @@ interface Props {
|
|||||||
autoCreate: boolean;
|
autoCreate: boolean;
|
||||||
defaultRootRole?: string;
|
defaultRootRole?: string;
|
||||||
emailDomains?: string;
|
emailDomains?: string;
|
||||||
|
|
||||||
};
|
};
|
||||||
setValue: (name: string, value: string | boolean) => void;
|
setValue: (name: string, value: string | boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AutoCreateForm({ data = { enabled: false, autoCreate: false }, setValue }: Props) {
|
export const AutoCreateForm = ({
|
||||||
|
data = { enabled: false, autoCreate: false },
|
||||||
|
setValue,
|
||||||
|
}: Props) => {
|
||||||
const updateAutoCreate = () => {
|
const updateAutoCreate = () => {
|
||||||
setValue('autoCreate', !data.autoCreate);
|
setValue('autoCreate', !data.autoCreate);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateDefaultRootRole = (evt: ChangeEvent<{ name?: string; value: unknown }>) => {
|
const updateDefaultRootRole = (
|
||||||
|
evt: ChangeEvent<{ name?: string; value: unknown }>
|
||||||
|
) => {
|
||||||
setValue('defaultRootRole', evt.target.value as string);
|
setValue('defaultRootRole', evt.target.value as string);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateField = (e: ChangeEvent<HTMLInputElement>) => {
|
const updateField = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setValue(e.target.name, e.target.value);
|
setValue(e.target.name, e.target.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item md={5}>
|
<Grid item md={5}>
|
||||||
@ -35,14 +48,15 @@ return (
|
|||||||
</p>
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item md={6} style={{ padding: '20px' }}>
|
<Grid item md={6} style={{ padding: '20px' }}>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={ <Switch
|
control={
|
||||||
|
<Switch
|
||||||
onChange={updateAutoCreate}
|
onChange={updateAutoCreate}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
checked={data.autoCreate}
|
checked={data.autoCreate}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
/>}
|
/>
|
||||||
|
}
|
||||||
label="Auto-create users"
|
label="Auto-create users"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -51,12 +65,15 @@ return (
|
|||||||
<Grid item md={5}>
|
<Grid item md={5}>
|
||||||
<strong>Default Root Role</strong>
|
<strong>Default Root Role</strong>
|
||||||
<p>
|
<p>
|
||||||
Choose which root role the user should get when no explicit role mapping exists.
|
Choose which root role the user should get when no
|
||||||
|
explicit role mapping exists.
|
||||||
</p>
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item md={6}>
|
<Grid item md={6}>
|
||||||
<FormControl style={{minWidth: '200px'}}>
|
<FormControl style={{ minWidth: '200px' }}>
|
||||||
<InputLabel id="defaultRootRole-label">Default Role</InputLabel>
|
<InputLabel id="defaultRootRole-label">
|
||||||
|
Default Role
|
||||||
|
</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
labelId="defaultRootRole-label"
|
labelId="defaultRootRole-label"
|
||||||
id="defaultRootRole"
|
id="defaultRootRole"
|
||||||
@ -66,9 +83,9 @@ return (
|
|||||||
onChange={updateDefaultRootRole}
|
onChange={updateDefaultRootRole}
|
||||||
>
|
>
|
||||||
{/*consider these from API or constants. */}
|
{/*consider these from API or constants. */}
|
||||||
<MenuItem value='Viewer'>Viewer</MenuItem>
|
<MenuItem value="Viewer">Viewer</MenuItem>
|
||||||
<MenuItem value='Editor'>Editor</MenuItem>
|
<MenuItem value="Editor">Editor</MenuItem>
|
||||||
<MenuItem value='Admin'>Admin</MenuItem>
|
<MenuItem value="Admin">Admin</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -77,8 +94,8 @@ return (
|
|||||||
<Grid item md={5}>
|
<Grid item md={5}>
|
||||||
<strong>Email domains</strong>
|
<strong>Email domains</strong>
|
||||||
<p>
|
<p>
|
||||||
Comma separated list of email domains
|
Comma separated list of email domains that should be
|
||||||
that should be allowed to sign in.
|
allowed to sign in.
|
||||||
</p>
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item md={6}>
|
<Grid item md={6}>
|
||||||
@ -97,6 +114,6 @@ return (
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Fragment>);
|
</Fragment>
|
||||||
}
|
);
|
||||||
export default AutoCreateForm;
|
};
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect, useContext } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
@ -8,30 +7,32 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from '@material-ui/core';
|
} from '@material-ui/core';
|
||||||
import { Alert } from '@material-ui/lab';
|
import { Alert } from '@material-ui/lab';
|
||||||
import PageContent from '../../common/PageContent/PageContent';
|
import PageContent from '../../../common/PageContent/PageContent';
|
||||||
import AccessContext from '../../../contexts/AccessContext';
|
import AccessContext from '../../../../contexts/AccessContext';
|
||||||
import { ADMIN } from '../../providers/AccessProvider/permissions';
|
import { ADMIN } from '../../../providers/AccessProvider/permissions';
|
||||||
|
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
|
||||||
|
import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
|
||||||
|
import useToast from '../../../../hooks/useToast';
|
||||||
|
import { formatUnknownError } from '../../../../utils/format-unknown-error';
|
||||||
|
import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
autoCreate: false,
|
autoCreate: false,
|
||||||
unleashHostname: location.hostname,
|
unleashHostname: location.hostname,
|
||||||
|
clientId: '',
|
||||||
|
clientSecret: '',
|
||||||
|
emailDomains: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function GoogleAuth({
|
export const GoogleAuth = () => {
|
||||||
config,
|
const { setToastData, setToastApiError } = useToast();
|
||||||
getGoogleConfig,
|
const { uiConfig } = useUiConfig();
|
||||||
updateGoogleConfig,
|
|
||||||
unleashUrl,
|
|
||||||
}) {
|
|
||||||
const [data, setData] = useState(initialState);
|
const [data, setData] = useState(initialState);
|
||||||
const [info, setInfo] = useState();
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const { config } = useAuthSettings('google');
|
||||||
useEffect(() => {
|
const { updateSettings, errors, loading } = useAuthSettingsApi('google');
|
||||||
getGoogleConfig();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.clientId) {
|
if (config.clientId) {
|
||||||
@ -43,10 +44,10 @@ function GoogleAuth({
|
|||||||
return <span>You need admin privileges to access this section.</span>;
|
return <span>You need admin privileges to access this section.</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateField = e => {
|
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setData({
|
setData({
|
||||||
...data,
|
...data,
|
||||||
[e.target.name]: e.target.value,
|
[event.target.name]: event.target.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -58,19 +59,22 @@ function GoogleAuth({
|
|||||||
setData({ ...data, autoCreate: !data.autoCreate });
|
setData({ ...data, autoCreate: !data.autoCreate });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async e => {
|
const onSubmit = async (event: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setInfo('...saving');
|
|
||||||
try {
|
try {
|
||||||
await updateGoogleConfig(data);
|
await updateSettings(removeEmptyStringFields(data));
|
||||||
setInfo('Settings stored');
|
setToastData({
|
||||||
setTimeout(() => setInfo(''), 2000);
|
title: 'Settings stored',
|
||||||
} catch (e) {
|
type: 'success',
|
||||||
setInfo(e.message);
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setToastApiError(formatUnknownError(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent>
|
<PageContent headerContent="">
|
||||||
<Grid container style={{ marginBottom: '1rem' }}>
|
<Grid container style={{ marginBottom: '1rem' }}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
@ -84,7 +88,7 @@ function GoogleAuth({
|
|||||||
</a>{' '}
|
</a>{' '}
|
||||||
to learn how to integrate with Google OAuth 2.0. <br />
|
to learn how to integrate with Google OAuth 2.0. <br />
|
||||||
Callback URL:{' '}
|
Callback URL:{' '}
|
||||||
<code>{unleashUrl}/auth/google/callback</code>
|
<code>{uiConfig.unleashUrl}/auth/google/callback</code>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -125,7 +129,7 @@ function GoogleAuth({
|
|||||||
label="Client ID"
|
label="Client ID"
|
||||||
name="clientId"
|
name="clientId"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
value={data.clientId || ''}
|
value={data.clientId}
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
@ -146,7 +150,7 @@ function GoogleAuth({
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="Client Secret"
|
label="Client Secret"
|
||||||
name="clientSecret"
|
name="clientSecret"
|
||||||
value={data.clientSecret || ''}
|
value={data.clientSecret}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -195,9 +199,7 @@ function GoogleAuth({
|
|||||||
onChange={updateAutoCreate}
|
onChange={updateAutoCreate}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
checked={data.autoCreate}
|
checked={data.autoCreate}
|
||||||
>
|
/>
|
||||||
Auto-create users
|
|
||||||
</Switch>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
@ -229,22 +231,18 @@ function GoogleAuth({
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>{' '}
|
</Button>{' '}
|
||||||
<small>{info}</small>
|
<p>
|
||||||
|
<small style={{ color: 'red' }}>
|
||||||
|
{errors?.message}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</form>
|
</form>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
GoogleAuth.propTypes = {
|
|
||||||
config: PropTypes.object,
|
|
||||||
unleashUrl: PropTypes.string,
|
|
||||||
getGoogleConfig: PropTypes.func.isRequired,
|
|
||||||
updateGoogleConfig: PropTypes.func.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GoogleAuth;
|
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect, useContext } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
@ -8,34 +7,40 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from '@material-ui/core';
|
} from '@material-ui/core';
|
||||||
import { Alert } from '@material-ui/lab';
|
import { Alert } from '@material-ui/lab';
|
||||||
import PageContent from '../../common/PageContent/PageContent';
|
import PageContent from '../../../common/PageContent/PageContent';
|
||||||
import AccessContext from '../../../contexts/AccessContext';
|
import AccessContext from '../../../../contexts/AccessContext';
|
||||||
import { ADMIN } from '../../providers/AccessProvider/permissions';
|
import { ADMIN } from '../../../providers/AccessProvider/permissions';
|
||||||
import AutoCreateForm from './AutoCreateForm/AutoCreateForm';
|
import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm';
|
||||||
|
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
|
||||||
|
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
|
||||||
|
import useToast from '../../../../hooks/useToast';
|
||||||
|
import { formatUnknownError } from '../../../../utils/format-unknown-error';
|
||||||
|
import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
enableSingleSignOut: false,
|
enableSingleSignOut: false,
|
||||||
autoCreate: false,
|
autoCreate: false,
|
||||||
unleashHostname: location.hostname,
|
unleashHostname: location.hostname,
|
||||||
|
clientId: '',
|
||||||
|
discoverUrl: '',
|
||||||
|
secret: '',
|
||||||
|
acrValues: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
export const OidcAuth = () => {
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
const [data, setData] = useState(initialState);
|
const [data, setData] = useState(initialState);
|
||||||
const [info, setInfo] = useState();
|
|
||||||
const [error, setError] = useState();
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const { config } = useAuthSettings('oidc');
|
||||||
useEffect(() => {
|
const { updateSettings, errors, loading } = useAuthSettingsApi('oidc');
|
||||||
getOidcConfig();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.discoverUrl) {
|
if (config.discoverUrl) {
|
||||||
setData(config);
|
setData(config);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
if (!hasAccess(ADMIN)) {
|
if (!hasAccess(ADMIN)) {
|
||||||
@ -46,8 +51,8 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateField = e => {
|
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setValue(e.target.name, e.target.value);
|
setValue(event.target.name, event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEnabled = () => {
|
const updateEnabled = () => {
|
||||||
@ -58,28 +63,29 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
setData({ ...data, enableSingleSignOut: !data.enableSingleSignOut });
|
setData({ ...data, enableSingleSignOut: !data.enableSingleSignOut });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setValue = (field, value) => {
|
const setValue = (name: string, value: string | boolean) => {
|
||||||
setData({
|
setData({
|
||||||
...data,
|
...data,
|
||||||
[field]: value,
|
[name]: value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async e => {
|
const onSubmit = async (event: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setInfo('...saving');
|
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
await updateOidcConfig(data);
|
await updateSettings(removeEmptyStringFields(data));
|
||||||
setInfo('Settings stored');
|
setToastData({
|
||||||
setTimeout(() => setInfo(''), 2000);
|
title: 'Settings stored',
|
||||||
} catch (e) {
|
type: 'success',
|
||||||
setInfo('');
|
});
|
||||||
setError(e.message);
|
} catch (err) {
|
||||||
|
setToastApiError(formatUnknownError(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent>
|
<PageContent headerContent="">
|
||||||
<Grid container style={{ marginBottom: '1rem' }}>
|
<Grid container style={{ marginBottom: '1rem' }}>
|
||||||
<Grid item md={12}>
|
<Grid item md={12}>
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
@ -94,7 +100,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
to learn how to integrate with specific Open Id Connect
|
to learn how to integrate with specific Open Id Connect
|
||||||
providers (Okta, Keycloak, Google, etc). <br />
|
providers (Okta, Keycloak, Google, etc). <br />
|
||||||
Callback URL:{' '}
|
Callback URL:{' '}
|
||||||
<code>{unleashUrl}/auth/oidc/callback</code>
|
<code>{uiConfig.unleashUrl}/auth/oidc/callback</code>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -128,7 +134,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="Discover URL"
|
label="Discover URL"
|
||||||
name="discoverUrl"
|
name="discoverUrl"
|
||||||
value={data.discoverUrl || ''}
|
value={data.discoverUrl}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -146,7 +152,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="Client ID"
|
label="Client ID"
|
||||||
name="clientId"
|
name="clientId"
|
||||||
value={data.clientId || ''}
|
value={data.clientId}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -167,7 +173,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="Client Secret"
|
label="Client Secret"
|
||||||
name="secret"
|
name="secret"
|
||||||
value={data.secret || ''}
|
value={data.secret}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -180,7 +186,10 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item md={5}>
|
<Grid item md={5}>
|
||||||
<strong>Enable Single Sign-Out</strong>
|
<strong>Enable Single Sign-Out</strong>
|
||||||
<p>If you enable Single Sign-Out Unleash will redirect the user to the IDP as part of the Sign-out process.</p>
|
<p>
|
||||||
|
If you enable Single Sign-Out Unleash will redirect
|
||||||
|
the user to the IDP as part of the Sign-out process.
|
||||||
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item md={6} style={{ padding: '20px' }}>
|
<Grid item md={6} style={{ padding: '20px' }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@ -204,15 +213,21 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item md={5}>
|
<Grid item md={5}>
|
||||||
<strong>ACR Values</strong>
|
<strong>ACR Values</strong>
|
||||||
<p>Requested Authentication Context Class Reference values. If multiple values are specified they should be "space" separated. Will be sent as "acr_values" as
|
<p>
|
||||||
part of the authentication request. Unleash will validate the acr value in the id token claims against the list of acr values.</p>
|
Requested Authentication Context Class Reference
|
||||||
|
values. If multiple values are specified they should
|
||||||
|
be "space" separated. Will be sent as "acr_values"
|
||||||
|
as part of the authentication request. Unleash will
|
||||||
|
validate the acr value in the id token claims
|
||||||
|
against the list of acr values.
|
||||||
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item md={6}>
|
<Grid item md={6}>
|
||||||
<TextField
|
<TextField
|
||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="ACR Values"
|
label="ACR Values"
|
||||||
name="acrValues"
|
name="acrValues"
|
||||||
value={data.acrValues || ''}
|
value={data.acrValues}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -229,23 +244,18 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>{' '}
|
</Button>{' '}
|
||||||
<small>{info}</small>
|
<p>
|
||||||
<small style={{ color: 'red' }}>{error}</small>
|
<small style={{ color: 'red' }}>
|
||||||
|
{errors?.message}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</form>
|
</form>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
OidcAuth.propTypes = {
|
|
||||||
config: PropTypes.object,
|
|
||||||
unleash: PropTypes.string,
|
|
||||||
getOidcConfig: PropTypes.func.isRequired,
|
|
||||||
updateOidcConfig: PropTypes.func.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OidcAuth;
|
|
@ -1,30 +1,28 @@
|
|||||||
import React, { useState, useContext, useEffect } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import {
|
import { Button, FormControlLabel, Grid, Switch } from '@material-ui/core';
|
||||||
Button,
|
|
||||||
FormControlLabel,
|
|
||||||
Grid,
|
|
||||||
Switch,
|
|
||||||
} from '@material-ui/core';
|
|
||||||
import { Alert } from '@material-ui/lab';
|
import { Alert } from '@material-ui/lab';
|
||||||
import PageContent from '../../common/PageContent/PageContent';
|
import PageContent from '../../../common/PageContent/PageContent';
|
||||||
import AccessContext from '../../../contexts/AccessContext';
|
import AccessContext from '../../../../contexts/AccessContext';
|
||||||
import { ADMIN } from '../../providers/AccessProvider/permissions';
|
import { ADMIN } from '../../../providers/AccessProvider/permissions';
|
||||||
import useAuthSettings from '../../../hooks/api/getters/useAuthSettings/useAuthSettings';
|
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
|
||||||
import useAuthSettingsApi, {ISimpleAuthSettings } from '../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
|
import useAuthSettingsApi, {
|
||||||
import useToast from '../../../hooks/useToast';
|
ISimpleAuthSettings,
|
||||||
|
} from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
|
||||||
|
import useToast from '../../../../hooks/useToast';
|
||||||
|
import { formatUnknownError } from '../../../../utils/format-unknown-error';
|
||||||
|
|
||||||
const PasswordAuthSettings = () => {
|
export const PasswordAuth = () => {
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
const { setToastData } = useToast();
|
|
||||||
const { config } = useAuthSettings('simple');
|
const { config } = useAuthSettings('simple');
|
||||||
const [disablePasswordAuth, setDisablePasswordAuth] = useState<boolean>(false);
|
const [disablePasswordAuth, setDisablePasswordAuth] =
|
||||||
const { updateSettings, errors, loading } = useAuthSettingsApi<ISimpleAuthSettings>('simple')
|
useState<boolean>(false);
|
||||||
|
const { updateSettings, errors, loading } =
|
||||||
|
useAuthSettingsApi<ISimpleAuthSettings>('simple');
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisablePasswordAuth(!!config.disabled);
|
setDisablePasswordAuth(!!config.disabled);
|
||||||
}, [ config.disabled ]);
|
}, [config.disabled]);
|
||||||
|
|
||||||
if (!hasAccess(ADMIN)) {
|
if (!hasAccess(ADMIN)) {
|
||||||
return (
|
return (
|
||||||
@ -38,12 +36,13 @@ const PasswordAuthSettings = () => {
|
|||||||
setDisablePasswordAuth(!disablePasswordAuth);
|
setDisablePasswordAuth(!disablePasswordAuth);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.SyntheticEvent) => {
|
||||||
const onSubmit = async evt => {
|
event.preventDefault();
|
||||||
evt.preventDefault();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings: ISimpleAuthSettings = { disabled: disablePasswordAuth };
|
const settings: ISimpleAuthSettings = {
|
||||||
|
disabled: disablePasswordAuth,
|
||||||
|
};
|
||||||
await updateSettings(settings);
|
await updateSettings(settings);
|
||||||
setToastData({
|
setToastData({
|
||||||
title: 'Successfully saved',
|
title: 'Successfully saved',
|
||||||
@ -52,20 +51,13 @@ const PasswordAuthSettings = () => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
show: true,
|
show: true,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setToastData({
|
setToastApiError(formatUnknownError(err));
|
||||||
title: 'Could not store settings',
|
setDisablePasswordAuth(config.disabled);
|
||||||
text: err?.message,
|
|
||||||
autoHideDuration: 4000,
|
|
||||||
type: 'error',
|
|
||||||
show: true,
|
|
||||||
});
|
|
||||||
setDisablePasswordAuth(config.disabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<PageContent headerContent=''>
|
<PageContent headerContent="">
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item md={5}>
|
<Grid item md={5}>
|
||||||
@ -82,7 +74,9 @@ const PasswordAuthSettings = () => {
|
|||||||
checked={!disablePasswordAuth}
|
checked={!disablePasswordAuth}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={!disablePasswordAuth ? 'Enabled' : 'Disabled'}
|
label={
|
||||||
|
!disablePasswordAuth ? 'Enabled' : 'Disabled'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -96,12 +90,14 @@ const PasswordAuthSettings = () => {
|
|||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>{' '}
|
</Button>{' '}
|
||||||
<p><small style={{ color: 'red' }}>{errors?.message}</small></p>
|
<p>
|
||||||
|
<small style={{ color: 'red' }}>
|
||||||
|
{errors?.message}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</form>
|
</form>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default PasswordAuthSettings;
|
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect, useContext } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
@ -8,32 +7,40 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from '@material-ui/core';
|
} from '@material-ui/core';
|
||||||
import { Alert } from '@material-ui/lab';
|
import { Alert } from '@material-ui/lab';
|
||||||
import PageContent from '../../common/PageContent/PageContent';
|
import PageContent from '../../../common/PageContent/PageContent';
|
||||||
import AccessContext from '../../../contexts/AccessContext';
|
import AccessContext from '../../../../contexts/AccessContext';
|
||||||
import { ADMIN } from '../../providers/AccessProvider/permissions';
|
import { ADMIN } from '../../../providers/AccessProvider/permissions';
|
||||||
import AutoCreateForm from './AutoCreateForm/AutoCreateForm';
|
import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm';
|
||||||
|
import useToast from '../../../../hooks/useToast';
|
||||||
|
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
|
||||||
|
import useAuthSettingsApi from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
|
||||||
|
import { formatUnknownError } from '../../../../utils/format-unknown-error';
|
||||||
|
import { removeEmptyStringFields } from '../../../../utils/remove-empty-string-fields';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
autoCreate: false,
|
autoCreate: false,
|
||||||
unleashHostname: location.hostname,
|
unleashHostname: location.hostname,
|
||||||
|
entityId: '',
|
||||||
|
signOnUrl: '',
|
||||||
|
certificate: '',
|
||||||
|
signOutUrl: '',
|
||||||
|
spCertificate: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
export const SamlAuth = () => {
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
const [data, setData] = useState(initialState);
|
const [data, setData] = useState(initialState);
|
||||||
const [info, setInfo] = useState();
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const { config } = useAuthSettings('saml');
|
||||||
useEffect(() => {
|
const { updateSettings, errors, loading } = useAuthSettingsApi('saml');
|
||||||
getSamlConfig();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.entityId) {
|
if (config.entityId) {
|
||||||
setData(config);
|
setData(config);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
if (!hasAccess(ADMIN)) {
|
if (!hasAccess(ADMIN)) {
|
||||||
@ -44,34 +51,37 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateField = e => {
|
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setValue(e.target.name, e.target.value);
|
setValue(event.target.name, event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEnabled = () => {
|
const updateEnabled = () => {
|
||||||
setData({ ...data, enabled: !data.enabled });
|
setData({ ...data, enabled: !data.enabled });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setValue = (field, value) => {
|
const setValue = (name: string, value: string | boolean) => {
|
||||||
setData({
|
setData({
|
||||||
...data,
|
...data,
|
||||||
[field]: value,
|
[name]: value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async e => {
|
const onSubmit = async (event: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setInfo('...saving');
|
|
||||||
try {
|
try {
|
||||||
await updateSamlConfig(data);
|
await updateSettings(removeEmptyStringFields(data));
|
||||||
setInfo('Settings stored');
|
setToastData({
|
||||||
setTimeout(() => setInfo(''), 2000);
|
title: 'Settings stored',
|
||||||
} catch (e) {
|
type: 'success',
|
||||||
setInfo(e.message);
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setToastApiError(formatUnknownError(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent>
|
<PageContent headerContent="">
|
||||||
<Grid container style={{ marginBottom: '1rem' }}>
|
<Grid container style={{ marginBottom: '1rem' }}>
|
||||||
<Grid item md={12}>
|
<Grid item md={12}>
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
@ -86,7 +96,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
|||||||
to learn how to integrate with specific SAML 2.0
|
to learn how to integrate with specific SAML 2.0
|
||||||
providers (Okta, Keycloak, etc). <br />
|
providers (Okta, Keycloak, etc). <br />
|
||||||
Callback URL:{' '}
|
Callback URL:{' '}
|
||||||
<code>{unleashUrl}/auth/saml/callback</code>
|
<code>{uiConfig.unleashUrl}/auth/saml/callback</code>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -120,7 +130,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="Entity ID"
|
label="Entity ID"
|
||||||
name="entityId"
|
name="entityId"
|
||||||
value={data.entityId || ''}
|
value={data.entityId}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -142,7 +152,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="Single Sign-On URL"
|
label="Single Sign-On URL"
|
||||||
name="signOnUrl"
|
name="signOnUrl"
|
||||||
value={data.signOnUrl || ''}
|
value={data.signOnUrl}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -164,7 +174,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="X.509 Certificate"
|
label="X.509 Certificate"
|
||||||
name="certificate"
|
name="certificate"
|
||||||
value={data.certificate || ''}
|
value={data.certificate}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@ -196,7 +206,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="Single Sign-out URL"
|
label="Single Sign-out URL"
|
||||||
name="signOutUrl"
|
name="signOutUrl"
|
||||||
value={data.signOutUrl || ''}
|
value={data.signOutUrl}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '400px' }}
|
style={{ width: '400px' }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -219,7 +229,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
|||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
label="X.509 Certificate"
|
label="X.509 Certificate"
|
||||||
name="spCertificate"
|
name="spCertificate"
|
||||||
value={data.spCertificate || ''}
|
value={data.spCertificate}
|
||||||
disabled={!data.enabled}
|
disabled={!data.enabled}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@ -243,22 +253,18 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>{' '}
|
</Button>{' '}
|
||||||
<small>{info}</small>
|
<p>
|
||||||
|
<small style={{ color: 'red' }}>
|
||||||
|
{errors?.message}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</form>
|
</form>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
SamlAuth.propTypes = {
|
|
||||||
config: PropTypes.object,
|
|
||||||
unleash: PropTypes.string,
|
|
||||||
getSamlConfig: PropTypes.func.isRequired,
|
|
||||||
updateSamlConfig: PropTypes.func.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SamlAuth;
|
|
@ -1,12 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import GoogleAuth from './google-auth';
|
|
||||||
import { getGoogleConfig, updateGoogleConfig } from '../../../store/e-admin-auth/actions';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
config: state.authAdmin.get('google'),
|
|
||||||
unleashUrl: state.uiConfig.toJS().unleashUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const Container = connect(mapStateToProps, { getGoogleConfig, updateGoogleConfig })(GoogleAuth);
|
|
||||||
|
|
||||||
export default Container;
|
|
@ -1,10 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import component from './authentication';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
authenticationType: state.uiConfig.toJS().authenticationType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const Container = connect(mapStateToProps, { })(component);
|
|
||||||
|
|
||||||
export default Container;
|
|
@ -1,12 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import OidcAuth from './oidc-auth';
|
|
||||||
import { getOidcConfig, updateOidcConfig } from '../../../store/e-admin-auth/actions';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
config: state.authAdmin.get('oidc'),
|
|
||||||
unleashUrl: state.uiConfig.toJS().unleashUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const OidcContainer = connect(mapStateToProps, { getOidcConfig, updateOidcConfig })(OidcAuth);
|
|
||||||
|
|
||||||
export default OidcContainer;
|
|
@ -1,12 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import SamlAuth from './saml-auth';
|
|
||||||
import { getSamlConfig, updateSamlConfig } from '../../../store/e-admin-auth/actions';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
config: state.authAdmin.get('saml'),
|
|
||||||
unleashUrl: state.uiConfig.toJS().unleashUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const Container = connect(mapStateToProps, { getSamlConfig, updateSamlConfig })(SamlAuth);
|
|
||||||
|
|
||||||
export default Container;
|
|
@ -14,15 +14,15 @@ import HeaderTitle from '../../common/HeaderTitle';
|
|||||||
import ConditionallyRender from '../../common/ConditionallyRender';
|
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||||
import { formatApiPath } from '../../../utils/format-path';
|
import { formatApiPath } from '../../../utils/format-path';
|
||||||
import useInvoices from '../../../hooks/api/getters/useInvoices/useInvoices';
|
import useInvoices from '../../../hooks/api/getters/useInvoices/useInvoices';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { IInvoice } from '../../../interfaces/invoice';
|
import { IInvoice } from '../../../interfaces/invoice';
|
||||||
|
import { useLocationSettings } from '../../../hooks/useLocationSettings';
|
||||||
|
|
||||||
const PORTAL_URL = formatApiPath('api/admin/invoices/portal');
|
const PORTAL_URL = formatApiPath('api/admin/invoices/portal');
|
||||||
|
|
||||||
const InvoiceList = () => {
|
const InvoiceList = () => {
|
||||||
const { refetchInvoices, invoices } = useInvoices();
|
const { refetchInvoices, invoices } = useInvoices();
|
||||||
const [isLoaded, setLoaded] = useState(false);
|
const [isLoaded, setLoaded] = useState(false);
|
||||||
const location = useLocation();
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refetchInvoices();
|
refetchInvoices();
|
||||||
@ -89,7 +89,7 @@ const InvoiceList = () => {
|
|||||||
{item.dueDate &&
|
{item.dueDate &&
|
||||||
formatDateWithLocale(
|
formatDateWithLocale(
|
||||||
item.dueDate,
|
item.dueDate,
|
||||||
location.locale
|
locationSettings.locale
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { Paper, Tabs, Tab } from '@material-ui/core';
|
import { Paper, Tab, Tabs } from '@material-ui/core';
|
||||||
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
|
||||||
const navLinkStyle = {
|
const navLinkStyle = {
|
||||||
@ -13,18 +13,17 @@ const navLinkStyle = {
|
|||||||
padding: '0.8rem 1.5rem',
|
padding: '0.8rem 1.5rem',
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeNavLinkStyle = {
|
const activeNavLinkStyle: React.CSSProperties = {
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
padding: '0.8rem 1.5rem',
|
padding: '0.8rem 1.5rem',
|
||||||
};
|
};
|
||||||
|
|
||||||
function AdminMenu({ history }) {
|
function AdminMenu() {
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
|
const { pathname } = useLocation();
|
||||||
const { flags } = uiConfig;
|
const { flags } = uiConfig;
|
||||||
|
|
||||||
const { location } = history;
|
|
||||||
const { pathname } = location;
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
style={{
|
style={{
|
||||||
@ -45,7 +44,7 @@ function AdminMenu({ history }) {
|
|||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
></Tab>
|
/>
|
||||||
{flags.RE && (
|
{flags.RE && (
|
||||||
<Tab
|
<Tab
|
||||||
value="/admin/roles"
|
value="/admin/roles"
|
||||||
@ -58,7 +57,7 @@ function AdminMenu({ history }) {
|
|||||||
<span>PROJECT ROLES</span>
|
<span>PROJECT ROLES</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
></Tab>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tab
|
<Tab
|
||||||
@ -72,7 +71,7 @@ function AdminMenu({ history }) {
|
|||||||
API Access
|
API Access
|
||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
></Tab>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
value="/admin/auth"
|
value="/admin/auth"
|
||||||
label={
|
label={
|
||||||
@ -84,7 +83,7 @@ function AdminMenu({ history }) {
|
|||||||
Single Sign-On
|
Single Sign-On
|
||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
></Tab>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
@ -14,6 +14,7 @@ import AccessContext from '../../../../../contexts/AccessContext';
|
|||||||
import { IUser } from '../../../../../interfaces/user';
|
import { IUser } from '../../../../../interfaces/user';
|
||||||
import { useStyles } from './UserListItem.styles';
|
import { useStyles } from './UserListItem.styles';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { ILocationSettings } from "../../../../../hooks/useLocationSettings";
|
||||||
|
|
||||||
interface IUserListItemProps {
|
interface IUserListItemProps {
|
||||||
user: IUser;
|
user: IUser;
|
||||||
@ -21,11 +22,7 @@ interface IUserListItemProps {
|
|||||||
openUpdateDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
openUpdateDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||||
openPwDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
openPwDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||||
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||||
location: ILocation;
|
locationSettings: ILocationSettings;
|
||||||
}
|
|
||||||
|
|
||||||
interface ILocation {
|
|
||||||
locale: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserListItem = ({
|
const UserListItem = ({
|
||||||
@ -34,7 +31,7 @@ const UserListItem = ({
|
|||||||
openDelDialog,
|
openDelDialog,
|
||||||
openPwDialog,
|
openPwDialog,
|
||||||
openUpdateDialog,
|
openUpdateDialog,
|
||||||
location,
|
locationSettings,
|
||||||
}: IUserListItemProps) => {
|
}: IUserListItemProps) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
@ -54,7 +51,7 @@ const UserListItem = ({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span data-loading>
|
<span data-loading>
|
||||||
{formatDateWithLocale(user.createdAt, location.locale)}
|
{formatDateWithLocale(user.createdAt, locationSettings.locale)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={styles.leftTableCell}>
|
<TableCell className={styles.leftTableCell}>
|
||||||
|
@ -20,10 +20,10 @@ import loadingData from './loadingData';
|
|||||||
import useLoading from '../../../../hooks/useLoading';
|
import useLoading from '../../../../hooks/useLoading';
|
||||||
import usePagination from '../../../../hooks/usePagination';
|
import usePagination from '../../../../hooks/usePagination';
|
||||||
import PaginateUI from '../../../common/PaginateUI/PaginateUI';
|
import PaginateUI from '../../../common/PaginateUI/PaginateUI';
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { IUser } from '../../../../interfaces/user';
|
import { IUser } from '../../../../interfaces/user';
|
||||||
import IRole from '../../../../interfaces/role';
|
import IRole from '../../../../interfaces/role';
|
||||||
import useToast from '../../../../hooks/useToast';
|
import useToast from '../../../../hooks/useToast';
|
||||||
|
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
|
||||||
|
|
||||||
const UsersList = () => {
|
const UsersList = () => {
|
||||||
const { users, roles, refetch, loading } = useUsers();
|
const { users, roles, refetch, loading } = useUsers();
|
||||||
@ -35,9 +35,8 @@ const UsersList = () => {
|
|||||||
userLoading,
|
userLoading,
|
||||||
userApiErrors,
|
userApiErrors,
|
||||||
} = useAdminUsersApi();
|
} = useAdminUsersApi();
|
||||||
const history = useHistory();
|
|
||||||
const { location } = history;
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const { locationSettings } = useLocationSettings()
|
||||||
const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
|
const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
|
||||||
open: false,
|
open: false,
|
||||||
});
|
});
|
||||||
@ -104,7 +103,7 @@ const UsersList = () => {
|
|||||||
user={user}
|
user={user}
|
||||||
openPwDialog={openPwDialog}
|
openPwDialog={openPwDialog}
|
||||||
openDelDialog={openDelDialog}
|
openDelDialog={openDelDialog}
|
||||||
location={location}
|
locationSettings={locationSettings}
|
||||||
renderRole={renderRole}
|
renderRole={renderRole}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@ -117,7 +116,7 @@ const UsersList = () => {
|
|||||||
user={user}
|
user={user}
|
||||||
openPwDialog={openPwDialog}
|
openPwDialog={openPwDialog}
|
||||||
openDelDialog={openDelDialog}
|
openDelDialog={openDelDialog}
|
||||||
location={location}
|
locationSettings={locationSettings}
|
||||||
renderRole={renderRole}
|
renderRole={renderRole}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -19,6 +19,7 @@ test('renders correctly if no application', () => {
|
|||||||
storeApplicationMetaData={jest.fn()}
|
storeApplicationMetaData={jest.fn()}
|
||||||
deleteApplication={jest.fn()}
|
deleteApplication={jest.fn()}
|
||||||
history={{}}
|
history={{}}
|
||||||
|
locationSettings={{ locale: 'en-GB' }}
|
||||||
/>
|
/>
|
||||||
</AccessProvider>
|
</AccessProvider>
|
||||||
)
|
)
|
||||||
@ -77,7 +78,7 @@ test('renders correctly without permission', () => {
|
|||||||
url: 'http://example.org',
|
url: 'http://example.org',
|
||||||
description: 'app description',
|
description: 'app description',
|
||||||
}}
|
}}
|
||||||
location={{ locale: 'en-GB' }}
|
locationSettings={{ locale: 'en-GB' }}
|
||||||
/>
|
/>
|
||||||
</AccessProvider>
|
</AccessProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
@ -140,7 +141,7 @@ test('renders correctly with permissions', () => {
|
|||||||
url: 'http://example.org',
|
url: 'http://example.org',
|
||||||
description: 'app description',
|
description: 'app description',
|
||||||
}}
|
}}
|
||||||
location={{ locale: 'en-GB' }}
|
locationSettings={{ locale: 'en-GB' }}
|
||||||
/>
|
/>
|
||||||
</AccessProvider>
|
</AccessProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -33,7 +33,7 @@ class ClientApplications extends PureComponent {
|
|||||||
fetchApplication: PropTypes.func.isRequired,
|
fetchApplication: PropTypes.func.isRequired,
|
||||||
appName: PropTypes.string,
|
appName: PropTypes.string,
|
||||||
application: PropTypes.object,
|
application: PropTypes.object,
|
||||||
location: PropTypes.object,
|
locationSettings: PropTypes.object.isRequired,
|
||||||
storeApplicationMetaData: PropTypes.func.isRequired,
|
storeApplicationMetaData: PropTypes.func.isRequired,
|
||||||
deleteApplication: PropTypes.func.isRequired,
|
deleteApplication: PropTypes.func.isRequired,
|
||||||
history: PropTypes.object.isRequired,
|
history: PropTypes.object.isRequired,
|
||||||
@ -54,8 +54,8 @@ class ClientApplications extends PureComponent {
|
|||||||
.finally(() => this.setState({ loading: false }));
|
.finally(() => this.setState({ loading: false }));
|
||||||
}
|
}
|
||||||
formatFullDateTime = v =>
|
formatFullDateTime = v =>
|
||||||
formatFullDateTimeWithLocale(v, this.props.location.locale);
|
formatFullDateTimeWithLocale(v, this.props.locationSettings.locale);
|
||||||
formatDate = v => formatDateWithLocale(v, this.props.location.locale);
|
formatDate = v => formatDateWithLocale(v, this.props.locationSettings.locale);
|
||||||
|
|
||||||
deleteApplication = async evt => {
|
deleteApplication = async evt => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import ApplicationEdit from './application-edit-component';
|
|
||||||
import { fetchApplication, storeApplicationMetaData, deleteApplication } from './../../store/application/actions';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
|
||||||
let application = state.applications.getIn(['apps', props.appName]);
|
|
||||||
const location = state.settings.toJS().location || {};
|
|
||||||
if (application) {
|
|
||||||
application = application.toJS();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
application,
|
|
||||||
location,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const Container = connect(mapStateToProps, {
|
|
||||||
fetchApplication,
|
|
||||||
storeApplicationMetaData,
|
|
||||||
deleteApplication,
|
|
||||||
})(ApplicationEdit);
|
|
||||||
|
|
||||||
export default Container;
|
|
@ -0,0 +1,30 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import ApplicationEdit from './application-edit-component';
|
||||||
|
import {
|
||||||
|
deleteApplication,
|
||||||
|
fetchApplication,
|
||||||
|
storeApplicationMetaData,
|
||||||
|
} from '../../store/application/actions';
|
||||||
|
import { useLocationSettings } from '../../hooks/useLocationSettings';
|
||||||
|
|
||||||
|
const ApplicationEditContainer = props => {
|
||||||
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
|
return <ApplicationEdit {...props} locationSettings={locationSettings} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state, props) => {
|
||||||
|
let application = state.applications.getIn(['apps', props.appName]);
|
||||||
|
if (application) {
|
||||||
|
application = application.toJS();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
application,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, {
|
||||||
|
fetchApplication,
|
||||||
|
storeApplicationMetaData,
|
||||||
|
deleteApplication,
|
||||||
|
})(ApplicationEditContainer);
|
36
frontend/src/component/archive/ArchiveListContainer.tsx
Normal file
36
frontend/src/component/archive/ArchiveListContainer.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useFeaturesArchive } from '../../hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
|
||||||
|
import FeatureToggleList from '../feature/FeatureToggleList/FeatureToggleList';
|
||||||
|
import useUiConfig from '../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { useFeaturesFilter } from '../../hooks/useFeaturesFilter';
|
||||||
|
import { useFeatureArchiveApi } from '../../hooks/api/actions/useFeatureArchiveApi/useReviveFeatureApi';
|
||||||
|
import useToast from '../../hooks/useToast';
|
||||||
|
import { useFeaturesSort } from '../../hooks/useFeaturesSort';
|
||||||
|
|
||||||
|
export const ArchiveListContainer = () => {
|
||||||
|
const { setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const { reviveFeature } = useFeatureArchiveApi();
|
||||||
|
const { archivedFeatures, loading, refetchArchived } = useFeaturesArchive();
|
||||||
|
const { filtered, filter, setFilter } = useFeaturesFilter(archivedFeatures);
|
||||||
|
const { sorted, sort, setSort } = useFeaturesSort(filtered);
|
||||||
|
|
||||||
|
const revive = (feature: string) => {
|
||||||
|
reviveFeature(feature)
|
||||||
|
.then(refetchArchived)
|
||||||
|
.catch(e => setToastApiError(e.toString()));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureToggleList
|
||||||
|
features={sorted}
|
||||||
|
loading={loading}
|
||||||
|
revive={revive}
|
||||||
|
flags={uiConfig.flags}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
sort={sort}
|
||||||
|
setSort={setSort}
|
||||||
|
archive
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,19 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import FeatureListComponent from '../feature/FeatureToggleList/FeatureToggleList';
|
|
||||||
import { fetchArchive, revive } from './../../store/archive/actions';
|
|
||||||
import { updateSettingForGroup } from './../../store/settings/actions';
|
|
||||||
import { mapStateToPropsConfigurable } from '../feature/FeatureToggleList';
|
|
||||||
|
|
||||||
const mapStateToProps = mapStateToPropsConfigurable(false);
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
fetcher: () => fetchArchive(),
|
|
||||||
revive,
|
|
||||||
updateSetting: updateSettingForGroup('feature'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const ArchiveListContainer = connect(
|
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(FeatureListComponent);
|
|
||||||
|
|
||||||
export default ArchiveListContainer;
|
|
@ -1,37 +0,0 @@
|
|||||||
.archiveList {
|
|
||||||
background-color: #fff;
|
|
||||||
color: rgba(0, 0, 0, 0.54);
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 16px 0 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listItemToggle {
|
|
||||||
width: 40%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 20%;
|
|
||||||
}
|
|
||||||
.listItemCreated {
|
|
||||||
width: 10%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 2px;
|
|
||||||
}
|
|
||||||
.listItemRevive {
|
|
||||||
width: 5%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 10%;
|
|
||||||
}
|
|
||||||
.toggleDetails {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 24px;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 18px;
|
|
||||||
color: rgba(0, 0, 0, 0.54);
|
|
||||||
display: block;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.strategiesList {
|
|
||||||
flex-shrink: 0;
|
|
||||||
float: right;
|
|
||||||
margin-left: 8px !important;
|
|
||||||
}
|
|
@ -13,7 +13,11 @@ interface IPermissionSwitchProps extends OverridableComponent<any> {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PermissionSwitch: React.FC<IPermissionSwitchProps> = ({
|
const PermissionSwitch = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
IPermissionSwitchProps
|
||||||
|
>((props, ref) => {
|
||||||
|
const {
|
||||||
permission,
|
permission,
|
||||||
tooltip = '',
|
tooltip = '',
|
||||||
disabled,
|
disabled,
|
||||||
@ -22,7 +26,8 @@ const PermissionSwitch: React.FC<IPermissionSwitchProps> = ({
|
|||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
} = props;
|
||||||
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
|
||||||
let access;
|
let access;
|
||||||
@ -45,11 +50,12 @@ const PermissionSwitch: React.FC<IPermissionSwitchProps> = ({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled || !access}
|
disabled={disabled || !access}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
ref={ref}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default PermissionSwitch;
|
export default PermissionSwitch;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { MenuItem } from '@material-ui/core';
|
import { MenuItem } from '@material-ui/core';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import DropdownMenu from '../DropdownMenu/DropdownMenu';
|
import DropdownMenu from '../DropdownMenu/DropdownMenu';
|
||||||
@ -9,20 +9,8 @@ const ALL_PROJECTS = { id: '*', name: '> All projects' };
|
|||||||
const ProjectSelect = ({ currentProjectId, updateCurrentProject, ...rest }) => {
|
const ProjectSelect = ({ currentProjectId, updateCurrentProject, ...rest }) => {
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let currentProject = projects.find(i => i.id === currentProjectId);
|
|
||||||
|
|
||||||
if (currentProject) {
|
|
||||||
setProject(currentProject.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProject('*');
|
|
||||||
/* eslint-disable-next-line */
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setProject = v => {
|
const setProject = v => {
|
||||||
const id = typeof v === 'string' ? v.trim() : '';
|
const id = v && typeof v === 'string' ? v.trim() : '*';
|
||||||
updateCurrentProject(id);
|
updateCurrentProject(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,9 +3,8 @@ import ProjectSelect from './ProjectSelect';
|
|||||||
import { fetchProjects } from '../../../store/project/actions';
|
import { fetchProjects } from '../../../store/project/actions';
|
||||||
|
|
||||||
const mapStateToProps = (state, ownProps) => ({
|
const mapStateToProps = (state, ownProps) => ({
|
||||||
|
...ownProps,
|
||||||
projects: state.projects.toJS(),
|
projects: state.projects.toJS(),
|
||||||
currentProjectId: ownProps.settings.currentProjectId || '*',
|
|
||||||
updateCurrentProject: id => ownProps.updateSetting('currentProjectId', id),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, { fetchProjects })(ProjectSelect);
|
export default connect(mapStateToProps, { fetchProjects })(ProjectSelect);
|
||||||
|
@ -66,6 +66,7 @@ const TabNav = ({
|
|||||||
|
|
||||||
TabNav.propTypes = {
|
TabNav.propTypes = {
|
||||||
tabData: PropTypes.array.isRequired,
|
tabData: PropTypes.array.isRequired,
|
||||||
|
navClass: PropTypes.string,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
startingTab: PropTypes.number,
|
startingTab: PropTypes.number,
|
||||||
};
|
};
|
||||||
|
@ -79,7 +79,7 @@ const CreateFeature = () => {
|
|||||||
title="Create Feature toggle"
|
title="Create Feature toggle"
|
||||||
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
|
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
|
||||||
The feature toggle is disabled when created and you decide when to enable"
|
The feature toggle is disabled when created and you decide when to enable"
|
||||||
documentationLink="https://docs.getunleash.io/"
|
documentationLink="https://docs.getunleash.io/advanced/feature_toggle_types"
|
||||||
formatApiCode={formatApiCode}
|
formatApiCode={formatApiCode}
|
||||||
>
|
>
|
||||||
<FeatureForm
|
<FeatureForm
|
||||||
|
@ -83,7 +83,7 @@ const EditFeature = () => {
|
|||||||
title="Edit Feature toggle"
|
title="Edit Feature toggle"
|
||||||
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
|
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
|
||||||
The feature toggle is disabled when created and you decide when to enable"
|
The feature toggle is disabled when created and you decide when to enable"
|
||||||
documentationLink="https://docs.getunleash.io/"
|
documentationLink="https://docs.getunleash.io/advanced/feature_toggle_types"
|
||||||
formatApiCode={formatApiCode}
|
formatApiCode={formatApiCode}
|
||||||
>
|
>
|
||||||
<FeatureForm
|
<FeatureForm
|
||||||
|
@ -11,6 +11,7 @@ import ConditionallyRender from '../../common/ConditionallyRender';
|
|||||||
import { trim } from '../../common/util';
|
import { trim } from '../../common/util';
|
||||||
import Input from '../../common/Input/Input';
|
import Input from '../../common/Input/Input';
|
||||||
import { CREATE_FEATURE } from '../../providers/AccessProvider/permissions';
|
import { CREATE_FEATURE } from '../../providers/AccessProvider/permissions';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
interface IFeatureToggleForm {
|
interface IFeatureToggleForm {
|
||||||
type: string;
|
type: string;
|
||||||
@ -22,8 +23,8 @@ interface IFeatureToggleForm {
|
|||||||
setName: React.Dispatch<React.SetStateAction<string>>;
|
setName: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setProject: React.Dispatch<React.SetStateAction<string>>;
|
setProject: React.Dispatch<React.SetStateAction<string>>;
|
||||||
validateToggleName: () => void;
|
|
||||||
setImpressionData: React.Dispatch<React.SetStateAction<boolean>>;
|
setImpressionData: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
validateToggleName?: () => void;
|
||||||
handleSubmit: (e: any) => void;
|
handleSubmit: (e: any) => void;
|
||||||
handleCancel: () => void;
|
handleCancel: () => void;
|
||||||
errors: { [key: string]: string };
|
errors: { [key: string]: string };
|
||||||
@ -52,6 +53,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { featureTypes } = useFeatureTypes();
|
const { featureTypes } = useFeatureTypes();
|
||||||
|
const history = useHistory();
|
||||||
const { permissions } = useUser();
|
const { permissions } = useUser();
|
||||||
const editable = mode !== 'Edit';
|
const editable = mode !== 'Edit';
|
||||||
|
|
||||||
@ -75,9 +77,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
|||||||
onFocus={() => clearErrors()}
|
onFocus={() => clearErrors()}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(trim(e.target.value))}
|
onChange={e => setName(trim(e.target.value))}
|
||||||
inputProps={{
|
data-test={CF_NAME_ID}
|
||||||
'data-test': CF_NAME_ID,
|
|
||||||
}}
|
|
||||||
onBlur={validateToggleName}
|
onBlur={validateToggleName}
|
||||||
/>
|
/>
|
||||||
<p className={styles.inputDescription}>
|
<p className={styles.inputDescription}>
|
||||||
@ -89,9 +89,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
|||||||
label={'Toggle type'}
|
label={'Toggle type'}
|
||||||
id="feature-type-select"
|
id="feature-type-select"
|
||||||
editable
|
editable
|
||||||
inputProps={{
|
data-test={CF_TYPE_ID}
|
||||||
'data-test': CF_TYPE_ID,
|
|
||||||
}}
|
|
||||||
IconComponent={KeyboardArrowDownOutlined}
|
IconComponent={KeyboardArrowDownOutlined}
|
||||||
className={styles.selectInput}
|
className={styles.selectInput}
|
||||||
/>
|
/>
|
||||||
@ -108,7 +106,12 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
|||||||
/>
|
/>
|
||||||
<FeatureProjectSelect
|
<FeatureProjectSelect
|
||||||
value={project}
|
value={project}
|
||||||
onChange={e => setProject(e.target.value)}
|
onChange={e => {
|
||||||
|
setProject(e.target.value);
|
||||||
|
history.replace(
|
||||||
|
`/projects/${e.target.value}/create-toggle`
|
||||||
|
);
|
||||||
|
}}
|
||||||
enabled={editable}
|
enabled={editable}
|
||||||
filter={projectFilterGenerator(
|
filter={projectFilterGenerator(
|
||||||
{ permissions },
|
{ permissions },
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { useContext, useLayoutEffect, useEffect } from 'react';
|
import { useContext } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Button, List, Tooltip, IconButton, ListItem } from '@material-ui/core';
|
import { Button, IconButton, List, ListItem, Tooltip } from '@material-ui/core';
|
||||||
import useMediaQuery from '@material-ui/core/useMediaQuery';
|
import useMediaQuery from '@material-ui/core/useMediaQuery';
|
||||||
import { Add } from '@material-ui/icons';
|
import { Add } from '@material-ui/icons';
|
||||||
|
|
||||||
@ -23,43 +23,31 @@ import { useStyles } from './styles';
|
|||||||
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
|
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
|
||||||
import { getCreateTogglePath } from '../../../utils/route-path-helpers';
|
import { getCreateTogglePath } from '../../../utils/route-path-helpers';
|
||||||
import { NAVIGATE_TO_CREATE_FEATURE } from '../../../testIds';
|
import { NAVIGATE_TO_CREATE_FEATURE } from '../../../testIds';
|
||||||
|
import { resolveFilteredProjectId } from '../../../hooks/useFeaturesFilter';
|
||||||
|
|
||||||
const FeatureToggleList = ({
|
const FeatureToggleList = ({
|
||||||
fetcher,
|
|
||||||
features,
|
features,
|
||||||
settings,
|
|
||||||
revive,
|
revive,
|
||||||
currentProjectId,
|
|
||||||
updateSetting,
|
|
||||||
featureMetrics,
|
|
||||||
toggleFeature,
|
|
||||||
archive,
|
archive,
|
||||||
loading,
|
loading,
|
||||||
flags,
|
flags,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
}) => {
|
}) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const smallScreen = useMediaQuery('(max-width:800px)');
|
const smallScreen = useMediaQuery('(max-width:800px)');
|
||||||
const mobileView = useMediaQuery('(max-width:600px)');
|
const mobileView = useMediaQuery('(max-width:600px)');
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const setFilterQuery = v => {
|
||||||
fetcher();
|
const query = v && typeof v === 'string' ? v.trim() : '';
|
||||||
}, [fetcher]);
|
setFilter(prev => ({ ...prev, query }));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateSetting('filter', '');
|
|
||||||
/* eslint-disable-next-line */
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleMetrics = () => {
|
|
||||||
updateSetting('showLastHour', !settings.showLastHour);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSort = v => {
|
const resolvedProjectId = resolveFilteredProjectId(filter);
|
||||||
updateSetting('sort', typeof v === 'string' ? v.trim() : '');
|
const createURL = getCreateTogglePath(resolvedProjectId, flags.E);
|
||||||
};
|
|
||||||
|
|
||||||
const createURL = getCreateTogglePath(currentProjectId, flags.E);
|
|
||||||
|
|
||||||
const renderFeatures = () => {
|
const renderFeatures = () => {
|
||||||
features.forEach(e => {
|
features.forEach(e => {
|
||||||
@ -70,11 +58,7 @@ const FeatureToggleList = ({
|
|||||||
return loadingFeatures.map(feature => (
|
return loadingFeatures.map(feature => (
|
||||||
<FeatureToggleListItem
|
<FeatureToggleListItem
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
settings={settings}
|
|
||||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
|
||||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
toggleFeature={toggleFeature}
|
|
||||||
revive={revive}
|
revive={revive}
|
||||||
hasAccess={hasAccess}
|
hasAccess={hasAccess}
|
||||||
className={'skeleton'}
|
className={'skeleton'}
|
||||||
@ -89,13 +73,7 @@ const FeatureToggleList = ({
|
|||||||
show={features.map(feature => (
|
show={features.map(feature => (
|
||||||
<FeatureToggleListItem
|
<FeatureToggleListItem
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
settings={settings}
|
|
||||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
|
||||||
metricsLastMinute={
|
|
||||||
featureMetrics.lastMinute[feature.name]
|
|
||||||
}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
toggleFeature={toggleFeature}
|
|
||||||
revive={revive}
|
revive={revive}
|
||||||
hasAccess={hasAccess}
|
hasAccess={hasAccess}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
@ -129,7 +107,7 @@ const FeatureToggleList = ({
|
|||||||
<div className={styles.featureContainer}>
|
<div className={styles.featureContainer}>
|
||||||
<div className={styles.searchBarContainer}>
|
<div className={styles.searchBarContainer}>
|
||||||
<SearchField
|
<SearchField
|
||||||
updateValue={updateSetting.bind(this, 'filter')}
|
updateValue={setFilterQuery}
|
||||||
className={classnames(styles.searchBar, {
|
className={classnames(styles.searchBar, {
|
||||||
skeleton: loading,
|
skeleton: loading,
|
||||||
})}
|
})}
|
||||||
@ -151,10 +129,10 @@ const FeatureToggleList = ({
|
|||||||
condition={!smallScreen}
|
condition={!smallScreen}
|
||||||
show={
|
show={
|
||||||
<FeatureToggleListActions
|
<FeatureToggleListActions
|
||||||
settings={settings}
|
filter={filter}
|
||||||
toggleMetrics={toggleMetrics}
|
setFilter={setFilter}
|
||||||
|
sort={sort}
|
||||||
setSort={setSort}
|
setSort={setSort}
|
||||||
updateSetting={updateSetting}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@ -175,7 +153,7 @@ const FeatureToggleList = ({
|
|||||||
disabled={
|
disabled={
|
||||||
!hasAccess(
|
!hasAccess(
|
||||||
CREATE_FEATURE,
|
CREATE_FEATURE,
|
||||||
currentProjectId
|
resolvedProjectId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -195,7 +173,7 @@ const FeatureToggleList = ({
|
|||||||
disabled={
|
disabled={
|
||||||
!hasAccess(
|
!hasAccess(
|
||||||
CREATE_FEATURE,
|
CREATE_FEATURE,
|
||||||
currentProjectId
|
resolvedProjectId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={classnames({
|
className={classnames({
|
||||||
@ -221,16 +199,14 @@ const FeatureToggleList = ({
|
|||||||
|
|
||||||
FeatureToggleList.propTypes = {
|
FeatureToggleList.propTypes = {
|
||||||
features: PropTypes.array.isRequired,
|
features: PropTypes.array.isRequired,
|
||||||
featureMetrics: PropTypes.object.isRequired,
|
|
||||||
fetcher: PropTypes.func,
|
|
||||||
revive: PropTypes.func,
|
revive: PropTypes.func,
|
||||||
updateSetting: PropTypes.func.isRequired,
|
|
||||||
toggleFeature: PropTypes.func,
|
|
||||||
settings: PropTypes.object,
|
|
||||||
history: PropTypes.object.isRequired,
|
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
currentProjectId: PropTypes.string.isRequired,
|
archive: PropTypes.bool,
|
||||||
flags: PropTypes.object,
|
flags: PropTypes.object,
|
||||||
|
filter: PropTypes.object.isRequired,
|
||||||
|
setFilter: PropTypes.func.isRequired,
|
||||||
|
sort: PropTypes.object.isRequired,
|
||||||
|
setSort: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FeatureToggleList;
|
export default FeatureToggleList;
|
||||||
|
@ -2,31 +2,21 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { MenuItem, Typography } from '@material-ui/core';
|
import { MenuItem, Typography } from '@material-ui/core';
|
||||||
// import { HourglassEmpty, HourglassFull } from '@material-ui/icons';
|
|
||||||
// import { MenuItemWithIcon } from '../../../common';
|
|
||||||
import DropdownMenu from '../../../common/DropdownMenu/DropdownMenu';
|
import DropdownMenu from '../../../common/DropdownMenu/DropdownMenu';
|
||||||
import ProjectSelect from '../../../common/ProjectSelect';
|
import ProjectSelect from '../../../common/ProjectSelect';
|
||||||
import { useStyles } from './styles';
|
import { useStyles } from './styles';
|
||||||
import useLoading from '../../../../hooks/useLoading';
|
import useLoading from '../../../../hooks/useLoading';
|
||||||
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import ConditionallyRender from '../../../common/ConditionallyRender';
|
import ConditionallyRender from '../../../common/ConditionallyRender';
|
||||||
|
import { createFeaturesFilterSortOptions } from '../../../../hooks/useFeaturesSort';
|
||||||
|
|
||||||
const sortingOptions = [
|
const sortOptions = createFeaturesFilterSortOptions();
|
||||||
{ type: 'name', displayName: 'Name' },
|
|
||||||
{ type: 'type', displayName: 'Type' },
|
|
||||||
{ type: 'enabled', displayName: 'Enabled' },
|
|
||||||
{ type: 'stale', displayName: 'Stale' },
|
|
||||||
{ type: 'created', displayName: 'Created' },
|
|
||||||
{ type: 'Last seen', displayName: 'Last seen' },
|
|
||||||
{ type: 'project', displayName: 'Project' },
|
|
||||||
{ type: 'metrics', displayName: 'Metrics' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FeatureToggleListActions = ({
|
const FeatureToggleListActions = ({
|
||||||
settings,
|
filter,
|
||||||
|
setFilter,
|
||||||
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
toggleMetrics,
|
|
||||||
updateSetting,
|
|
||||||
loading,
|
loading,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@ -34,65 +24,33 @@ const FeatureToggleListActions = ({
|
|||||||
const ref = useLoading(loading);
|
const ref = useLoading(loading);
|
||||||
|
|
||||||
const handleSort = e => {
|
const handleSort = e => {
|
||||||
const target = e.target.getAttribute('data-target');
|
const type = e.target.getAttribute('data-target')?.trim();
|
||||||
setSort(target);
|
type && setSort(prev => ({ ...prev, type }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDisabled = type => settings.sort === type;
|
const isDisabled = s => s === sort.type;
|
||||||
|
const selectedOption = sortOptions.find(o => o.type === sort.type) || sortOptions[0];
|
||||||
|
|
||||||
const renderSortingOptions = () =>
|
const renderSortingOptions = () =>
|
||||||
sortingOptions.map(option => (
|
sortOptions.map(option => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
style={{ fontSize: '14px' }}
|
style={{ fontSize: '14px' }}
|
||||||
key={option.type}
|
key={option.type}
|
||||||
disabled={isDisabled(option.type)}
|
disabled={isDisabled(option.type)}
|
||||||
data-target={option.type}
|
data-target={option.type}
|
||||||
>
|
>
|
||||||
{option.displayName}
|
{option.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
));
|
));
|
||||||
|
|
||||||
/*
|
|
||||||
const renderMetricsOptions = () => [
|
|
||||||
<MenuItemWithIcon
|
|
||||||
style={{ fontSize: '14px' }}
|
|
||||||
icon={HourglassEmpty}
|
|
||||||
disabled={!settings.showLastHour}
|
|
||||||
data-target="minute"
|
|
||||||
label="Last minute"
|
|
||||||
key={1}
|
|
||||||
/>,
|
|
||||||
<MenuItemWithIcon
|
|
||||||
style={{ fontSize: '14px' }}
|
|
||||||
icon={HourglassFull}
|
|
||||||
disabled={settings.showLastHour}
|
|
||||||
data-target="hour"
|
|
||||||
label="Last hour"
|
|
||||||
key={2}
|
|
||||||
/>,
|
|
||||||
];
|
|
||||||
*/
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.actions} ref={ref}>
|
<div className={styles.actions} ref={ref}>
|
||||||
<Typography variant="body2" data-loading>
|
<Typography variant="body2" data-loading>
|
||||||
Sorted by:
|
Sorted by:
|
||||||
</Typography>
|
</Typography>
|
||||||
{/* }
|
|
||||||
<DropdownMenu
|
|
||||||
id={'metric'}
|
|
||||||
label={`Last ${settings.showLastHour ? 'hour' : 'minute'}`}
|
|
||||||
title="Metric interval"
|
|
||||||
callback={toggleMetrics}
|
|
||||||
renderOptions={renderMetricsOptions}
|
|
||||||
className=""
|
|
||||||
style={{ textTransform: 'lowercase', fontWeight: 'normal' }}
|
|
||||||
data-loading
|
|
||||||
/>
|
|
||||||
{*/}
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
id={'sorting'}
|
id={'sorting'}
|
||||||
label={`By ${settings.sort}`}
|
label={`By ${selectedOption.name}`}
|
||||||
callback={handleSort}
|
callback={handleSort}
|
||||||
renderOptions={renderSortingOptions}
|
renderOptions={renderSortingOptions}
|
||||||
title="Sort by"
|
title="Sort by"
|
||||||
@ -104,8 +62,8 @@ const FeatureToggleListActions = ({
|
|||||||
condition={uiConfig.flags.P}
|
condition={uiConfig.flags.P}
|
||||||
show={
|
show={
|
||||||
<ProjectSelect
|
<ProjectSelect
|
||||||
settings={settings}
|
currentProjectId={filter.project}
|
||||||
updateSetting={updateSetting}
|
updateCurrentProject={project => setFilter(prev => ({ ...prev, project }))}
|
||||||
style={{
|
style={{
|
||||||
textTransform: 'lowercase',
|
textTransform: 'lowercase',
|
||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
@ -119,10 +77,11 @@ const FeatureToggleListActions = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
FeatureToggleListActions.propTypes = {
|
FeatureToggleListActions.propTypes = {
|
||||||
settings: PropTypes.object,
|
filter: PropTypes.object,
|
||||||
|
setFilter: PropTypes.func,
|
||||||
|
sort: PropTypes.object,
|
||||||
setSort: PropTypes.func,
|
setSort: PropTypes.func,
|
||||||
toggleMetrics: PropTypes.func,
|
toggleMetrics: PropTypes.func,
|
||||||
updateSetting: PropTypes.func,
|
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
import { useFeatures } from '../../../hooks/api/getters/useFeatures/useFeatures';
|
||||||
|
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { useFeaturesFilter } from '../../../hooks/useFeaturesFilter';
|
||||||
|
import FeatureToggleList from './FeatureToggleList';
|
||||||
|
import { useFeaturesSort } from '../../../hooks/useFeaturesSort';
|
||||||
|
|
||||||
|
export const FeatureToggleListContainer = () => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const { features, loading } = useFeatures();
|
||||||
|
const { filtered, filter, setFilter } = useFeaturesFilter(features);
|
||||||
|
const { sorted, sort, setSort } = useFeaturesSort(filtered);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureToggleList
|
||||||
|
features={sorted}
|
||||||
|
loading={loading}
|
||||||
|
flags={uiConfig.flags}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
sort={sort}
|
||||||
|
setSort={setSort}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -22,10 +22,6 @@ import PermissionIconButton from '../../../common/PermissionIconButton/Permissio
|
|||||||
|
|
||||||
const FeatureToggleListItem = ({
|
const FeatureToggleListItem = ({
|
||||||
feature,
|
feature,
|
||||||
toggleFeature,
|
|
||||||
settings,
|
|
||||||
metricsLastHour = { yes: 0, no: 0, isFallback: true },
|
|
||||||
metricsLastMinute = { yes: 0, no: 0, isFallback: true },
|
|
||||||
revive,
|
revive,
|
||||||
hasAccess,
|
hasAccess,
|
||||||
flags = {},
|
flags = {},
|
||||||
@ -164,10 +160,6 @@ const FeatureToggleListItem = ({
|
|||||||
|
|
||||||
FeatureToggleListItem.propTypes = {
|
FeatureToggleListItem.propTypes = {
|
||||||
feature: PropTypes.object,
|
feature: PropTypes.object,
|
||||||
toggleFeature: PropTypes.func,
|
|
||||||
settings: PropTypes.object,
|
|
||||||
metricsLastHour: PropTypes.object,
|
|
||||||
metricsLastMinute: PropTypes.object,
|
|
||||||
revive: PropTypes.func,
|
revive: PropTypes.func,
|
||||||
hasAccess: PropTypes.func.isRequired,
|
hasAccess: PropTypes.func.isRequired,
|
||||||
flags: PropTypes.object,
|
flags: PropTypes.object,
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
import React, { memo } from 'react';
|
|
||||||
import { Chip } from '@material-ui/core';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { useStyles } from './styles';
|
|
||||||
|
|
||||||
const FeatureToggleListItemChip = ({ type, types, onClick }) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const typeObject = types.find(o => o.id === type) || {
|
|
||||||
id: type,
|
|
||||||
name: type,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Chip className={styles.typeChip} title={typeObject.description} label={typeObject.name} onClick={onClick} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
FeatureToggleListItemChip.propTypes = {
|
|
||||||
type: PropTypes.string.isRequired,
|
|
||||||
types: PropTypes.array,
|
|
||||||
onClick: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(FeatureToggleListItemChip);
|
|
@ -1,10 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import Component from './FeatureToggleListItemChip';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
types: state.featureTypes.toJS(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const FeatureType = connect(mapStateToProps)(Component);
|
|
||||||
|
|
||||||
export default FeatureType;
|
|
@ -1,10 +0,0 @@
|
|||||||
import { makeStyles } from '@material-ui/styles';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles(theme => ({
|
|
||||||
typeChip: {
|
|
||||||
margin: '0 8px',
|
|
||||||
background: 'transparent',
|
|
||||||
border: `1px solid ${theme.palette.primary.main}`,
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
},
|
|
||||||
}));
|
|
@ -117,7 +117,7 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
<span
|
<span
|
||||||
className="MuiButton-label"
|
className="MuiButton-label"
|
||||||
>
|
>
|
||||||
By name
|
By Name
|
||||||
<span
|
<span
|
||||||
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
||||||
>
|
>
|
||||||
@ -185,12 +185,6 @@ exports[`renders correctly with one feature 1`] = `
|
|||||||
}
|
}
|
||||||
flags={Object {}}
|
flags={Object {}}
|
||||||
hasAccess={[Function]}
|
hasAccess={[Function]}
|
||||||
settings={
|
|
||||||
Object {
|
|
||||||
"sort": "name",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toggleFeature={[MockFunction]}
|
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -315,7 +309,7 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
|||||||
<span
|
<span
|
||||||
className="MuiButton-label"
|
className="MuiButton-label"
|
||||||
>
|
>
|
||||||
By name
|
By Name
|
||||||
<span
|
<span
|
||||||
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
||||||
>
|
>
|
||||||
@ -386,12 +380,6 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
|||||||
}
|
}
|
||||||
flags={Object {}}
|
flags={Object {}}
|
||||||
hasAccess={[Function]}
|
hasAccess={[Function]}
|
||||||
settings={
|
|
||||||
Object {
|
|
||||||
"sort": "name",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toggleFeature={[MockFunction]}
|
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,8 +7,6 @@ import renderer from 'react-test-renderer';
|
|||||||
|
|
||||||
import theme from '../../../../themes/main-theme';
|
import theme from '../../../../themes/main-theme';
|
||||||
|
|
||||||
jest.mock('../FeatureToggleListItem/FeatureToggleListItemChip');
|
|
||||||
|
|
||||||
test('renders correctly with one feature', () => {
|
test('renders correctly with one feature', () => {
|
||||||
const feature = {
|
const feature = {
|
||||||
name: 'Another',
|
name: 'Another',
|
||||||
@ -26,18 +24,12 @@ test('renders correctly with one feature', () => {
|
|||||||
],
|
],
|
||||||
createdAt: '2018-02-04T20:27:52.127Z',
|
createdAt: '2018-02-04T20:27:52.127Z',
|
||||||
};
|
};
|
||||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
|
||||||
const settings = { sort: 'name' };
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<FeatureToggleListItem
|
<FeatureToggleListItem
|
||||||
key={0}
|
key={0}
|
||||||
settings={settings}
|
|
||||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
|
||||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
toggleFeature={jest.fn()}
|
|
||||||
hasAccess={() => true}
|
hasAccess={() => true}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
@ -63,18 +55,12 @@ test('renders correctly with one feature without permission', () => {
|
|||||||
],
|
],
|
||||||
createdAt: '2018-02-04T20:27:52.127Z',
|
createdAt: '2018-02-04T20:27:52.127Z',
|
||||||
};
|
};
|
||||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
|
||||||
const settings = { sort: 'name' };
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<FeatureToggleListItem
|
<FeatureToggleListItem
|
||||||
key={0}
|
key={0}
|
||||||
settings={settings}
|
|
||||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
|
||||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
toggleFeature={jest.fn()}
|
|
||||||
hasAccess={() => true}
|
hasAccess={() => true}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -25,8 +25,7 @@ test('renders correctly with one feature', () => {
|
|||||||
name: 'Another',
|
name: 'Another',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
|
||||||
const settings = { sort: 'name' };
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
@ -35,13 +34,12 @@ test('renders correctly with one feature', () => {
|
|||||||
>
|
>
|
||||||
<FeatureToggleList
|
<FeatureToggleList
|
||||||
updateSetting={jest.fn()}
|
updateSetting={jest.fn()}
|
||||||
settings={settings}
|
filter={{}}
|
||||||
history={{}}
|
setFilter={jest.fn()}
|
||||||
featureMetrics={featureMetrics}
|
sort={{}}
|
||||||
|
setSort={jest.fn()}
|
||||||
features={features}
|
features={features}
|
||||||
toggleFeature={jest.fn()}
|
|
||||||
fetcher={jest.fn()}
|
fetcher={jest.fn()}
|
||||||
currentProjectId="default"
|
|
||||||
flags={{}}
|
flags={{}}
|
||||||
/>
|
/>
|
||||||
</AccessProvider>
|
</AccessProvider>
|
||||||
@ -58,8 +56,6 @@ test('renders correctly with one feature without permissions', () => {
|
|||||||
name: 'Another',
|
name: 'Another',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
|
||||||
const settings = { sort: 'name' };
|
|
||||||
const tree = renderer.create(
|
const tree = renderer.create(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
@ -67,14 +63,12 @@ test('renders correctly with one feature without permissions', () => {
|
|||||||
store={createFakeStore([{ permission: CREATE_FEATURE }])}
|
store={createFakeStore([{ permission: CREATE_FEATURE }])}
|
||||||
>
|
>
|
||||||
<FeatureToggleList
|
<FeatureToggleList
|
||||||
updateSetting={jest.fn()}
|
filter={{}}
|
||||||
settings={settings}
|
setFilter={jest.fn()}
|
||||||
history={{}}
|
sort={{}}
|
||||||
featureMetrics={featureMetrics}
|
setSort={jest.fn()}
|
||||||
features={features}
|
features={features}
|
||||||
toggleFeature={jest.fn()}
|
|
||||||
fetcher={jest.fn()}
|
fetcher={jest.fn()}
|
||||||
currentProjectId="default"
|
|
||||||
flags={{}}
|
flags={{}}
|
||||||
/>
|
/>
|
||||||
</AccessProvider>
|
</AccessProvider>
|
||||||
|
@ -1,151 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import {
|
|
||||||
toggleFeature,
|
|
||||||
fetchFeatureToggles,
|
|
||||||
} from '../../../store/feature-toggle/actions';
|
|
||||||
import { updateSettingForGroup } from '../../../store/settings/actions';
|
|
||||||
import FeatureToggleList from './FeatureToggleList';
|
|
||||||
|
|
||||||
function checkConstraints(strategy, regex) {
|
|
||||||
if (!strategy.constraints) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return strategy.constraints.some(c => c.values.some(v => regex.test(v)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCurrentProjectId(settings) {
|
|
||||||
if (!settings.currentProjectId || settings.currentProjectId === '*') {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
return settings.currentProjectId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mapStateToPropsConfigurable = isFeature => state => {
|
|
||||||
const featureMetrics = state.featureMetrics.toJS();
|
|
||||||
const flags = state.uiConfig.toJS().flags;
|
|
||||||
const settings = state.settings.toJS().feature || {};
|
|
||||||
let features = isFeature
|
|
||||||
? state.features.toJS()
|
|
||||||
: state.archive.get('list').toArray();
|
|
||||||
|
|
||||||
if (settings.currentProjectId && settings.currentProjectId !== '*') {
|
|
||||||
features = features.filter(
|
|
||||||
f => f.project === settings.currentProjectId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (settings.filter) {
|
|
||||||
try {
|
|
||||||
const regex = new RegExp(settings.filter, 'i');
|
|
||||||
features = features.filter(feature => {
|
|
||||||
if (!isFeature) {
|
|
||||||
return (
|
|
||||||
regex.test(feature.name) ||
|
|
||||||
regex.test(feature.description) ||
|
|
||||||
(settings.filter.length > 1 &&
|
|
||||||
regex.test(JSON.stringify(feature)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
feature.strategies.some(s => checkConstraints(s, regex)) ||
|
|
||||||
regex.test(feature.name) ||
|
|
||||||
regex.test(feature.description) ||
|
|
||||||
feature.strategies.some(
|
|
||||||
s => s && s.name && regex.test(s.name)
|
|
||||||
) ||
|
|
||||||
(settings.filter.length > 1 &&
|
|
||||||
regex.test(JSON.stringify(feature)))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Invalid filter regex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!settings.sort) {
|
|
||||||
settings.sort = 'name';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.sort === 'enabled') {
|
|
||||||
features = features.sort((a, b) =>
|
|
||||||
// eslint-disable-next-line
|
|
||||||
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
|
|
||||||
);
|
|
||||||
} else if (settings.sort === 'stale') {
|
|
||||||
features = features.sort((a, b) =>
|
|
||||||
// eslint-disable-next-line
|
|
||||||
a.stale === b.stale ? 0 : a.stale ? -1 : 1
|
|
||||||
);
|
|
||||||
} else if (settings.sort === 'created') {
|
|
||||||
features = features.sort((a, b) =>
|
|
||||||
new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1
|
|
||||||
);
|
|
||||||
} else if (settings.sort === 'Last seen') {
|
|
||||||
features = features.sort((a, b) =>
|
|
||||||
new Date(a.lastSeenAt) > new Date(b.lastSeenAt) ? -1 : 1
|
|
||||||
);
|
|
||||||
} else if (settings.sort === 'name') {
|
|
||||||
features = features.sort((a, b) => {
|
|
||||||
if (a.name < b.name) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (a.name > b.name) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
} else if (settings.sort === 'project') {
|
|
||||||
features = features.sort((a, b) =>
|
|
||||||
a.project.length > b.project.length ? -1 : 1
|
|
||||||
);
|
|
||||||
} else if (settings.sort === 'type') {
|
|
||||||
features = features.sort((a, b) => {
|
|
||||||
if (a.type < b.type) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (a.type > b.type) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
} else if (settings.sort === 'metrics') {
|
|
||||||
const target = settings.showLastHour
|
|
||||||
? featureMetrics.lastHour
|
|
||||||
: featureMetrics.lastMinute;
|
|
||||||
|
|
||||||
features = features.sort((a, b) => {
|
|
||||||
if (!target[a.name]) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (!target[b.name]) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (target[a.name].yes > target[b.name].yes) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
features,
|
|
||||||
currentProjectId: resolveCurrentProjectId(settings),
|
|
||||||
featureMetrics,
|
|
||||||
archive: !isFeature,
|
|
||||||
settings,
|
|
||||||
flags,
|
|
||||||
loading: state.apiCalls.fetchTogglesState.loading,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const mapStateToProps = mapStateToPropsConfigurable(true);
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
toggleFeature,
|
|
||||||
fetcher: () => fetchFeatureToggles(),
|
|
||||||
updateSetting: updateSettingForGroup('feature'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const FeatureToggleListContainer = connect(
|
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(FeatureToggleList);
|
|
||||||
|
|
||||||
export default FeatureToggleListContainer;
|
|
@ -1,25 +1,21 @@
|
|||||||
import { Tooltip } from '@material-ui/core';
|
import { Tooltip } from '@material-ui/core';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { formatDateWithLocale, formatFullDateTimeWithLocale } from '../../../common/util';
|
import { formatDateWithLocale, formatFullDateTimeWithLocale } from '../../../common/util';
|
||||||
|
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
|
||||||
|
|
||||||
interface CreatedAtProps {
|
interface CreatedAtProps {
|
||||||
time: Date;
|
time: Date;
|
||||||
//@ts-ignore
|
|
||||||
location: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreatedAt = ({time, location}: CreatedAtProps) => {
|
const CreatedAt = ({time}: CreatedAtProps) => {
|
||||||
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={`Created at ${formatFullDateTimeWithLocale(time, location.locale)}`}>
|
<Tooltip title={`Created at ${formatFullDateTimeWithLocale(time, locationSettings.locale)}`}>
|
||||||
<span>
|
<span>
|
||||||
{formatDateWithLocale(time, location.locale)}
|
{formatDateWithLocale(time, locationSettings.locale)}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: any) => ({
|
export default CreatedAt;
|
||||||
location: state.settings.toJS().location,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(CreatedAt);
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Redirect, useParams } from 'react-router-dom';
|
import { Redirect, useParams } from 'react-router-dom';
|
||||||
import useFeatures from '../../../hooks/api/getters/useFeatures/useFeatures';
|
import { useFeatures } from '../../../hooks/api/getters/useFeatures/useFeatures';
|
||||||
import { IFeatureToggle } from '../../../interfaces/featureToggle';
|
import { IFeatureToggle } from '../../../interfaces/featureToggle';
|
||||||
import { getTogglePath } from '../../../utils/route-path-helpers';
|
import { getTogglePath } from '../../../utils/route-path-helpers';
|
||||||
|
|
||||||
|
@ -50,9 +50,8 @@ const useFeatureForm = (
|
|||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
projectId: project,
|
description,
|
||||||
description: description,
|
impressionData,
|
||||||
impressionData
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -11,24 +11,21 @@ import EventCard from './EventCard/EventCard';
|
|||||||
import { useStyles } from './EventLog.styles.js';
|
import { useStyles } from './EventLog.styles.js';
|
||||||
|
|
||||||
const EventLog = ({
|
const EventLog = ({
|
||||||
updateSetting,
|
|
||||||
title,
|
title,
|
||||||
history,
|
history,
|
||||||
settings,
|
eventSettings,
|
||||||
|
setEventSettings,
|
||||||
|
locationSettings,
|
||||||
displayInline,
|
displayInline,
|
||||||
location,
|
|
||||||
hideName,
|
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const toggleShowDiff = () => {
|
const toggleShowDiff = () => {
|
||||||
updateSetting('showData', !settings.showData);
|
setEventSettings({ showData: !eventSettings.showData });
|
||||||
};
|
};
|
||||||
const formatFulldateTime = v => {
|
const formatFulldateTime = v => {
|
||||||
return formatFullDateTimeWithLocale(v, location.locale);
|
return formatFullDateTimeWithLocale(v, locationSettings.locale);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showData = settings.showData;
|
|
||||||
|
|
||||||
if (!history || history.length < 0) {
|
if (!history || history.length < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -44,7 +41,7 @@ const EventLog = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (showData) {
|
if (eventSettings.showData) {
|
||||||
entries = history.map(entry => (
|
entries = history.map(entry => (
|
||||||
<EventJson key={`log${entry.id}`} entry={entry} />
|
<EventJson key={`log${entry.id}`} entry={entry} />
|
||||||
));
|
));
|
||||||
@ -63,7 +60,7 @@ const EventLog = ({
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={showData}
|
checked={eventSettings.showData}
|
||||||
onChange={toggleShowDiff}
|
onChange={toggleShowDiff}
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
@ -82,12 +79,12 @@ const EventLog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
EventLog.propTypes = {
|
EventLog.propTypes = {
|
||||||
updateSettings: PropTypes.func,
|
history: PropTypes.array,
|
||||||
|
eventSettings: PropTypes.object.isRequired,
|
||||||
|
setEventSettings: PropTypes.func.isRequired,
|
||||||
|
locationSettings: PropTypes.object.isRequired,
|
||||||
title: PropTypes.string,
|
title: PropTypes.string,
|
||||||
settings: PropTypes.object,
|
|
||||||
displayInline: PropTypes.bool,
|
displayInline: PropTypes.bool,
|
||||||
location: PropTypes.object,
|
|
||||||
hideName: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EventLog;
|
export default EventLog;
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import EventLog from './EventLog';
|
|
||||||
import { updateSettingForGroup } from '../../../store/settings/actions';
|
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
|
||||||
const settings = state.settings.toJS().history || {};
|
|
||||||
const location = state.settings.toJS().location || {};
|
|
||||||
return {
|
|
||||||
settings,
|
|
||||||
location,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const EventLogContainer = connect(mapStateToProps, {
|
|
||||||
updateSetting: updateSettingForGroup('history'),
|
|
||||||
})(EventLog);
|
|
||||||
|
|
||||||
export default EventLogContainer;
|
|
27
frontend/src/component/history/EventLog/index.tsx
Normal file
27
frontend/src/component/history/EventLog/index.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import EventLog from './EventLog';
|
||||||
|
import { useEventSettings } from "../../../hooks/useEventSettings";
|
||||||
|
import { useLocationSettings } from "../../../hooks/useLocationSettings";
|
||||||
|
|
||||||
|
interface IEventLogContainerProps {
|
||||||
|
title: string;
|
||||||
|
history: unknown[];
|
||||||
|
displayInline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventLogContainer = (props: IEventLogContainerProps) => {
|
||||||
|
const { locationSettings } = useLocationSettings();
|
||||||
|
const { eventSettings, setEventSettings } = useEventSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventLog
|
||||||
|
title={props.title}
|
||||||
|
history={props.history}
|
||||||
|
eventSettings={eventSettings}
|
||||||
|
setEventSettings={setEventSettings}
|
||||||
|
locationSettings={locationSettings}
|
||||||
|
displayInline={props.displayInline}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventLogContainer;
|
@ -398,12 +398,7 @@ Array [
|
|||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"component": Object {
|
"component": [Function],
|
||||||
"$$typeof": Symbol(react.memo),
|
|
||||||
"WrappedComponent": [Function],
|
|
||||||
"compare": null,
|
|
||||||
"type": [Function],
|
|
||||||
},
|
|
||||||
"layout": "main",
|
"layout": "main",
|
||||||
"menu": Object {
|
"menu": Object {
|
||||||
"adminSettings": true,
|
"adminSettings": true,
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import CopyFeatureToggle from '../../page/features/copy';
|
import CopyFeatureToggle from '../../page/features/copy';
|
||||||
import Features from '../../page/features';
|
import { FeatureToggleListContainer } from '../feature/FeatureToggleList/FeatureToggleListContainer';
|
||||||
import CreateStrategies from '../../page/strategies/create';
|
import CreateStrategies from '../../page/strategies/create';
|
||||||
import StrategyView from '../../page/strategies/show';
|
import StrategyView from '../../page/strategies/show';
|
||||||
import Strategies from '../../page/strategies';
|
import Strategies from '../../page/strategies';
|
||||||
import HistoryPage from '../../page/history';
|
import HistoryPage from '../../page/history';
|
||||||
import HistoryTogglePage from '../../page/history/toggle';
|
import HistoryTogglePage from '../../page/history/toggle';
|
||||||
import Archive from '../../page/archive';
|
import { ArchiveListContainer } from '../archive/ArchiveListContainer';
|
||||||
|
import Applications from '../../page/applications';
|
||||||
|
import ApplicationView from '../../page/applications/view';
|
||||||
import ListTagTypes from '../../page/tag-types';
|
import ListTagTypes from '../../page/tag-types';
|
||||||
import Addons from '../../page/addons';
|
import Addons from '../../page/addons';
|
||||||
import AddonsCreate from '../../page/addons/create';
|
import AddonsCreate from '../../page/addons/create';
|
||||||
@ -14,7 +16,7 @@ import Admin from '../admin';
|
|||||||
import AdminApi from '../admin/api';
|
import AdminApi from '../admin/api';
|
||||||
import AdminInvoice from '../admin/invoice/InvoiceAdminPage';
|
import AdminInvoice from '../admin/invoice/InvoiceAdminPage';
|
||||||
import AdminUsers from '../admin/users/UsersAdmin';
|
import AdminUsers from '../admin/users/UsersAdmin';
|
||||||
import AdminAuth from '../admin/auth';
|
import { AuthSettings } from '../admin/auth/AuthSettings';
|
||||||
import Login from '../user/Login/Login';
|
import Login from '../user/Login/Login';
|
||||||
import { P, C, E, EEA, RE } from '../common/flags';
|
import { P, C, E, EEA, RE } from '../common/flags';
|
||||||
import NewUser from '../user/NewUser';
|
import NewUser from '../user/NewUser';
|
||||||
@ -22,7 +24,7 @@ import ResetPassword from '../user/ResetPassword/ResetPassword';
|
|||||||
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
|
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
|
||||||
import ProjectListNew from '../project/ProjectList/ProjectList';
|
import ProjectListNew from '../project/ProjectList/ProjectList';
|
||||||
import Project from '../project/Project/Project';
|
import Project from '../project/Project/Project';
|
||||||
import RedirectArchive from '../feature/RedirectArchive/RedirectArchive';
|
import RedirectArchive from '../archive/RedirectArchive';
|
||||||
import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
|
import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
|
||||||
import FeatureView from '../feature/FeatureView/FeatureView';
|
import FeatureView from '../feature/FeatureView/FeatureView';
|
||||||
import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles';
|
import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles';
|
||||||
@ -183,7 +185,7 @@ export const routes = [
|
|||||||
{
|
{
|
||||||
path: '/features',
|
path: '/features',
|
||||||
title: 'Feature Toggles',
|
title: 'Feature Toggles',
|
||||||
component: Features,
|
component: FeatureToggleListContainer,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
menu: { mobile: true },
|
menu: { mobile: true },
|
||||||
@ -373,7 +375,7 @@ export const routes = [
|
|||||||
{
|
{
|
||||||
path: '/archive',
|
path: '/archive',
|
||||||
title: 'Archived Toggles',
|
title: 'Archived Toggles',
|
||||||
component: Archive,
|
component: ArchiveListContainer,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
menu: {},
|
menu: {},
|
||||||
@ -447,7 +449,7 @@ export const routes = [
|
|||||||
path: '/admin/auth',
|
path: '/admin/auth',
|
||||||
parent: '/admin',
|
parent: '/admin',
|
||||||
title: 'Single Sign-On',
|
title: 'Single Sign-On',
|
||||||
component: AdminAuth,
|
component: AuthSettings,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
layout: 'main',
|
layout: 'main',
|
||||||
menu: { adminSettings: true },
|
menu: { adminSettings: true },
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import OutsideClickHandler from 'react-outside-click-handler';
|
import OutsideClickHandler from 'react-outside-click-handler';
|
||||||
|
|
||||||
import { Avatar, Button } from '@material-ui/core';
|
import { Avatar, Button } from '@material-ui/core';
|
||||||
@ -9,16 +8,18 @@ import { useStyles } from './UserProfile.styles';
|
|||||||
import { useCommonStyles } from '../../../common.styles';
|
import { useCommonStyles } from '../../../common.styles';
|
||||||
import UserProfileContent from './UserProfileContent/UserProfileContent';
|
import UserProfileContent from './UserProfileContent/UserProfileContent';
|
||||||
import { IUser } from "../../../interfaces/user";
|
import { IUser } from "../../../interfaces/user";
|
||||||
|
import { ILocationSettings } from "../../../hooks/useLocationSettings";
|
||||||
|
|
||||||
interface IUserProfileProps {
|
interface IUserProfileProps {
|
||||||
profile: IUser
|
profile: IUser
|
||||||
updateSettingLocation: (field: 'locale', value: string) => void
|
locationSettings: ILocationSettings
|
||||||
|
setLocationSettings: React.Dispatch<React.SetStateAction<ILocationSettings>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfile = ({
|
const UserProfile = ({
|
||||||
profile,
|
profile,
|
||||||
location,
|
locationSettings,
|
||||||
updateSettingLocation,
|
setLocationSettings,
|
||||||
}: IUserProfileProps) => {
|
}: IUserProfileProps) => {
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [currentLocale, setCurrentLocale] = useState<string>();
|
const [currentLocale, setCurrentLocale] = useState<string>();
|
||||||
@ -40,17 +41,15 @@ const UserProfile = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const locale = location.locale || navigator.language;
|
|
||||||
let found = possibleLocales.find(l =>
|
let found = possibleLocales.find(l =>
|
||||||
l.toLowerCase().includes(locale.toLowerCase())
|
l.toLowerCase().includes(locationSettings.locale.toLowerCase())
|
||||||
);
|
);
|
||||||
setCurrentLocale(found);
|
setCurrentLocale(found);
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
setPossibleLocales(prev => [...prev, locale]);
|
setPossibleLocales(prev => [...prev, locationSettings.locale]);
|
||||||
}
|
}
|
||||||
/* eslint-disable-next-line*/
|
/* eslint-disable-next-line*/
|
||||||
}, []);
|
}, [locationSettings]);
|
||||||
|
|
||||||
const email = profile ? profile.email : '';
|
const email = profile ? profile.email : '';
|
||||||
const imageUrl = email ? profile.imageUrl : 'unknown-user.png';
|
const imageUrl = email ? profile.imageUrl : 'unknown-user.png';
|
||||||
@ -75,7 +74,7 @@ const UserProfile = ({
|
|||||||
showProfile={showProfile}
|
showProfile={showProfile}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
updateSettingLocation={updateSettingLocation}
|
setLocationSettings={setLocationSettings}
|
||||||
possibleLocales={possibleLocales}
|
possibleLocales={possibleLocales}
|
||||||
setCurrentLocale={setCurrentLocale}
|
setCurrentLocale={setCurrentLocale}
|
||||||
currentLocale={currentLocale}
|
currentLocale={currentLocale}
|
||||||
@ -85,10 +84,4 @@ const UserProfile = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
UserProfile.propTypes = {
|
|
||||||
profile: PropTypes.object,
|
|
||||||
location: PropTypes.object,
|
|
||||||
updateSettingLocation: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserProfile;
|
export default UserProfile;
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import ConditionallyRender from '../../../common/ConditionallyRender';
|
import ConditionallyRender from '../../../common/ConditionallyRender';
|
||||||
import {
|
import {
|
||||||
Paper,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Typography,
|
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
Select,
|
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
} from '@material-ui/core';
|
} from '@material-ui/core';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { useStyles } from './UserProfileContent.styles';
|
import { useStyles } from './UserProfileContent.styles';
|
||||||
@ -17,26 +17,29 @@ import EditProfile from '../EditProfile/EditProfile';
|
|||||||
import legacyStyles from '../../user.module.scss';
|
import legacyStyles from '../../user.module.scss';
|
||||||
import { getBasePath } from '../../../../utils/format-path';
|
import { getBasePath } from '../../../../utils/format-path';
|
||||||
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { IUser } from "../../../../interfaces/user";
|
import { IUser } from '../../../../interfaces/user';
|
||||||
|
import { ILocationSettings } from '../../../../hooks/useLocationSettings';
|
||||||
|
|
||||||
interface IUserProfileContentProps {
|
interface IUserProfileContentProps {
|
||||||
showProfile: boolean
|
showProfile: boolean;
|
||||||
profile: IUser
|
profile: IUser;
|
||||||
possibleLocales: string[]
|
possibleLocales: string[];
|
||||||
updateSettingLocation: (field: 'locale', value: string) => void
|
imageUrl: string;
|
||||||
imageUrl: string
|
currentLocale?: string;
|
||||||
currentLocale?: string
|
setCurrentLocale: (value: string) => void;
|
||||||
setCurrentLocale: (value: string) => void
|
setLocationSettings: React.Dispatch<
|
||||||
|
React.SetStateAction<ILocationSettings>
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfileContent = ({
|
const UserProfileContent = ({
|
||||||
showProfile,
|
showProfile,
|
||||||
profile,
|
profile,
|
||||||
possibleLocales,
|
possibleLocales,
|
||||||
updateSettingLocation,
|
|
||||||
imageUrl,
|
imageUrl,
|
||||||
currentLocale,
|
currentLocale,
|
||||||
setCurrentLocale,
|
setCurrentLocale,
|
||||||
|
setLocationSettings,
|
||||||
}: IUserProfileContentProps) => {
|
}: IUserProfileContentProps) => {
|
||||||
const commonStyles = useCommonStyles();
|
const commonStyles = useCommonStyles();
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
@ -44,10 +47,6 @@ const UserProfileContent = ({
|
|||||||
const [editingProfile, setEditingProfile] = useState(false);
|
const [editingProfile, setEditingProfile] = useState(false);
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
const setLocale = (value: string) => {
|
|
||||||
updateSettingLocation('locale', value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const profileAvatarClasses = classnames(styles.avatar, {
|
const profileAvatarClasses = classnames(styles.avatar, {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@ -61,9 +60,9 @@ const UserProfileContent = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<{ value: unknown }>) => {
|
const handleChange = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||||
const value = e.target.value as string;
|
const locale = e.target.value as string;
|
||||||
setCurrentLocale(value);
|
setCurrentLocale(locale);
|
||||||
setLocale(value);
|
setLocationSettings({ locale });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
import useUser from '../../../hooks/api/getters/useUser/useUser';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import UserProfile from './UserProfile';
|
|
||||||
import { updateSettingForGroup } from '../../../store/settings/actions';
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
updateSettingLocation: updateSettingForGroup('location'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
location: state.settings ? state.settings.toJS().location : {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(
|
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(props => {
|
|
||||||
const user = useUser();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserProfile
|
|
||||||
location={props.location}
|
|
||||||
updateSettingLocation={props.updateSettingLocation}
|
|
||||||
profile={user.user}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
18
frontend/src/component/user/UserProfile/index.tsx
Normal file
18
frontend/src/component/user/UserProfile/index.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import useUser from '../../../hooks/api/getters/useUser/useUser';
|
||||||
|
import UserProfile from './UserProfile';
|
||||||
|
import { useLocationSettings } from '../../../hooks/useLocationSettings';
|
||||||
|
|
||||||
|
const UserProfileContainer = () => {
|
||||||
|
const user = useUser();
|
||||||
|
const { locationSettings, setLocationSettings } = useLocationSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserProfile
|
||||||
|
locationSettings={locationSettings}
|
||||||
|
setLocationSettings={setLocationSettings}
|
||||||
|
profile={user.user}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserProfileContainer;
|
@ -46,7 +46,7 @@ const useAPI = ({
|
|||||||
handleUnauthorized,
|
handleUnauthorized,
|
||||||
propagateErrors = false,
|
propagateErrors = false,
|
||||||
}: IUseAPI) => {
|
}: IUseAPI) => {
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const defaultOptions: RequestInit = {
|
const defaultOptions: RequestInit = {
|
||||||
|
@ -12,7 +12,6 @@ export const handleBadRequest = async (
|
|||||||
if (!setErrors) return;
|
if (!setErrors) return;
|
||||||
if (res) {
|
if (res) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
setErrors({message: data.message});
|
setErrors({message: data.message});
|
||||||
throw new Error(data.message);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import useAPI from '../useApi/useApi';
|
||||||
|
|
||||||
|
export const useFeatureArchiveApi = () => {
|
||||||
|
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||||
|
propagateErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviveFeature = async (feature: string) => {
|
||||||
|
const path = `api/admin/archive/revive/${feature}`;
|
||||||
|
const req = createRequest(path, { method: 'POST' });
|
||||||
|
return makeRequest(req.caller, req.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { reviveFeature, errors, loading };
|
||||||
|
};
|
@ -1,40 +1,39 @@
|
|||||||
import useSWR, { mutate, SWRConfiguration } from 'swr';
|
import useSWR, { mutate, SWRConfiguration } from 'swr';
|
||||||
import { useState, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { formatApiPath } from '../../../../utils/format-path';
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { IFeatureToggle } from '../../../../interfaces/featureToggle';
|
||||||
|
|
||||||
const useFeatures = (options: SWRConfiguration = {}) => {
|
const PATH = formatApiPath('api/admin/features');
|
||||||
const fetcher = async () => {
|
|
||||||
const path = formatApiPath('api/admin/features/');
|
|
||||||
return fetch(path, {
|
|
||||||
method: 'GET',
|
|
||||||
})
|
|
||||||
.then(handleErrorResponses('Features'))
|
|
||||||
.then(res => res.json());
|
|
||||||
};
|
|
||||||
|
|
||||||
const FEATURES_CACHE_KEY = 'api/admin/features/';
|
export interface IUseFeaturesOutput {
|
||||||
|
features: IFeatureToggle[];
|
||||||
|
refetchFeatures: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = useSWR(FEATURES_CACHE_KEY, fetcher, {
|
export const useFeatures = (options?: SWRConfiguration): IUseFeaturesOutput => {
|
||||||
...options,
|
const { data, error } = useSWR<{ features: IFeatureToggle[] }>(
|
||||||
});
|
PATH,
|
||||||
|
fetchFeatures,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(!error && !data);
|
const refetchFeatures = useCallback(() => {
|
||||||
|
mutate(PATH).catch(console.warn);
|
||||||
const refetchFeatures = () => {
|
}, []);
|
||||||
mutate(FEATURES_CACHE_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(!error && !data);
|
|
||||||
}, [data, error]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
features: data?.features || [],
|
features: data?.features || [],
|
||||||
error,
|
loading: !error && !data,
|
||||||
loading,
|
|
||||||
refetchFeatures,
|
refetchFeatures,
|
||||||
|
error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useFeatures;
|
const fetchFeatures = () => {
|
||||||
|
return fetch(PATH, { method: 'GET' })
|
||||||
|
.then(handleErrorResponses('Features'))
|
||||||
|
.then(res => res.json());
|
||||||
|
};
|
||||||
|
@ -0,0 +1,41 @@
|
|||||||
|
import useSWR, { mutate, SWRConfiguration } from 'swr';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { formatApiPath } from '../../../../utils/format-path';
|
||||||
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { IFeatureToggle } from '../../../../interfaces/featureToggle';
|
||||||
|
|
||||||
|
const PATH = formatApiPath('api/admin/archive/features');
|
||||||
|
|
||||||
|
export interface UseFeaturesArchiveOutput {
|
||||||
|
archivedFeatures: IFeatureToggle[];
|
||||||
|
refetchArchived: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFeaturesArchive = (
|
||||||
|
options?: SWRConfiguration
|
||||||
|
): UseFeaturesArchiveOutput => {
|
||||||
|
const { data, error } = useSWR<{ features: IFeatureToggle[] }>(
|
||||||
|
PATH,
|
||||||
|
fetchArchivedFeatures,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchArchived = useCallback(() => {
|
||||||
|
mutate(PATH).catch(console.warn);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
archivedFeatures: data?.features || [],
|
||||||
|
refetchArchived,
|
||||||
|
loading: !error && !data,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchArchivedFeatures = () => {
|
||||||
|
return fetch(PATH, { method: 'GET' })
|
||||||
|
.then(handleErrorResponses('Archive'))
|
||||||
|
.then(res => res.json());
|
||||||
|
};
|
@ -5,7 +5,7 @@ export const defaultValue = {
|
|||||||
version: '3.x',
|
version: '3.x',
|
||||||
environment: '',
|
environment: '',
|
||||||
slogan: 'The enterprise ready feature toggle service.',
|
slogan: 'The enterprise ready feature toggle service.',
|
||||||
flags: { P: false, C: false, E: false },
|
flags: { P: false, C: false, E: false, RE: false },
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
value: 'Documentation',
|
value: 'Documentation',
|
||||||
|
27
frontend/src/hooks/useEventSettings.ts
Normal file
27
frontend/src/hooks/useEventSettings.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { getBasePath } from '../utils/format-path';
|
||||||
|
import { createPersistentGlobalState } from './usePersistentGlobalState';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface IEventSettings {
|
||||||
|
showData: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUseEventSettingsOutput {
|
||||||
|
eventSettings: IEventSettings;
|
||||||
|
setEventSettings: React.Dispatch<React.SetStateAction<IEventSettings>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEventSettings = (): IUseEventSettingsOutput => {
|
||||||
|
const [eventSettings, setEventSettings] = useGlobalState();
|
||||||
|
|
||||||
|
return { eventSettings, setEventSettings };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInitialValue = (): IEventSettings => {
|
||||||
|
return { showData: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
const useGlobalState = createPersistentGlobalState<IEventSettings>(
|
||||||
|
`${getBasePath()}:useEventSettings:v1`,
|
||||||
|
createInitialValue()
|
||||||
|
);
|
117
frontend/src/hooks/useFeaturesFilter.ts
Normal file
117
frontend/src/hooks/useFeaturesFilter.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { IFeatureToggle } from '../interfaces/featureToggle';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { getBasePath } from '../utils/format-path';
|
||||||
|
import { createPersistentGlobalState } from './usePersistentGlobalState';
|
||||||
|
|
||||||
|
export interface IFeaturesFilter {
|
||||||
|
query?: string;
|
||||||
|
project: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFeaturesSortOutput {
|
||||||
|
filtered: IFeatureToggle[];
|
||||||
|
filter: IFeaturesFilter;
|
||||||
|
setFilter: React.Dispatch<React.SetStateAction<IFeaturesFilter>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the features filter state globally, and in localStorage.
|
||||||
|
// When changing the format of IFeaturesFilter, change the version as well.
|
||||||
|
const useFeaturesFilterState = createPersistentGlobalState<IFeaturesFilter>(
|
||||||
|
`${getBasePath()}:useFeaturesFilter:v1`,
|
||||||
|
{ project: '*' }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useFeaturesFilter = (
|
||||||
|
features: IFeatureToggle[]
|
||||||
|
): IFeaturesSortOutput => {
|
||||||
|
const [filter, setFilter] = useFeaturesFilterState();
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return filterFeatures(features, filter);
|
||||||
|
}, [features, filter]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setFilter,
|
||||||
|
filter,
|
||||||
|
filtered,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return the current project ID a project has been selected,
|
||||||
|
// or the 'default' project if showing all projects.
|
||||||
|
export const resolveFilteredProjectId = (filter: IFeaturesFilter): string => {
|
||||||
|
if (!filter.project || filter.project === '*') {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter.project;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterFeatures = (
|
||||||
|
features: IFeatureToggle[],
|
||||||
|
filter: IFeaturesFilter
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
return filterFeaturesByQuery(
|
||||||
|
filterFeaturesByProject(features, filter),
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterFeaturesByProject = (
|
||||||
|
features: IFeatureToggle[],
|
||||||
|
filter: IFeaturesFilter
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
return filter.project === '*'
|
||||||
|
? features
|
||||||
|
: features.filter(f => f.project === filter.project);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterFeaturesByQuery = (
|
||||||
|
features: IFeatureToggle[],
|
||||||
|
filter: IFeaturesFilter
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
if (!filter.query) {
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse the search query as a RegExp.
|
||||||
|
// Return all features if it can't be parsed.
|
||||||
|
try {
|
||||||
|
const regExp = new RegExp(filter.query, 'i');
|
||||||
|
return features.filter(f => filterFeatureByRegExp(f, filter, regExp));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
return features;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterFeatureByRegExp = (
|
||||||
|
feature: IFeatureToggle,
|
||||||
|
filter: IFeaturesFilter,
|
||||||
|
regExp: RegExp
|
||||||
|
): boolean => {
|
||||||
|
if (regExp.test(feature.name) || regExp.test(feature.description)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
filter.query &&
|
||||||
|
filter.query.length > 1 &&
|
||||||
|
regExp.test(JSON.stringify(feature))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feature.strategies) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return feature.strategies.some(
|
||||||
|
s =>
|
||||||
|
regExp.test(s.name) ||
|
||||||
|
s.constraints.some(c => c.values.some(v => regExp.test(v)))
|
||||||
|
);
|
||||||
|
};
|
137
frontend/src/hooks/useFeaturesSort.ts
Normal file
137
frontend/src/hooks/useFeaturesSort.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { IFeatureToggle } from '../interfaces/featureToggle';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { getBasePath } from '../utils/format-path';
|
||||||
|
import { createPersistentGlobalState } from './usePersistentGlobalState';
|
||||||
|
|
||||||
|
type FeaturesSortType =
|
||||||
|
| 'name'
|
||||||
|
| 'type'
|
||||||
|
| 'enabled'
|
||||||
|
| 'stale'
|
||||||
|
| 'created'
|
||||||
|
| 'last-seen'
|
||||||
|
| 'project';
|
||||||
|
|
||||||
|
interface IFeaturesSort {
|
||||||
|
type: FeaturesSortType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFeaturesSortOutput {
|
||||||
|
sort: IFeaturesSort;
|
||||||
|
sorted: IFeatureToggle[];
|
||||||
|
setSort: React.Dispatch<React.SetStateAction<IFeaturesSort>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFeaturesFilterSortOption {
|
||||||
|
type: FeaturesSortType;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the features sort state globally, and in localStorage.
|
||||||
|
// When changing the format of IFeaturesSort, change the version as well.
|
||||||
|
const useFeaturesSortState = createPersistentGlobalState<IFeaturesSort>(
|
||||||
|
`${getBasePath()}:useFeaturesSort:v1`,
|
||||||
|
{ type: 'name' }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useFeaturesSort = (
|
||||||
|
features: IFeatureToggle[]
|
||||||
|
): IFeaturesSortOutput => {
|
||||||
|
const [sort, setSort] = useFeaturesSortState();
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return sortFeatures(features, sort);
|
||||||
|
}, [features, sort]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setSort,
|
||||||
|
sort,
|
||||||
|
sorted,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFeaturesFilterSortOptions =
|
||||||
|
(): IFeaturesFilterSortOption[] => {
|
||||||
|
return [
|
||||||
|
{ type: 'name', name: 'Name' },
|
||||||
|
{ type: 'type', name: 'Type' },
|
||||||
|
{ type: 'enabled', name: 'Enabled' },
|
||||||
|
{ type: 'stale', name: 'Stale' },
|
||||||
|
{ type: 'created', name: 'Created' },
|
||||||
|
{ type: 'last-seen', name: 'Last seen' },
|
||||||
|
{ type: 'project', name: 'Project' },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortFeatures = (
|
||||||
|
features: IFeatureToggle[],
|
||||||
|
sort: IFeaturesSort
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
switch (sort.type) {
|
||||||
|
case 'enabled':
|
||||||
|
return sortByEnabled(features);
|
||||||
|
case 'stale':
|
||||||
|
return sortByStale(features);
|
||||||
|
case 'created':
|
||||||
|
return sortByCreated(features);
|
||||||
|
case 'last-seen':
|
||||||
|
return sortByLastSeen(features);
|
||||||
|
case 'name':
|
||||||
|
return sortByName(features);
|
||||||
|
case 'project':
|
||||||
|
return sortByProject(features);
|
||||||
|
case 'type':
|
||||||
|
return sortByType(features);
|
||||||
|
default:
|
||||||
|
console.error(`Unknown feature sort type: ${sort.type}`);
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByEnabled = (
|
||||||
|
features: Readonly<IFeatureToggle[]>
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
return [...features].sort((a, b) =>
|
||||||
|
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByStale = (
|
||||||
|
features: Readonly<IFeatureToggle[]>
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
return [...features].sort((a, b) =>
|
||||||
|
a.stale === b.stale ? 0 : a.stale ? -1 : 1
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByLastSeen = (
|
||||||
|
features: Readonly<IFeatureToggle[]>
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
return [...features].sort((a, b) =>
|
||||||
|
a.lastSeenAt && b.lastSeenAt
|
||||||
|
? a.lastSeenAt.localeCompare(b.lastSeenAt)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByCreated = (
|
||||||
|
features: Readonly<IFeatureToggle[]>
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
return [...features].sort((a, b) =>
|
||||||
|
new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByName = (features: Readonly<IFeatureToggle[]>): IFeatureToggle[] => {
|
||||||
|
return [...features].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByProject = (
|
||||||
|
features: Readonly<IFeatureToggle[]>
|
||||||
|
): IFeatureToggle[] => {
|
||||||
|
return [...features].sort((a, b) => a.project.localeCompare(b.project));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortByType = (features: Readonly<IFeatureToggle[]>): IFeatureToggle[] => {
|
||||||
|
return [...features].sort((a, b) => a.type.localeCompare(b.type));
|
||||||
|
};
|
29
frontend/src/hooks/useLocationSettings.ts
Normal file
29
frontend/src/hooks/useLocationSettings.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { getBasePath } from '../utils/format-path';
|
||||||
|
import { createPersistentGlobalState } from './usePersistentGlobalState';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface ILocationSettings {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUseLocationSettingsOutput {
|
||||||
|
locationSettings: ILocationSettings;
|
||||||
|
setLocationSettings: React.Dispatch<
|
||||||
|
React.SetStateAction<ILocationSettings>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLocationSettings = (): IUseLocationSettingsOutput => {
|
||||||
|
const [locationSettings, setLocationSettings] = useGlobalState();
|
||||||
|
|
||||||
|
return { locationSettings, setLocationSettings };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInitialValue = (): ILocationSettings => {
|
||||||
|
return { locale: navigator.language };
|
||||||
|
};
|
||||||
|
|
||||||
|
const useGlobalState = createPersistentGlobalState<ILocationSettings>(
|
||||||
|
`${getBasePath()}:useLocationSettings:v1`,
|
||||||
|
createInitialValue()
|
||||||
|
);
|
29
frontend/src/hooks/usePersistentGlobalState.ts
Normal file
29
frontend/src/hooks/usePersistentGlobalState.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createGlobalState } from 'react-hooks-global-state';
|
||||||
|
import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage';
|
||||||
|
|
||||||
|
type UsePersistentGlobalState<T> = () => [
|
||||||
|
value: T,
|
||||||
|
setValue: React.Dispatch<React.SetStateAction<T>>
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a hook that stores global state (shared across all hook instances).
|
||||||
|
// The state is also persisted to localStorage and restored on page load.
|
||||||
|
// The localStorage state is not synced between tabs.
|
||||||
|
export const createPersistentGlobalState = <T extends object>(
|
||||||
|
key: string,
|
||||||
|
initialValue: T
|
||||||
|
): UsePersistentGlobalState<T> => {
|
||||||
|
const container = createGlobalState<{ [key: string]: T }>({
|
||||||
|
[key]: getLocalStorageItem(key) ?? initialValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setGlobalState = (value: React.SetStateAction<T>) => {
|
||||||
|
const prev = container.getGlobalState(key);
|
||||||
|
const next = typeof value === 'function' ? value(prev) : value;
|
||||||
|
container.setGlobalState(key, next);
|
||||||
|
setLocalStorageItem(key, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => [container.useGlobalState(key)[0], setGlobalState];
|
||||||
|
};
|
@ -32,8 +32,9 @@ export interface IFeatureTogglePayload {
|
|||||||
export interface IFeatureToggle {
|
export interface IFeatureToggle {
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
createdAt: Date;
|
enabled?: boolean;
|
||||||
lastSeenAt?: Date;
|
createdAt: string;
|
||||||
|
lastSeenAt?: string;
|
||||||
description: string;
|
description: string;
|
||||||
environments: IFeatureEnvironment[];
|
environments: IFeatureEnvironment[];
|
||||||
name: string;
|
name: string;
|
||||||
@ -41,6 +42,7 @@ export interface IFeatureToggle {
|
|||||||
type: string;
|
type: string;
|
||||||
variants: IFeatureVariant[];
|
variants: IFeatureVariant[];
|
||||||
impressionData: boolean;
|
impressionData: boolean;
|
||||||
|
strategies?: IFeatureStrategy[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureEnvironment {
|
export interface IFeatureEnvironment {
|
||||||
|
@ -15,6 +15,7 @@ export interface IFlags {
|
|||||||
C: boolean;
|
C: boolean;
|
||||||
P: boolean;
|
P: boolean;
|
||||||
E: boolean;
|
E: boolean;
|
||||||
|
RE: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Archive from '../../component/archive/archive-list-container';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const render = ({ match: { params }, history }) => <Archive name={params.name} history={history} />;
|
|
||||||
render.propTypes = {
|
|
||||||
match: PropTypes.object,
|
|
||||||
history: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default render;
|
|
@ -1,11 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import FeatureListContainer from '../../component/feature/FeatureToggleList';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const render = ({ history }) => <FeatureListContainer history={history} />;
|
|
||||||
|
|
||||||
render.propTypes = {
|
|
||||||
history: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default render;
|
|
@ -1,32 +0,0 @@
|
|||||||
import api from './api';
|
|
||||||
import { dispatchError } from '../util';
|
|
||||||
|
|
||||||
export const REVIVE_TOGGLE = 'REVIVE_TOGGLE';
|
|
||||||
export const RECEIVE_ARCHIVE = 'RECEIVE_ARCHIVE';
|
|
||||||
export const ERROR_RECEIVE_ARCHIVE = 'ERROR_RECEIVE_ARCHIVE';
|
|
||||||
|
|
||||||
const receiveArchive = json => ({
|
|
||||||
type: RECEIVE_ARCHIVE,
|
|
||||||
value: json.features,
|
|
||||||
});
|
|
||||||
|
|
||||||
const reviveToggle = archiveFeatureToggle => ({
|
|
||||||
type: REVIVE_TOGGLE,
|
|
||||||
value: archiveFeatureToggle,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function revive(featureToggle) {
|
|
||||||
return dispatch =>
|
|
||||||
api
|
|
||||||
.revive(featureToggle)
|
|
||||||
.then(() => dispatch(reviveToggle(featureToggle)))
|
|
||||||
.catch(dispatchError(dispatch, ERROR_RECEIVE_ARCHIVE));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchArchive() {
|
|
||||||
return dispatch =>
|
|
||||||
api
|
|
||||||
.fetchAll()
|
|
||||||
.then(json => dispatch(receiveArchive(json)))
|
|
||||||
.catch(dispatchError(dispatch, ERROR_RECEIVE_ARCHIVE));
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { formatApiPath } from '../../utils/format-path';
|
|
||||||
import { throwIfNotSuccess, headers } from '../api-helper';
|
|
||||||
|
|
||||||
const URI = formatApiPath('api/admin/archive');
|
|
||||||
|
|
||||||
function fetchAll() {
|
|
||||||
return fetch(`${URI}/features`, { credentials: 'include' })
|
|
||||||
.then(throwIfNotSuccess)
|
|
||||||
.then(response => response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
function revive(featureName) {
|
|
||||||
return fetch(`${URI}/revive/${featureName}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
credentials: 'include',
|
|
||||||
}).then(throwIfNotSuccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
fetchAll,
|
|
||||||
revive,
|
|
||||||
};
|
|
@ -1,23 +0,0 @@
|
|||||||
import { List, Map as $Map } from 'immutable';
|
|
||||||
import { RECEIVE_ARCHIVE, REVIVE_TOGGLE } from './actions';
|
|
||||||
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
|
|
||||||
|
|
||||||
function getInitState() {
|
|
||||||
return new $Map({ list: new List() });
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveStore = (state = getInitState(), action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case REVIVE_TOGGLE:
|
|
||||||
return state.update('list', list => list.filter(item => item.name !== action.value));
|
|
||||||
case RECEIVE_ARCHIVE:
|
|
||||||
return state.set('list', new List(action.value));
|
|
||||||
case USER_LOGOUT:
|
|
||||||
case USER_LOGIN:
|
|
||||||
return getInitState();
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default archiveStore;
|
|
@ -1,91 +0,0 @@
|
|||||||
import api from './api';
|
|
||||||
import { dispatchError } from '../util';
|
|
||||||
export const RECIEVE_GOOGLE_CONFIG = 'RECIEVE_GOOGLE_CONFIG';
|
|
||||||
export const RECIEVE_GOOGLE_CONFIG_ERROR = 'RECIEVE_GOOGLE_CONFIG_ERROR';
|
|
||||||
export const UPDATE_GOOGLE_AUTH = 'UPDATE_GOOGLE_AUTH';
|
|
||||||
export const UPDATE_GOOGLE_AUTH_ERROR = 'UPDATE_GOOGLE_AUTH_ERROR';
|
|
||||||
export const RECIEVE_SAML_CONFIG = 'RECIEVE_SAML_CONFIG';
|
|
||||||
export const RECIEVE_SAML_CONFIG_ERROR = 'RECIEVE_SAML_CONFIG_ERROR';
|
|
||||||
export const UPDATE_SAML_AUTH = 'UPDATE_SAML_AUTH';
|
|
||||||
export const UPDATE_SAML_AUTH_ERROR = 'UPDATE_SAML_AUTH_ERROR';
|
|
||||||
export const RECIEVE_OIDC_CONFIG = 'RECIEVE_OIDC_CONFIG';
|
|
||||||
export const RECIEVE_OIDC_CONFIG_ERROR = 'RECIEVE_OIDC_CONFIG_ERROR';
|
|
||||||
export const UPDATE_OIDC_AUTH = 'UPDATE_OIDC_AUTH';
|
|
||||||
export const UPDATE_OIDC_AUTH_ERROR = 'UPDATE_OIDC_AUTH_ERROR';
|
|
||||||
|
|
||||||
const debug = require('debug')('unleash:e-admin-auth-actions');
|
|
||||||
|
|
||||||
export function getGoogleConfig() {
|
|
||||||
debug('Start fetching google-auth config');
|
|
||||||
return dispatch =>
|
|
||||||
api
|
|
||||||
.getGoogleConfig()
|
|
||||||
.then(config =>
|
|
||||||
dispatch({
|
|
||||||
type: RECIEVE_GOOGLE_CONFIG,
|
|
||||||
config,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch(dispatchError(dispatch, RECIEVE_GOOGLE_CONFIG_ERROR));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateGoogleConfig(data) {
|
|
||||||
return dispatch =>
|
|
||||||
api
|
|
||||||
.updateGoogleConfig(data)
|
|
||||||
.then(config => dispatch({ type: UPDATE_GOOGLE_AUTH, config }))
|
|
||||||
.catch(e => {
|
|
||||||
dispatchError(dispatch, UPDATE_GOOGLE_AUTH_ERROR)(e);
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSamlConfig() {
|
|
||||||
debug('Start fetching Saml-auth config');
|
|
||||||
return dispatch =>
|
|
||||||
api
|
|
||||||
.getSamlConfig()
|
|
||||||
.then(config =>
|
|
||||||
dispatch({
|
|
||||||
type: RECIEVE_SAML_CONFIG,
|
|
||||||
config,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch(dispatchError(dispatch, RECIEVE_SAML_CONFIG_ERROR));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSamlConfig(data) {
|
|
||||||
return dispatch =>
|
|
||||||
api
|
|
||||||
.updateSamlConfig(data)
|
|
||||||
.then(config => dispatch({ type: UPDATE_SAML_AUTH, config }))
|
|
||||||
.catch(e => {
|
|
||||||
dispatchError(dispatch, UPDATE_SAML_AUTH_ERROR)(e);
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOidcConfig() {
|
|
||||||
debug('Start fetching OIDC-auth config');
|
|
||||||
return dispatch =>
|
|
||||||
api
|
|
||||||
.getOidcConfig()
|
|
||||||
.then(config =>
|
|
||||||
dispatch({
|
|
||||||
type: RECIEVE_OIDC_CONFIG,
|
|
||||||
config,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch(dispatchError(dispatch, RECIEVE_OIDC_CONFIG_ERROR));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateOidcConfig(data) {
|
|
||||||
return dispatch =>
|
|
||||||
api
|
|
||||||
.updateOidcConfig(data)
|
|
||||||
.then(config => dispatch({ type: UPDATE_OIDC_AUTH, config }))
|
|
||||||
.catch(e => {
|
|
||||||
dispatchError(dispatch, UPDATE_OIDC_AUTH_ERROR)(e);
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,66 +0,0 @@
|
|||||||
import { throwIfNotSuccess, headers } from '../api-helper';
|
|
||||||
import { formatApiPath } from '../../utils/format-path';
|
|
||||||
|
|
||||||
const GOOGLE_URI = formatApiPath('api/admin/auth/google/settings');
|
|
||||||
const SAML_URI = formatApiPath('api/admin/auth/saml/settings');
|
|
||||||
const OIDC_URI = formatApiPath('api/admin/auth/oidc/settings');
|
|
||||||
|
|
||||||
function getGoogleConfig() {
|
|
||||||
return fetch(GOOGLE_URI, { headers, credentials: 'include' })
|
|
||||||
.then(throwIfNotSuccess)
|
|
||||||
.then(response => response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateGoogleConfig(data) {
|
|
||||||
return fetch(GOOGLE_URI, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
.then(throwIfNotSuccess)
|
|
||||||
.then(response => response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSamlConfig() {
|
|
||||||
return fetch(SAML_URI, { headers, credentials: 'include' })
|
|
||||||
.then(throwIfNotSuccess)
|
|
||||||
.then(response => response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSamlConfig(data) {
|
|
||||||
return fetch(SAML_URI, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
.then(throwIfNotSuccess)
|
|
||||||
.then(response => response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOidcConfig() {
|
|
||||||
return fetch(OIDC_URI, { headers, credentials: 'include' })
|
|
||||||
.then(throwIfNotSuccess)
|
|
||||||
.then(response => response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOidcConfig(data) {
|
|
||||||
return fetch(OIDC_URI, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
.then(throwIfNotSuccess)
|
|
||||||
.then(response => response.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
getGoogleConfig,
|
|
||||||
updateGoogleConfig,
|
|
||||||
getSamlConfig,
|
|
||||||
updateSamlConfig,
|
|
||||||
getOidcConfig,
|
|
||||||
updateOidcConfig,
|
|
||||||
};
|
|
@ -1,20 +0,0 @@
|
|||||||
import { Map as $Map } from 'immutable';
|
|
||||||
import { RECIEVE_GOOGLE_CONFIG, UPDATE_GOOGLE_AUTH, RECIEVE_SAML_CONFIG, UPDATE_SAML_AUTH, UPDATE_OIDC_AUTH, RECIEVE_OIDC_CONFIG } from './actions';
|
|
||||||
|
|
||||||
const store = (state = new $Map({ google: {}, saml: {}, oidc: {} }), action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case UPDATE_GOOGLE_AUTH:
|
|
||||||
case RECIEVE_GOOGLE_CONFIG:
|
|
||||||
return state.set('google', action.config);
|
|
||||||
case UPDATE_SAML_AUTH:
|
|
||||||
case RECIEVE_SAML_CONFIG:
|
|
||||||
return state.set('saml', action.config);
|
|
||||||
case UPDATE_OIDC_AUTH:
|
|
||||||
case RECIEVE_OIDC_CONFIG:
|
|
||||||
return state.set('oidc', action.config);
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default store;
|
|
@ -6,16 +6,13 @@ import featureTags from './feature-tags';
|
|||||||
import tagTypes from './tag-type';
|
import tagTypes from './tag-type';
|
||||||
import tags from './tag';
|
import tags from './tag';
|
||||||
import strategies from './strategy';
|
import strategies from './strategy';
|
||||||
import archive from './archive';
|
|
||||||
import error from './error';
|
import error from './error';
|
||||||
import settings from './settings';
|
|
||||||
import user from './user';
|
import user from './user';
|
||||||
import applications from './application';
|
import applications from './application';
|
||||||
import uiConfig from './ui-config';
|
import uiConfig from './ui-config';
|
||||||
import context from './context';
|
import context from './context';
|
||||||
import projects from './project';
|
import projects from './project';
|
||||||
import addons from './addons';
|
import addons from './addons';
|
||||||
import authAdmin from './e-admin-auth';
|
|
||||||
import apiCalls from './api-calls';
|
import apiCalls from './api-calls';
|
||||||
import invoiceAdmin from './e-admin-invoice';
|
import invoiceAdmin from './e-admin-invoice';
|
||||||
import feedback from './feedback';
|
import feedback from './feedback';
|
||||||
@ -28,16 +25,13 @@ const unleashStore = combineReducers({
|
|||||||
tagTypes,
|
tagTypes,
|
||||||
tags,
|
tags,
|
||||||
featureTags,
|
featureTags,
|
||||||
archive,
|
|
||||||
error,
|
error,
|
||||||
settings,
|
|
||||||
user,
|
user,
|
||||||
applications,
|
applications,
|
||||||
uiConfig,
|
uiConfig,
|
||||||
context,
|
context,
|
||||||
projects,
|
projects,
|
||||||
addons,
|
addons,
|
||||||
authAdmin,
|
|
||||||
apiCalls,
|
apiCalls,
|
||||||
invoiceAdmin,
|
invoiceAdmin,
|
||||||
feedback,
|
feedback,
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
export const UPDATE_SETTING = 'UPDATE_SETTING';
|
|
||||||
|
|
||||||
export const updateSetting = (group, field, value) => ({
|
|
||||||
type: UPDATE_SETTING,
|
|
||||||
group,
|
|
||||||
field,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateSettingForGroup = group => (field, value) => updateSetting(group, field, value);
|
|
@ -1,44 +0,0 @@
|
|||||||
import { fromJS } from 'immutable';
|
|
||||||
import { UPDATE_SETTING } from './actions';
|
|
||||||
import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
|
|
||||||
|
|
||||||
import { getBasePath } from '../../utils/format-path';
|
|
||||||
|
|
||||||
const localStorage = window.localStorage || {
|
|
||||||
setItem: () => {},
|
|
||||||
getItem: () => {},
|
|
||||||
};
|
|
||||||
const basePath = getBasePath();
|
|
||||||
const SETTINGS = `${basePath}:settings`;
|
|
||||||
|
|
||||||
const DEFAULT = fromJS({ location: {} });
|
|
||||||
|
|
||||||
function getInitState() {
|
|
||||||
try {
|
|
||||||
const state = JSON.parse(localStorage.getItem(SETTINGS));
|
|
||||||
return state ? DEFAULT.merge(state) : DEFAULT;
|
|
||||||
} catch (e) {
|
|
||||||
return DEFAULT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSetting(state, action) {
|
|
||||||
const newState = state.updateIn([action.group, action.field], () => action.value);
|
|
||||||
|
|
||||||
localStorage.setItem(SETTINGS, JSON.stringify(newState.toJSON()));
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingStore = (state = getInitState(), action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case UPDATE_SETTING:
|
|
||||||
return updateSetting(state, action);
|
|
||||||
case USER_LOGOUT:
|
|
||||||
case USER_LOGIN:
|
|
||||||
return getInitState();
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default settingStore;
|
|
8
frontend/src/utils/format-unknown-error.test.ts
Normal file
8
frontend/src/utils/format-unknown-error.test.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { formatUnknownError } from './format-unknown-error';
|
||||||
|
|
||||||
|
test('formatUnknownError', () => {
|
||||||
|
expect(formatUnknownError(1)).toEqual('Unknown error');
|
||||||
|
expect(formatUnknownError('1')).toEqual('1');
|
||||||
|
expect(formatUnknownError(new Error('1'))).toEqual('1');
|
||||||
|
expect(formatUnknownError(new Error())).toEqual('Error');
|
||||||
|
});
|
10
frontend/src/utils/format-unknown-error.ts
Normal file
10
frontend/src/utils/format-unknown-error.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Get a human-readable error message string from a caught value.
|
||||||
|
export const formatUnknownError = (error: unknown): string => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || error.toString();
|
||||||
|
} else if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
} else {
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
};
|
11
frontend/src/utils/remove-empty-string-fields.test.ts
Normal file
11
frontend/src/utils/remove-empty-string-fields.test.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { removeEmptyStringFields } from './remove-empty-string-fields';
|
||||||
|
|
||||||
|
test('removeEmptyStringFields', () => {
|
||||||
|
expect(removeEmptyStringFields({})).toEqual({});
|
||||||
|
expect(removeEmptyStringFields({ a: undefined })).toEqual({ a: undefined });
|
||||||
|
expect(removeEmptyStringFields({ a: 0 })).toEqual({ a: 0 });
|
||||||
|
expect(removeEmptyStringFields({ a: 1 })).toEqual({ a: 1 });
|
||||||
|
expect(removeEmptyStringFields({ a: '1' })).toEqual({ a: '1' });
|
||||||
|
expect(removeEmptyStringFields({ a: '' })).toEqual({});
|
||||||
|
expect(removeEmptyStringFields({ a: '', b: '2' })).toEqual({ b: '2' });
|
||||||
|
});
|
9
frontend/src/utils/remove-empty-string-fields.ts
Normal file
9
frontend/src/utils/remove-empty-string-fields.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// Remove fields from an object if their value is the empty string.
|
||||||
|
export const removeEmptyStringFields = (object: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}): { [key: string]: unknown } => {
|
||||||
|
const entries = Object.entries(object);
|
||||||
|
const filtered = entries.filter(([, v]) => v !== '');
|
||||||
|
|
||||||
|
return Object.fromEntries(filtered);
|
||||||
|
};
|
29
frontend/src/utils/storage.ts
Normal file
29
frontend/src/utils/storage.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// Get an item from localStorage.
|
||||||
|
// Returns undefined if the browser denies access.
|
||||||
|
export function getLocalStorageItem<T>(key: string): T | undefined {
|
||||||
|
try {
|
||||||
|
return parseStoredItem<T>(window.localStorage.getItem(key));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store an item in localStorage.
|
||||||
|
// Does nothing if the browser denies access.
|
||||||
|
export function setLocalStorageItem(key: string, value: unknown) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse an item from localStorage.
|
||||||
|
// Returns undefined if the item could not be parsed.
|
||||||
|
function parseStoredItem<T>(data: string | null): T | undefined {
|
||||||
|
try {
|
||||||
|
return data ? JSON.parse(data) : undefined;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
}
|
@ -10310,6 +10310,11 @@ react-error-overlay@^6.0.9:
|
|||||||
resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz"
|
resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz"
|
||||||
integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
|
integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
|
||||||
|
|
||||||
|
react-hooks-global-state@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-hooks-global-state/-/react-hooks-global-state-1.0.2.tgz#37bbc3203a0be9f3ac0658abfd28dd7ce7ee166c"
|
||||||
|
integrity sha512-UcWz+VjcUUCQ7bXGmOhanGII3j22zyPSjwJnQWeycxFYj/etBxIbz9xziEm4sv5+OqGuS7bzvpx24XkCxgJ7Bg==
|
||||||
|
|
||||||
react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6:
|
react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6:
|
||||||
version "16.13.1"
|
version "16.13.1"
|
||||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
||||||
|
Loading…
Reference in New Issue
Block a user