1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-18 01:18:23 +02: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'; import useAddons from 'hooks/api/getters/useAddons/useAddons';
const style: React.CSSProperties = { const style: React.CSSProperties = {
width: '40px', width: '32.5px',
height: '40px', height: '32.5px',
marginRight: '16px', marginRight: '16px',
float: 'left', borderRadius: '50%',
}; };
const getAddonIcon = (name: string): ReactElement => { export const getAddonIcon = (name: string): ReactElement => {
switch (name) { switch (name) {
case 'slack': case 'slack':
return ( return (
@ -71,20 +71,17 @@ const getAddonIcon = (name: string): ReactElement => {
}; };
export const AddonList = () => { export const AddonList = () => {
const { providers, addons } = useAddons(); const { providers, addons, loading } = useAddons();
return ( return (
<> <>
<ConditionallyRender <ConditionallyRender
condition={addons.length > 0} condition={addons.length > 0}
show={<ConfiguredAddons getAddonIcon={getAddonIcon} />} show={<ConfiguredAddons />}
/> />
<br /> <br />
<AvailableAddons <AvailableAddons loading={loading} providers={providers} />
providers={providers}
getAddonIcon={getAddonIcon}
/>
</> </>
); );
}; };

View File

@ -1,15 +1,24 @@
import { ReactElement } from 'react'; import { useMemo } from 'react';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { import {
List, Table,
ListItem, SortableTableHeader,
ListItemAvatar, TableBody,
ListItemSecondaryAction, TableCell,
ListItemText, TableRow,
} from '@mui/material'; TablePlaceholder,
import { CREATE_ADDON } from 'component/providers/AccessProvider/permissions'; } from 'component/common/Table';
import { useNavigate } from 'react-router-dom';
import PermissionButton from 'component/common/PermissionButton/PermissionButton'; 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 { interface IProvider {
name: string; name: string;
@ -21,40 +30,153 @@ interface IProvider {
} }
interface IAvailableAddonsProps { interface IAvailableAddonsProps {
getAddonIcon: (name: string) => ReactElement;
providers: IProvider[]; providers: IProvider[];
loading: boolean;
} }
export const AvailableAddons = ({ export const AvailableAddons = ({
providers, providers,
getAddonIcon, loading,
}: IAvailableAddonsProps) => { }: 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) => ( return providers.map(({ name, displayName, description }) => ({
<ListItem key={provider.name}> name,
<ListItemAvatar>{getAddonIcon(provider.name)}</ListItemAvatar> displayName,
<ListItemText description,
primary={provider.displayName} }));
secondary={provider.description} }, [providers, loading]);
/>
<ListItemSecondaryAction> const columns = useMemo(
<PermissionButton () => [
permission={CREATE_ADDON} {
onClick={() => navigate(`/addons/create/${provider.name}`)} id: 'Icon',
> Cell: ({
Configure row: {
</PermissionButton> original: { name },
</ListItemSecondaryAction> },
</ListItem> }: 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 ( return (
<PageContent header="Available addons"> <PageContent
<List> isLoading={loading}
{providers.map((provider: IProvider) => header={<PageHeader title="Available addons" />}
renderProvider(provider) >
)} <Table {...getTableProps()}>
</List> <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> </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 { import { useMemo } from 'react';
List, import { Box, Table, TableBody, TableCell, TableRow } from '@mui/material';
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
} from '@mui/material';
import { Delete, Edit, Visibility, VisibilityOff } from '@mui/icons-material'; import { Delete, Edit, Visibility, VisibilityOff } from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { import {
DELETE_ADDON, DELETE_ADDON,
UPDATE_ADDON, UPDATE_ADDON,
} from 'component/providers/AccessProvider/permissions'; } 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 { PageContent } from 'component/common/PageContent/PageContent';
import useAddons from 'hooks/api/getters/useAddons/useAddons'; import useAddons from 'hooks/api/getters/useAddons/useAddons';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi'; import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
import { ReactElement, useContext, useState } from 'react'; import { useState, useCallback } from 'react';
import AccessContext from 'contexts/AccessContext';
import { IAddon } from 'interfaces/addons'; import { IAddon } from 'interfaces/addons';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { formatUnknownError } from 'utils/formatUnknownError'; 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 { export const ConfiguredAddons = () => {
getAddonIcon: (name: string) => ReactElement; const { refetchAddons, addons, loading } = useAddons();
}
export const ConfiguredAddons = ({ getAddonIcon }: IConfigureAddonsProps) => {
const { refetchAddons, addons } = useAddons();
const { updateAddon, removeAddon } = useAddonsApi(); const { updateAddon, removeAddon } = useAddonsApi();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
const [showDelete, setShowDelete] = useState(false); const [showDelete, setShowDelete] = useState(false);
const [deletedAddon, setDeletedAddon] = useState<IAddon>({ const [deletedAddon, setDeletedAddon] = useState<IAddon>({
id: 0, id: 0,
@ -43,27 +39,117 @@ export const ConfiguredAddons = ({ getAddonIcon }: IConfigureAddonsProps) => {
parameters: {}, parameters: {},
}); });
const sortAddons = (addons: IAddon[]) => { const data = useMemo(() => {
if (!addons) return []; if (loading) {
return Array(5).fill({
return addons.sort((addonA: IAddon, addonB: IAddon) => { name: 'Addon name',
return addonA.id - addonB.id; description: 'Addon description when loading',
});
};
const toggleAddon = 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));
} }
};
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) => { const onRemoveAddon = async (addon: IAddon) => {
try { try {
@ -74,78 +160,56 @@ export const ConfiguredAddons = ({ getAddonIcon }: IConfigureAddonsProps) => {
title: 'Success', title: 'Success',
text: 'Deleted addon successfully', text: 'Deleted addon successfully',
}); });
} catch (e) { } catch (error: unknown) {
setToastData({ setToastApiError(formatUnknownError(error));
type: 'error',
title: 'Error',
text: 'Can not delete addon',
});
} }
}; };
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 ( return (
<PageContent header="Configured addons"> <PageContent
<List> isLoading={loading}
{sortAddons(addons).map((addon: IAddon) => renderAddon(addon))} header={<PageHeader title="Configured addons" />}
</List> >
<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 <Dialogue
open={showDelete} open={showDelete}
onClick={() => { 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 { IAddon } from 'interfaces/addons';
import { useCallback } from 'react';
import useAPI from '../useApi/useApi'; import useAPI from '../useApi/useApi';
const useAddonsApi = () => { const useAddonsApi = () => {
@ -15,13 +16,7 @@ const useAddonsApi = () => {
body: JSON.stringify(addonConfig), body: JSON.stringify(addonConfig),
}); });
try { return makeRequest(req.caller, req.id);
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
}; };
const removeAddon = async (id: number) => { const removeAddon = async (id: number) => {
@ -29,29 +24,22 @@ const useAddonsApi = () => {
const req = createRequest(path, { const req = createRequest(path, {
method: 'DELETE', method: 'DELETE',
}); });
try {
const res = await makeRequest(req.caller, req.id);
return res; return await makeRequest(req.caller, req.id);
} catch (e) {
throw e;
}
}; };
const updateAddon = async (addonConfig: IAddon) => { const updateAddon = useCallback(
const path = `${URI}/${addonConfig.id}`; async (addonConfig: IAddon) => {
const req = createRequest(path, { const path = `${URI}/${addonConfig.id}`;
method: 'PUT', const req = createRequest(path, {
body: JSON.stringify(addonConfig), method: 'PUT',
}); body: JSON.stringify(addonConfig),
try { });
const res = await makeRequest(req.caller, req.id);
return res; return makeRequest(req.caller, req.id);
} catch (e) { },
throw e; [createRequest, makeRequest]
} );
};
return { return {
createAddon, createAddon,

View File

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

View File

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