From 10c3acd27dc4f1eeda938168d4423aa470f10b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 10 Jan 2024 10:33:51 +0000 Subject: [PATCH] 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) --- .../src/component/admin/roles/RolesPage.tsx | 9 +- .../src/component/common/TabNav/TabLink.tsx | 25 ++ .../AvailableIntegrations.tsx | 389 +++++++++--------- .../ConfiguredIntegrations.tsx | 28 +- .../IntegrationCard/IntegrationCard.tsx | 75 ++-- .../IntegrationList/IntegrationList.tsx | 157 +++++-- .../__snapshots__/routes.test.tsx.snap | 2 +- frontend/src/component/menu/routes.ts | 2 +- .../useIncomingWebhooks.ts | 4 +- 9 files changed, 432 insertions(+), 259 deletions(-) create mode 100644 frontend/src/component/common/TabNav/TabLink.tsx diff --git a/frontend/src/component/admin/roles/RolesPage.tsx b/frontend/src/component/admin/roles/RolesPage.tsx index fb759f8efd..ddca15fde9 100644 --- a/frontend/src/component/admin/roles/RolesPage.tsx +++ b/frontend/src/component/admin/roles/RolesPage.tsx @@ -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={ - - - {label} ({total}) - - + + {label} ({total}) + } sx={{ padding: 0 }} /> diff --git a/frontend/src/component/common/TabNav/TabLink.tsx b/frontend/src/component/common/TabNav/TabLink.tsx new file mode 100644 index 0000000000..48c0ea5d7a --- /dev/null +++ b/frontend/src/component/common/TabNav/TabLink.tsx @@ -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 = ({ to, children }) => ( + {children} +); diff --git a/frontend/src/component/integrations/IntegrationList/AvailableIntegrations/AvailableIntegrations.tsx b/frontend/src/component/integrations/IntegrationList/AvailableIntegrations/AvailableIntegrations.tsx index 6b25081584..e9af0481da 100644 --- a/frontend/src/component/integrations/IntegrationList/AvailableIntegrations/AvailableIntegrations.tsx +++ b/frontend/src/component/integrations/IntegrationList/AvailableIntegrations/AvailableIntegrations.tsx @@ -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 = ({ 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 ( - } - isLoading={loading} - > - - - - {providers - ?.sort( - (a, b) => - a.displayName?.localeCompare( - b.displayName, - ) || 0, - ) - .map( + + +
+ + Unleash crafted + + + Unleash is built to be extended. We have crafted + integrations to make it easier for you to get started. + +
+ + {providers + ?.sort( + (a, b) => + a.displayName?.localeCompare(b.displayName) || + 0, + ) + .map( + ({ + name, + displayName, + description, + deprecated, + }) => ( + + ), + )} + + } + /> + {/* TODO: sort providers from backend with custom providers */} + {customProviders?.map( + ({ name, displayName, description }) => ( + + ), + )} + + +
+ +
+ + Performance and security + + + Connect Unleash to private, scalable, and distributed + relays. + +
+ + + + +
+ +
+ + Official SDKs + + + In order to connect your application to Unleash you will + need a client SDK (software developer kit) for your + programming language and an{' '} + + API token + + +
+ + + + + Server-side SDKs + + + Server-side clients run on your server and + communicate directly with your Unleash instance. + + + + {serverSdks?.map( ({ name, displayName, description, - deprecated, + documentationUrl, }) => ( ), )} - {/* TODO: sort providers from backend with custom providers */} - {customProviders?.map( - ({ name, displayName, description }) => ( - - ), - )} - - -
- -
- - Performance and security - - - Connect Unleash to private, scalable, and - distributed relays. - -
- - - - -
- -
- - Official SDKs - - - In order to connect your application to Unleash you - will need a client SDK (software developer kit) for - your programming language and an{' '} - - API token - - -
- - - - - Server-side SDKs - - + + + + + Client-side SDKs + + + Client-side SDKs can connect to the{' '} + - Server-side clients run on your server and - communicate directly with your Unleash - instance. - - - - {serverSdks?.map( - ({ - name, - displayName, - description, - documentationUrl, - }) => ( - - ), - )} - - - - - - Client-side SDKs - - {' '} + or to the{' '} + - Client-side SDKs can connect to the{' '} + Unleash front-end API + + , but not to the regular Unleash client API. + + + + {clientSdks?.map( + ({ + name, + displayName, + description, + documentationUrl, + }) => ( + + ), + )} + + + + +
+ + Community SDKs + + - Unleash Edge + Here's some of the fantastic work {' '} - or to the{' '} - - Unleash front-end API - - , but not to the regular Unleash client API. + our community has built to make Unleash work + in even more contexts. - - - {clientSdks?.map( - ({ - name, - displayName, - description, - documentationUrl, - }) => ( - - ), - )} - - - - -
- - Community SDKs - - - - Here's some of the fantastic work - {' '} - our community has built to make Unleash - work in even more contexts. - -
-
-
- - - - +
+
+
+
+
+
); }; diff --git a/frontend/src/component/integrations/IntegrationList/ConfiguredIntegrations/ConfiguredIntegrations.tsx b/frontend/src/component/integrations/IntegrationList/ConfiguredIntegrations/ConfiguredIntegrations.tsx index a730f8256a..7dc81d56a3 100644 --- a/frontend/src/component/integrations/IntegrationList/ConfiguredIntegrations/ConfiguredIntegrations.tsx +++ b/frontend/src/component/integrations/IntegrationList/ConfiguredIntegrations/ConfiguredIntegrations.tsx @@ -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 = ({ addons, providers, }) => { - const counter = addons.length ? `(${addons.length})` : ''; const ref = useLoading(loading || false); return ( - } - sx={(theme) => ({ marginBottom: theme.spacing(2) })} - isLoading={loading} - > + +
+ + Configured integrations + + + These are the integrations that are currently configured for + your Unleash instance. + +
{addons ?.sort(({ id: a }, { id: b }) => a - b) @@ -56,6 +66,6 @@ export const ConfiguredIntegrations: VFC = ({ ); })} -
+ ); }; diff --git a/frontend/src/component/integrations/IntegrationList/IntegrationCard/IntegrationCard.tsx b/frontend/src/component/integrations/IntegrationList/IntegrationCard/IntegrationCard.tsx index e12f39a737..4c843f92d0 100644 --- a/frontend/src/component/integrations/IntegrationList/IntegrationCard/IntegrationCard.tsx +++ b/frontend/src/component/integrations/IntegrationList/IntegrationCard/IntegrationCard.tsx @@ -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 = ({ isEnabled, configureActionText = 'Configure', link, + onClick, addon, deprecated, isExternal = false, @@ -102,7 +113,7 @@ export const IntegrationCard: VFC = ({ }; const content = ( - <> + {title} @@ -143,25 +154,37 @@ export const IntegrationCard: VFC = ({ elseShow={} /> - + ); - if (isExternal) { + if (onClick) { return ( - { + handleClick(); + onClick(); + }} + > + {content} + + ); + } else if (isExternal) { + return ( + {content} - + ); } else { return ( - + {content} - + ); } }; diff --git a/frontend/src/component/integrations/IntegrationList/IntegrationList.tsx b/frontend/src/component/integrations/IntegrationList/IntegrationList.tsx index 097f988617..949f802b48 100644 --- a/frontend/src/component/integrations/IntegrationList/IntegrationList.tsx +++ b/frontend/src/component/integrations/IntegrationList/IntegrationList.tsx @@ -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 ( - <> - 0} - show={ - - } - /> - - + + + + {tabs.map(({ label, path }) => ( + + {label} + + } + sx={{ + padding: 0, + }} + /> + ))} + + + + + New incoming webhook + + } + /> + + + } + elseShow={} + /> + } + isLoading={loading} + withTabs={incomingWebhooksEnabled} + > + + TODO: Implement} + /> + + 0} + show={ + + } + /> + + + } + /> + + ); }; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 4449186498..3ecab6df3b 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -346,7 +346,7 @@ exports[`returns all baseRoutes 1`] = ` "advanced": true, "mobile": true, }, - "path": "/integrations", + "path": "/integrations/*", "title": "Integrations", "type": "protected", }, diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 900c652049..db22e0d4e2 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -350,7 +350,7 @@ export const routes: IRoute[] = [ menu: {}, }, { - path: '/integrations', + path: '/integrations/*', title: 'Integrations', component: IntegrationList, hidden: false, diff --git a/frontend/src/hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks.ts b/frontend/src/hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks.ts index 9ca49f1e83..724a6f7adf 100644 --- a/frontend/src/hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks.ts +++ b/frontend/src/hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks.ts @@ -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,