diff --git a/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx index e375ebc6c2..0f6bb7a29a 100644 --- a/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx +++ b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBar.tsx @@ -9,51 +9,9 @@ import { add, formatDistanceToNowStrict, isAfter, parseISO } from 'date-fns'; import { formatDateYMD } from 'utils/formatDate'; import { useLocationSettings } from 'hooks/useLocationSettings'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { InviteLinkBarContent } from './InviteLinkBarContent'; export const InviteLinkBar: VFC = () => { - const navigate = useNavigate(); - const { data, loading } = useInviteTokens(); - const ref = useLoading(loading); - const { trackEvent } = usePlausibleTracker(); - const inviteToken = - data?.tokens?.find((token) => token.name === 'default') ?? null; - const inviteLink = inviteToken?.url; - const createdAt = data?.tokens?.[0]?.createdAt ?? ''; - const expiresAt = data?.tokens?.[0]?.expiresAt ?? ''; - const expires = expiresAt || false; - const isExpired = Boolean( - expires && isAfter(new Date(), parseISO(expires)), - ); - const willExpireSoon = - expires && isAfter(add(new Date(), { days: 14 }), parseISO(expires)); - const expiresIn = expires - ? formatDistanceToNowStrict(parseISO(expires)) - : false; - const { locationSettings } = useLocationSettings(); - - const expireDateComponent = ( - - {expiresIn} - - ); - - const onInviteLinkActionClick = () => { - trackEvent('invite', { - props: { - eventType: inviteLink - ? 'link bar action: edit' - : 'link bar action: create', - }, - }); - - navigate('/admin/invite-link'); - }; - return ( ({ @@ -67,70 +25,8 @@ export const InviteLinkBar: VFC = () => { border: '2px solid', borderColor: theme.palette.background.alternative, })} - ref={ref} > - - - - {`You have an invite link created on ${formatDateYMD( - createdAt, - locationSettings.locale, - )} `} - - that expired {expireDateComponent}{' '} - ago - - } - elseShow={ - <> - that will expire in{' '} - {expireDateComponent} - - } - /> - - - - } - elseShow={ - - You can easily create an invite link here that you - can share and use to invite people from your company - to your Unleash setup. - - } - /> - - - - + ); }; diff --git a/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBarContent.tsx b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBarContent.tsx new file mode 100644 index 0000000000..c4cc222f51 --- /dev/null +++ b/frontend/src/component/admin/users/InviteLinkBar/InviteLinkBarContent.tsx @@ -0,0 +1,135 @@ +import { useNavigate } from 'react-router-dom'; +import { Box, Button, styled, Typography } from '@mui/material'; +import useLoading from 'hooks/useLoading'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useInviteTokens } from 'hooks/api/getters/useInviteTokens/useInviteTokens'; +import { LinkField } from '../LinkField/LinkField'; +import { add, formatDistanceToNowStrict, isAfter, parseISO } from 'date-fns'; +import { formatDateYMD } from 'utils/formatDate'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; + +interface IInviteLinkBarContentProps { + onActionClick?: () => void; +} + +export const StyledBox = styled(Box)(() => ({ + mb: { + xs: 1, + md: 0, + }, + display: 'flex', + justifyContent: 'center', + flexDirection: 'column', +})); + +export const StyledButtonBox = styled(Box)(() => ({ + display: 'flex', + alignItems: 'center', + flexGrow: 1, +})); + +export const InviteLinkBarContent = ({ + onActionClick, +}: IInviteLinkBarContentProps) => { + const navigate = useNavigate(); + const { data, loading } = useInviteTokens(); + const ref = useLoading(loading); + const { trackEvent } = usePlausibleTracker(); + const inviteToken = + data?.tokens?.find((token) => token.name === 'default') ?? null; + const inviteLink = inviteToken?.url; + const createdAt = data?.tokens?.[0]?.createdAt ?? ''; + const expiresAt = data?.tokens?.[0]?.expiresAt ?? ''; + const expires = expiresAt || false; + const isExpired = Boolean( + expires && isAfter(new Date(), parseISO(expires)), + ); + const willExpireSoon = + expires && isAfter(add(new Date(), { days: 14 }), parseISO(expires)); + const expiresIn = expires + ? formatDistanceToNowStrict(parseISO(expires)) + : false; + const { locationSettings } = useLocationSettings(); + + const expireDateComponent = ( + + {expiresIn} + + ); + + const onInviteLinkActionClick = () => { + trackEvent('invite', { + props: { + eventType: inviteLink + ? 'link bar action: edit' + : 'link bar action: create', + }, + }); + navigate('/admin/invite-link'); + onActionClick?.(); + }; + return ( + <> + + + + {`You have an invite link created on ${formatDateYMD( + createdAt, + locationSettings.locale, + )} `} + + that expired {expireDateComponent}{' '} + ago + + } + elseShow={ + <> + that will expire in{' '} + {expireDateComponent} + + } + /> + + + + } + elseShow={ + + You can easily create an invite link here that you + can share and use to invite people from your company + to your Unleash setup. + + } + /> + + + + + + ); +}; diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx index 924bd95e3f..04cd0ba178 100644 --- a/frontend/src/component/menu/Header/Header.tsx +++ b/frontend/src/component/menu/Header/Header.tsx @@ -34,6 +34,8 @@ import { ThemeMode } from 'component/common/ThemeMode/ThemeMode'; import { useThemeMode } from 'hooks/useThemeMode'; import { Notifications } from 'component/common/Notifications/Notifications'; import { useAdminRoutes } from 'component/admin/useAdminRoutes'; +import InviteLinkButton from './InviteLink/InviteLinkButton/InviteLinkButton'; +import { useUiFlag } from '../../../hooks/useUiFlag'; const StyledHeader = styled(AppBar)(({ theme }) => ({ backgroundColor: theme.palette.background.paper, @@ -43,7 +45,7 @@ const StyledHeader = styled(AppBar)(({ theme }) => ({ zIndex: 300, })); -const StyledContainer = styled(Container)(({ theme }) => ({ +const StyledContainer = styled(Container)(() => ({ display: 'flex', alignItems: 'center', maxWidth: 1280, @@ -111,6 +113,7 @@ const Header: VFC = () => { const [adminRef, setAdminRef] = useState(null); const [configRef, setConfigRef] = useState(null); + const disableNotifications = useUiFlag('disableNotifications'); const { uiConfig, isOss } = useUiConfig(); const smallScreen = useMediaQuery(theme.breakpoints.down('md')); const [openDrawer, setOpenDrawer] = useState(false); @@ -198,6 +201,7 @@ const Header: VFC = () => { /> + { {' '} } /> @@ -250,7 +251,10 @@ const Header: VFC = () => { options={filteredMainRoutes.adminRoutes} anchorEl={adminRef} handleClose={onAdminClose} - style={{ top: 5, left: -100 }} + style={{ + top: 5, + left: -100, + }} />{' '} diff --git a/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.test.tsx b/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.test.tsx new file mode 100644 index 0000000000..b00f78439f --- /dev/null +++ b/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.test.tsx @@ -0,0 +1,34 @@ +import { render } from 'utils/testRenderer'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import InviteLinkButton from './InviteLinkButton'; +import { AccessProviderMock } from 'component/providers/AccessProvider/AccessProviderMock'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; + +const server = testServerSetup(); + +const setupApi = () => { + testServerRoute(server, '/api/admin/ui-config', { + flags: { + newInviteLink: true, + }, + }); +}; +test('Do not show button to non admins', async () => { + setupApi(); + render( + + + , + ); + + expect(screen.queryByLabelText('Invite users')).not.toBeInTheDocument(); +}); + +test('Show button to non admins', async () => { + setupApi(); + render(, { permissions: [{ permission: ADMIN }] }); + + await screen.findByLabelText('Invite users'); +}); diff --git a/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.tsx b/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.tsx new file mode 100644 index 0000000000..99efef1262 --- /dev/null +++ b/frontend/src/component/menu/Header/InviteLink/InviteLinkButton/InviteLinkButton.tsx @@ -0,0 +1,52 @@ +import React, { useContext, useState } from 'react'; +import { ClickAwayListener, IconButton, styled, Tooltip } from '@mui/material'; +import { useId } from 'hooks/useId'; +import { focusable } from 'themes/themeStyles'; +import AccessContext from 'contexts/AccessContext'; +import { PersonAdd } from '@mui/icons-material'; +import { InviteLinkContent } from '../InviteLinkContent'; +import { useUiFlag } from '../../../../../hooks/useUiFlag'; + +const StyledContainer = styled('div')(() => ({ + position: 'relative', +})); + +const StyledIconButton = styled(IconButton)(({ theme }) => ({ + ...focusable(theme), + borderRadius: 100, +})); + +const InviteLinkButton = () => { + const [showInviteLinkContent, setShowInviteLinkContent] = useState(false); + const newInviteLink = useUiFlag('newInviteLink'); + const modalId = useId(); + + const { isAdmin } = useContext(AccessContext); + + if (!isAdmin || !newInviteLink) { + return null; + } + + return ( + setShowInviteLinkContent(false)}> + + + setShowInviteLinkContent(true)} + size='large' + disableRipple + > + + + + + + + ); +}; + +export default InviteLinkButton; diff --git a/frontend/src/component/menu/Header/InviteLink/InviteLinkContent.tsx b/frontend/src/component/menu/Header/InviteLink/InviteLinkContent.tsx new file mode 100644 index 0000000000..37a73e71ce --- /dev/null +++ b/frontend/src/component/menu/Header/InviteLink/InviteLinkContent.tsx @@ -0,0 +1,52 @@ +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Button, Paper, Typography, styled, Link } from '@mui/material'; +import { basePath } from 'utils/formatPath'; +import { IUser } from 'interfaces/user'; +import OpenInNew from '@mui/icons-material/OpenInNew'; +import { Link as RouterLink } from 'react-router-dom'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; +import { InviteLinkBar } from '../../../admin/users/InviteLinkBar/InviteLinkBar'; +import { InviteLinkBarContent } from '../../../admin/users/InviteLinkBar/InviteLinkBarContent'; + +const StyledPaper = styled(Paper)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + alignItems: 'flex-start', + padding: theme.spacing(3), + borderRadius: theme.shape.borderRadiusMedium, + boxShadow: theme.boxShadows.popup, + position: 'absolute', + zIndex: 5000, + right: -255, + minWidth: theme.spacing(80), + [theme.breakpoints.down('md')]: { + width: '100%', + padding: '1rem', + }, +})); + +interface IInviteLinkContentProps { + id: string; + showInviteLinkContent: boolean; + setShowInviteLinkContent: (showInviteLinkContent: boolean) => void; +} + +export const InviteLinkContent = ({ + id, + showInviteLinkContent, + setShowInviteLinkContent, +}: IInviteLinkContentProps) => ( + + { + setShowInviteLinkContent(false); + }} + /> + + } + /> +); diff --git a/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx index 3fedbd3f6d..559d04e590 100644 --- a/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx +++ b/frontend/src/component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm.tsx @@ -212,7 +212,7 @@ const ProjectEnterpriseSettingsForm: React.FC = }; const onSetFeatureNamingExample = (example: string) => { - setFeatureNamingExample && setFeatureNamingExample(example); + setFeatureNamingExample?.(example); updateNamingExampleError({ pattern: featureNamingPattern || '', example, diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 5361d45981..b45407c367 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -105,6 +105,7 @@ exports[`should create default config 1`] = ` }, "migrationLock": true, "multipleRoles": false, + "newInviteLink": false, "personalAccessTokensKillSwitch": false, "privateProjects": false, "proPlanAutoCharge": false, @@ -147,6 +148,7 @@ exports[`should create default config 1`] = ` }, "migrationLock": true, "multipleRoles": false, + "newInviteLink": false, "personalAccessTokensKillSwitch": false, "privateProjects": false, "proPlanAutoCharge": false, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 8cbac5f959..307835bbfe 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -28,6 +28,7 @@ export type IFlagKey = | 'doraMetrics' | 'variantTypeNumber' | 'accessOverview' + | 'newInviteLink' | 'privateProjects' | 'dependentFeatures' | 'datadogJsonTemplate' @@ -138,6 +139,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_PRIVATE_PROJECTS, false, ), + newInviteLink: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_NEW_INVITE_LINK, + false, + ), accessOverview: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_ACCESS_OVERVIEW, false, diff --git a/src/server-dev.ts b/src/server-dev.ts index 6f412585a9..96d5ee5ba9 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -42,6 +42,7 @@ process.nextTick(async () => { doraMetrics: true, variantTypeNumber: true, privateProjects: true, + newInviteLink: true, accessOverview: true, datadogJsonTemplate: true, dependentFeatures: true,