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-html5-backend": "14.1.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-hooks-global-state": "^1.0.2",
|
||||
"react-outside-click-handler": "1.3.0",
|
||||
"react-redux": "7.2.6",
|
||||
"react-router-dom": "5.3.0",
|
||||
|
@ -31,6 +31,7 @@ import Dialogue from '../../../common/Dialogue';
|
||||
import { CREATE_API_TOKEN_BUTTON } from '../../../../testIds';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
|
||||
|
||||
interface IApiToken {
|
||||
createdAt: Date;
|
||||
@ -41,16 +42,13 @@ interface IApiToken {
|
||||
environment: string;
|
||||
}
|
||||
|
||||
interface IApiTokenList {
|
||||
location: any;
|
||||
}
|
||||
|
||||
const ApiTokenList = ({ location }: IApiTokenList) => {
|
||||
const ApiTokenList = () => {
|
||||
const styles = useStyles();
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { uiConfig } = useUiConfig();
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [delToken, setDeleteToken] = useState<IApiToken>();
|
||||
const { locationSettings } = useLocationSettings()
|
||||
const { setToastData } = useToast();
|
||||
const { tokens, loading, refetch, error } = useApiTokens();
|
||||
const { deleteToken } = useApiTokensApi();
|
||||
@ -150,7 +148,7 @@ const ApiTokenList = ({ location }: IApiTokenList) => {
|
||||
>
|
||||
{formatDateWithLocale(
|
||||
item.createdAt,
|
||||
location.locale
|
||||
locationSettings.locale
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
|
@ -5,7 +5,7 @@ import AdminMenu from '../menu/AdminMenu';
|
||||
import usePermissions from '../../../hooks/usePermissions';
|
||||
import ConditionallyRender from '../../common/ConditionallyRender';
|
||||
|
||||
const ApiPage = ({ history, location }) => {
|
||||
const ApiPage = ({ history }) => {
|
||||
const { isAdmin } = usePermissions();
|
||||
|
||||
return (
|
||||
@ -14,7 +14,7 @@ const ApiPage = ({ history, location }) => {
|
||||
condition={isAdmin()}
|
||||
show={<AdminMenu history={history} />}
|
||||
/>
|
||||
<ApiTokenList location={location} />
|
||||
<ApiTokenList />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -22,7 +22,6 @@ const ApiPage = ({ history, location }) => {
|
||||
ApiPage.propTypes = {
|
||||
match: PropTypes.object.isRequired,
|
||||
history: PropTypes.object.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ApiPage;
|
||||
|
@ -1,16 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AdminMenu from '../menu/AdminMenu';
|
||||
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 PageContent from '../../common/PageContent/PageContent';
|
||||
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 = [
|
||||
{
|
||||
label: 'OpenID Connect',
|
||||
@ -22,7 +24,7 @@ function AdminAuthPage({ authenticationType, history }) {
|
||||
},
|
||||
{
|
||||
label: 'Password',
|
||||
component: <PasswordAuthSettings />,
|
||||
component: <PasswordAuth />,
|
||||
},
|
||||
{
|
||||
label: 'Google',
|
||||
@ -32,7 +34,7 @@ function AdminAuthPage({ authenticationType, history }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminMenu history={history} />
|
||||
<AdminMenu />
|
||||
<PageContent headerContent="Single Sign-On">
|
||||
<ConditionallyRender
|
||||
condition={authenticationType === 'enterprise'}
|
||||
@ -80,12 +82,4 @@ function AdminAuthPage({ authenticationType, history }) {
|
||||
</PageContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AdminAuthPage.propTypes = {
|
||||
match: PropTypes.object.isRequired,
|
||||
history: PropTypes.object.isRequired,
|
||||
authenticationType: PropTypes.string,
|
||||
};
|
||||
|
||||
export default AdminAuthPage;
|
@ -1,102 +1,119 @@
|
||||
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?: {
|
||||
enabled: boolean;
|
||||
autoCreate: boolean;
|
||||
defaultRootRole?: string;
|
||||
emailDomains?: string;
|
||||
|
||||
};
|
||||
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 = () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.name, e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
<strong>Auto-create users</strong>
|
||||
<p>
|
||||
Enable automatic creation of new users when signing in.
|
||||
</p>
|
||||
return (
|
||||
<Fragment>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
<strong>Auto-create users</strong>
|
||||
<p>
|
||||
Enable automatic creation of new users when signing in.
|
||||
</p>
|
||||
</Grid>
|
||||
<Grid item md={6} style={{ padding: '20px' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
onChange={updateAutoCreate}
|
||||
name="enabled"
|
||||
checked={data.autoCreate}
|
||||
disabled={!data.enabled}
|
||||
/>
|
||||
}
|
||||
label="Auto-create users"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item md={6} style={{ padding: '20px' }}>
|
||||
|
||||
<FormControlLabel
|
||||
control={ <Switch
|
||||
onChange={updateAutoCreate}
|
||||
name="enabled"
|
||||
checked={data.autoCreate}
|
||||
disabled={!data.enabled}
|
||||
/>}
|
||||
label="Auto-create users"
|
||||
/>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
<strong>Default Root Role</strong>
|
||||
<p>
|
||||
Choose which root role the user should get when no
|
||||
explicit role mapping exists.
|
||||
</p>
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<FormControl style={{ minWidth: '200px' }}>
|
||||
<InputLabel id="defaultRootRole-label">
|
||||
Default Role
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="defaultRootRole-label"
|
||||
id="defaultRootRole"
|
||||
name="defaultRootRole"
|
||||
disabled={!data.autoCreate || !data.enabled}
|
||||
value={data.defaultRootRole || 'Editor'}
|
||||
onChange={updateDefaultRootRole}
|
||||
>
|
||||
{/*consider these from API or constants. */}
|
||||
<MenuItem value="Viewer">Viewer</MenuItem>
|
||||
<MenuItem value="Editor">Editor</MenuItem>
|
||||
<MenuItem value="Admin">Admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
<strong>Default Root Role</strong>
|
||||
<p>
|
||||
Choose which root role the user should get when no explicit role mapping exists.
|
||||
</p>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
<strong>Email domains</strong>
|
||||
<p>
|
||||
Comma separated list of email domains that should be
|
||||
allowed to sign in.
|
||||
</p>
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<TextField
|
||||
onChange={updateField}
|
||||
label="Email domains"
|
||||
name="emailDomains"
|
||||
disabled={!data.autoCreate || !data.enabled}
|
||||
required={!!data.autoCreate}
|
||||
value={data.emailDomains || ''}
|
||||
placeholder="@company.com, @anotherCompany.com"
|
||||
style={{ width: '400px' }}
|
||||
rows={2}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<FormControl style={{minWidth: '200px'}}>
|
||||
<InputLabel id="defaultRootRole-label">Default Role</InputLabel>
|
||||
<Select
|
||||
labelId="defaultRootRole-label"
|
||||
id="defaultRootRole"
|
||||
name="defaultRootRole"
|
||||
disabled={!data.autoCreate || !data.enabled}
|
||||
value={data.defaultRootRole || 'Editor'}
|
||||
onChange={updateDefaultRootRole}
|
||||
>
|
||||
{/*consider these from API or constants. */}
|
||||
<MenuItem value='Viewer'>Viewer</MenuItem>
|
||||
<MenuItem value='Editor'>Editor</MenuItem>
|
||||
<MenuItem value='Admin'>Admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
<strong>Email domains</strong>
|
||||
<p>
|
||||
Comma separated list of email domains
|
||||
that should be allowed to sign in.
|
||||
</p>
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<TextField
|
||||
onChange={updateField}
|
||||
label="Email domains"
|
||||
name="emailDomains"
|
||||
disabled={!data.autoCreate || !data.enabled}
|
||||
required={!!data.autoCreate}
|
||||
value={data.emailDomains || ''}
|
||||
placeholder="@company.com, @anotherCompany.com"
|
||||
style={{ width: '400px' }}
|
||||
rows={2}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Fragment>);
|
||||
}
|
||||
export default AutoCreateForm;
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControlLabel,
|
||||
@ -8,30 +7,32 @@ import {
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import PageContent from '../../common/PageContent/PageContent';
|
||||
import AccessContext from '../../../contexts/AccessContext';
|
||||
import { ADMIN } from '../../providers/AccessProvider/permissions';
|
||||
import PageContent from '../../../common/PageContent/PageContent';
|
||||
import AccessContext from '../../../../contexts/AccessContext';
|
||||
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 = {
|
||||
enabled: false,
|
||||
autoCreate: false,
|
||||
unleashHostname: location.hostname,
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
emailDomains: '',
|
||||
};
|
||||
|
||||
function GoogleAuth({
|
||||
config,
|
||||
getGoogleConfig,
|
||||
updateGoogleConfig,
|
||||
unleashUrl,
|
||||
}) {
|
||||
export const GoogleAuth = () => {
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
const [data, setData] = useState(initialState);
|
||||
const [info, setInfo] = useState();
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
|
||||
useEffect(() => {
|
||||
getGoogleConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const { config } = useAuthSettings('google');
|
||||
const { updateSettings, errors, loading } = useAuthSettingsApi('google');
|
||||
|
||||
useEffect(() => {
|
||||
if (config.clientId) {
|
||||
@ -43,10 +44,10 @@ function GoogleAuth({
|
||||
return <span>You need admin privileges to access this section.</span>;
|
||||
}
|
||||
|
||||
const updateField = e => {
|
||||
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setData({
|
||||
...data,
|
||||
[e.target.name]: e.target.value,
|
||||
[event.target.name]: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
@ -58,19 +59,22 @@ function GoogleAuth({
|
||||
setData({ ...data, autoCreate: !data.autoCreate });
|
||||
};
|
||||
|
||||
const onSubmit = async e => {
|
||||
e.preventDefault();
|
||||
setInfo('...saving');
|
||||
const onSubmit = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await updateGoogleConfig(data);
|
||||
setInfo('Settings stored');
|
||||
setTimeout(() => setInfo(''), 2000);
|
||||
} catch (e) {
|
||||
setInfo(e.message);
|
||||
await updateSettings(removeEmptyStringFields(data));
|
||||
setToastData({
|
||||
title: 'Settings stored',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
setToastApiError(formatUnknownError(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent>
|
||||
<PageContent headerContent="">
|
||||
<Grid container style={{ marginBottom: '1rem' }}>
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
@ -84,7 +88,7 @@ function GoogleAuth({
|
||||
</a>{' '}
|
||||
to learn how to integrate with Google OAuth 2.0. <br />
|
||||
Callback URL:{' '}
|
||||
<code>{unleashUrl}/auth/google/callback</code>
|
||||
<code>{uiConfig.unleashUrl}/auth/google/callback</code>
|
||||
</Alert>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@ -125,7 +129,7 @@ function GoogleAuth({
|
||||
label="Client ID"
|
||||
name="clientId"
|
||||
placeholder=""
|
||||
value={data.clientId || ''}
|
||||
value={data.clientId}
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@ -146,7 +150,7 @@ function GoogleAuth({
|
||||
onChange={updateField}
|
||||
label="Client Secret"
|
||||
name="clientSecret"
|
||||
value={data.clientSecret || ''}
|
||||
value={data.clientSecret}
|
||||
placeholder=""
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
@ -195,9 +199,7 @@ function GoogleAuth({
|
||||
onChange={updateAutoCreate}
|
||||
name="enabled"
|
||||
checked={data.autoCreate}
|
||||
>
|
||||
Auto-create users
|
||||
</Switch>
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={3}>
|
||||
@ -229,22 +231,18 @@ function GoogleAuth({
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
Save
|
||||
</Button>{' '}
|
||||
<small>{info}</small>
|
||||
<p>
|
||||
<small style={{ color: 'red' }}>
|
||||
{errors?.message}
|
||||
</small>
|
||||
</p>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</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 PropTypes from 'prop-types';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControlLabel,
|
||||
@ -8,34 +7,40 @@ import {
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import PageContent from '../../common/PageContent/PageContent';
|
||||
import AccessContext from '../../../contexts/AccessContext';
|
||||
import { ADMIN } from '../../providers/AccessProvider/permissions';
|
||||
import AutoCreateForm from './AutoCreateForm/AutoCreateForm';
|
||||
import PageContent from '../../../common/PageContent/PageContent';
|
||||
import AccessContext from '../../../../contexts/AccessContext';
|
||||
import { ADMIN } from '../../../providers/AccessProvider/permissions';
|
||||
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 = {
|
||||
enabled: false,
|
||||
enableSingleSignOut: false,
|
||||
autoCreate: false,
|
||||
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 [info, setInfo] = useState();
|
||||
const [error, setError] = useState();
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
|
||||
useEffect(() => {
|
||||
getOidcConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const { config } = useAuthSettings('oidc');
|
||||
const { updateSettings, errors, loading } = useAuthSettingsApi('oidc');
|
||||
|
||||
useEffect(() => {
|
||||
if (config.discoverUrl) {
|
||||
setData(config);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
if (!hasAccess(ADMIN)) {
|
||||
@ -46,8 +51,8 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
);
|
||||
}
|
||||
|
||||
const updateField = e => {
|
||||
setValue(e.target.name, e.target.value);
|
||||
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.target.name, event.target.value);
|
||||
};
|
||||
|
||||
const updateEnabled = () => {
|
||||
@ -58,28 +63,29 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
setData({ ...data, enableSingleSignOut: !data.enableSingleSignOut });
|
||||
};
|
||||
|
||||
const setValue = (field, value) => {
|
||||
const setValue = (name: string, value: string | boolean) => {
|
||||
setData({
|
||||
...data,
|
||||
[field]: value,
|
||||
[name]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async e => {
|
||||
e.preventDefault();
|
||||
setInfo('...saving');
|
||||
setError('');
|
||||
const onSubmit = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await updateOidcConfig(data);
|
||||
setInfo('Settings stored');
|
||||
setTimeout(() => setInfo(''), 2000);
|
||||
} catch (e) {
|
||||
setInfo('');
|
||||
setError(e.message);
|
||||
await updateSettings(removeEmptyStringFields(data));
|
||||
setToastData({
|
||||
title: 'Settings stored',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
setToastApiError(formatUnknownError(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent>
|
||||
<PageContent headerContent="">
|
||||
<Grid container style={{ marginBottom: '1rem' }}>
|
||||
<Grid item md={12}>
|
||||
<Alert severity="info">
|
||||
@ -94,7 +100,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
to learn how to integrate with specific Open Id Connect
|
||||
providers (Okta, Keycloak, Google, etc). <br />
|
||||
Callback URL:{' '}
|
||||
<code>{unleashUrl}/auth/oidc/callback</code>
|
||||
<code>{uiConfig.unleashUrl}/auth/oidc/callback</code>
|
||||
</Alert>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@ -128,7 +134,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
onChange={updateField}
|
||||
label="Discover URL"
|
||||
name="discoverUrl"
|
||||
value={data.discoverUrl || ''}
|
||||
value={data.discoverUrl}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
@ -146,7 +152,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
onChange={updateField}
|
||||
label="Client ID"
|
||||
name="clientId"
|
||||
value={data.clientId || ''}
|
||||
value={data.clientId}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
@ -167,7 +173,7 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
onChange={updateField}
|
||||
label="Client Secret"
|
||||
name="secret"
|
||||
value={data.secret || ''}
|
||||
value={data.secret}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
@ -180,7 +186,10 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
<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 item md={6} style={{ padding: '20px' }}>
|
||||
<FormControlLabel
|
||||
@ -204,15 +213,21 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
<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
|
||||
part of the authentication request. Unleash will validate the acr value in the id token claims against the list of acr values.</p>
|
||||
<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 item md={6}>
|
||||
<TextField
|
||||
onChange={updateField}
|
||||
label="ACR Values"
|
||||
name="acrValues"
|
||||
value={data.acrValues || ''}
|
||||
value={data.acrValues}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
@ -229,23 +244,18 @@ function OidcAuth({ config, getOidcConfig, updateOidcConfig, unleashUrl }) {
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
Save
|
||||
</Button>{' '}
|
||||
<small>{info}</small>
|
||||
<small style={{ color: 'red' }}>{error}</small>
|
||||
<p>
|
||||
<small style={{ color: 'red' }}>
|
||||
{errors?.message}
|
||||
</small>
|
||||
</p>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</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 {
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Switch,
|
||||
} from '@material-ui/core';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, FormControlLabel, Grid, Switch } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import PageContent from '../../common/PageContent/PageContent';
|
||||
import AccessContext from '../../../contexts/AccessContext';
|
||||
import { ADMIN } from '../../providers/AccessProvider/permissions';
|
||||
import useAuthSettings from '../../../hooks/api/getters/useAuthSettings/useAuthSettings';
|
||||
import useAuthSettingsApi, {ISimpleAuthSettings } from '../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
|
||||
import useToast from '../../../hooks/useToast';
|
||||
import PageContent from '../../../common/PageContent/PageContent';
|
||||
import AccessContext from '../../../../contexts/AccessContext';
|
||||
import { ADMIN } from '../../../providers/AccessProvider/permissions';
|
||||
import useAuthSettings from '../../../../hooks/api/getters/useAuthSettings/useAuthSettings';
|
||||
import useAuthSettingsApi, {
|
||||
ISimpleAuthSettings,
|
||||
} from '../../../../hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
|
||||
import useToast from '../../../../hooks/useToast';
|
||||
import { formatUnknownError } from '../../../../utils/format-unknown-error';
|
||||
|
||||
const PasswordAuthSettings = () => {
|
||||
|
||||
const { setToastData } = useToast();
|
||||
export const PasswordAuth = () => {
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { config } = useAuthSettings('simple');
|
||||
const [disablePasswordAuth, setDisablePasswordAuth] = useState<boolean>(false);
|
||||
const { updateSettings, errors, loading } = useAuthSettingsApi<ISimpleAuthSettings>('simple')
|
||||
const [disablePasswordAuth, setDisablePasswordAuth] =
|
||||
useState<boolean>(false);
|
||||
const { updateSettings, errors, loading } =
|
||||
useAuthSettingsApi<ISimpleAuthSettings>('simple');
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setDisablePasswordAuth(!!config.disabled);
|
||||
}, [ config.disabled ]);
|
||||
}, [config.disabled]);
|
||||
|
||||
if (!hasAccess(ADMIN)) {
|
||||
return (
|
||||
@ -38,12 +36,13 @@ const PasswordAuthSettings = () => {
|
||||
setDisablePasswordAuth(!disablePasswordAuth);
|
||||
};
|
||||
|
||||
|
||||
const onSubmit = async evt => {
|
||||
evt.preventDefault();
|
||||
const onSubmit = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
const settings: ISimpleAuthSettings = { disabled: disablePasswordAuth };
|
||||
const settings: ISimpleAuthSettings = {
|
||||
disabled: disablePasswordAuth,
|
||||
};
|
||||
await updateSettings(settings);
|
||||
setToastData({
|
||||
title: 'Successfully saved',
|
||||
@ -52,20 +51,13 @@ const PasswordAuthSettings = () => {
|
||||
type: 'success',
|
||||
show: true,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setToastData({
|
||||
title: 'Could not store settings',
|
||||
text: err?.message,
|
||||
autoHideDuration: 4000,
|
||||
type: 'error',
|
||||
show: true,
|
||||
});
|
||||
setDisablePasswordAuth(config.disabled)
|
||||
} catch (err) {
|
||||
setToastApiError(formatUnknownError(err));
|
||||
setDisablePasswordAuth(config.disabled);
|
||||
}
|
||||
|
||||
};
|
||||
return (
|
||||
<PageContent headerContent=''>
|
||||
<PageContent headerContent="">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={5}>
|
||||
@ -82,7 +74,9 @@ const PasswordAuthSettings = () => {
|
||||
checked={!disablePasswordAuth}
|
||||
/>
|
||||
}
|
||||
label={!disablePasswordAuth ? 'Enabled' : 'Disabled'}
|
||||
label={
|
||||
!disablePasswordAuth ? 'Enabled' : 'Disabled'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@ -96,12 +90,14 @@ const PasswordAuthSettings = () => {
|
||||
>
|
||||
Save
|
||||
</Button>{' '}
|
||||
<p><small style={{ color: 'red' }}>{errors?.message}</small></p>
|
||||
<p>
|
||||
<small style={{ color: 'red' }}>
|
||||
{errors?.message}
|
||||
</small>
|
||||
</p>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordAuthSettings;
|
||||
};
|
@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControlLabel,
|
||||
@ -8,32 +7,40 @@ import {
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import PageContent from '../../common/PageContent/PageContent';
|
||||
import AccessContext from '../../../contexts/AccessContext';
|
||||
import { ADMIN } from '../../providers/AccessProvider/permissions';
|
||||
import AutoCreateForm from './AutoCreateForm/AutoCreateForm';
|
||||
import PageContent from '../../../common/PageContent/PageContent';
|
||||
import AccessContext from '../../../../contexts/AccessContext';
|
||||
import { ADMIN } from '../../../providers/AccessProvider/permissions';
|
||||
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 = {
|
||||
enabled: false,
|
||||
autoCreate: false,
|
||||
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 [info, setInfo] = useState();
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
|
||||
useEffect(() => {
|
||||
getSamlConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const { config } = useAuthSettings('saml');
|
||||
const { updateSettings, errors, loading } = useAuthSettingsApi('saml');
|
||||
|
||||
useEffect(() => {
|
||||
if (config.entityId) {
|
||||
setData(config);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
if (!hasAccess(ADMIN)) {
|
||||
@ -44,34 +51,37 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
||||
);
|
||||
}
|
||||
|
||||
const updateField = e => {
|
||||
setValue(e.target.name, e.target.value);
|
||||
const updateField = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.target.name, event.target.value);
|
||||
};
|
||||
|
||||
const updateEnabled = () => {
|
||||
setData({ ...data, enabled: !data.enabled });
|
||||
};
|
||||
|
||||
const setValue = (field, value) => {
|
||||
const setValue = (name: string, value: string | boolean) => {
|
||||
setData({
|
||||
...data,
|
||||
[field]: value,
|
||||
[name]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async e => {
|
||||
e.preventDefault();
|
||||
setInfo('...saving');
|
||||
const onSubmit = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await updateSamlConfig(data);
|
||||
setInfo('Settings stored');
|
||||
setTimeout(() => setInfo(''), 2000);
|
||||
} catch (e) {
|
||||
setInfo(e.message);
|
||||
await updateSettings(removeEmptyStringFields(data));
|
||||
setToastData({
|
||||
title: 'Settings stored',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
setToastApiError(formatUnknownError(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent>
|
||||
<PageContent headerContent="">
|
||||
<Grid container style={{ marginBottom: '1rem' }}>
|
||||
<Grid item md={12}>
|
||||
<Alert severity="info">
|
||||
@ -86,7 +96,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
||||
to learn how to integrate with specific SAML 2.0
|
||||
providers (Okta, Keycloak, etc). <br />
|
||||
Callback URL:{' '}
|
||||
<code>{unleashUrl}/auth/saml/callback</code>
|
||||
<code>{uiConfig.unleashUrl}/auth/saml/callback</code>
|
||||
</Alert>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@ -120,7 +130,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
||||
onChange={updateField}
|
||||
label="Entity ID"
|
||||
name="entityId"
|
||||
value={data.entityId || ''}
|
||||
value={data.entityId}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
@ -142,7 +152,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
||||
onChange={updateField}
|
||||
label="Single Sign-On URL"
|
||||
name="signOnUrl"
|
||||
value={data.signOnUrl || ''}
|
||||
value={data.signOnUrl}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
@ -164,7 +174,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
||||
onChange={updateField}
|
||||
label="X.509 Certificate"
|
||||
name="certificate"
|
||||
value={data.certificate || ''}
|
||||
value={data.certificate}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '100%' }}
|
||||
InputProps={{
|
||||
@ -196,7 +206,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
||||
onChange={updateField}
|
||||
label="Single Sign-out URL"
|
||||
name="signOutUrl"
|
||||
value={data.signOutUrl || ''}
|
||||
value={data.signOutUrl}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '400px' }}
|
||||
variant="outlined"
|
||||
@ -219,7 +229,7 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
||||
onChange={updateField}
|
||||
label="X.509 Certificate"
|
||||
name="spCertificate"
|
||||
value={data.spCertificate || ''}
|
||||
value={data.spCertificate}
|
||||
disabled={!data.enabled}
|
||||
style={{ width: '100%' }}
|
||||
InputProps={{
|
||||
@ -243,22 +253,18 @@ function SamlAuth({ config, getSamlConfig, updateSamlConfig, unleashUrl }) {
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
Save
|
||||
</Button>{' '}
|
||||
<small>{info}</small>
|
||||
<p>
|
||||
<small style={{ color: 'red' }}>
|
||||
{errors?.message}
|
||||
</small>
|
||||
</p>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</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 { formatApiPath } from '../../../utils/format-path';
|
||||
import useInvoices from '../../../hooks/api/getters/useInvoices/useInvoices';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { IInvoice } from '../../../interfaces/invoice';
|
||||
import { useLocationSettings } from '../../../hooks/useLocationSettings';
|
||||
|
||||
const PORTAL_URL = formatApiPath('api/admin/invoices/portal');
|
||||
|
||||
const InvoiceList = () => {
|
||||
const { refetchInvoices, invoices } = useInvoices();
|
||||
const [isLoaded, setLoaded] = useState(false);
|
||||
const location = useLocation();
|
||||
const { locationSettings } = useLocationSettings();
|
||||
|
||||
useEffect(() => {
|
||||
refetchInvoices();
|
||||
@ -89,7 +89,7 @@ const InvoiceList = () => {
|
||||
{item.dueDate &&
|
||||
formatDateWithLocale(
|
||||
item.dueDate,
|
||||
location.locale
|
||||
locationSettings.locale
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Paper, Tabs, Tab } from '@material-ui/core';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { Paper, Tab, Tabs } from '@material-ui/core';
|
||||
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||
|
||||
const navLinkStyle = {
|
||||
@ -13,18 +13,17 @@ const navLinkStyle = {
|
||||
padding: '0.8rem 1.5rem',
|
||||
};
|
||||
|
||||
const activeNavLinkStyle = {
|
||||
const activeNavLinkStyle: React.CSSProperties = {
|
||||
fontWeight: 'bold',
|
||||
borderRadius: '3px',
|
||||
padding: '0.8rem 1.5rem',
|
||||
};
|
||||
|
||||
function AdminMenu({ history }) {
|
||||
function AdminMenu() {
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { pathname } = useLocation();
|
||||
const { flags } = uiConfig;
|
||||
|
||||
const { location } = history;
|
||||
const { pathname } = location;
|
||||
return (
|
||||
<Paper
|
||||
style={{
|
||||
@ -45,7 +44,7 @@ function AdminMenu({ history }) {
|
||||
<span>Users</span>
|
||||
</NavLink>
|
||||
}
|
||||
></Tab>
|
||||
/>
|
||||
{flags.RE && (
|
||||
<Tab
|
||||
value="/admin/roles"
|
||||
@ -58,7 +57,7 @@ function AdminMenu({ history }) {
|
||||
<span>PROJECT ROLES</span>
|
||||
</NavLink>
|
||||
}
|
||||
></Tab>
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tab
|
||||
@ -72,7 +71,7 @@ function AdminMenu({ history }) {
|
||||
API Access
|
||||
</NavLink>
|
||||
}
|
||||
></Tab>
|
||||
/>
|
||||
<Tab
|
||||
value="/admin/auth"
|
||||
label={
|
||||
@ -84,7 +83,7 @@ function AdminMenu({ history }) {
|
||||
Single Sign-On
|
||||
</NavLink>
|
||||
}
|
||||
></Tab>
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
);
|
||||
|
@ -14,6 +14,7 @@ import AccessContext from '../../../../../contexts/AccessContext';
|
||||
import { IUser } from '../../../../../interfaces/user';
|
||||
import { useStyles } from './UserListItem.styles';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { ILocationSettings } from "../../../../../hooks/useLocationSettings";
|
||||
|
||||
interface IUserListItemProps {
|
||||
user: IUser;
|
||||
@ -21,11 +22,7 @@ interface IUserListItemProps {
|
||||
openUpdateDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||
openPwDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||
location: ILocation;
|
||||
}
|
||||
|
||||
interface ILocation {
|
||||
locale: string;
|
||||
locationSettings: ILocationSettings;
|
||||
}
|
||||
|
||||
const UserListItem = ({
|
||||
@ -34,7 +31,7 @@ const UserListItem = ({
|
||||
openDelDialog,
|
||||
openPwDialog,
|
||||
openUpdateDialog,
|
||||
location,
|
||||
locationSettings,
|
||||
}: IUserListItemProps) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const history = useHistory()
|
||||
@ -54,7 +51,7 @@ const UserListItem = ({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span data-loading>
|
||||
{formatDateWithLocale(user.createdAt, location.locale)}
|
||||
{formatDateWithLocale(user.createdAt, locationSettings.locale)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={styles.leftTableCell}>
|
||||
|
@ -20,10 +20,10 @@ import loadingData from './loadingData';
|
||||
import useLoading from '../../../../hooks/useLoading';
|
||||
import usePagination from '../../../../hooks/usePagination';
|
||||
import PaginateUI from '../../../common/PaginateUI/PaginateUI';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { IUser } from '../../../../interfaces/user';
|
||||
import IRole from '../../../../interfaces/role';
|
||||
import useToast from '../../../../hooks/useToast';
|
||||
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
|
||||
|
||||
const UsersList = () => {
|
||||
const { users, roles, refetch, loading } = useUsers();
|
||||
@ -35,9 +35,8 @@ const UsersList = () => {
|
||||
userLoading,
|
||||
userApiErrors,
|
||||
} = useAdminUsersApi();
|
||||
const history = useHistory();
|
||||
const { location } = history;
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { locationSettings } = useLocationSettings()
|
||||
const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
|
||||
open: false,
|
||||
});
|
||||
@ -104,7 +103,7 @@ const UsersList = () => {
|
||||
user={user}
|
||||
openPwDialog={openPwDialog}
|
||||
openDelDialog={openDelDialog}
|
||||
location={location}
|
||||
locationSettings={locationSettings}
|
||||
renderRole={renderRole}
|
||||
/>
|
||||
));
|
||||
@ -117,7 +116,7 @@ const UsersList = () => {
|
||||
user={user}
|
||||
openPwDialog={openPwDialog}
|
||||
openDelDialog={openDelDialog}
|
||||
location={location}
|
||||
locationSettings={locationSettings}
|
||||
renderRole={renderRole}
|
||||
/>
|
||||
);
|
||||
|
@ -19,6 +19,7 @@ test('renders correctly if no application', () => {
|
||||
storeApplicationMetaData={jest.fn()}
|
||||
deleteApplication={jest.fn()}
|
||||
history={{}}
|
||||
locationSettings={{ locale: 'en-GB' }}
|
||||
/>
|
||||
</AccessProvider>
|
||||
)
|
||||
@ -77,7 +78,7 @@ test('renders correctly without permission', () => {
|
||||
url: 'http://example.org',
|
||||
description: 'app description',
|
||||
}}
|
||||
location={{ locale: 'en-GB' }}
|
||||
locationSettings={{ locale: 'en-GB' }}
|
||||
/>
|
||||
</AccessProvider>
|
||||
</ThemeProvider>
|
||||
@ -140,7 +141,7 @@ test('renders correctly with permissions', () => {
|
||||
url: 'http://example.org',
|
||||
description: 'app description',
|
||||
}}
|
||||
location={{ locale: 'en-GB' }}
|
||||
locationSettings={{ locale: 'en-GB' }}
|
||||
/>
|
||||
</AccessProvider>
|
||||
</ThemeProvider>
|
||||
|
@ -33,7 +33,7 @@ class ClientApplications extends PureComponent {
|
||||
fetchApplication: PropTypes.func.isRequired,
|
||||
appName: PropTypes.string,
|
||||
application: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
locationSettings: PropTypes.object.isRequired,
|
||||
storeApplicationMetaData: PropTypes.func.isRequired,
|
||||
deleteApplication: PropTypes.func.isRequired,
|
||||
history: PropTypes.object.isRequired,
|
||||
@ -54,8 +54,8 @@ class ClientApplications extends PureComponent {
|
||||
.finally(() => this.setState({ loading: false }));
|
||||
}
|
||||
formatFullDateTime = v =>
|
||||
formatFullDateTimeWithLocale(v, this.props.location.locale);
|
||||
formatDate = v => formatDateWithLocale(v, this.props.location.locale);
|
||||
formatFullDateTimeWithLocale(v, this.props.locationSettings.locale);
|
||||
formatDate = v => formatDateWithLocale(v, this.props.locationSettings.locale);
|
||||
|
||||
deleteApplication = async evt => {
|
||||
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,16 +13,21 @@ interface IPermissionSwitchProps extends OverridableComponent<any> {
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
const PermissionSwitch: React.FC<IPermissionSwitchProps> = ({
|
||||
permission,
|
||||
tooltip = '',
|
||||
disabled,
|
||||
projectId,
|
||||
environmentId,
|
||||
checked,
|
||||
onChange,
|
||||
...rest
|
||||
}) => {
|
||||
const PermissionSwitch = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
IPermissionSwitchProps
|
||||
>((props, ref) => {
|
||||
const {
|
||||
permission,
|
||||
tooltip = '',
|
||||
disabled,
|
||||
projectId,
|
||||
environmentId,
|
||||
checked,
|
||||
onChange,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
|
||||
let access;
|
||||
@ -45,11 +50,12 @@ const PermissionSwitch: React.FC<IPermissionSwitchProps> = ({
|
||||
onChange={onChange}
|
||||
disabled={disabled || !access}
|
||||
checked={checked}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default PermissionSwitch;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { MenuItem } from '@material-ui/core';
|
||||
import PropTypes from 'prop-types';
|
||||
import DropdownMenu from '../DropdownMenu/DropdownMenu';
|
||||
@ -9,20 +9,8 @@ const ALL_PROJECTS = { id: '*', name: '> All projects' };
|
||||
const ProjectSelect = ({ currentProjectId, updateCurrentProject, ...rest }) => {
|
||||
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 id = typeof v === 'string' ? v.trim() : '';
|
||||
const id = v && typeof v === 'string' ? v.trim() : '*';
|
||||
updateCurrentProject(id);
|
||||
};
|
||||
|
||||
|
@ -3,9 +3,8 @@ import ProjectSelect from './ProjectSelect';
|
||||
import { fetchProjects } from '../../../store/project/actions';
|
||||
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
...ownProps,
|
||||
projects: state.projects.toJS(),
|
||||
currentProjectId: ownProps.settings.currentProjectId || '*',
|
||||
updateCurrentProject: id => ownProps.updateSetting('currentProjectId', id),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, { fetchProjects })(ProjectSelect);
|
||||
|
@ -66,6 +66,7 @@ const TabNav = ({
|
||||
|
||||
TabNav.propTypes = {
|
||||
tabData: PropTypes.array.isRequired,
|
||||
navClass: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
startingTab: PropTypes.number,
|
||||
};
|
||||
|
@ -79,7 +79,7 @@ const CreateFeature = () => {
|
||||
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.
|
||||
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}
|
||||
>
|
||||
<FeatureForm
|
||||
|
@ -83,7 +83,7 @@ const EditFeature = () => {
|
||||
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.
|
||||
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}
|
||||
>
|
||||
<FeatureForm
|
||||
|
@ -11,6 +11,7 @@ import ConditionallyRender from '../../common/ConditionallyRender';
|
||||
import { trim } from '../../common/util';
|
||||
import Input from '../../common/Input/Input';
|
||||
import { CREATE_FEATURE } from '../../providers/AccessProvider/permissions';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
interface IFeatureToggleForm {
|
||||
type: string;
|
||||
@ -22,8 +23,8 @@ interface IFeatureToggleForm {
|
||||
setName: React.Dispatch<React.SetStateAction<string>>;
|
||||
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProject: React.Dispatch<React.SetStateAction<string>>;
|
||||
validateToggleName: () => void;
|
||||
setImpressionData: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
validateToggleName?: () => void;
|
||||
handleSubmit: (e: any) => void;
|
||||
handleCancel: () => void;
|
||||
errors: { [key: string]: string };
|
||||
@ -52,6 +53,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const { featureTypes } = useFeatureTypes();
|
||||
const history = useHistory();
|
||||
const { permissions } = useUser();
|
||||
const editable = mode !== 'Edit';
|
||||
|
||||
@ -75,9 +77,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
||||
onFocus={() => clearErrors()}
|
||||
value={name}
|
||||
onChange={e => setName(trim(e.target.value))}
|
||||
inputProps={{
|
||||
'data-test': CF_NAME_ID,
|
||||
}}
|
||||
data-test={CF_NAME_ID}
|
||||
onBlur={validateToggleName}
|
||||
/>
|
||||
<p className={styles.inputDescription}>
|
||||
@ -89,9 +89,7 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
||||
label={'Toggle type'}
|
||||
id="feature-type-select"
|
||||
editable
|
||||
inputProps={{
|
||||
'data-test': CF_TYPE_ID,
|
||||
}}
|
||||
data-test={CF_TYPE_ID}
|
||||
IconComponent={KeyboardArrowDownOutlined}
|
||||
className={styles.selectInput}
|
||||
/>
|
||||
@ -108,7 +106,12 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
||||
/>
|
||||
<FeatureProjectSelect
|
||||
value={project}
|
||||
onChange={e => setProject(e.target.value)}
|
||||
onChange={e => {
|
||||
setProject(e.target.value);
|
||||
history.replace(
|
||||
`/projects/${e.target.value}/create-toggle`
|
||||
);
|
||||
}}
|
||||
enabled={editable}
|
||||
filter={projectFilterGenerator(
|
||||
{ permissions },
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { useContext, useLayoutEffect, useEffect } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
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 { Add } from '@material-ui/icons';
|
||||
|
||||
@ -23,43 +23,31 @@ import { useStyles } from './styles';
|
||||
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
|
||||
import { getCreateTogglePath } from '../../../utils/route-path-helpers';
|
||||
import { NAVIGATE_TO_CREATE_FEATURE } from '../../../testIds';
|
||||
import { resolveFilteredProjectId } from '../../../hooks/useFeaturesFilter';
|
||||
|
||||
const FeatureToggleList = ({
|
||||
fetcher,
|
||||
features,
|
||||
settings,
|
||||
revive,
|
||||
currentProjectId,
|
||||
updateSetting,
|
||||
featureMetrics,
|
||||
toggleFeature,
|
||||
archive,
|
||||
loading,
|
||||
flags,
|
||||
filter,
|
||||
setFilter,
|
||||
sort,
|
||||
setSort,
|
||||
}) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const styles = useStyles();
|
||||
const smallScreen = useMediaQuery('(max-width:800px)');
|
||||
const mobileView = useMediaQuery('(max-width:600px)');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
fetcher();
|
||||
}, [fetcher]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSetting('filter', '');
|
||||
/* eslint-disable-next-line */
|
||||
}, []);
|
||||
|
||||
const toggleMetrics = () => {
|
||||
updateSetting('showLastHour', !settings.showLastHour);
|
||||
const setFilterQuery = v => {
|
||||
const query = v && typeof v === 'string' ? v.trim() : '';
|
||||
setFilter(prev => ({ ...prev, query }));
|
||||
};
|
||||
|
||||
const setSort = v => {
|
||||
updateSetting('sort', typeof v === 'string' ? v.trim() : '');
|
||||
};
|
||||
|
||||
const createURL = getCreateTogglePath(currentProjectId, flags.E);
|
||||
const resolvedProjectId = resolveFilteredProjectId(filter);
|
||||
const createURL = getCreateTogglePath(resolvedProjectId, flags.E);
|
||||
|
||||
const renderFeatures = () => {
|
||||
features.forEach(e => {
|
||||
@ -70,11 +58,7 @@ const FeatureToggleList = ({
|
||||
return loadingFeatures.map(feature => (
|
||||
<FeatureToggleListItem
|
||||
key={feature.name}
|
||||
settings={settings}
|
||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
||||
feature={feature}
|
||||
toggleFeature={toggleFeature}
|
||||
revive={revive}
|
||||
hasAccess={hasAccess}
|
||||
className={'skeleton'}
|
||||
@ -89,13 +73,7 @@ const FeatureToggleList = ({
|
||||
show={features.map(feature => (
|
||||
<FeatureToggleListItem
|
||||
key={feature.name}
|
||||
settings={settings}
|
||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
||||
metricsLastMinute={
|
||||
featureMetrics.lastMinute[feature.name]
|
||||
}
|
||||
feature={feature}
|
||||
toggleFeature={toggleFeature}
|
||||
revive={revive}
|
||||
hasAccess={hasAccess}
|
||||
flags={flags}
|
||||
@ -129,7 +107,7 @@ const FeatureToggleList = ({
|
||||
<div className={styles.featureContainer}>
|
||||
<div className={styles.searchBarContainer}>
|
||||
<SearchField
|
||||
updateValue={updateSetting.bind(this, 'filter')}
|
||||
updateValue={setFilterQuery}
|
||||
className={classnames(styles.searchBar, {
|
||||
skeleton: loading,
|
||||
})}
|
||||
@ -151,10 +129,10 @@ const FeatureToggleList = ({
|
||||
condition={!smallScreen}
|
||||
show={
|
||||
<FeatureToggleListActions
|
||||
settings={settings}
|
||||
toggleMetrics={toggleMetrics}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
updateSetting={updateSetting}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
@ -175,7 +153,7 @@ const FeatureToggleList = ({
|
||||
disabled={
|
||||
!hasAccess(
|
||||
CREATE_FEATURE,
|
||||
currentProjectId
|
||||
resolvedProjectId
|
||||
)
|
||||
}
|
||||
>
|
||||
@ -195,7 +173,7 @@ const FeatureToggleList = ({
|
||||
disabled={
|
||||
!hasAccess(
|
||||
CREATE_FEATURE,
|
||||
currentProjectId
|
||||
resolvedProjectId
|
||||
)
|
||||
}
|
||||
className={classnames({
|
||||
@ -221,16 +199,14 @@ const FeatureToggleList = ({
|
||||
|
||||
FeatureToggleList.propTypes = {
|
||||
features: PropTypes.array.isRequired,
|
||||
featureMetrics: PropTypes.object.isRequired,
|
||||
fetcher: PropTypes.func,
|
||||
revive: PropTypes.func,
|
||||
updateSetting: PropTypes.func.isRequired,
|
||||
toggleFeature: PropTypes.func,
|
||||
settings: PropTypes.object,
|
||||
history: PropTypes.object.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
currentProjectId: PropTypes.string.isRequired,
|
||||
archive: PropTypes.bool,
|
||||
flags: PropTypes.object,
|
||||
filter: PropTypes.object.isRequired,
|
||||
setFilter: PropTypes.func.isRequired,
|
||||
sort: PropTypes.object.isRequired,
|
||||
setSort: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default FeatureToggleList;
|
||||
|
@ -2,31 +2,21 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
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 ProjectSelect from '../../../common/ProjectSelect';
|
||||
import { useStyles } from './styles';
|
||||
import useLoading from '../../../../hooks/useLoading';
|
||||
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import ConditionallyRender from '../../../common/ConditionallyRender';
|
||||
import { createFeaturesFilterSortOptions } from '../../../../hooks/useFeaturesSort';
|
||||
|
||||
const sortingOptions = [
|
||||
{ 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 sortOptions = createFeaturesFilterSortOptions();
|
||||
|
||||
const FeatureToggleListActions = ({
|
||||
settings,
|
||||
filter,
|
||||
setFilter,
|
||||
sort,
|
||||
setSort,
|
||||
toggleMetrics,
|
||||
updateSetting,
|
||||
loading,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
@ -34,65 +24,33 @@ const FeatureToggleListActions = ({
|
||||
const ref = useLoading(loading);
|
||||
|
||||
const handleSort = e => {
|
||||
const target = e.target.getAttribute('data-target');
|
||||
setSort(target);
|
||||
const type = e.target.getAttribute('data-target')?.trim();
|
||||
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 = () =>
|
||||
sortingOptions.map(option => (
|
||||
sortOptions.map(option => (
|
||||
<MenuItem
|
||||
style={{ fontSize: '14px' }}
|
||||
key={option.type}
|
||||
disabled={isDisabled(option.type)}
|
||||
data-target={option.type}
|
||||
>
|
||||
{option.displayName}
|
||||
{option.name}
|
||||
</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 (
|
||||
<div className={styles.actions} ref={ref}>
|
||||
<Typography variant="body2" data-loading>
|
||||
Sorted by:
|
||||
</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
|
||||
id={'sorting'}
|
||||
label={`By ${settings.sort}`}
|
||||
label={`By ${selectedOption.name}`}
|
||||
callback={handleSort}
|
||||
renderOptions={renderSortingOptions}
|
||||
title="Sort by"
|
||||
@ -104,8 +62,8 @@ const FeatureToggleListActions = ({
|
||||
condition={uiConfig.flags.P}
|
||||
show={
|
||||
<ProjectSelect
|
||||
settings={settings}
|
||||
updateSetting={updateSetting}
|
||||
currentProjectId={filter.project}
|
||||
updateCurrentProject={project => setFilter(prev => ({ ...prev, project }))}
|
||||
style={{
|
||||
textTransform: 'lowercase',
|
||||
fontWeight: 'normal',
|
||||
@ -119,10 +77,11 @@ const FeatureToggleListActions = ({
|
||||
};
|
||||
|
||||
FeatureToggleListActions.propTypes = {
|
||||
settings: PropTypes.object,
|
||||
filter: PropTypes.object,
|
||||
setFilter: PropTypes.func,
|
||||
sort: PropTypes.object,
|
||||
setSort: PropTypes.func,
|
||||
toggleMetrics: PropTypes.func,
|
||||
updateSetting: PropTypes.func,
|
||||
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 = ({
|
||||
feature,
|
||||
toggleFeature,
|
||||
settings,
|
||||
metricsLastHour = { yes: 0, no: 0, isFallback: true },
|
||||
metricsLastMinute = { yes: 0, no: 0, isFallback: true },
|
||||
revive,
|
||||
hasAccess,
|
||||
flags = {},
|
||||
@ -164,10 +160,6 @@ const FeatureToggleListItem = ({
|
||||
|
||||
FeatureToggleListItem.propTypes = {
|
||||
feature: PropTypes.object,
|
||||
toggleFeature: PropTypes.func,
|
||||
settings: PropTypes.object,
|
||||
metricsLastHour: PropTypes.object,
|
||||
metricsLastMinute: PropTypes.object,
|
||||
revive: PropTypes.func,
|
||||
hasAccess: PropTypes.func.isRequired,
|
||||
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
|
||||
className="MuiButton-label"
|
||||
>
|
||||
By name
|
||||
By Name
|
||||
<span
|
||||
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
||||
>
|
||||
@ -185,12 +185,6 @@ exports[`renders correctly with one feature 1`] = `
|
||||
}
|
||||
flags={Object {}}
|
||||
hasAccess={[Function]}
|
||||
settings={
|
||||
Object {
|
||||
"sort": "name",
|
||||
}
|
||||
}
|
||||
toggleFeature={[MockFunction]}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
@ -315,7 +309,7 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
||||
<span
|
||||
className="MuiButton-label"
|
||||
>
|
||||
By name
|
||||
By Name
|
||||
<span
|
||||
className="MuiButton-endIcon MuiButton-iconSizeMedium"
|
||||
>
|
||||
@ -386,12 +380,6 @@ exports[`renders correctly with one feature without permissions 1`] = `
|
||||
}
|
||||
flags={Object {}}
|
||||
hasAccess={[Function]}
|
||||
settings={
|
||||
Object {
|
||||
"sort": "name",
|
||||
}
|
||||
}
|
||||
toggleFeature={[MockFunction]}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -7,8 +7,6 @@ import renderer from 'react-test-renderer';
|
||||
|
||||
import theme from '../../../../themes/main-theme';
|
||||
|
||||
jest.mock('../FeatureToggleListItem/FeatureToggleListItemChip');
|
||||
|
||||
test('renders correctly with one feature', () => {
|
||||
const feature = {
|
||||
name: 'Another',
|
||||
@ -26,18 +24,12 @@ test('renders correctly with one feature', () => {
|
||||
],
|
||||
createdAt: '2018-02-04T20:27:52.127Z',
|
||||
};
|
||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
||||
const settings = { sort: 'name' };
|
||||
const tree = renderer.create(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
<FeatureToggleListItem
|
||||
key={0}
|
||||
settings={settings}
|
||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
||||
feature={feature}
|
||||
toggleFeature={jest.fn()}
|
||||
hasAccess={() => true}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
@ -63,18 +55,12 @@ test('renders correctly with one feature without permission', () => {
|
||||
],
|
||||
createdAt: '2018-02-04T20:27:52.127Z',
|
||||
};
|
||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
||||
const settings = { sort: 'name' };
|
||||
const tree = renderer.create(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
<FeatureToggleListItem
|
||||
key={0}
|
||||
settings={settings}
|
||||
metricsLastHour={featureMetrics.lastHour[feature.name]}
|
||||
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
|
||||
feature={feature}
|
||||
toggleFeature={jest.fn()}
|
||||
hasAccess={() => true}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
|
@ -25,8 +25,7 @@ test('renders correctly with one feature', () => {
|
||||
name: 'Another',
|
||||
},
|
||||
];
|
||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
||||
const settings = { sort: 'name' };
|
||||
|
||||
const tree = renderer.create(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
@ -35,13 +34,12 @@ test('renders correctly with one feature', () => {
|
||||
>
|
||||
<FeatureToggleList
|
||||
updateSetting={jest.fn()}
|
||||
settings={settings}
|
||||
history={{}}
|
||||
featureMetrics={featureMetrics}
|
||||
filter={{}}
|
||||
setFilter={jest.fn()}
|
||||
sort={{}}
|
||||
setSort={jest.fn()}
|
||||
features={features}
|
||||
toggleFeature={jest.fn()}
|
||||
fetcher={jest.fn()}
|
||||
currentProjectId="default"
|
||||
flags={{}}
|
||||
/>
|
||||
</AccessProvider>
|
||||
@ -58,8 +56,6 @@ test('renders correctly with one feature without permissions', () => {
|
||||
name: 'Another',
|
||||
},
|
||||
];
|
||||
const featureMetrics = { lastHour: {}, lastMinute: {}, seenApps: {} };
|
||||
const settings = { sort: 'name' };
|
||||
const tree = renderer.create(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
@ -67,14 +63,12 @@ test('renders correctly with one feature without permissions', () => {
|
||||
store={createFakeStore([{ permission: CREATE_FEATURE }])}
|
||||
>
|
||||
<FeatureToggleList
|
||||
updateSetting={jest.fn()}
|
||||
settings={settings}
|
||||
history={{}}
|
||||
featureMetrics={featureMetrics}
|
||||
filter={{}}
|
||||
setFilter={jest.fn()}
|
||||
sort={{}}
|
||||
setSort={jest.fn()}
|
||||
features={features}
|
||||
toggleFeature={jest.fn()}
|
||||
fetcher={jest.fn()}
|
||||
currentProjectId="default"
|
||||
flags={{}}
|
||||
/>
|
||||
</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 { connect } from 'react-redux';
|
||||
import { formatDateWithLocale, formatFullDateTimeWithLocale } from '../../../common/util';
|
||||
import { useLocationSettings } from "../../../../hooks/useLocationSettings";
|
||||
|
||||
interface CreatedAtProps {
|
||||
time: Date;
|
||||
//@ts-ignore
|
||||
location: any;
|
||||
}
|
||||
|
||||
const CreatedAt = ({time, location}: CreatedAtProps) => {
|
||||
const CreatedAt = ({time}: CreatedAtProps) => {
|
||||
const { locationSettings } = useLocationSettings();
|
||||
|
||||
return (
|
||||
<Tooltip title={`Created at ${formatFullDateTimeWithLocale(time, location.locale)}`}>
|
||||
<Tooltip title={`Created at ${formatFullDateTimeWithLocale(time, locationSettings.locale)}`}>
|
||||
<span>
|
||||
{formatDateWithLocale(time, location.locale)}
|
||||
{formatDateWithLocale(time, locationSettings.locale)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: any) => ({
|
||||
location: state.settings.toJS().location,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(CreatedAt);
|
||||
export default CreatedAt;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { getTogglePath } from '../../../utils/route-path-helpers';
|
||||
|
||||
|
@ -50,9 +50,8 @@ const useFeatureForm = (
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
projectId: project,
|
||||
description: description,
|
||||
impressionData
|
||||
description,
|
||||
impressionData,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -11,24 +11,21 @@ import EventCard from './EventCard/EventCard';
|
||||
import { useStyles } from './EventLog.styles.js';
|
||||
|
||||
const EventLog = ({
|
||||
updateSetting,
|
||||
title,
|
||||
history,
|
||||
settings,
|
||||
eventSettings,
|
||||
setEventSettings,
|
||||
locationSettings,
|
||||
displayInline,
|
||||
location,
|
||||
hideName,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const toggleShowDiff = () => {
|
||||
updateSetting('showData', !settings.showData);
|
||||
setEventSettings({ showData: !eventSettings.showData });
|
||||
};
|
||||
const formatFulldateTime = v => {
|
||||
return formatFullDateTimeWithLocale(v, location.locale);
|
||||
return formatFullDateTimeWithLocale(v, locationSettings.locale);
|
||||
};
|
||||
|
||||
const showData = settings.showData;
|
||||
|
||||
if (!history || history.length < 0) {
|
||||
return null;
|
||||
}
|
||||
@ -44,7 +41,7 @@ const EventLog = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (showData) {
|
||||
if (eventSettings.showData) {
|
||||
entries = history.map(entry => (
|
||||
<EventJson key={`log${entry.id}`} entry={entry} />
|
||||
));
|
||||
@ -63,7 +60,7 @@ const EventLog = ({
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={showData}
|
||||
checked={eventSettings.showData}
|
||||
onChange={toggleShowDiff}
|
||||
color="primary"
|
||||
/>
|
||||
@ -82,12 +79,12 @@ const EventLog = ({
|
||||
};
|
||||
|
||||
EventLog.propTypes = {
|
||||
updateSettings: PropTypes.func,
|
||||
history: PropTypes.array,
|
||||
eventSettings: PropTypes.object.isRequired,
|
||||
setEventSettings: PropTypes.func.isRequired,
|
||||
locationSettings: PropTypes.object.isRequired,
|
||||
title: PropTypes.string,
|
||||
settings: PropTypes.object,
|
||||
displayInline: PropTypes.bool,
|
||||
location: PropTypes.object,
|
||||
hideName: PropTypes.bool,
|
||||
};
|
||||
|
||||
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",
|
||||
},
|
||||
Object {
|
||||
"component": Object {
|
||||
"$$typeof": Symbol(react.memo),
|
||||
"WrappedComponent": [Function],
|
||||
"compare": null,
|
||||
"type": [Function],
|
||||
},
|
||||
"component": [Function],
|
||||
"layout": "main",
|
||||
"menu": Object {
|
||||
"adminSettings": true,
|
||||
|
@ -1,11 +1,13 @@
|
||||
import CopyFeatureToggle from '../../page/features/copy';
|
||||
import Features from '../../page/features';
|
||||
import { FeatureToggleListContainer } from '../feature/FeatureToggleList/FeatureToggleListContainer';
|
||||
import CreateStrategies from '../../page/strategies/create';
|
||||
import StrategyView from '../../page/strategies/show';
|
||||
import Strategies from '../../page/strategies';
|
||||
import HistoryPage from '../../page/history';
|
||||
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 Addons from '../../page/addons';
|
||||
import AddonsCreate from '../../page/addons/create';
|
||||
@ -14,7 +16,7 @@ import Admin from '../admin';
|
||||
import AdminApi from '../admin/api';
|
||||
import AdminInvoice from '../admin/invoice/InvoiceAdminPage';
|
||||
import AdminUsers from '../admin/users/UsersAdmin';
|
||||
import AdminAuth from '../admin/auth';
|
||||
import { AuthSettings } from '../admin/auth/AuthSettings';
|
||||
import Login from '../user/Login/Login';
|
||||
import { P, C, E, EEA, RE } from '../common/flags';
|
||||
import NewUser from '../user/NewUser';
|
||||
@ -22,7 +24,7 @@ import ResetPassword from '../user/ResetPassword/ResetPassword';
|
||||
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
|
||||
import ProjectListNew from '../project/ProjectList/ProjectList';
|
||||
import Project from '../project/Project/Project';
|
||||
import RedirectArchive from '../feature/RedirectArchive/RedirectArchive';
|
||||
import RedirectArchive from '../archive/RedirectArchive';
|
||||
import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
|
||||
import FeatureView from '../feature/FeatureView/FeatureView';
|
||||
import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles';
|
||||
@ -183,7 +185,7 @@ export const routes = [
|
||||
{
|
||||
path: '/features',
|
||||
title: 'Feature Toggles',
|
||||
component: Features,
|
||||
component: FeatureToggleListContainer,
|
||||
type: 'protected',
|
||||
layout: 'main',
|
||||
menu: { mobile: true },
|
||||
@ -373,7 +375,7 @@ export const routes = [
|
||||
{
|
||||
path: '/archive',
|
||||
title: 'Archived Toggles',
|
||||
component: Archive,
|
||||
component: ArchiveListContainer,
|
||||
type: 'protected',
|
||||
layout: 'main',
|
||||
menu: {},
|
||||
@ -447,7 +449,7 @@ export const routes = [
|
||||
path: '/admin/auth',
|
||||
parent: '/admin',
|
||||
title: 'Single Sign-On',
|
||||
component: AdminAuth,
|
||||
component: AuthSettings,
|
||||
type: 'protected',
|
||||
layout: 'main',
|
||||
menu: { adminSettings: true },
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import OutsideClickHandler from 'react-outside-click-handler';
|
||||
|
||||
import { Avatar, Button } from '@material-ui/core';
|
||||
@ -9,16 +8,18 @@ import { useStyles } from './UserProfile.styles';
|
||||
import { useCommonStyles } from '../../../common.styles';
|
||||
import UserProfileContent from './UserProfileContent/UserProfileContent';
|
||||
import { IUser } from "../../../interfaces/user";
|
||||
import { ILocationSettings } from "../../../hooks/useLocationSettings";
|
||||
|
||||
interface IUserProfileProps {
|
||||
profile: IUser
|
||||
updateSettingLocation: (field: 'locale', value: string) => void
|
||||
locationSettings: ILocationSettings
|
||||
setLocationSettings: React.Dispatch<React.SetStateAction<ILocationSettings>>
|
||||
}
|
||||
|
||||
const UserProfile = ({
|
||||
profile,
|
||||
location,
|
||||
updateSettingLocation,
|
||||
locationSettings,
|
||||
setLocationSettings,
|
||||
}: IUserProfileProps) => {
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [currentLocale, setCurrentLocale] = useState<string>();
|
||||
@ -40,17 +41,15 @@ const UserProfile = ({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const locale = location.locale || navigator.language;
|
||||
let found = possibleLocales.find(l =>
|
||||
l.toLowerCase().includes(locale.toLowerCase())
|
||||
l.toLowerCase().includes(locationSettings.locale.toLowerCase())
|
||||
);
|
||||
setCurrentLocale(found);
|
||||
|
||||
if (!found) {
|
||||
setPossibleLocales(prev => [...prev, locale]);
|
||||
setPossibleLocales(prev => [...prev, locationSettings.locale]);
|
||||
}
|
||||
/* eslint-disable-next-line*/
|
||||
}, []);
|
||||
}, [locationSettings]);
|
||||
|
||||
const email = profile ? profile.email : '';
|
||||
const imageUrl = email ? profile.imageUrl : 'unknown-user.png';
|
||||
@ -75,7 +74,7 @@ const UserProfile = ({
|
||||
showProfile={showProfile}
|
||||
imageUrl={imageUrl}
|
||||
profile={profile}
|
||||
updateSettingLocation={updateSettingLocation}
|
||||
setLocationSettings={setLocationSettings}
|
||||
possibleLocales={possibleLocales}
|
||||
setCurrentLocale={setCurrentLocale}
|
||||
currentLocale={currentLocale}
|
||||
@ -85,10 +84,4 @@ const UserProfile = ({
|
||||
);
|
||||
};
|
||||
|
||||
UserProfile.propTypes = {
|
||||
profile: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
updateSettingLocation: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
|
@ -1,13 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import ConditionallyRender from '../../../common/ConditionallyRender';
|
||||
import {
|
||||
Paper,
|
||||
Avatar,
|
||||
Typography,
|
||||
Button,
|
||||
FormControl,
|
||||
Select,
|
||||
InputLabel,
|
||||
Paper,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import classnames from 'classnames';
|
||||
import { useStyles } from './UserProfileContent.styles';
|
||||
@ -17,26 +17,29 @@ import EditProfile from '../EditProfile/EditProfile';
|
||||
import legacyStyles from '../../user.module.scss';
|
||||
import { getBasePath } from '../../../../utils/format-path';
|
||||
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 {
|
||||
showProfile: boolean
|
||||
profile: IUser
|
||||
possibleLocales: string[]
|
||||
updateSettingLocation: (field: 'locale', value: string) => void
|
||||
imageUrl: string
|
||||
currentLocale?: string
|
||||
setCurrentLocale: (value: string) => void
|
||||
showProfile: boolean;
|
||||
profile: IUser;
|
||||
possibleLocales: string[];
|
||||
imageUrl: string;
|
||||
currentLocale?: string;
|
||||
setCurrentLocale: (value: string) => void;
|
||||
setLocationSettings: React.Dispatch<
|
||||
React.SetStateAction<ILocationSettings>
|
||||
>;
|
||||
}
|
||||
|
||||
const UserProfileContent = ({
|
||||
showProfile,
|
||||
profile,
|
||||
possibleLocales,
|
||||
updateSettingLocation,
|
||||
imageUrl,
|
||||
currentLocale,
|
||||
setCurrentLocale,
|
||||
setLocationSettings,
|
||||
}: IUserProfileContentProps) => {
|
||||
const commonStyles = useCommonStyles();
|
||||
const { uiConfig } = useUiConfig();
|
||||
@ -44,10 +47,6 @@ const UserProfileContent = ({
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
const styles = useStyles();
|
||||
|
||||
const setLocale = (value: string) => {
|
||||
updateSettingLocation('locale', value);
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
const profileAvatarClasses = classnames(styles.avatar, {
|
||||
// @ts-expect-error
|
||||
@ -61,9 +60,9 @@ const UserProfileContent = ({
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||
const value = e.target.value as string;
|
||||
setCurrentLocale(value);
|
||||
setLocale(value);
|
||||
const locale = e.target.value as string;
|
||||
setCurrentLocale(locale);
|
||||
setLocationSettings({ locale });
|
||||
};
|
||||
|
||||
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,
|
||||
propagateErrors = false,
|
||||
}: IUseAPI) => {
|
||||
const [errors, setErrors] = useState({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const defaultOptions: RequestInit = {
|
||||
|
@ -12,7 +12,6 @@ export const handleBadRequest = async (
|
||||
if (!setErrors) return;
|
||||
if (res) {
|
||||
const data = await res.json();
|
||||
|
||||
setErrors({message: 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 { useState, useEffect } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { formatApiPath } from '../../../../utils/format-path';
|
||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||
import { IFeatureToggle } from '../../../../interfaces/featureToggle';
|
||||
|
||||
const useFeatures = (options: SWRConfiguration = {}) => {
|
||||
const fetcher = async () => {
|
||||
const path = formatApiPath('api/admin/features/');
|
||||
return fetch(path, {
|
||||
method: 'GET',
|
||||
})
|
||||
.then(handleErrorResponses('Features'))
|
||||
.then(res => res.json());
|
||||
};
|
||||
const PATH = formatApiPath('api/admin/features');
|
||||
|
||||
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, {
|
||||
...options,
|
||||
});
|
||||
export const useFeatures = (options?: SWRConfiguration): IUseFeaturesOutput => {
|
||||
const { data, error } = useSWR<{ features: IFeatureToggle[] }>(
|
||||
PATH,
|
||||
fetchFeatures,
|
||||
options
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(!error && !data);
|
||||
|
||||
const refetchFeatures = () => {
|
||||
mutate(FEATURES_CACHE_KEY);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(!error && !data);
|
||||
}, [data, error]);
|
||||
const refetchFeatures = useCallback(() => {
|
||||
mutate(PATH).catch(console.warn);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
features: data?.features || [],
|
||||
error,
|
||||
loading,
|
||||
loading: !error && !data,
|
||||
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',
|
||||
environment: '',
|
||||
slogan: 'The enterprise ready feature toggle service.',
|
||||
flags: { P: false, C: false, E: false },
|
||||
flags: { P: false, C: false, E: false, RE: false },
|
||||
links: [
|
||||
{
|
||||
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 {
|
||||
stale: boolean;
|
||||
archived: boolean;
|
||||
createdAt: Date;
|
||||
lastSeenAt?: Date;
|
||||
enabled?: boolean;
|
||||
createdAt: string;
|
||||
lastSeenAt?: string;
|
||||
description: string;
|
||||
environments: IFeatureEnvironment[];
|
||||
name: string;
|
||||
@ -41,6 +42,7 @@ export interface IFeatureToggle {
|
||||
type: string;
|
||||
variants: IFeatureVariant[];
|
||||
impressionData: boolean;
|
||||
strategies?: IFeatureStrategy[];
|
||||
}
|
||||
|
||||
export interface IFeatureEnvironment {
|
||||
|
@ -15,6 +15,7 @@ export interface IFlags {
|
||||
C: boolean;
|
||||
P: boolean;
|
||||
E: boolean;
|
||||
RE: boolean;
|
||||
}
|
||||
|
||||
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 tags from './tag';
|
||||
import strategies from './strategy';
|
||||
import archive from './archive';
|
||||
import error from './error';
|
||||
import settings from './settings';
|
||||
import user from './user';
|
||||
import applications from './application';
|
||||
import uiConfig from './ui-config';
|
||||
import context from './context';
|
||||
import projects from './project';
|
||||
import addons from './addons';
|
||||
import authAdmin from './e-admin-auth';
|
||||
import apiCalls from './api-calls';
|
||||
import invoiceAdmin from './e-admin-invoice';
|
||||
import feedback from './feedback';
|
||||
@ -28,16 +25,13 @@ const unleashStore = combineReducers({
|
||||
tagTypes,
|
||||
tags,
|
||||
featureTags,
|
||||
archive,
|
||||
error,
|
||||
settings,
|
||||
user,
|
||||
applications,
|
||||
uiConfig,
|
||||
context,
|
||||
projects,
|
||||
addons,
|
||||
authAdmin,
|
||||
apiCalls,
|
||||
invoiceAdmin,
|
||||
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"
|
||||
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:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
|
||||
|
Loading…
Reference in New Issue
Block a user