1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-04 00:18:40 +01: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 ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { IRole } from 'interfaces/role';
import { TabLink } from 'component/common/TabNav/TabLink';
const StyledHeader = styled('div')(() => ({
display: 'flex',
@ -91,11 +92,9 @@ export const RolesPage = () => {
key={label}
value={path}
label={
<CenteredNavLink to={path}>
<span>
{label} ({total})
</span>
</CenteredNavLink>
<TabLink to={path}>
{label} ({total})
</TabLink>
}
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 { Box, Typography, styled } from '@mui/material';
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 { JIRA_INFO } from '../../ViewIntegration/JiraIntegration/JiraIntegration';
import { StyledCardsGrid } from '../IntegrationList.styles';
import { RequestIntegrationCard } from '../RequestIntegrationCard/RequestIntegrationCard';
import { OFFICIAL_SDKS } from './SDKs';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useUiFlag } from 'hooks/useUiFlag';
interface IAvailableIntegrationsProps {
providers: AddonTypeSchema[];
loading?: boolean;
onNewIncomingWebhook: () => void;
}
const StyledContainer = styled('div')(({ theme }) => ({
@ -53,219 +53,228 @@ const StyledGrayContainer = styled('div')(({ theme }) => ({
export const AvailableIntegrations: VFC<IAvailableIntegrationsProps> = ({
providers,
loading,
onNewIncomingWebhook,
}) => {
const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
const customProviders = [JIRA_INFO];
const serverSdks = OFFICIAL_SDKS.filter((sdk) => sdk.type === 'server');
const clientSdks = OFFICIAL_SDKS.filter((sdk) => sdk.type === 'client');
return (
<PageContent
header={<PageHeader title='Integrations' secondary />}
isLoading={loading}
>
<StyledContainer>
<StyledSection>
<StyledCardsGrid>
{providers
?.sort(
(a, b) =>
a.displayName?.localeCompare(
b.displayName,
) || 0,
)
.map(
<StyledContainer>
<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>
{providers
?.sort(
(a, b) =>
a.displayName?.localeCompare(b.displayName) ||
0,
)
.map(
({
name,
displayName,
description,
deprecated,
}) => (
<IntegrationCard
key={name}
icon={name}
title={displayName || name}
description={description}
link={`/integrations/create/${name}`}
deprecated={deprecated}
/>
),
)}
<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 */}
{customProviders?.map(
({ name, displayName, description }) => (
<IntegrationCard
key={name}
icon={name}
title={displayName || name}
description={description}
link={`/integrations/view/${name}`}
configureActionText='Learn more'
/>
),
)}
<RequestIntegrationCard />
</StyledCardsGrid>
</StyledSection>
<StyledSection>
<div>
<Typography component='h3' variant='h2'>
Performance and security
</Typography>
<Typography variant='body2' color='text.secondary'>
Connect Unleash to private, scalable, and distributed
relays.
</Typography>
</div>
<StyledCardsGrid>
<IntegrationCard
icon='unleash'
title='Unleash Edge'
description="Unleash Edge is built to help you scale Unleash. As a successor of Unleash Proxy it's even faster and more versatile."
link='/integrations/view/edge'
configureActionText='Learn more'
/>
<IntegrationCard
icon='unleash'
title='Unleash Proxy'
description='The Unleash Proxy is a lightweight, stateless proxy that sits between your Unleash client SDKs and the Unleash API.'
link='https://docs.getunleash.io/reference/unleash-proxy'
configureActionText='View documentation'
deprecated='Try Unleash Edge instead. It has all the features of Unleash Proxy and more.'
isExternal
/>
</StyledCardsGrid>
</StyledSection>
<StyledSection>
<div>
<Typography component='h3' variant='h2'>
Official SDKs
</Typography>
<Typography variant='body2' color='text.secondary'>
In order to connect your application to Unleash you will
need a client SDK (software developer kit) for your
programming language and an{' '}
<a
href='https://docs.getunleash.io/how-to/how-to-create-api-tokens'
target='_blank'
rel='noopener noreferrer'
>
API token
</a>
</Typography>
</div>
<StyledSdksSection>
<StyledSdksGroup>
<Box>
<Typography component='h4' variant='h4'>
Server-side SDKs
</Typography>
<Typography variant='body2' color='text.secondary'>
Server-side clients run on your server and
communicate directly with your Unleash instance.
</Typography>
</Box>
<StyledCardsGrid small>
{serverSdks?.map(
({
name,
displayName,
description,
deprecated,
documentationUrl,
}) => (
<IntegrationCard
key={name}
icon={name}
title={displayName || name}
description={description}
link={`/integrations/create/${name}`}
deprecated={deprecated}
link={documentationUrl}
configureActionText={
'View documentation'
}
isExternal
/>
),
)}
{/* TODO: sort providers from backend with custom providers */}
{customProviders?.map(
({ name, displayName, description }) => (
<IntegrationCard
key={name}
icon={name}
title={displayName || name}
description={description}
link={`/integrations/view/${name}`}
configureActionText='Learn more'
/>
),
)}
<RequestIntegrationCard />
</StyledCardsGrid>
</StyledSection>
<StyledSection>
<div>
<Typography component='h3' variant='h2'>
Performance and security
</Typography>
<Typography variant='body2' color='text.secondary'>
Connect Unleash to private, scalable, and
distributed relays.
</Typography>
</div>
<StyledCardsGrid>
<IntegrationCard
icon='unleash'
title='Unleash Edge'
description="Unleash Edge is built to help you scale Unleash. As a successor of Unleash Proxy it's even faster and more versatile."
link='/integrations/view/edge'
configureActionText='Learn more'
/>
<IntegrationCard
icon='unleash'
title='Unleash Proxy'
description='The Unleash Proxy is a lightweight, stateless proxy that sits between your Unleash client SDKs and the Unleash API.'
link='https://docs.getunleash.io/reference/unleash-proxy'
configureActionText='View documentation'
deprecated='Try Unleash Edge instead. It has all the features of Unleash Proxy and more.'
isExternal
/>
</StyledCardsGrid>
</StyledSection>
<StyledSection>
<div>
<Typography component='h3' variant='h2'>
Official SDKs
</Typography>
<Typography variant='body2' color='text.secondary'>
In order to connect your application to Unleash you
will need a client SDK (software developer kit) for
your programming language and an{' '}
<a
href='https://docs.getunleash.io/how-to/how-to-create-api-tokens'
target='_blank'
rel='noopener noreferrer'
>
API token
</a>
</Typography>
</div>
<StyledSdksSection>
<StyledSdksGroup>
<Box>
<Typography component='h4' variant='h4'>
Server-side SDKs
</Typography>
<Typography
variant='body2'
color='text.secondary'
</StyledCardsGrid>
</StyledSdksGroup>
<StyledSdksGroup>
<Box>
<Typography component='h4' variant='h4'>
Client-side SDKs
</Typography>
<Typography variant='body2' color='text.secondary'>
Client-side SDKs can connect to the{' '}
<a
href='https://docs.getunleash.io/reference/unleash-edge'
target='_blank'
rel='noopener noreferrer'
>
Server-side clients run on your server and
communicate directly with your Unleash
instance.
</Typography>
</Box>
<StyledCardsGrid small>
{serverSdks?.map(
({
name,
displayName,
description,
documentationUrl,
}) => (
<IntegrationCard
key={name}
icon={name}
title={displayName || name}
description={description}
link={documentationUrl}
configureActionText={
'View documentation'
}
isExternal
/>
),
)}
</StyledCardsGrid>
</StyledSdksGroup>
<StyledSdksGroup>
<Box>
<Typography component='h4' variant='h4'>
Client-side SDKs
</Typography>
<Typography
variant='body2'
color='text.secondary'
Unleash Edge
</a>{' '}
or to the{' '}
<a
href='https://docs.getunleash.io/reference/front-end-api'
target='_blank'
rel='noopener noreferrer'
>
Client-side SDKs can connect to the{' '}
Unleash front-end API
</a>
, but not to the regular Unleash client API.
</Typography>
</Box>
<StyledCardsGrid small>
{clientSdks?.map(
({
name,
displayName,
description,
documentationUrl,
}) => (
<IntegrationCard
key={name}
icon={name}
title={displayName || name}
description={description}
link={documentationUrl}
configureActionText={
'View documentation'
}
isExternal
/>
),
)}
</StyledCardsGrid>
</StyledSdksGroup>
<StyledSdksGroup>
<StyledGrayContainer>
<div>
<Typography component='h4' variant='h3'>
Community SDKs
</Typography>
<Typography>
<a
href='https://docs.getunleash.io/reference/unleash-edge'
href='https://docs.getunleash.io/reference/sdks#community-sdks'
target='_blank'
rel='noopener noreferrer'
>
Unleash Edge
Here's some of the fantastic work
</a>{' '}
or to the{' '}
<a
href='https://docs.getunleash.io/reference/front-end-api'
target='_blank'
rel='noopener noreferrer'
>
Unleash front-end API
</a>
, but not to the regular Unleash client API.
our community has built to make Unleash work
in even more contexts.
</Typography>
</Box>
<StyledCardsGrid small>
{clientSdks?.map(
({
name,
displayName,
description,
documentationUrl,
}) => (
<IntegrationCard
key={name}
icon={name}
title={displayName || name}
description={description}
link={documentationUrl}
configureActionText={
'View documentation'
}
isExternal
/>
),
)}
</StyledCardsGrid>
</StyledSdksGroup>
<StyledSdksGroup>
<StyledGrayContainer>
<div>
<Typography component='h4' variant='h3'>
Community SDKs
</Typography>
<Typography>
<a
href='https://docs.getunleash.io/reference/sdks#community-sdks'
target='_blank'
rel='noopener noreferrer'
>
Here's some of the fantastic work
</a>{' '}
our community has built to make Unleash
work in even more contexts.
</Typography>
</div>
</StyledGrayContainer>
</StyledSdksGroup>
</StyledSdksSection>
</StyledSection>
</StyledContainer>
</PageContent>
</div>
</StyledGrayContainer>
</StyledSdksGroup>
</StyledSdksSection>
</StyledSection>
</StyledContainer>
);
};

View File

@ -1,10 +1,16 @@
import { AddonSchema, AddonTypeSchema } from 'openapi';
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 { IntegrationCard } from '../IntegrationCard/IntegrationCard';
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 = {
loading: boolean;
@ -17,15 +23,19 @@ export const ConfiguredIntegrations: VFC<ConfiguredIntegrationsProps> = ({
addons,
providers,
}) => {
const counter = addons.length ? `(${addons.length})` : '';
const ref = useLoading(loading || false);
return (
<PageContent
header={<PageHeader title={`Configured integrations ${counter}`} />}
sx={(theme) => ({ marginBottom: theme.spacing(2) })}
isLoading={loading}
>
<StyledConfiguredSection>
<div>
<Typography component='h3' variant='h2'>
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}>
{addons
?.sort(({ id: a }, { id: b }) => a - b)
@ -56,6 +66,6 @@ export const ConfiguredIntegrations: VFC<ConfiguredIntegrationsProps> = ({
);
})}
</StyledCardsGrid>
</PageContent>
</StyledConfiguredSection>
);
};

View File

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

View File

@ -1,38 +1,143 @@
import { VFC } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useAddons from 'hooks/api/getters/useAddons/useAddons';
import { AvailableIntegrations } from './AvailableIntegrations/AvailableIntegrations';
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';
const StyledHeader = styled('div')(() => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}));
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 loadingPlaceholderAddons: AddonSchema[] = Array.from({ length: 4 })
.fill({})
.map((_, id) => ({
id,
provider: 'mock',
description: 'mock integratino',
events: [],
projects: [],
parameters: {},
enabled: false,
}));
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 (
<>
<ConditionallyRender
condition={addons.length > 0}
show={
<ConfiguredIntegrations
addons={loading ? loadingPlaceholderAddons : addons}
providers={providers}
loading={loading}
/>
}
/>
<AvailableIntegrations providers={providers} loading={loading} />
</>
<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
condition={addons.length > 0}
show={
<ConfiguredIntegrations
addons={addons}
providers={providers}
loading={loading}
/>
}
/>
<AvailableIntegrations
providers={providers}
onNewIncomingWebhook={onNewIncomingWebhook}
/>
</>
}
/>
</Routes>
</PageContent>
);
};

View File

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

View File

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

View File

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