1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

Feat/new addons table (#1021)

* feat: initial list of available addons

* feat: add columns

* fix: update referential equality

* fix: remove search

* fix: remove unused imports

* fix: padding

* fix: imports

* refactor: based on comments
This commit is contained in:
Fredrik Strand Oseberg 2022-05-25 15:37:32 +02:00 committed by GitHub
parent a791ea59c1
commit ded3c22bb1
9 changed files with 524 additions and 197 deletions

View File

@ -0,0 +1,71 @@
import { Avatar } from '@mui/material';
import { DeviceHub } from '@mui/icons-material';
import { formatAssetPath } from 'utils/formatPath';
import slackIcon from 'assets/icons/slack.svg';
import jiraIcon from 'assets/icons/jira.svg';
import webhooksIcon from 'assets/icons/webhooks.svg';
import teamsIcon from 'assets/icons/teams.svg';
import dataDogIcon from 'assets/icons/datadog.svg';
const style: React.CSSProperties = {
width: '32.5px',
height: '32.5px',
marginRight: '16px',
borderRadius: '50%',
};
interface IAddonIconProps {
name: string;
}
export const AddonIcon = ({ name }: IAddonIconProps) => {
switch (name) {
case 'slack':
return (
<img
style={style}
alt="Slack logo"
src={formatAssetPath(slackIcon)}
/>
);
case 'jira-comment':
return (
<img
style={style}
alt="JIRA logo"
src={formatAssetPath(jiraIcon)}
/>
);
case 'webhook':
return (
<img
style={style}
alt="Generic Webhook logo"
src={formatAssetPath(webhooksIcon)}
/>
);
case 'teams':
return (
<img
style={style}
alt="Microsoft Teams logo"
src={formatAssetPath(teamsIcon)}
/>
);
case 'datadog':
return (
<img
style={style}
alt="Datadog logo"
src={formatAssetPath(dataDogIcon)}
/>
);
default:
return (
<Avatar>
<DeviceHub />
</Avatar>
);
}
};

View File

@ -13,13 +13,13 @@ import { formatAssetPath } from 'utils/formatPath';
import useAddons from 'hooks/api/getters/useAddons/useAddons';
const style: React.CSSProperties = {
width: '40px',
height: '40px',
width: '32.5px',
height: '32.5px',
marginRight: '16px',
float: 'left',
borderRadius: '50%',
};
const getAddonIcon = (name: string): ReactElement => {
export const getAddonIcon = (name: string): ReactElement => {
switch (name) {
case 'slack':
return (
@ -71,20 +71,17 @@ const getAddonIcon = (name: string): ReactElement => {
};
export const AddonList = () => {
const { providers, addons } = useAddons();
const { providers, addons, loading } = useAddons();
return (
<>
<ConditionallyRender
condition={addons.length > 0}
show={<ConfiguredAddons getAddonIcon={getAddonIcon} />}
show={<ConfiguredAddons />}
/>
<br />
<AvailableAddons
providers={providers}
getAddonIcon={getAddonIcon}
/>
<AvailableAddons loading={loading} providers={providers} />
</>
);
};

View File

@ -1,15 +1,24 @@
import { ReactElement } from 'react';
import { useMemo } from 'react';
import { PageContent } from 'component/common/PageContent/PageContent';
import {
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
} from '@mui/material';
import { CREATE_ADDON } from 'component/providers/AccessProvider/permissions';
import { useNavigate } from 'react-router-dom';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
Table,
SortableTableHeader,
TableBody,
TableCell,
TableRow,
TablePlaceholder,
} from 'component/common/Table';
import { useTable, useSortBy } from 'react-table';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import { ConfigureAddonButton } from './ConfigureAddonButton/ConfigureAddonButton';
import { AddonIcon } from '../AddonIcon/AddonIcon';
interface IProvider {
name: string;
@ -21,40 +30,153 @@ interface IProvider {
}
interface IAvailableAddonsProps {
getAddonIcon: (name: string) => ReactElement;
providers: IProvider[];
loading: boolean;
}
export const AvailableAddons = ({
providers,
getAddonIcon,
loading,
}: IAvailableAddonsProps) => {
const navigate = useNavigate();
const data = useMemo(() => {
if (loading) {
return Array(5).fill({
name: 'Provider name',
description: 'Provider description when loading',
});
}
const renderProvider = (provider: IProvider) => (
<ListItem key={provider.name}>
<ListItemAvatar>{getAddonIcon(provider.name)}</ListItemAvatar>
<ListItemText
primary={provider.displayName}
secondary={provider.description}
/>
<ListItemSecondaryAction>
<PermissionButton
permission={CREATE_ADDON}
onClick={() => navigate(`/addons/create/${provider.name}`)}
>
Configure
</PermissionButton>
</ListItemSecondaryAction>
</ListItem>
return providers.map(({ name, displayName, description }) => ({
name,
displayName,
description,
}));
}, [providers, loading]);
const columns = useMemo(
() => [
{
id: 'Icon',
Cell: ({
row: {
original: { name },
},
}: any) => {
return (
<IconCell icon={<AddonIcon name={name as string} />} />
);
},
},
{
Header: 'Name',
accessor: 'name',
width: '90%',
Cell: ({
row: {
original: { name, description },
},
}: any) => {
return (
<LinkCell
data-loading
title={name}
subtitle={description}
/>
);
},
sortType: 'alphanumeric',
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original } }: any) => (
<ActionCell>
<ConfigureAddonButton name={original.name} />
</ActionCell>
),
width: 150,
disableSortBy: true,
},
{
accessor: 'description',
disableSortBy: true,
},
],
[]
);
const initialState = useMemo(
() => ({
sortBy: [{ id: 'name', desc: false }],
hiddenColumns: ['description'],
}),
[]
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { globalFilter },
} = useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
},
useSortBy
);
return (
<PageContent header="Available addons">
<List>
{providers.map((provider: IProvider) =>
renderProvider(provider)
)}
</List>
<PageContent
isLoading={loading}
header={<PageHeader title="Available addons" />}
>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No providers found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No providers available.
</TablePlaceholder>
}
/>
}
/>
</PageContent>
);
};

View File

@ -0,0 +1,21 @@
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { CREATE_ADDON } from 'component/providers/AccessProvider/permissions';
import { useNavigate } from 'react-router-dom';
interface IConfigureAddonButtonProps {
name: string;
}
export const ConfigureAddonButton = ({ name }: IConfigureAddonButtonProps) => {
const navigate = useNavigate();
return (
<PermissionButton
permission={CREATE_ADDON}
variant="outlined"
onClick={() => navigate(`/addons/create/${name}`)}
>
Configure
</PermissionButton>
);
};

View File

@ -1,38 +1,34 @@
import {
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
} from '@mui/material';
import { useMemo } from 'react';
import { Box, Table, TableBody, TableCell, TableRow } from '@mui/material';
import { Delete, Edit, Visibility, VisibilityOff } from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
DELETE_ADDON,
UPDATE_ADDON,
} from 'component/providers/AccessProvider/permissions';
import { Link, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { PageContent } from 'component/common/PageContent/PageContent';
import useAddons from 'hooks/api/getters/useAddons/useAddons';
import useToast from 'hooks/useToast';
import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
import { ReactElement, useContext, useState } from 'react';
import AccessContext from 'contexts/AccessContext';
import { useState, useCallback } from 'react';
import { IAddon } from 'interfaces/addons';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { formatUnknownError } from 'utils/formatUnknownError';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { sortTypes } from 'utils/sortTypes';
import { useTable, useSortBy } from 'react-table';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { SortableTableHeader, TablePlaceholder } from 'component/common/Table';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { AddonIcon } from '../AddonIcon/AddonIcon';
import { ConfiguredAddonsActionsCell } from './ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell';
interface IConfigureAddonsProps {
getAddonIcon: (name: string) => ReactElement;
}
export const ConfiguredAddons = ({ getAddonIcon }: IConfigureAddonsProps) => {
const { refetchAddons, addons } = useAddons();
export const ConfiguredAddons = () => {
const { refetchAddons, addons, loading } = useAddons();
const { updateAddon, removeAddon } = useAddonsApi();
const { setToastData, setToastApiError } = useToast();
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
const [showDelete, setShowDelete] = useState(false);
const [deletedAddon, setDeletedAddon] = useState<IAddon>({
id: 0,
@ -43,27 +39,117 @@ export const ConfiguredAddons = ({ getAddonIcon }: IConfigureAddonsProps) => {
parameters: {},
});
const sortAddons = (addons: IAddon[]) => {
if (!addons) return [];
return addons.sort((addonA: IAddon, addonB: IAddon) => {
return addonA.id - addonB.id;
});
};
const toggleAddon = async (addon: IAddon) => {
try {
await updateAddon({ ...addon, enabled: !addon.enabled });
refetchAddons();
setToastData({
type: 'success',
title: 'Success',
text: 'Addon state switched successfully',
const data = useMemo(() => {
if (loading) {
return Array(5).fill({
name: 'Addon name',
description: 'Addon description when loading',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return addons.map(addon => ({
...addon,
}));
}, [addons, loading]);
const toggleAddon = useCallback(
async (addon: IAddon) => {
try {
await updateAddon({ ...addon, enabled: !addon.enabled });
refetchAddons();
setToastData({
type: 'success',
title: 'Success',
text: 'Addon state switched successfully',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
},
[setToastApiError, refetchAddons, setToastData, updateAddon]
);
const columns = useMemo(
() => [
{
id: 'Icon',
Cell: ({
row: {
original: { provider },
},
}: any) => (
<IconCell icon={<AddonIcon name={provider as string} />} />
),
},
{
Header: 'Name',
accessor: 'provider',
width: '90%',
Cell: ({
row: {
original: { provider, description },
},
}: any) => {
return (
<LinkCell
data-loading
title={provider}
subtitle={description}
/>
);
},
sortType: 'alphanumeric',
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original } }: any) => (
<ConfiguredAddonsActionsCell
setShowDelete={setShowDelete}
toggleAddon={toggleAddon}
setDeletedAddon={setDeletedAddon}
original={original as IAddon}
/>
),
width: 150,
disableSortBy: true,
},
{
accessor: 'description',
disableSortBy: true,
},
],
[toggleAddon]
);
const initialState = useMemo(
() => ({
sortBy: [{ id: 'provider', desc: false }],
hiddenColumns: ['description'],
}),
[]
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { globalFilter },
} = useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
},
useSortBy
);
const onRemoveAddon = async (addon: IAddon) => {
try {
@ -74,78 +160,56 @@ export const ConfiguredAddons = ({ getAddonIcon }: IConfigureAddonsProps) => {
title: 'Success',
text: 'Deleted addon successfully',
});
} catch (e) {
setToastData({
type: 'error',
title: 'Error',
text: 'Can not delete addon',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const renderAddon = (addon: IAddon) => (
<ListItem key={addon.id}>
<ListItemAvatar>{getAddonIcon(addon.provider)}</ListItemAvatar>
<ListItemText
primary={
<span>
<ConditionallyRender
condition={hasAccess(UPDATE_ADDON)}
show={
<Link
style={{
textDecoration: 'none',
color: 'inherit',
}}
to={`/addons/edit/${addon.id}`}
>
<strong>{addon.provider}</strong>
</Link>
}
elseShow={<strong>{addon.provider}</strong>}
/>
{addon.enabled ? null : <small> (Disabled)</small>}
</span>
}
secondary={addon.description}
/>
<ListItemSecondaryAction>
<PermissionIconButton
permission={UPDATE_ADDON}
onClick={() => toggleAddon(addon)}
tooltipProps={{ title: 'Toggle addon' }}
>
<ConditionallyRender
condition={addon.enabled}
show={<Visibility />}
elseShow={<VisibilityOff />}
/>
</PermissionIconButton>
<PermissionIconButton
permission={UPDATE_ADDON}
tooltipProps={{ title: 'Edit Addon' }}
onClick={() => navigate(`/addons/edit/${addon.id}`)}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_ADDON}
tooltipProps={{ title: 'Remove Addon' }}
onClick={() => {
setDeletedAddon(addon);
setShowDelete(true);
}}
>
<Delete />
</PermissionIconButton>
</ListItemSecondaryAction>
</ListItem>
);
return (
<PageContent header="Configured addons">
<List>
{sortAddons(addons).map((addon: IAddon) => renderAddon(addon))}
</List>
<PageContent
isLoading={loading}
header={<PageHeader title="Configured addons" />}
>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell
{...cell.getCellProps()}
padding="none"
>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No addons found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No addons configured
</TablePlaceholder>
}
/>
}
/>
<Dialogue
open={showDelete}
onClick={() => {

View File

@ -0,0 +1,58 @@
import { Visibility, VisibilityOff, Edit, Delete } from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import {
UPDATE_ADDON,
DELETE_ADDON,
} from 'component/providers/AccessProvider/permissions';
import { IAddon } from 'interfaces/addons';
import { useNavigate } from 'react-router-dom';
interface IConfiguredAddonsActionsCellProps {
toggleAddon: (addon: IAddon) => Promise<void>;
original: IAddon;
setShowDelete: React.Dispatch<React.SetStateAction<boolean>>;
setDeletedAddon: React.Dispatch<React.SetStateAction<IAddon>>;
}
export const ConfiguredAddonsActionsCell = ({
toggleAddon,
setShowDelete,
setDeletedAddon,
original,
}: IConfiguredAddonsActionsCellProps) => {
const navigate = useNavigate();
return (
<ActionCell>
<PermissionIconButton
permission={UPDATE_ADDON}
onClick={() => toggleAddon(original)}
tooltipProps={{ title: 'Toggle addon' }}
>
<ConditionallyRender
condition={original.enabled}
show={<Visibility />}
elseShow={<VisibilityOff />}
/>
</PermissionIconButton>
<PermissionIconButton
permission={UPDATE_ADDON}
tooltipProps={{ title: 'Edit Addon' }}
onClick={() => navigate(`/addons/edit/${original.id}`)}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_ADDON}
tooltipProps={{ title: 'Remove Addon' }}
onClick={() => {
setDeletedAddon(original);
setShowDelete(true);
}}
>
<Delete />
</PermissionIconButton>
</ActionCell>
);
};

View File

@ -1,4 +1,5 @@
import { IAddon } from 'interfaces/addons';
import { useCallback } from 'react';
import useAPI from '../useApi/useApi';
const useAddonsApi = () => {
@ -15,13 +16,7 @@ const useAddonsApi = () => {
body: JSON.stringify(addonConfig),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
return makeRequest(req.caller, req.id);
};
const removeAddon = async (id: number) => {
@ -29,29 +24,22 @@ const useAddonsApi = () => {
const req = createRequest(path, {
method: 'DELETE',
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
return await makeRequest(req.caller, req.id);
};
const updateAddon = async (addonConfig: IAddon) => {
const path = `${URI}/${addonConfig.id}`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify(addonConfig),
});
try {
const res = await makeRequest(req.caller, req.id);
const updateAddon = useCallback(
async (addonConfig: IAddon) => {
const path = `${URI}/${addonConfig.id}`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify(addonConfig),
});
return res;
} catch (e) {
throw e;
}
};
return makeRequest(req.caller, req.id);
},
[createRequest, makeRequest]
);
return {
createAddon,

View File

@ -1,5 +1,5 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IAddon, IAddonProvider } from 'interfaces/addons';
@ -23,9 +23,9 @@ const useAddons = (options: SWRConfiguration = {}) => {
const { data, error } = useSWR<IAddonsResponse>(KEY, fetcher, options);
const [loading, setLoading] = useState(!error && !data);
const refetchAddons = () => {
const refetchAddons = useCallback(() => {
mutate(KEY);
};
}, [KEY]);
useEffect(() => {
setLoading(!error && !data);

View File

@ -1,4 +1,4 @@
import { useContext } from 'react';
import { useCallback, useContext } from 'react';
import UIContext from '../contexts/UIContext';
import { IToast } from '../interfaces/toast';
@ -11,24 +11,30 @@ const useToast = () => {
show: false,
}));
const setToastApiError = (errorText: string, overrides?: IToast) => {
setToast({
title: 'Something went wrong',
text: `We had trouble talking to our API. Here's why: ${errorText}`,
type: 'error',
show: true,
autoHideDuration: 12000,
...overrides,
});
};
const setToastApiError = useCallback(
(errorText: string, overrides?: IToast) => {
setToast({
title: 'Something went wrong',
text: `We had trouble talking to our API. Here's why: ${errorText}`,
type: 'error',
show: true,
autoHideDuration: 12000,
...overrides,
});
},
[setToast]
);
const setToastData = (toast: IToast) => {
if (toast.persist) {
setToast({ ...toast, show: true });
} else {
setToast({ ...toast, show: true, autoHideDuration: 6000 });
}
};
const setToastData = useCallback(
(toast: IToast) => {
if (toast.persist) {
setToast({ ...toast, show: true });
} else {
setToast({ ...toast, show: true, autoHideDuration: 6000 });
}
},
[setToast]
);
return { setToastData, setToastApiError, hideToast };
};