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:
parent
65f424156c
commit
69286339fc
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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');
|
||||
});
|
@ -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;
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
@ -212,7 +212,7 @@ const ProjectEnterpriseSettingsForm: React.FC<IProjectEnterpriseSettingsForm> =
|
||||
};
|
||||
|
||||
const onSetFeatureNamingExample = (example: string) => {
|
||||
setFeatureNamingExample && setFeatureNamingExample(example);
|
||||
setFeatureNamingExample?.(example);
|
||||
updateNamingExampleError({
|
||||
pattern: featureNamingPattern || '',
|
||||
example,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -42,6 +42,7 @@ process.nextTick(async () => {
|
||||
doraMetrics: true,
|
||||
variantTypeNumber: true,
|
||||
privateProjects: true,
|
||||
newInviteLink: true,
|
||||
accessOverview: true,
|
||||
datadogJsonTemplate: true,
|
||||
dependentFeatures: true,
|
||||
|
Loading…
Reference in New Issue
Block a user