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

chore: adapt integrations layout for incoming webhooks (#5828)

https://linear.app/unleash/issue/2-1823/adapt-integrations-page-to-incoming-webhooks-tab-layout

Adapts the current integrations page to the incoming webhooks feature,
which includes things like:
- Displaying both configured and available integrations in a single
"page block"
 - Implement tabs
 - Add "Incoming Webhooks" integration card
 - Adapt the existing `IntegrationCard` component to support `onClick`

This also includes a small girl scouting fix: Some tabs (like on the
roles page) did not correctly reflect the active tab.

### `incomingWebhooks` disabled


![image](https://github.com/Unleash/unleash/assets/14320932/f5c1c61b-0eb1-487e-ab5a-c65e9fc168c8)

### `incomingWebhooks` enabled

Notice the new "Incoming webhooks" tab and integration card.


![image](https://github.com/Unleash/unleash/assets/14320932/f5680ad5-4a00-4acb-bc8d-77160cc91034)
This commit is contained in:
Nuno Góis 2024-01-10 10:33:51 +00:00 committed by GitHub
parent 336eab9c5a
commit 10c3acd27d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 432 additions and 259 deletions

View File

@ -15,6 +15,7 @@ import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Add } from '@mui/icons-material'; import { Add } from '@mui/icons-material';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { IRole } from 'interfaces/role'; import { IRole } from 'interfaces/role';
import { TabLink } from 'component/common/TabNav/TabLink';
const StyledHeader = styled('div')(() => ({ const StyledHeader = styled('div')(() => ({
display: 'flex', display: 'flex',
@ -91,11 +92,9 @@ export const RolesPage = () => {
key={label} key={label}
value={path} value={path}
label={ label={
<CenteredNavLink to={path}> <TabLink to={path}>
<span>
{label} ({total}) {label} ({total})
</span> </TabLink>
</CenteredNavLink>
} }
sx={{ padding: 0 }} sx={{ padding: 0 }}
/> />

View File

@ -0,0 +1,25 @@
import { styled } from '@mui/material';
import { FC } from 'react';
import { Link } from 'react-router-dom';
const StyledTabLink = styled(Link)(({ theme }) => ({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
textDecoration: 'none',
color: 'inherit',
padding: theme.spacing(0, 5),
'&.active': {
fontWeight: 'bold',
},
}));
interface ICenteredTabLinkProps {
to: string;
}
export const TabLink: FC<ICenteredTabLinkProps> = ({ to, children }) => (
<StyledTabLink to={to}>{children}</StyledTabLink>
);

View File

@ -1,17 +1,17 @@
import { type VFC } from 'react'; import { type VFC } from 'react';
import { Box, Typography, styled } from '@mui/material'; import { Box, Typography, styled } from '@mui/material';
import type { AddonTypeSchema } from 'openapi'; import type { AddonTypeSchema } from 'openapi';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { IntegrationCard } from '../IntegrationCard/IntegrationCard'; import { IntegrationCard } from '../IntegrationCard/IntegrationCard';
import { JIRA_INFO } from '../../ViewIntegration/JiraIntegration/JiraIntegration'; import { JIRA_INFO } from '../../ViewIntegration/JiraIntegration/JiraIntegration';
import { StyledCardsGrid } from '../IntegrationList.styles'; import { StyledCardsGrid } from '../IntegrationList.styles';
import { RequestIntegrationCard } from '../RequestIntegrationCard/RequestIntegrationCard'; import { RequestIntegrationCard } from '../RequestIntegrationCard/RequestIntegrationCard';
import { OFFICIAL_SDKS } from './SDKs'; import { OFFICIAL_SDKS } from './SDKs';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useUiFlag } from 'hooks/useUiFlag';
interface IAvailableIntegrationsProps { interface IAvailableIntegrationsProps {
providers: AddonTypeSchema[]; providers: AddonTypeSchema[];
loading?: boolean; onNewIncomingWebhook: () => void;
} }
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
@ -53,26 +53,32 @@ const StyledGrayContainer = styled('div')(({ theme }) => ({
export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
providers, providers,
loading, onNewIncomingWebhook,
}) => { }) => {
const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
const customProviders = [JIRA_INFO]; const customProviders = [JIRA_INFO];
const serverSdks = OFFICIAL_SDKS.filter((sdk) => sdk.type === 'server'); const serverSdks = OFFICIAL_SDKS.filter((sdk) => sdk.type === 'server');
const clientSdks = OFFICIAL_SDKS.filter((sdk) => sdk.type === 'client'); const clientSdks = OFFICIAL_SDKS.filter((sdk) => sdk.type === 'client');
return ( return (
<PageContent
header={<PageHeader title='Integrations' secondary />}
isLoading={loading}
>
<StyledContainer> <StyledContainer>
<StyledSection> <StyledSection>
<div>
<Typography component='h3' variant='h2'>
Unleash crafted
</Typography>
<Typography variant='body2' color='text.secondary'>
Unleash is built to be extended. We have crafted
integrations to make it easier for you to get started.
</Typography>
</div>
<StyledCardsGrid> <StyledCardsGrid>
{providers {providers
?.sort( ?.sort(
(a, b) => (a, b) =>
a.displayName?.localeCompare( a.displayName?.localeCompare(b.displayName) ||
b.displayName, 0,
) || 0,
) )
.map( .map(
({ ({
@ -91,6 +97,17 @@ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
/> />
), ),
)} )}
<ConditionallyRender
condition={incomingWebhooksEnabled}
show={
<IntegrationCard
icon='webhook'
title='Incoming Webhooks'
description='Incoming Webhooks allow third party services to send observable events to Unleash.'
onClick={onNewIncomingWebhook}
/>
}
/>
{/* TODO: sort providers from backend with custom providers */} {/* TODO: sort providers from backend with custom providers */}
{customProviders?.map( {customProviders?.map(
({ name, displayName, description }) => ( ({ name, displayName, description }) => (
@ -113,8 +130,8 @@ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
Performance and security Performance and security
</Typography> </Typography>
<Typography variant='body2' color='text.secondary'> <Typography variant='body2' color='text.secondary'>
Connect Unleash to private, scalable, and Connect Unleash to private, scalable, and distributed
distributed relays. relays.
</Typography> </Typography>
</div> </div>
<StyledCardsGrid> <StyledCardsGrid>
@ -142,9 +159,9 @@ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
Official SDKs Official SDKs
</Typography> </Typography>
<Typography variant='body2' color='text.secondary'> <Typography variant='body2' color='text.secondary'>
In order to connect your application to Unleash you In order to connect your application to Unleash you will
will need a client SDK (software developer kit) for need a client SDK (software developer kit) for your
your programming language and an{' '} programming language and an{' '}
<a <a
href='https://docs.getunleash.io/how-to/how-to-create-api-tokens' href='https://docs.getunleash.io/how-to/how-to-create-api-tokens'
target='_blank' target='_blank'
@ -160,13 +177,9 @@ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
<Typography component='h4' variant='h4'> <Typography component='h4' variant='h4'>
Server-side SDKs Server-side SDKs
</Typography> </Typography>
<Typography <Typography variant='body2' color='text.secondary'>
variant='body2'
color='text.secondary'
>
Server-side clients run on your server and Server-side clients run on your server and
communicate directly with your Unleash communicate directly with your Unleash instance.
instance.
</Typography> </Typography>
</Box> </Box>
<StyledCardsGrid small> <StyledCardsGrid small>
@ -197,10 +210,7 @@ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
<Typography component='h4' variant='h4'> <Typography component='h4' variant='h4'>
Client-side SDKs Client-side SDKs
</Typography> </Typography>
<Typography <Typography variant='body2' color='text.secondary'>
variant='body2'
color='text.secondary'
>
Client-side SDKs can connect to the{' '} Client-side SDKs can connect to the{' '}
<a <a
href='https://docs.getunleash.io/reference/unleash-edge' href='https://docs.getunleash.io/reference/unleash-edge'
@ -257,8 +267,8 @@ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
> >
Here's some of the fantastic work Here's some of the fantastic work
</a>{' '} </a>{' '}
our community has built to make Unleash our community has built to make Unleash work
work in even more contexts. in even more contexts.
</Typography> </Typography>
</div> </div>
</StyledGrayContainer> </StyledGrayContainer>
@ -266,6 +276,5 @@ export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
</StyledSdksSection> </StyledSdksSection>
</StyledSection> </StyledSection>
</StyledContainer> </StyledContainer>
</PageContent>
); );
}; };

View File

@ -1,10 +1,16 @@
import { AddonSchema, AddonTypeSchema } from 'openapi'; import { AddonSchema, AddonTypeSchema } from 'openapi';
import useLoading from 'hooks/useLoading'; import useLoading from 'hooks/useLoading';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { StyledCardsGrid } from '../IntegrationList.styles'; import { StyledCardsGrid } from '../IntegrationList.styles';
import { IntegrationCard } from '../IntegrationCard/IntegrationCard'; import { IntegrationCard } from '../IntegrationCard/IntegrationCard';
import { VFC } from 'react'; import { VFC } from 'react';
import { Typography, styled } from '@mui/material';
const StyledConfiguredSection = styled('section')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
marginBottom: theme.spacing(8),
}));
type ConfiguredIntegrationsProps = { type ConfiguredIntegrationsProps = {
loading: boolean; loading: boolean;
@ -17,15 +23,19 @@ export const ConfiguredIntegrations: VFC<ConfiguredIntegrationsProps> = ({
addons, addons,
providers, providers,
}) => { }) => {
const counter = addons.length ? `(${addons.length})` : '';
const ref = useLoading(loading || false); const ref = useLoading(loading || false);
return ( return (
<PageContent <StyledConfiguredSection>
header={<PageHeader title={`Configured integrations ${counter}`} />} <div>
sx={(theme) => ({ marginBottom: theme.spacing(2) })} <Typography component='h3' variant='h2'>
isLoading={loading} Configured integrations
> </Typography>
<Typography variant='body2' color='text.secondary'>
These are the integrations that are currently configured for
your Unleash instance.
</Typography>
</div>
<StyledCardsGrid ref={ref}> <StyledCardsGrid ref={ref}>
{addons {addons
?.sort(({ id: a }, { id: b }) => a - b) ?.sort(({ id: a }, { id: b }) => a - b)
@ -56,6 +66,6 @@ export const ConfiguredIntegrations: VFC<ConfiguredIntegrationsProps> = ({
); );
})} })}
</StyledCardsGrid> </StyledCardsGrid>
</PageContent> </StyledConfiguredSection>
); );
}; };

View File

@ -1,6 +1,6 @@
import { VFC } from 'react'; import { VFC } from 'react';
import { Link } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { styled, Tooltip, Typography } from '@mui/material'; import { Link, styled, Tooltip, Typography } from '@mui/material';
import { IntegrationIcon } from '../IntegrationIcon/IntegrationIcon'; import { IntegrationIcon } from '../IntegrationIcon/IntegrationIcon';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
@ -10,46 +10,56 @@ import type { AddonSchema } from 'openapi';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
interface IIntegrationCardProps { interface IIntegrationCardBaseProps {
id?: string | number; id?: string | number;
icon?: string; icon?: string;
title: string; title: string;
description?: string; description?: string;
isEnabled?: boolean; isEnabled?: boolean;
configureActionText?: string; configureActionText?: string;
link: string;
isExternal?: boolean;
addon?: AddonSchema; addon?: AddonSchema;
deprecated?: string; deprecated?: string;
} }
const StyledLink = styled(Link)(({ theme }) => ({ interface IIntegrationCardWithLinkProps extends IIntegrationCardBaseProps {
link: string;
isExternal?: boolean;
onClick?: never;
}
interface IIntegrationCardWithOnClickProps extends IIntegrationCardBaseProps {
link?: never;
isExternal?: never;
onClick: () => void;
}
type IIntegrationCardProps =
| IIntegrationCardWithLinkProps
| IIntegrationCardWithOnClickProps;
const StyledCard = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
padding: theme.spacing(3), padding: theme.spacing(3),
height: '100%',
borderRadius: `${theme.shape.borderRadiusMedium}px`, borderRadius: `${theme.shape.borderRadiusMedium}px`,
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
textDecoration: 'none',
color: 'inherit',
boxShadow: theme.boxShadows.card, boxShadow: theme.boxShadows.card,
':hover': { ':hover': {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
}, },
})); }));
const StyledAnchor = styled('a')(({ theme }) => ({ const StyledLink = styled(Link)({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(3),
borderRadius: `${theme.shape.borderRadiusMedium}px`,
border: `1px solid ${theme.palette.divider}`,
textDecoration: 'none', textDecoration: 'none',
color: 'inherit', color: 'inherit',
boxShadow: theme.boxShadows.card, textAlign: 'left',
':hover': { }) as typeof Link;
backgroundColor: theme.palette.action.hover,
}, const StyledRouterLink = styled(RouterLink)({
})); textDecoration: 'none',
color: 'inherit',
});
const StyledHeader = styled('div')(({ theme }) => ({ const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -85,6 +95,7 @@ export const IntegrationCard: VFC<IIntegrationCardProps> = ({
isEnabled, isEnabled,
configureActionText = 'Configure', configureActionText = 'Configure',
link, link,
onClick,
addon, addon,
deprecated, deprecated,
isExternal = false, isExternal = false,
@ -102,7 +113,7 @@ export const IntegrationCard: VFC<IIntegrationCardProps> = ({
}; };
const content = ( const content = (
<> <StyledCard>
<StyledHeader> <StyledHeader>
<StyledTitle variant='h3' data-loading> <StyledTitle variant='h3' data-loading>
<IntegrationIcon name={icon as string} /> {title} <IntegrationIcon name={icon as string} /> {title}
@ -143,25 +154,37 @@ export const IntegrationCard: VFC<IIntegrationCardProps> = ({
elseShow={<ChevronRightIcon />} elseShow={<ChevronRightIcon />}
/> />
</StyledAction> </StyledAction>
</> </StyledCard>
); );
if (isExternal) { if (onClick) {
return ( return (
<StyledAnchor <StyledLink
component='button'
onClick={() => {
handleClick();
onClick();
}}
>
{content}
</StyledLink>
);
} else if (isExternal) {
return (
<StyledLink
href={link} href={link}
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
onClick={handleClick} onClick={handleClick}
> >
{content} {content}
</StyledAnchor> </StyledLink>
); );
} else { } else {
return ( return (
<StyledLink to={link} onClick={handleClick}> <StyledRouterLink to={link} onClick={handleClick}>
{content} {content}
</StyledLink> </StyledRouterLink>
); );
} }
}; };

View File

@ -1,38 +1,143 @@
import { VFC } from 'react'; import { VFC } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useAddons from 'hooks/api/getters/useAddons/useAddons'; import useAddons from 'hooks/api/getters/useAddons/useAddons';
import { AvailableIntegrations } from './AvailableIntegrations/AvailableIntegrations'; import { AvailableIntegrations } from './AvailableIntegrations/AvailableIntegrations';
import { ConfiguredIntegrations } from './ConfiguredIntegrations/ConfiguredIntegrations'; import { ConfiguredIntegrations } from './ConfiguredIntegrations/ConfiguredIntegrations';
import { AddonSchema } from 'openapi'; import { Tab, Tabs, styled, useTheme } from '@mui/material';
import { Add } from '@mui/icons-material';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { useUiFlag } from 'hooks/useUiFlag';
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
import { PageContent } from 'component/common/PageContent/PageContent';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { TabLink } from 'component/common/TabNav/TabLink';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
export const IntegrationList: VFC = () => { const StyledHeader = styled('div')(() => ({
const { providers, addons, loading } = useAddons(); display: 'flex',
justifyContent: 'space-between',
const loadingPlaceholderAddons: AddonSchema[] = Array.from({ length: 4 }) alignItems: 'center',
.fill({})
.map((_, id) => ({
id,
provider: 'mock',
description: 'mock integratino',
events: [],
projects: [],
parameters: {},
enabled: false,
})); }));
const StyledTabsContainer = styled('div')({
flex: 1,
});
const StyledActions = styled('div')({
display: 'flex',
alignItems: 'center',
});
export const IntegrationList: VFC = () => {
const { pathname } = useLocation();
const navigate = useNavigate();
const theme = useTheme();
const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
const { providers, addons, loading } = useAddons();
const { incomingWebhooks } = useIncomingWebhooks();
const onNewIncomingWebhook = () => {
navigate('/integrations/incoming-webhooks');
// TODO: Implement:
// setSelectedIncomingWebhook(undefined);
// setIncomingWebhookModalOpen(true);
};
const tabs = [
{
label: 'Integrations',
path: '/integrations',
},
{
label: `Incoming webhooks (${incomingWebhooks.length})`,
path: '/integrations/incoming-webhooks',
},
];
return ( return (
<PageContent
header={
<ConditionallyRender
condition={incomingWebhooksEnabled}
show={
<StyledHeader>
<StyledTabsContainer>
<Tabs
value={pathname}
indicatorColor='primary'
textColor='primary'
variant='scrollable'
allowScrollButtonsMobile
>
{tabs.map(({ label, path }) => (
<Tab
key={label}
value={path}
label={
<TabLink to={path}>
{label}
</TabLink>
}
sx={{
padding: 0,
}}
/>
))}
</Tabs>
</StyledTabsContainer>
<StyledActions>
<ConditionallyRender
condition={pathname.includes(
'incoming-webhooks',
)}
show={
<ResponsiveButton
onClick={onNewIncomingWebhook}
maxWidth={`${theme.breakpoints.values.sm}px`}
Icon={Add}
permission={ADMIN}
>
New incoming webhook
</ResponsiveButton>
}
/>
</StyledActions>
</StyledHeader>
}
elseShow={<PageHeader title='Integrations' />}
/>
}
isLoading={loading}
withTabs={incomingWebhooksEnabled}
>
<Routes>
<Route
path='incoming-webhooks'
element={<span>TODO: Implement</span>}
/>
<Route
path='*'
element={
<> <>
<ConditionallyRender <ConditionallyRender
condition={addons.length > 0} condition={addons.length > 0}
show={ show={
<ConfiguredIntegrations <ConfiguredIntegrations
addons={loading ? loadingPlaceholderAddons : addons} addons={addons}
providers={providers} providers={providers}
loading={loading} loading={loading}
/> />
} }
/> />
<AvailableIntegrations providers={providers} loading={loading} /> <AvailableIntegrations
providers={providers}
onNewIncomingWebhook={onNewIncomingWebhook}
/>
</> </>
}
/>
</Routes>
</PageContent>
); );
}; };

View File

@ -346,7 +346,7 @@ exports[`returns all baseRoutes 1`] = `
"advanced": true, "advanced": true,
"mobile": true, "mobile": true,
}, },
"path": "/integrations", "path": "/integrations/*",
"title": "Integrations", "title": "Integrations",
"type": "protected", "type": "protected",
}, },

View File

@ -350,7 +350,7 @@ export const routes: IRoute[] = [
menu: {}, menu: {},
}, },
{ {
path: '/integrations', path: '/integrations/*',
title: 'Integrations', title: 'Integrations',
component: IntegrationList, component: IntegrationList,
hidden: false, hidden: false,

View File

@ -4,14 +4,16 @@ import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig'; import useUiConfig from '../useUiConfig/useUiConfig';
import { IIncomingWebhook } from 'interfaces/incomingWebhook'; import { IIncomingWebhook } from 'interfaces/incomingWebhook';
import { useUiFlag } from 'hooks/useUiFlag';
const ENDPOINT = 'api/admin/incoming-webhooks'; const ENDPOINT = 'api/admin/incoming-webhooks';
export const useIncomingWebhooks = () => { export const useIncomingWebhooks = () => {
const { isEnterprise } = useUiConfig(); const { isEnterprise } = useUiConfig();
const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
const { data, error, mutate } = useConditionalSWR( const { data, error, mutate } = useConditionalSWR(
isEnterprise(), isEnterprise() && incomingWebhooksEnabled,
{ incomingWebhooks: [] }, { incomingWebhooks: [] },
formatApiPath(ENDPOINT), formatApiPath(ENDPOINT),
fetcher, fetcher,