1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

feat: make invite link more visible (#4984)

This commit is contained in:
Jaanus Sellin 2023-10-11 14:31:32 +03:00 committed by GitHub
parent 65f424156c
commit 69286339fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 294 additions and 113 deletions

View File

@ -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 = (
<Typography
component='span'
variant='body2'
color={willExpireSoon ? 'warning.dark' : 'inherit'}
fontWeight='bold'
>
{expiresIn}
</Typography>
);
const onInviteLinkActionClick = () => {
trackEvent('invite', {
props: {
eventType: inviteLink
? 'link bar action: edit'
: 'link bar action: create',
},
});
navigate('/admin/invite-link');
};
return (
<Box
sx={(theme) => ({
@ -67,70 +25,8 @@ export const InviteLinkBar: VFC = () => {
border: '2px solid',
borderColor: theme.palette.background.alternative,
})}
ref={ref}
>
<Box
sx={{
mb: { xs: 1, md: 0 },
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<ConditionallyRender
condition={Boolean(inviteLink)}
show={
<>
<Typography variant='body2' sx={{ mb: 1 }}>
{`You have an invite link created on ${formatDateYMD(
createdAt,
locationSettings.locale,
)} `}
<ConditionallyRender
condition={isExpired}
show={
<>
that expired {expireDateComponent}{' '}
ago
</>
}
elseShow={
<>
that will expire in{' '}
{expireDateComponent}
</>
}
/>
</Typography>
<LinkField small inviteLink={inviteLink!} />
</>
}
elseShow={
<Typography variant='body2' data-loading>
You can easily create an invite link here that you
can share and use to invite people from your company
to your Unleash setup.
</Typography>
}
/>
</Box>
<Box
sx={{
minWidth: 200,
display: 'flex',
justifyContent: { xs: 'center', md: 'flex-end' },
alignItems: 'center',
flexGrow: 1,
}}
>
<Button
variant='outlined'
onClick={onInviteLinkActionClick}
data-loading
>
{inviteLink ? 'Update' : 'Create'} invite link
</Button>
</Box>
<InviteLinkBarContent />
</Box>
);
};

View File

@ -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 = (
<Typography
component='span'
variant='body2'
color={willExpireSoon ? 'warning.dark' : 'inherit'}
fontWeight='bold'
>
{expiresIn}
</Typography>
);
const onInviteLinkActionClick = () => {
trackEvent('invite', {
props: {
eventType: inviteLink
? 'link bar action: edit'
: 'link bar action: create',
},
});
navigate('/admin/invite-link');
onActionClick?.();
};
return (
<>
<StyledBox ref={ref}>
<ConditionallyRender
condition={Boolean(inviteLink)}
show={
<>
<Typography variant='body2' sx={{ mb: 1 }}>
{`You have an invite link created on ${formatDateYMD(
createdAt,
locationSettings.locale,
)} `}
<ConditionallyRender
condition={isExpired}
show={
<>
that expired {expireDateComponent}{' '}
ago
</>
}
elseShow={
<>
that will expire in{' '}
{expireDateComponent}
</>
}
/>
</Typography>
<LinkField small inviteLink={inviteLink!} />
</>
}
elseShow={
<Typography variant='body2' data-loading>
You can easily create an invite link here that you
can share and use to invite people from your company
to your Unleash setup.
</Typography>
}
/>
</StyledBox>
<StyledButtonBox
sx={{
justifyContent: {
xs: 'center',
md: 'flex-end',
},
}}
>
<Button
variant='outlined'
onClick={onInviteLinkActionClick}
data-loading
>
{inviteLink ? 'Update' : 'Create'} invite link
</Button>
</StyledButtonBox>
</>
);
};

View File

@ -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<HTMLButtonElement | null>(null);
const [configRef, setConfigRef] = useState<HTMLButtonElement | null>(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 = () => {
/>
</StyledLinks>
<StyledUserContainer>
<InviteLinkButton />
<Tooltip
title={
themeMode === 'dark'
@ -215,10 +219,7 @@ const Header: VFC = () => {
</IconButton>
</Tooltip>{' '}
<ConditionallyRender
condition={
!isOss() &&
!uiConfig?.flags.disableNotifications
}
condition={!isOss() && !disableNotifications}
show={<Notifications />}
/>
<Tooltip title='Documentation' arrow>
@ -250,7 +251,10 @@ const Header: VFC = () => {
options={filteredMainRoutes.adminRoutes}
anchorEl={adminRef}
handleClose={onAdminClose}
style={{ top: 5, left: -100 }}
style={{
top: 5,
left: -100,
}}
/>{' '}
<UserProfile />
</StyledUserContainer>

View File

@ -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(
<AccessProviderMock permissions={[]}>
<InviteLinkButton />
</AccessProviderMock>,
);
expect(screen.queryByLabelText('Invite users')).not.toBeInTheDocument();
});
test('Show button to non admins', async () => {
setupApi();
render(<InviteLinkButton />, { permissions: [{ permission: ADMIN }] });
await screen.findByLabelText('Invite users');
});

View File

@ -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 (
<ClickAwayListener onClickAway={() => setShowInviteLinkContent(false)}>
<StyledContainer>
<Tooltip title='Invite users' arrow>
<StyledIconButton
onClick={() => setShowInviteLinkContent(true)}
size='large'
disableRipple
>
<PersonAdd />
</StyledIconButton>
</Tooltip>
<InviteLinkContent
showInviteLinkContent={showInviteLinkContent}
setShowInviteLinkContent={setShowInviteLinkContent}
id={modalId}
/>
</StyledContainer>
</ClickAwayListener>
);
};
export default InviteLinkButton;

View File

@ -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) => (
<ConditionallyRender
condition={showInviteLinkContent}
show={
<StyledPaper className='dropdown-outline' id={id}>
<InviteLinkBarContent
onActionClick={() => {
setShowInviteLinkContent(false);
}}
/>
</StyledPaper>
}
/>
);

View File

@ -212,7 +212,7 @@ const ProjectEnterpriseSettingsForm: React.FC<IProjectEnterpriseSettingsForm> =
};
const onSetFeatureNamingExample = (example: string) => {
setFeatureNamingExample && setFeatureNamingExample(example);
setFeatureNamingExample?.(example);
updateNamingExampleError({
pattern: featureNamingPattern || '',
example,

View File

@ -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,

View File

@ -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,

View File

@ -42,6 +42,7 @@ process.nextTick(async () => {
doraMetrics: true,
variantTypeNumber: true,
privateProjects: true,
newInviteLink: true,
accessOverview: true,
datadogJsonTemplate: true,
dependentFeatures: true,