1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +02:00

Merge pull request #672 from Unleash/refactor/applications

refactor: application
This commit is contained in:
Youssef Khedher 2022-02-10 11:36:22 +01:00 committed by GitHub
commit b77c4a84f0
21 changed files with 713 additions and 783 deletions

View File

@ -0,0 +1,149 @@
/* eslint react/no-multi-comp:off */
import { useContext, useState } from 'react';
import {
Avatar,
Link,
Icon,
IconButton,
LinearProgress,
Typography,
} from '@material-ui/core';
import { Link as LinkIcon } from '@material-ui/icons';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import { formatDateWithLocale } from '../../common/util';
import { UPDATE_APPLICATION } from '../../providers/AccessProvider/permissions';
import { ApplicationView } from '../ApplicationView/ApplicationView';
import { ApplicationUpdate } from '../ApplicationUpdate/ApplicationUpdate';
import TabNav from '../../common/TabNav/TabNav';
import Dialogue from '../../common/Dialogue';
import PageContent from '../../common/PageContent';
import HeaderTitle from '../../common/HeaderTitle';
import AccessContext from '../../../contexts/AccessContext';
import useApplicationsApi from '../../../hooks/api/actions/useApplicationsApi/useApplicationsApi';
import useApplication from '../../../hooks/api/getters/useApplication/useApplication';
import { useHistory, useParams } from 'react-router-dom';
import { useLocationSettings } from '../../../hooks/useLocationSettings';
import useToast from '../../../hooks/useToast';
import PermissionButton from '../../common/PermissionButton/PermissionButton';
export const ApplicationEdit = () => {
const history = useHistory();
const { name } = useParams<{ name: string }>();
const { application, loading } = useApplication(name);
const { appName, url, description, icon = 'apps', createdAt } = application;
const { hasAccess } = useContext(AccessContext);
const { deleteApplication } = useApplicationsApi();
const { locationSettings } = useLocationSettings();
const { setToastData, setToastApiError } = useToast();
const [showDialog, setShowDialog] = useState(false);
const toggleModal = () => {
setShowDialog(!showDialog);
};
const formatDate = (v: string) =>
formatDateWithLocale(v, locationSettings.locale);
const onDeleteApplication = async (evt: Event) => {
evt.preventDefault();
try {
await deleteApplication(appName);
setToastData({
title: 'Deleted Successfully',
text: 'Application deleted successfully',
type: 'success',
});
history.push('/applications');
} catch (e: any) {
setToastApiError(e.toString());
}
};
const renderModal = () => (
<Dialogue
open={showDialog}
onClose={toggleModal}
onClick={onDeleteApplication}
title="Are you sure you want to delete this application?"
/>
);
const tabData = [
{
label: 'Application overview',
component: <ApplicationView />,
},
{
label: 'Edit application',
component: <ApplicationUpdate application={application} />,
},
];
if (loading) {
return (
<div>
<p>Loading...</p>
<LinearProgress />
</div>
);
} else if (!application) {
return <p>Application ({appName}) not found</p>;
}
return (
<PageContent
headerContent={
<HeaderTitle
title={
<span
style={{
display: 'flex',
alignItems: 'center',
}}
>
<Avatar style={{ marginRight: '8px' }}>
<Icon>{icon || 'apps'}</Icon>
</Avatar>
{appName}
</span>
}
actions={
<>
<ConditionallyRender
condition={Boolean(url)}
show={
<IconButton component={Link} href={url}>
<LinkIcon />
</IconButton>
}
/>
<PermissionButton
title="Delete application"
onClick={toggleModal}
permission={UPDATE_APPLICATION}
>
Delete
</PermissionButton>
</>
}
/>
}
>
<div>
<Typography variant="body1">{description || ''}</Typography>
<Typography variant="body2">
Created: <strong>{formatDate(createdAt)}</strong>
</Typography>
</div>
<ConditionallyRender
condition={hasAccess(UPDATE_APPLICATION)}
show={
<div>
{renderModal()}
<TabNav tabData={tabData} />
</div>
}
/>
</PageContent>
);
};

View File

@ -0,0 +1,68 @@
import { useMemo, useState } from 'react';
import { CircularProgress } from '@material-ui/core';
import { Warning } from '@material-ui/icons';
import { AppsLinkList, styles as commonStyles } from '../../common';
import SearchField from '../../common/SearchField/SearchField';
import PageContent from '../../common/PageContent/PageContent';
import HeaderTitle from '../../common/HeaderTitle';
import useApplications from '../../../hooks/api/getters/useApplications/useApplications';
import ConditionallyRender from '../../common/ConditionallyRender';
export const ApplicationList = () => {
const { applications, loading } = useApplications();
const [filter, setFilter] = useState('');
const filteredApplications = useMemo(() => {
const regExp = new RegExp(filter, 'i');
return filter
? applications?.filter(a => regExp.test(a.appName))
: applications;
}, [applications, filter]);
const renderNoApplications = () => (
<>
<section style={{ textAlign: 'center' }}>
<Warning /> <br />
<br />
Oh snap, it does not seem like you have connected any
applications. To connect your application to Unleash you will
require a Client SDK.
<br />
<br />
You can read more about how to use Unleash in your application
in the{' '}
<a href="https://docs.getunleash.io/docs/sdks/">
documentation.
</a>
</section>
</>
);
if (!filteredApplications) {
return <CircularProgress variant="indeterminate" />;
}
return (
<>
<div className={commonStyles.searchField}>
<SearchField value={filter} updateValue={setFilter} />
</div>
<PageContent headerContent={<HeaderTitle title="Applications" />}>
<div className={commonStyles.fullwidth}>
<ConditionallyRender
condition={filteredApplications.length > 0}
show={<AppsLinkList apps={filteredApplications} />}
elseShow={
<ConditionallyRender
condition={loading}
show={<div>...loading</div>}
elseShow={renderNoApplications()}
/>
}
/>
</div>
</PageContent>
</>
);
};

View File

@ -0,0 +1,83 @@
import { ChangeEvent, useState } from 'react';
import { TextField, Grid } from '@material-ui/core';
import { useCommonStyles } from '../../../common.styles';
import icons from '../icon-names';
import GeneralSelect from '../../common/GeneralSelect/GeneralSelect';
import useApplicationsApi from '../../../hooks/api/actions/useApplicationsApi/useApplicationsApi';
import useToast from '../../../hooks/useToast';
import { IApplication } from '../../../interfaces/application';
interface IApplicationUpdateProps {
application: IApplication;
}
export const ApplicationUpdate = ({ application }: IApplicationUpdateProps) => {
const { storeApplicationMetaData } = useApplicationsApi();
const { appName, icon, url, description } = application;
const [localUrl, setLocalUrl] = useState(url || '');
const [localDescription, setLocalDescription] = useState(description || '');
const { setToastData, setToastApiError } = useToast();
const commonStyles = useCommonStyles();
const handleChange = (
evt: ChangeEvent<{ name?: string | undefined; value: unknown }>,
field: string,
value: string
) => {
evt.preventDefault();
try {
storeApplicationMetaData(appName, field, value);
setToastData({
type: 'success',
title: 'Updated Successfully',
text: `${field} successfully updated`,
});
} catch (e: any) {
setToastApiError(e.toString());
}
};
return (
<Grid container style={{ marginTop: '1rem' }}>
<Grid item sm={12} xs={12} className={commonStyles.contentSpacingY}>
<Grid item>
<GeneralSelect
name="iconSelect"
id="selectIcon"
label="Icon"
options={icons.map(v => ({ key: v, label: v }))}
value={icon || 'apps'}
onChange={e =>
handleChange(e, 'icon', e.target.value as string)
}
/>
</Grid>
<Grid item>
<TextField
value={localUrl}
onChange={e => setLocalUrl(e.target.value)}
label="Application URL"
placeholder="https://example.com"
type="url"
variant="outlined"
size="small"
onBlur={e => handleChange(e, 'url', localUrl)}
/>
</Grid>
<Grid item>
<TextField
value={localDescription}
label="Description"
variant="outlined"
size="small"
rows={2}
onChange={e => setLocalDescription(e.target.value)}
onBlur={e =>
handleChange(e, 'description', localDescription)
}
/>
</Grid>
</Grid>
</Grid>
);
};

View File

@ -1,6 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useContext } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
Grid,
List,
@ -9,30 +8,45 @@ import {
ListItemAvatar,
Typography,
} from '@material-ui/core';
import { Report, Extension, Timeline, FlagRounded } from '@material-ui/icons';
import { shorten } from '../common';
import {
Report,
Extension,
Timeline,
FlagRounded,
SvgIconComponent,
} from '@material-ui/icons';
import { shorten } from '../../common';
import {
CREATE_FEATURE,
CREATE_STRATEGY,
} from '../providers/AccessProvider/permissions';
import ConditionallyRender from '../common/ConditionallyRender/ConditionallyRender';
import { getTogglePath } from '../../utils/route-path-helpers';
function ApplicationView({
seenToggles,
hasAccess,
strategies,
instances,
formatFullDateTime,
}) {
const notFoundListItem = ({ createUrl, name, permission }) => (
} from '../../providers/AccessProvider/permissions';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import { getTogglePath } from '../../../utils/route-path-helpers';
import useApplication from '../../../hooks/api/getters/useApplication/useApplication';
import AccessContext from '../../../contexts/AccessContext';
import { formatFullDateTimeWithLocale } from '../../common/util';
export const ApplicationView = () => {
const { hasAccess } = useContext(AccessContext);
const { name } = useParams<{ name: string }>();
const { application } = useApplication(name);
const { instances, strategies, seenToggles } = application;
const notFoundListItem = ({
createUrl,
name,
permission,
}: {
createUrl: string;
name: string;
permission: string;
}) => (
<ConditionallyRender
key={`not_found_conditional_${name}`}
condition={hasAccess(permission)}
show={
<ListItem key={`not_found_${name}`}>
<ListItemAvatar>
<Report style={{color: 'red'}} />
<Report style={{ color: 'red' }} />
</ListItemAvatar>
<ListItemText
primary={<Link to={`${createUrl}`}>{name}</Link>}
@ -54,13 +68,18 @@ function ApplicationView({
/>
);
// eslint-disable-next-line react/prop-types
const foundListItem = ({
viewUrl,
name,
description,
Icon,
i,
}: {
viewUrl: string;
name: string;
description: string;
Icon: SvgIconComponent;
i: number;
}) => (
<ListItem key={`found_${name}-${i}`}>
<ListItemAvatar>
@ -83,10 +102,7 @@ function ApplicationView({
<hr />
<List>
{seenToggles.map(
(
{ name, description, notFound, project },
i
) => (
({ name, description, notFound, project }, i) => (
<ConditionallyRender
key={`toggle_conditional_${name}`}
condition={notFound}
@ -97,7 +113,7 @@ function ApplicationView({
i,
})}
elseShow={foundListItem({
viewUrl: getTogglePath(project, name),
viewUrl: getTogglePath(project, name, true),
name,
description,
Icon: FlagRounded,
@ -114,26 +130,26 @@ function ApplicationView({
</Typography>
<hr />
<List>
{strategies.map(({ name, description, notFound }, i) => (
<ConditionallyRender
key={`strategies_conditional_${name}`}
condition={notFound}
show={notFoundListItem({
createUrl: '/strategies/create',
name,
permission: CREATE_STRATEGY,
i,
})}
elseShow={foundListItem({
viewUrl: '/strategies/view',
name,
Icon: Extension,
enabled: undefined,
description,
i,
})}
/>
))}
{strategies.map(
({ name, description, notFound }, i: number) => (
<ConditionallyRender
key={`strategies_conditional_${name}`}
condition={notFound}
show={notFoundListItem({
createUrl: '/strategies/create',
name,
permission: CREATE_STRATEGY,
})}
elseShow={foundListItem({
viewUrl: '/strategies/view',
name,
Icon: Extension,
description,
i,
})}
/>
)
)}
</List>
</Grid>
<Grid item xl={12} md={12}>
@ -143,7 +159,17 @@ function ApplicationView({
<hr />
<List>
{instances.map(
({ instanceId, clientIp, lastSeen, sdkVersion }) => (
({
instanceId,
clientIp,
lastSeen,
sdkVersion,
}: {
instanceId: string;
clientIp: string;
lastSeen: string;
sdkVersion: string;
}) => (
<ListItem key={`${instanceId}`}>
<ListItemAvatar>
<Timeline />
@ -152,16 +178,22 @@ function ApplicationView({
primary={
<ConditionallyRender
key={`${instanceId}_conditional`}
condition={sdkVersion}
show={`${instanceId} (${sdkVersion})`}
elseShow={instanceId}
condition={Boolean(sdkVersion)}
show={
<span>
{instanceId} {sdkVersion}
</span>
}
elseShow={<span>{instanceId}</span>}
/>
}
secondary={
<span>
{clientIp} last seen at{' '}
<small>
{formatFullDateTime(lastSeen)}
{formatFullDateTimeWithLocale(
lastSeen
)}
</small>
</span>
}
@ -173,17 +205,4 @@ function ApplicationView({
</Grid>
</Grid>
);
}
ApplicationView.propTypes = {
createUrl: PropTypes.string,
name: PropTypes.string,
permission: PropTypes.string,
instances: PropTypes.array.isRequired,
seenToggles: PropTypes.array.isRequired,
strategies: PropTypes.array.isRequired,
hasAccess: PropTypes.func.isRequired,
formatFullDateTime: PropTypes.func.isRequired,
};
export default ApplicationView;

View File

@ -22,223 +22,43 @@ exports[`renders correctly if no application 1`] = `
`;
exports[`renders correctly with permissions 1`] = `
<div
className="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
style={
Object {
"borderRadius": "10px",
"boxShadow": "none",
}
}
>
<div>
<p>
Loading...
</p>
<div
className="makeStyles-headerContainer-1"
className="MuiLinearProgress-root MuiLinearProgress-colorPrimary MuiLinearProgress-indeterminate"
role="progressbar"
>
<div
className="makeStyles-headerTitleContainer-5"
>
<div
className=""
data-loading={true}
>
<h2
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
>
<span
style={
Object {
"alignItems": "center",
"display": "flex",
}
}
>
<div
className="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault"
style={
Object {
"marginRight": "8px",
}
}
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
apps
</span>
</div>
test-app
</span>
</h2>
</div>
<div
className="makeStyles-headerActions-7"
>
<a
aria-disabled={false}
className="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiButtonBase-root MuiIconButton-root MuiTypography-colorPrimary"
href="http://example.org"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
>
<span
className="MuiIconButton-label"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
/>
</svg>
</span>
</a>
</div>
</div>
</div>
<div
className="makeStyles-bodyContainer-2"
>
<div>
<p
className="MuiTypography-root MuiTypography-body1"
>
app description
</p>
<p
className="MuiTypography-root MuiTypography-body2"
>
Created:
<strong>
Invalid Date
</strong>
</p>
</div>
className="MuiLinearProgress-bar MuiLinearProgress-barColorPrimary MuiLinearProgress-bar1Indeterminate"
style={Object {}}
/>
<div
className="MuiLinearProgress-bar MuiLinearProgress-bar2Indeterminate MuiLinearProgress-barColorPrimary"
style={Object {}}
/>
</div>
</div>
`;
exports[`renders correctly without permission 1`] = `
<div
className="MuiPaper-root MuiPaper-elevation1 MuiPaper-rounded"
style={
Object {
"borderRadius": "10px",
"boxShadow": "none",
}
}
>
<div>
<p>
Loading...
</p>
<div
className="makeStyles-headerContainer-1"
className="MuiLinearProgress-root MuiLinearProgress-colorPrimary MuiLinearProgress-indeterminate"
role="progressbar"
>
<div
className="makeStyles-headerTitleContainer-5"
>
<div
className=""
data-loading={true}
>
<h2
className="MuiTypography-root makeStyles-headerTitle-6 MuiTypography-h2"
>
<span
style={
Object {
"alignItems": "center",
"display": "flex",
}
}
>
<div
className="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault"
style={
Object {
"marginRight": "8px",
}
}
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
apps
</span>
</div>
test-app
</span>
</h2>
</div>
<div
className="makeStyles-headerActions-7"
>
<a
aria-disabled={false}
className="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiButtonBase-root MuiIconButton-root MuiTypography-colorPrimary"
href="http://example.org"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
>
<span
className="MuiIconButton-label"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
/>
</svg>
</span>
</a>
</div>
</div>
</div>
<div
className="makeStyles-bodyContainer-2"
>
<div>
<p
className="MuiTypography-root MuiTypography-body1"
>
app description
</p>
<p
className="MuiTypography-root MuiTypography-body2"
>
Created:
<strong>
Invalid Date
</strong>
</p>
</div>
className="MuiLinearProgress-bar MuiLinearProgress-barColorPrimary MuiLinearProgress-bar1Indeterminate"
style={Object {}}
/>
<div
className="MuiLinearProgress-bar MuiLinearProgress-bar2Indeterminate MuiLinearProgress-barColorPrimary"
style={Object {}}
/>
</div>
</div>
`;

View File

@ -1,26 +1,30 @@
import React from 'react';
import { ThemeProvider } from '@material-ui/core';
import ClientApplications from '../application-edit-component';
import { ApplicationEdit } from '../ApplicationEdit/ApplicationEdit';
import renderer from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ADMIN } from '../../providers/AccessProvider/permissions';
import theme from '../../../themes/main-theme';
import { createFakeStore } from '../../../accessStoreFake';
import AccessProvider from '../../providers/AccessProvider/AccessProvider';
import UIProvider from '../../providers/UIProvider/UIProvider';
test('renders correctly if no application', () => {
const tree = renderer
.create(
<AccessProvider store={createFakeStore([{ permission: ADMIN }])}>
<ClientApplications
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
deleteApplication={jest.fn()}
history={{}}
locationSettings={{ locale: 'en-GB' }}
/>
<ThemeProvider theme={theme}>
<UIProvider>
<MemoryRouter initialEntries={['/test']}>
<ApplicationEdit
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
deleteApplication={jest.fn()}
history={{}}
locationSettings={{ locale: 'en-GB' }}
/>
</MemoryRouter>
</UIProvider>
</ThemeProvider>
</AccessProvider>
)
.toJSON();
@ -33,54 +37,56 @@ test('renders correctly without permission', () => {
.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
<AccessProvider store={createFakeStore([])}>
<ClientApplications
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
deleteApplication={jest.fn()}
history={{}}
application={{
appName: 'test-app',
instances: [
{
instanceId: 'instance-1',
clientIp: '123.123.123.123',
lastSeen: '2017-02-23T15:56:49',
sdkVersion: '4.0',
},
],
strategies: [
{
name: 'StrategyA',
description: 'A description',
},
{
name: 'StrategyB',
description: 'B description',
notFound: true,
},
],
seenToggles: [
{
name: 'ToggleA',
description: 'this is A toggle',
enabled: true,
project: 'default',
},
{
name: 'ToggleB',
description: 'this is B toggle',
enabled: false,
notFound: true,
project: 'default',
},
],
url: 'http://example.org',
description: 'app description',
}}
locationSettings={{ locale: 'en-GB' }}
/>
</AccessProvider>
<UIProvider>
<AccessProvider store={createFakeStore([])}>
<ApplicationEdit
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
deleteApplication={jest.fn()}
history={{}}
application={{
appName: 'test-app',
instances: [
{
instanceId: 'instance-1',
clientIp: '123.123.123.123',
lastSeen: '2017-02-23T15:56:49',
sdkVersion: '4.0',
},
],
strategies: [
{
name: 'StrategyA',
description: 'A description',
},
{
name: 'StrategyB',
description: 'B description',
notFound: true,
},
],
seenToggles: [
{
name: 'ToggleA',
description: 'this is A toggle',
enabled: true,
project: 'default',
},
{
name: 'ToggleB',
description: 'this is B toggle',
enabled: false,
notFound: true,
project: 'default',
},
],
url: 'http://example.org',
description: 'app description',
}}
locationSettings={{ locale: 'en-GB' }}
/>
</AccessProvider>
</UIProvider>
</ThemeProvider>
</MemoryRouter>
)
@ -94,56 +100,58 @@ test('renders correctly with permissions', () => {
.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
<AccessProvider
store={createFakeStore([{ permission: ADMIN }])}
>
<ClientApplications
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
history={{}}
deleteApplication={jest.fn()}
application={{
appName: 'test-app',
instances: [
{
instanceId: 'instance-1',
clientIp: '123.123.123.123',
lastSeen: '2017-02-23T15:56:49',
sdkVersion: '4.0',
},
],
strategies: [
{
name: 'StrategyA',
description: 'A description',
},
{
name: 'StrategyB',
description: 'B description',
notFound: true,
},
],
seenToggles: [
{
name: 'ToggleA',
description: 'this is A toggle',
enabled: true,
project: 'default',
},
{
name: 'ToggleB',
description: 'this is B toggle',
enabled: false,
notFound: true,
project: 'default',
},
],
url: 'http://example.org',
description: 'app description',
}}
locationSettings={{ locale: 'en-GB' }}
/>
</AccessProvider>
<UIProvider>
<AccessProvider
store={createFakeStore([{ permission: ADMIN }])}
>
<ApplicationEdit
fetchApplication={() => Promise.resolve({})}
storeApplicationMetaData={jest.fn()}
history={{}}
deleteApplication={jest.fn()}
application={{
appName: 'test-app',
instances: [
{
instanceId: 'instance-1',
clientIp: '123.123.123.123',
lastSeen: '2017-02-23T15:56:49',
sdkVersion: '4.0',
},
],
strategies: [
{
name: 'StrategyA',
description: 'A description',
},
{
name: 'StrategyB',
description: 'B description',
notFound: true,
},
],
seenToggles: [
{
name: 'ToggleA',
description: 'this is A toggle',
enabled: true,
project: 'default',
},
{
name: 'ToggleB',
description: 'this is B toggle',
enabled: false,
notFound: true,
project: 'default',
},
],
url: 'http://example.org',
description: 'app description',
}}
locationSettings={{ locale: 'en-GB' }}
/>
</AccessProvider>
</UIProvider>
</ThemeProvider>
</MemoryRouter>
)

View File

@ -1,196 +0,0 @@
/* eslint react/no-multi-comp:off */
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import {
Avatar,
Link,
Icon,
IconButton,
Button,
LinearProgress,
Typography,
} from '@material-ui/core';
import { Link as LinkIcon } from '@material-ui/icons';
import ConditionallyRender from '../common/ConditionallyRender/ConditionallyRender';
import {
formatFullDateTimeWithLocale,
formatDateWithLocale,
} from '../common/util';
import { UPDATE_APPLICATION } from '../providers/AccessProvider/permissions';
import ApplicationView from './application-view';
import ApplicationUpdate from './application-update';
import TabNav from '../common/TabNav/TabNav';
import Dialogue from '../common/Dialogue';
import PageContent from '../common/PageContent';
import HeaderTitle from '../common/HeaderTitle';
import AccessContext from '../../contexts/AccessContext';
class ClientApplications extends PureComponent {
static contextType = AccessContext;
static propTypes = {
fetchApplication: PropTypes.func.isRequired,
appName: PropTypes.string,
application: PropTypes.object,
locationSettings: PropTypes.object.isRequired,
storeApplicationMetaData: PropTypes.func.isRequired,
deleteApplication: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
};
constructor(props) {
super();
this.state = {
activeTab: 0,
loading: !props.application,
prompt: false,
};
}
componentDidMount() {
this.props
.fetchApplication(this.props.appName)
.finally(() => this.setState({ loading: false }));
}
formatFullDateTime = v =>
formatFullDateTimeWithLocale(v, this.props.locationSettings.locale);
formatDate = v => formatDateWithLocale(v, this.props.locationSettings.locale);
deleteApplication = async evt => {
evt.preventDefault();
// if (window.confirm('Are you sure you want to remove this application?')) {
const { deleteApplication, appName } = this.props;
await deleteApplication(appName);
this.props.history.push('/applications');
// }
};
render() {
if (this.state.loading) {
return (
<div>
<p>Loading...</p>
<LinearProgress />
</div>
);
} else if (!this.props.application) {
return <p>Application ({this.props.appName}) not found</p>;
}
const { hasAccess } = this.context;
const { application, storeApplicationMetaData } = this.props;
const {
appName,
instances,
strategies,
seenToggles,
url,
description,
icon = 'apps',
createdAt,
} = application;
const toggleModal = () => {
this.setState(prev => ({ ...prev, prompt: !prev.prompt }));
};
const renderModal = () => (
<Dialogue
open={this.state.prompt}
onClose={toggleModal}
onClick={this.deleteApplication}
title="Are you sure you want to delete this application?"
/>
);
const tabData = [
{
label: 'Application overview',
component: (
<ApplicationView
strategies={strategies}
instances={instances}
seenToggles={seenToggles}
hasAccess={hasAccess}
formatFullDateTime={this.formatFullDateTime}
/>
),
},
{
label: 'Edit application',
component: (
<ApplicationUpdate
application={application}
storeApplicationMetaData={storeApplicationMetaData}
/>
),
},
];
return (
<PageContent
headerContent={
<HeaderTitle
title={
<span
style={{
display: 'flex',
alignItems: 'center',
}}
>
<Avatar style={{ marginRight: '8px' }}>
<Icon>{icon || 'apps'}</Icon>
</Avatar>
{appName}
</span>
}
actions={
<>
<ConditionallyRender
condition={url}
show={
<IconButton component={Link} href={url}>
<LinkIcon />
</IconButton>
}
/>
<ConditionallyRender
condition={hasAccess(UPDATE_APPLICATION)}
show={
<Button
color="secondary"
title="Delete application"
onClick={toggleModal}
>
Delete
</Button>
}
/>
</>
}
/>
}
>
<div>
<Typography variant="body1">{description || ''}</Typography>
<Typography variant="body2">
Created: <strong>{this.formatDate(createdAt)}</strong>
</Typography>
</div>
<ConditionallyRender
condition={hasAccess(UPDATE_APPLICATION)}
show={
<div>
{renderModal()}
<TabNav tabData={tabData} />
</div>
}
/>
</PageContent>
);
}
}
export default ClientApplications;

View File

@ -1,30 +0,0 @@
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);

View File

@ -1,69 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { CircularProgress } from '@material-ui/core';
import { Warning } from '@material-ui/icons';
import { AppsLinkList, styles as commonStyles } from '../common';
import SearchField from '../common/SearchField/SearchField';
import PageContent from '../common/PageContent/PageContent';
import HeaderTitle from '../common/HeaderTitle';
const Empty = () => (
<React.Fragment>
<section style={{ textAlign: 'center' }}>
<Warning /> <br />
<br />
Oh snap, it does not seem like you have connected any applications.
To connect your application to Unleash you will require a Client
SDK.
<br />
<br />
You can read more about how to use Unleash in your application in
the{' '}
<a href="https://docs.getunleash.io/docs/sdks/">documentation.</a>
</section>
</React.Fragment>
);
const ClientStrategies = ({ fetchAll, applications }) => {
const [filter, setFilter] = useState('');
useEffect(() => {
fetchAll();
}, [fetchAll]);
const filteredApplications = useMemo(() => {
const regExp = new RegExp(filter, 'i');
return filter
? applications?.filter(a => regExp.test(a.appName))
: applications;
}, [applications, filter]);
if (!filteredApplications) {
return <CircularProgress variant="indeterminate" />;
}
return (
<>
<div className={commonStyles.searchField}>
<SearchField value={filter} updateValue={setFilter} />
</div>
<PageContent headerContent={<HeaderTitle title="Applications" />}>
<div className={commonStyles.fullwidth}>
{filteredApplications.length > 0 ? (
<AppsLinkList apps={filteredApplications} />
) : (
<Empty />
)}
</div>
</PageContent>
</>
);
};
ClientStrategies.propTypes = {
applications: PropTypes.array,
fetchAll: PropTypes.func.isRequired,
};
export default ClientStrategies;

View File

@ -1,15 +0,0 @@
import { connect } from 'react-redux';
import ApplicationList from './application-list-component';
import { fetchAll } from '../../store/application/actions';
const mapStateToProps = state => ({
applications: state.applications.get('list').toJS(),
});
const mapDispatchToProps = {
fetchAll,
};
const Container = connect(mapStateToProps, mapDispatchToProps)(ApplicationList);
export default Container;

View File

@ -1,72 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { TextField, Grid } from '@material-ui/core';
import { useCommonStyles } from '../../common.styles';
import icons from './icon-names';
import GeneralSelect from '../common/GeneralSelect/GeneralSelect';
function ApplicationUpdate({ application, storeApplicationMetaData }) {
const { appName, icon, url, description } = application;
const [localUrl, setLocalUrl] = useState(url);
const [localDescription, setLocalDescription] = useState(description);
const commonStyles = useCommonStyles();
return (
<Grid container style={{ marginTop: '1rem' }}>
<Grid item sm={12} xs={12} className={commonStyles.contentSpacingY}>
<Grid item>
<GeneralSelect
label="Icon"
options={icons.map(v => ({ key: v, label: v }))}
value={icon || 'apps'}
onChange={e =>
storeApplicationMetaData(
appName,
'icon',
e.target.value
)
}
/>
</Grid>
<Grid item>
<TextField
value={localUrl}
onChange={e => setLocalUrl(e.target.value)}
label="Application URL"
placeholder="https://example.com"
type="url"
variant="outlined"
size="small"
onBlur={() =>
storeApplicationMetaData(appName, 'url', localUrl)
}
/>
</Grid>
<Grid item>
<TextField
value={localDescription}
label="Description"
variant="outlined"
size="small"
rows={2}
onChange={e => setLocalDescription(e.target.value)}
onBlur={() =>
storeApplicationMetaData(
appName,
'description',
localDescription
)
}
/>
</Grid>
</Grid>
</Grid>
);
}
ApplicationUpdate.propTypes = {
application: PropTypes.object.isRequired,
storeApplicationMetaData: PropTypes.func.isRequired,
};
export default ApplicationUpdate;

View File

@ -7,7 +7,7 @@ import SearchIcon from '@material-ui/icons/Search';
import { useStyles } from './styles';
function SearchField({ updateValue, className }) {
function SearchField({ updateValue, className = '' }) {
const styles = useStyles();
const [localValue, setLocalValue] = useState('');

View File

@ -13,7 +13,12 @@ const a11yProps = index => ({
'aria-controls': `tabpanel-${index}`,
});
const TabNav = ({ tabData, className, navClass, startingTab = 0 }) => {
const TabNav = ({
tabData,
className = '',
navClass = '',
startingTab = 0,
}) => {
const styles = useStyles();
const [activeTab, setActiveTab] = useState(startingTab);
const history = useHistory();

View File

@ -6,8 +6,6 @@ import Strategies from '../../page/strategies';
import HistoryPage from '../../page/history';
import HistoryTogglePage from '../../page/history/toggle';
import { ArchiveListContainer } from '../archive/ArchiveListContainer';
import Applications from '../../page/applications';
import ApplicationView from '../../page/applications/view';
import { TagTypeList } from '../tags/TagTypeList/TagTypeList';
import { AddonList } from '../addons/AddonList/AddonList';
import Admin from '../admin';
@ -41,6 +39,8 @@ import EditProject from '../project/Project/EditProject/EditProject';
import CreateProject from '../project/Project/CreateProject/CreateProject';
import CreateFeature from '../feature/CreateFeature/CreateFeature';
import EditFeature from '../feature/EditFeature/EditFeature';
import { ApplicationEdit } from '../application/ApplicationEdit/ApplicationEdit';
import { ApplicationList } from '../application/ApplicationList/ApplicationList';
import ContextList from '../context/ContextList/ContextList';
import RedirectFeatureView from '../feature/RedirectFeatureView/RedirectFeatureView';
import { CreateAddon } from '../addons/CreateAddon/CreateAddon';
@ -193,7 +193,7 @@ export const routes = [
path: '/applications/:name',
title: ':name',
parent: '/applications',
component: ApplicationView,
component: ApplicationEdit,
type: 'protected',
layout: 'main',
menu: {},
@ -201,7 +201,7 @@ export const routes = [
{
path: '/applications',
title: 'Applications',
component: Applications,
component: ApplicationList,
type: 'protected',
layout: 'main',
menu: { mobile: true, advanced: true },

View File

@ -0,0 +1,50 @@
import useAPI from '../useApi/useApi';
const useApplicationsApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const URI = 'api/admin/metrics/applications';
const storeApplicationMetaData = async (
appName: string,
key: string,
value: string
) => {
const data: { [key: string]: any } = {};
data[key] = value;
const path = `${URI}/${appName}`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(data),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const deleteApplication = async (appName: string) => {
const path = `${URI}/${appName}`;
const req = createRequest(path, { method: 'DELETE' });
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
return {
storeApplicationMetaData,
deleteApplication,
errors,
loading,
};
};
export default useApplicationsApi;

View File

@ -0,0 +1,63 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IApplication } from '../../../../interfaces/application';
interface IUseApplicationOutput {
application: IApplication;
refetchApplication: () => void;
loading: boolean;
error?: Error;
APPLICATION_CACHE_KEY: string;
}
const useApplication = (
name: string,
options: SWRConfiguration = {}
): IUseApplicationOutput => {
const path = formatApiPath(`api/admin/metrics/applications/${name}`);
const fetcher = async () => {
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('Application'))
.then(res => res.json());
};
const APPLICATION_CACHE_KEY = `api/admin/metrics/applications/${name}`;
const { data, error } = useSWR(APPLICATION_CACHE_KEY, fetcher, {
...options,
});
const [loading, setLoading] = useState(!error && !data);
const refetchApplication = () => {
mutate(APPLICATION_CACHE_KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
application: data || {
appName: name,
color: '',
createdAt: '2022-02-02T21:04:00.268Z',
descriotion: '',
instances: [],
strategies: [],
seenToggles: [],
url: '',
},
error,
loading,
refetchApplication,
APPLICATION_CACHE_KEY,
};
};
export default useApplication;

View File

@ -0,0 +1,53 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IApplication } from '../../../../interfaces/application';
const path = formatApiPath('api/admin/metrics/applications');
interface IUseApplicationsOutput {
applications: IApplication[];
refetchApplications: () => void;
loading: boolean;
error?: Error;
APPLICATIONS_CACHE_KEY: string;
}
const useApplications = (
options: SWRConfiguration = {}
): IUseApplicationsOutput => {
const fetcher = async () => {
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('Applications data'))
.then(res => res.json());
};
const APPLICATIONS_CACHE_KEY = 'api/admin/metrics/applications';
const { data, error } = useSWR(APPLICATIONS_CACHE_KEY, fetcher, {
...options,
});
const [loading, setLoading] = useState(!error && !data);
const refetchApplications = () => {
mutate(APPLICATIONS_CACHE_KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
applications: data?.applications || {},
error,
loading,
refetchApplications,
APPLICATIONS_CACHE_KEY,
};
};
export default useApplications;

View File

@ -0,0 +1,12 @@
export interface IApplication {
appName: string;
color: string;
createdAt: string;
description: string;
icon: string;
instances: [];
links: object;
seenToggles: [];
strategies: [];
url: string;
}

View File

@ -1,6 +0,0 @@
import React from 'react';
import ApplicationListConmponent from '../../component/application/application-list-container';
const render = () => <ApplicationListConmponent />;
export default render;

View File

@ -1,12 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ApplicationEditComponent from '../../component/application/application-edit-container';
const render = ({ match: { params }, history }) => <ApplicationEditComponent appName={params.name} history={history} />;
render.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default render;