1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-15 17:50:48 +02:00

feat: add trial expiration warning banner (#985)

* refactor: simplify useApiGetter cache keys

* refactor: simplify basePath helpers

* refactor: add UNLEASH_BASE_PATH frontend env var

* refactor: make sure AnnouncerElement does not affect the layout

* refactor: draw texture image above footer

* refactor: extract domain check helpers

* refactor: fix a few ts-expect-errors

* feat: add trial expiration warning banner

* refactor: fix IInstanceStatus interface prefix

* refactor: use ConditionallyRender in InstanceStatus

* refactor: simplify env helper functions

* refactor: use FC in InstanceStatus

* refactor: warn about expired trials

* refactor: fix eslint warnings

* refactor: disable banner outside of localhost

* refactor: use new instance state field name
This commit is contained in:
olav 2022-05-19 14:06:18 +02:00 committed by GitHub
parent 06b0a29ea8
commit 2e367b3a04
37 changed files with 534 additions and 143 deletions

View File

@ -2,7 +2,6 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
container: { container: {
height: '100%',
'& ul': { '& ul': {
margin: 0, margin: 0,
}, },

View File

@ -9,5 +9,6 @@ export const useStyles = makeStyles()({
height: 1, height: 1,
margin: -1, margin: -1,
padding: 0, padding: 0,
overflow: 'hidden',
}, },
}); });

View File

@ -0,0 +1,22 @@
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
import React, { FC } from 'react';
import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
export const InstanceStatus: FC = ({ children }) => {
const { instanceStatus } = useInstanceStatus();
return (
<div hidden={!instanceStatus} style={{ height: '100%' }}>
<ConditionallyRender
condition={Boolean(instanceStatus)}
show={() => (
<InstanceStatusBarMemo instanceStatus={instanceStatus!} />
)}
/>
{children}
</div>
);
};
const InstanceStatusBarMemo = React.memo(InstanceStatusBar);

View File

@ -0,0 +1,61 @@
import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
import { InstanceState } from 'interfaces/instance';
import { render } from 'utils/testRenderer';
import { screen } from '@testing-library/react';
import { addDays } from 'date-fns';
import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
import { UNKNOWN_INSTANCE_STATUS } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
test('InstanceStatusBar should be hidden by default', async () => {
render(<InstanceStatusBar instanceStatus={UNKNOWN_INSTANCE_STATUS} />);
expect(
screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
).not.toBeInTheDocument();
});
test('InstanceStatusBar should be hidden when the trial is far from expired', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: 'pro',
state: InstanceState.TRIAL,
trialExpiry: addDays(new Date(), 15).toISOString(),
}}
/>
);
expect(
screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
).not.toBeInTheDocument();
});
test('InstanceStatusBar should warn when the trial is about to expire', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: 'pro',
state: InstanceState.TRIAL,
trialExpiry: addDays(new Date(), 5).toISOString(),
}}
/>
);
expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial has expired', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: 'pro',
state: InstanceState.TRIAL,
trialExpiry: new Date().toISOString(),
}}
/>
);
expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});

View File

@ -0,0 +1,97 @@
import { styled, Button } from '@mui/material';
import { colors } from 'themes/colors';
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
import { differenceInDays, parseISO } from 'date-fns';
import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
import { Info } from '@mui/icons-material';
interface IInstanceStatusBarProps {
instanceStatus: IInstanceStatus;
}
export const InstanceStatusBar = ({
instanceStatus,
}: IInstanceStatusBarProps) => {
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
if (
instanceStatus.state === InstanceState.TRIAL &&
typeof trialDaysRemaining === 'number' &&
trialDaysRemaining <= 0
) {
return (
<StyledBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<span>
<strong>Heads up!</strong> Your free trial of the{' '}
{instanceStatus.plan.toUpperCase()} version has expired.
</span>
<ContactButton />
</StyledBar>
);
}
if (
instanceStatus.state === InstanceState.TRIAL &&
typeof trialDaysRemaining === 'number' &&
trialDaysRemaining <= 10
) {
return (
<StyledBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<span>
<strong>Heads up!</strong> You have{' '}
<strong>{trialDaysRemaining} days</strong> remaining of your
free trial of the {instanceStatus.plan.toUpperCase()}{' '}
version.
</span>
<ContactButton />
</StyledBar>
);
}
return null;
};
const ContactButton = () => {
return (
<StyledButton
href="mailto:support@getunleash.zendesk.com"
variant="outlined"
>
Contact us
</StyledButton>
);
};
const calculateTrialDaysRemaining = (
instanceStatus: IInstanceStatus
): number | undefined => {
return instanceStatus.trialExpiry
? differenceInDays(parseISO(instanceStatus.trialExpiry), new Date())
: undefined;
};
const StyledBar = styled('aside')(({ theme }) => ({
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(2),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: colors.blue[200],
background: colors.blue[50],
color: colors.blue[700],
}));
const StyledButton = styled(Button)(({ theme }) => ({
whiteSpace: 'nowrap',
minWidth: '8rem',
marginLeft: theme.spacing(2),
}));
const StyledInfoIcon = styled(Info)(({ theme }) => ({
color: colors.blue[500],
}));

View File

@ -0,0 +1,83 @@
// Vitest Snapshot v1
exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
<aside
class="mui-1rw10cs"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
data-testid="InfoIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
/>
</svg>
<span>
<strong>
Heads up!
</strong>
Your free trial of the
PRO
version has expired.
</span>
<a
class="MuiButton-root MuiButton-outlined MuiButton-outlinedPrimary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButtonBase-root mui-l66s6r-MuiButtonBase-root-MuiButton-root"
href="mailto:support@getunleash.zendesk.com"
tabindex="0"
>
Contact us
<span
class="MuiTouchRipple-root mui-8je8zh-MuiTouchRipple-root"
/>
</a>
</aside>
`;
exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
<aside
class="mui-1rw10cs"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
data-testid="InfoIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
/>
</svg>
<span>
<strong>
Heads up!
</strong>
You have
<strong>
4
days
</strong>
remaining of your free trial of the
PRO
version.
</span>
<a
class="MuiButton-root MuiButton-outlined MuiButton-outlinedPrimary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButtonBase-root mui-l66s6r-MuiButtonBase-root-MuiButton-root"
href="mailto:support@getunleash.zendesk.com"
tabindex="0"
>
Contact us
<span
class="MuiTouchRipple-root mui-8je8zh-MuiTouchRipple-root"
/>
</a>
</aside>
`;

View File

@ -4,6 +4,7 @@ interface IPercentageCircleProps {
styles?: object; styles?: object;
percentage: number; percentage: number;
secondaryPieColor?: string; secondaryPieColor?: string;
className?: string;
} }
const PercentageCircle = ({ const PercentageCircle = ({

View File

@ -54,7 +54,6 @@ const FeatureOverviewEnvironmentMetrics = ({
</p> </p>
</div> </div>
<PercentageCircle <PercentageCircle
// @ts-expect-error
className={styles.percentageCircle} className={styles.percentageCircle}
percentage={percentage} percentage={percentage}
data-loading data-loading

View File

@ -36,8 +36,7 @@ const FeatureOverviewMetaData = () => {
Project: {project} Project: {project}
</span> </span>
<ConditionallyRender <ConditionallyRender
// @ts-expect-error condition={Boolean(description)}
condition={description}
show={ show={
<span className={styles.bodyItem} data-loading> <span className={styles.bodyItem} data-loading>
<div>Description:</div> <div>Description:</div>

View File

@ -97,8 +97,7 @@ const FeatureOverviewTags: React.FC<IFeatureOverviewTagsProps> = ({
} }
}; };
// @ts-expect-error const renderTag = (t: ITag) => (
const renderTag = t => (
<Chip <Chip
icon={tagIcon(t.type)} icon={tagIcon(t.type)}
className={styles.tagChip} className={styles.tagChip}

View File

@ -182,7 +182,7 @@ exports[`FeedbackCESForm 1`] = `
<div <div
aria-atomic="true" aria-atomic="true"
aria-live="polite" aria-live="polite"
class="tss-1cl4o05-container" class="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
/> />

View File

@ -1,10 +1,9 @@
export const useFeedbackCESEnabled = (): boolean => { import {
const { hostname } = window.location; isLocalhostDomain,
isUnleashDomain,
isVercelBranchDomain,
} from 'utils/env';
return ( export const useFeedbackCESEnabled = (): boolean => {
hostname === 'localhost' || return isUnleashDomain() || isVercelBranchDomain() || isLocalhostDomain();
hostname.endsWith('.vercel.app') ||
hostname.endsWith('.getunleash.io') ||
hostname.endsWith('.unleash-hosted.com')
);
}; };

View File

@ -54,19 +54,20 @@ export const MainLayout = ({ children }: IMainLayoutProps) => {
{children} {children}
</div> </div>
</Grid> </Grid>
<div style={{ overflow: 'hidden' }}>
<img <img
src={formatAssetPath(textureImage)} src={formatAssetPath(textureImage)}
alt="" alt=""
style={{ style={{
display: 'block',
position: 'fixed', position: 'fixed',
zIndex: 1, zIndex: 0,
bottom: 0, bottom: 0,
right: 0, right: 0,
width: 400, width: 400,
pointerEvents: 'none',
userSelect: 'none',
}} }}
/> />
</div>
</main> </main>
<Footer /> <Footer />
</Grid> </Grid>

View File

@ -26,7 +26,7 @@ exports[`renders correctly with empty version 1`] = `
<div <div
aria-atomic="true" aria-atomic="true"
aria-live="polite" aria-live="polite"
class="tss-1cl4o05-container" class="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
/> />
@ -60,7 +60,7 @@ exports[`renders correctly with ui-config 1`] = `
<div <div
aria-atomic="true" aria-atomic="true"
aria-live="polite" aria-live="polite"
class="tss-1cl4o05-container" class="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
/> />
@ -94,7 +94,7 @@ exports[`renders correctly with versionInfo 1`] = `
<div <div
aria-atomic="true" aria-atomic="true"
aria-live="polite" aria-live="polite"
class="tss-1cl4o05-container" class="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
/> />
@ -121,7 +121,7 @@ exports[`renders correctly without uiConfig 1`] = `
<div <div
aria-atomic="true" aria-atomic="true"
aria-live="polite" aria-live="polite"
class="tss-1cl4o05-container" class="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
/> />

View File

@ -2,7 +2,6 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
footer: { footer: {
background: 'white',
padding: '2rem 4rem', padding: '2rem 4rem',
width: '100%', width: '100%',
flexGrow: 1, flexGrow: 1,

View File

@ -3,7 +3,7 @@
exports[`should render DrawerMenu 1`] = ` exports[`should render DrawerMenu 1`] = `
[ [
<footer <footer
className="tss-1bvpe5r-footer" className="tss-wd65c0-footer"
> >
<div <div
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 mui-16chest-MuiGrid-root" className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 mui-16chest-MuiGrid-root"
@ -438,7 +438,7 @@ exports[`should render DrawerMenu 1`] = `
<div <div
aria-atomic={true} aria-atomic={true}
aria-live="polite" aria-live="polite"
className="tss-1cl4o05-container" className="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
/>, />,
@ -448,7 +448,7 @@ exports[`should render DrawerMenu 1`] = `
exports[`should render DrawerMenu with "features" selected 1`] = ` exports[`should render DrawerMenu with "features" selected 1`] = `
[ [
<footer <footer
className="tss-1bvpe5r-footer" className="tss-wd65c0-footer"
> >
<div <div
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 mui-16chest-MuiGrid-root" className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 mui-16chest-MuiGrid-root"
@ -883,7 +883,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
<div <div
aria-atomic={true} aria-atomic={true}
aria-live="polite" aria-live="polite"
className="tss-1cl4o05-container" className="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
/>, />,

View File

@ -7,7 +7,7 @@ import ExitToApp from '@mui/icons-material/ExitToApp';
import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg'; import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg';
import NavigationLink from '../NavigationLink/NavigationLink'; import NavigationLink from '../NavigationLink/NavigationLink';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
import { IFlags } from 'interfaces/uiConfig'; import { IFlags } from 'interfaces/uiConfig';
import { IRoute } from 'interfaces/route'; import { IRoute } from 'interfaces/route';
import styles from './DrawerMenu.module.scss'; // FIXME: useStyle - theme import styles from './DrawerMenu.module.scss'; // FIXME: useStyle - theme
@ -119,10 +119,7 @@ export const DrawerMenu: VFC<IDrawerMenuProps> = ({
<Divider /> <Divider />
<div className={styles.iconLinkList}> <div className={styles.iconLinkList}>
{renderLinks()} {renderLinks()}
<a <a className={styles.iconLink} href={`${basePath}/logout`}>
className={styles.iconLink}
href={`${getBasePath()}/logout`}
>
<ExitToApp className={styles.navigationIcon} /> <ExitToApp className={styles.navigationIcon} />
Sign out Sign out
</a> </a>

View File

@ -13,12 +13,9 @@ describe('useOptimisticUpdate', () => {
}); });
it('should have working setter', () => { it('should have working setter', () => {
const { result, rerender } = renderHook( const { result } = renderHook(state => useOptimisticUpdate(state), {
state => useOptimisticUpdate(state),
{
initialProps: 'initial', initialProps: 'initial',
} });
);
act(() => { act(() => {
result.current[1]('updated'); result.current[1]('updated');
@ -41,12 +38,9 @@ describe('useOptimisticUpdate', () => {
}); });
it('should have working rollback', () => { it('should have working rollback', () => {
const { result, rerender } = renderHook( const { result } = renderHook(state => useOptimisticUpdate(state), {
state => useOptimisticUpdate(state),
{
initialProps: 'initial', initialProps: 'initial',
} });
);
act(() => { act(() => {
result.current[1]('updated'); result.current[1]('updated');

View File

@ -256,7 +256,7 @@ exports[`renders correctly 1`] = `
<div <div
aria-atomic={true} aria-atomic={true}
aria-live="polite" aria-live="polite"
className="tss-1cl4o05-container" className="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
> >

View File

@ -72,7 +72,7 @@ exports[`renders an empty list correctly 1`] = `
<div <div
aria-atomic={true} aria-atomic={true}
aria-live="polite" aria-live="polite"
className="tss-1cl4o05-container" className="tss-i8rqz1-container"
data-testid="ANNOUNCER_ELEMENT_TEST_ID" data-testid="ANNOUNCER_ELEMENT_TEST_ID"
role="status" role="status"
/>, />,

View File

@ -9,14 +9,14 @@ import {
Select, Select,
Typography, Typography,
SelectChangeEvent, SelectChangeEvent,
Alert,
} from '@mui/material'; } from '@mui/material';
import classnames from 'classnames'; import classnames from 'classnames';
import { useStyles } from 'component/user/UserProfile/UserProfileContent/UserProfileContent.styles'; import { useStyles } from 'component/user/UserProfile/UserProfileContent/UserProfileContent.styles';
import { useThemeStyles } from 'themes/themeStyles'; import { useThemeStyles } from 'themes/themeStyles';
import { Alert } from '@mui/material';
import EditProfile from '../EditProfile/EditProfile'; import EditProfile from '../EditProfile/EditProfile';
import legacyStyles from '../../user.module.scss'; import legacyStyles from '../../user.module.scss';
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { IUser } from 'interfaces/user'; import { IUser } from 'interfaces/user';
import { ILocationSettings } from 'hooks/useLocationSettings'; import { ILocationSettings } from 'hooks/useLocationSettings';
@ -163,7 +163,7 @@ const UserProfileContent = ({
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
href={`${getBasePath()}/logout`} href={`${basePath}/logout`}
> >
Logout Logout
</Button> </Button>

View File

@ -1,11 +1,6 @@
import useSWR, { SWRConfiguration, mutate } from 'swr'; import useSWR, { SWRConfiguration, Key } from 'swr';
import { useCallback } from 'react'; import { useCallback } from 'react';
type CacheKey =
| 'apiAdminFeaturesGet'
| 'apiAdminArchiveFeaturesGet'
| ['apiAdminArchiveFeaturesGet', string?];
interface IUseApiGetterOutput<T> { interface IUseApiGetterOutput<T> {
data?: T; data?: T;
refetch: () => void; refetch: () => void;
@ -14,15 +9,15 @@ interface IUseApiGetterOutput<T> {
} }
export const useApiGetter = <T>( export const useApiGetter = <T>(
cacheKey: CacheKey, cacheKey: Key,
fetcher: () => Promise<T>, fetcher: () => Promise<T>,
options?: SWRConfiguration options?: SWRConfiguration
): IUseApiGetterOutput<T> => { ): IUseApiGetterOutput<T> => {
const { data, error } = useSWR<T>(cacheKey, fetcher, options); const { data, error, mutate } = useSWR<T>(cacheKey, fetcher, options);
const refetch = useCallback(() => { const refetch = useCallback(() => {
mutate(cacheKey).catch(console.warn); mutate().catch(console.warn);
}, [cacheKey]); }, [mutate]);
return { return {
data, data,

View File

@ -0,0 +1,48 @@
import { IInstanceStatus } from 'interfaces/instance';
import { useApiGetter } from 'hooks/api/getters/useApiGetter/useApiGetter';
import { formatApiPath } from 'utils/formatPath';
import { isLocalhostDomain } from 'utils/env';
export interface IUseInstanceStatusOutput {
instanceStatus?: IInstanceStatus;
refetchInstanceStatus: () => void;
loading: boolean;
error?: Error;
}
export const useInstanceStatus = (): IUseInstanceStatusOutput => {
const { data, refetch, loading, error } = useApiGetter(
'useInstanceStatus',
fetchInstanceStatus
);
return {
instanceStatus: data,
refetchInstanceStatus: refetch,
loading,
error,
};
};
const fetchInstanceStatus = async (): Promise<IInstanceStatus> => {
if (!enableInstanceStatusBarFeature()) {
return UNKNOWN_INSTANCE_STATUS;
}
const res = await fetch(formatApiPath('api/instance/status'));
if (!res.ok) {
return UNKNOWN_INSTANCE_STATUS;
}
return res.json();
};
// TODO(olav): Enable instance status bar feature outside of localhost.
const enableInstanceStatusBarFeature = () => {
return isLocalhostDomain();
};
export const UNKNOWN_INSTANCE_STATUS: IInstanceStatus = {
plan: 'unknown',
};

View File

@ -1,4 +1,4 @@
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
import { createPersistentGlobalStateHook } from './usePersistentGlobalState'; import { createPersistentGlobalStateHook } from './usePersistentGlobalState';
import React from 'react'; import React from 'react';
@ -22,6 +22,6 @@ const createInitialValue = (): IEventSettings => {
}; };
const useGlobalState = createPersistentGlobalStateHook<IEventSettings>( const useGlobalState = createPersistentGlobalStateHook<IEventSettings>(
`${getBasePath()}:useEventSettings:v1`, `${basePath}:useEventSettings:v1`,
createInitialValue() createInitialValue()
); );

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
import { createPersistentGlobalStateHook } from './usePersistentGlobalState'; import { createPersistentGlobalStateHook } from './usePersistentGlobalState';
import { import {
expired, expired,
@ -38,7 +38,7 @@ export interface IFeaturesFilterSortOption {
// Store the features sort state globally, and in localStorage. // Store the features sort state globally, and in localStorage.
// When changing the format of IFeaturesSort, change the version as well. // When changing the format of IFeaturesSort, change the version as well.
const useFeaturesSortState = createPersistentGlobalStateHook<IFeaturesSort>( const useFeaturesSortState = createPersistentGlobalStateHook<IFeaturesSort>(
`${getBasePath()}:useFeaturesSort:v1`, `${basePath}:useFeaturesSort:v1`,
{ type: 'name' } { type: 'name' }
); );

View File

@ -1,4 +1,4 @@
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
import { createPersistentGlobalStateHook } from './usePersistentGlobalState'; import { createPersistentGlobalStateHook } from './usePersistentGlobalState';
import React from 'react'; import React from 'react';
@ -24,6 +24,6 @@ const createInitialValue = (): ILocationSettings => {
}; };
const useGlobalState = createPersistentGlobalStateHook<ILocationSettings>( const useGlobalState = createPersistentGlobalStateHook<ILocationSettings>(
`${getBasePath()}:useLocationSettings:v1`, `${basePath}:useLocationSettings:v1`,
createInitialValue() createInitialValue()
); );

View File

@ -1,6 +1,6 @@
import { IUser } from 'interfaces/user'; import { IUser } from 'interfaces/user';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
import { createGlobalStateHook } from 'hooks/useGlobalState'; import { createGlobalStateHook } from 'hooks/useGlobalState';
export interface IUsersFilter { export interface IUsersFilter {
@ -16,7 +16,7 @@ export interface IUsersFilterOutput {
// Store the users filter state globally, and in localStorage. // Store the users filter state globally, and in localStorage.
// When changing the format of IUsersFilter, change the version as well. // When changing the format of IUsersFilter, change the version as well.
const useUsersFilterState = createGlobalStateHook<IUsersFilter>( const useUsersFilterState = createGlobalStateHook<IUsersFilter>(
`${getBasePath()}:useUsersFilter:v1`, `${basePath}:useUsersFilter:v1`,
{ query: '' } { query: '' }
); );

View File

@ -1,6 +1,6 @@
import { IUser } from 'interfaces/user'; import { IUser } from 'interfaces/user';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
import { createPersistentGlobalStateHook } from './usePersistentGlobalState'; import { createPersistentGlobalStateHook } from './usePersistentGlobalState';
import useUsers from 'hooks/api/getters/useUsers/useUsers'; import useUsers from 'hooks/api/getters/useUsers/useUsers';
import IRole from 'interfaces/role'; import IRole from 'interfaces/role';
@ -26,7 +26,7 @@ export interface IUsersFilterSortOption {
// Store the users sort state globally, and in localStorage. // Store the users sort state globally, and in localStorage.
// When changing the format of IUsersSort, change the version as well. // When changing the format of IUsersSort, change the version as well.
const useUsersSortState = createPersistentGlobalStateHook<IUsersSort>( const useUsersSortState = createPersistentGlobalStateHook<IUsersSort>(
`${getBasePath()}:useUsersSort:v1`, `${basePath}:useUsersSort:v1`,
{ type: 'created', desc: false } { type: 'created', desc: false }
); );

View File

@ -10,21 +10,24 @@ import { ThemeProvider } from 'themes/ThemeProvider';
import { App } from 'component/App'; import { App } from 'component/App';
import { ScrollTop } from 'component/common/ScrollTop/ScrollTop'; import { ScrollTop } from 'component/common/ScrollTop/ScrollTop';
import { AccessProvider } from 'component/providers/AccessProvider/AccessProvider'; import { AccessProvider } from 'component/providers/AccessProvider/AccessProvider';
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/FeedbackCESProvider'; import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/FeedbackCESProvider';
import UIProvider from 'component/providers/UIProvider/UIProvider'; import UIProvider from 'component/providers/UIProvider/UIProvider';
import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider'; import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider';
import { InstanceStatus } from 'component/common/InstanceStatus/InstanceStatus';
ReactDOM.render( ReactDOM.render(
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<UIProvider> <UIProvider>
<AccessProvider> <AccessProvider>
<BrowserRouter basename={`${getBasePath()}`}> <BrowserRouter basename={basePath}>
<ThemeProvider> <ThemeProvider>
<AnnouncerProvider> <AnnouncerProvider>
<FeedbackCESProvider> <FeedbackCESProvider>
<InstanceStatus>
<ScrollTop /> <ScrollTop />
<App /> <App />
</InstanceStatus>
</FeedbackCESProvider> </FeedbackCESProvider>
</AnnouncerProvider> </AnnouncerProvider>
</ThemeProvider> </ThemeProvider>

View File

@ -0,0 +1,16 @@
export interface IInstanceStatus {
plan: string;
trialExpiry?: string;
trialStart?: string;
trialExtended?: number;
billingCenter?: string;
state?: InstanceState;
}
export enum InstanceState {
UNASSIGNED = 'UNASSIGNED',
TRIAL = 'TRIAL',
ACTIVE = 'ACTIVE',
EXPIRED = 'EXPIRED',
CHURNED = 'CHURNED',
}

View File

@ -0,0 +1,25 @@
import {
isLocalhostDomain,
isVercelBranchDomain,
isUnleashDomain,
} from 'utils/env';
test('isLocalhostDomain', () => {
expect(isLocalhostDomain()).toEqual(true);
expect(isLocalhostDomain('unleash-hosted.com')).toEqual(false);
});
test('isUnleashDomain', () => {
expect(isUnleashDomain()).toEqual(false);
expect(isUnleashDomain('vercel.app')).toEqual(false);
expect(isUnleashDomain('app.getunleash.io')).toEqual(true);
expect(isUnleashDomain('app.unleash-hosted.com')).toEqual(true);
});
test('isVercelBranchDomain', () => {
expect(isVercelBranchDomain()).toEqual(false);
expect(isVercelBranchDomain('getunleash.io')).toEqual(false);
expect(isVercelBranchDomain('unleash-hosted.com')).toEqual(false);
expect(isVercelBranchDomain('vercel.app')).toEqual(false);
expect(isVercelBranchDomain('branch.vercel.app')).toEqual(true);
});

View File

@ -0,0 +1,9 @@
export const isLocalhostDomain = (hostname = window.location.hostname) =>
hostname === 'localhost';
export const isUnleashDomain = (hostname = window.location.hostname) =>
hostname.endsWith('.getunleash.io') ||
hostname.endsWith('.unleash-hosted.com');
export const isVercelBranchDomain = (hostname = window.location.hostname) =>
hostname.endsWith('.vercel.app');

View File

@ -0,0 +1,48 @@
import {
parseBasePath,
formatAssetPath,
formatApiPath,
} from 'utils/formatPath';
test('formatBasePath', () => {
expect(parseBasePath()).toEqual('');
expect(parseBasePath('')).toEqual('');
expect(parseBasePath('/')).toEqual('');
expect(parseBasePath('a')).toEqual('/a');
expect(parseBasePath('/a')).toEqual('/a');
expect(parseBasePath('/a/')).toEqual('/a');
expect(parseBasePath('a/b/')).toEqual('/a/b');
expect(parseBasePath('//a//b//')).toEqual('/a/b');
});
test('formatAssetPath', () => {
expect(formatAssetPath('')).toEqual('');
expect(formatAssetPath('/')).toEqual('');
expect(formatAssetPath('a')).toEqual('/a');
expect(formatAssetPath('/a')).toEqual('/a');
expect(formatAssetPath('/a/')).toEqual('/a');
expect(formatAssetPath('a/b/')).toEqual('/a/b');
expect(formatAssetPath('', '')).toEqual('');
expect(formatAssetPath('/', '/')).toEqual('');
expect(formatAssetPath('a', 'x')).toEqual('/x/a');
expect(formatAssetPath('/a', '/x')).toEqual('/x/a');
expect(formatAssetPath('/a/', '/x/')).toEqual('/x/a');
expect(formatAssetPath('a/b/', 'x/y/')).toEqual('/x/y/a/b');
expect(formatAssetPath('//a//b//', '//x//y//')).toEqual('/x/y/a/b');
});
test('formatApiPath', () => {
expect(formatApiPath('')).toEqual('');
expect(formatApiPath('/')).toEqual('');
expect(formatApiPath('a')).toEqual('/a');
expect(formatApiPath('/a')).toEqual('/a');
expect(formatApiPath('/a/')).toEqual('/a');
expect(formatApiPath('a/b/')).toEqual('/a/b');
expect(formatApiPath('', '')).toEqual('');
expect(formatApiPath('/', '/')).toEqual('');
expect(formatApiPath('a', 'x')).toEqual('/x/a');
expect(formatApiPath('/a', '/x')).toEqual('/x/a');
expect(formatApiPath('/a/', '/x/')).toEqual('/x/a');
expect(formatApiPath('a/b/', 'x/y/')).toEqual('/x/y/a/b');
expect(formatApiPath('//a//b//', '//x//y//')).toEqual('/x/y/a/b');
});

View File

@ -1,51 +1,40 @@
export const getBasePathGenerator = () => { export const formatApiPath = (path: string, base = basePath): string => {
let basePath: string | undefined; return joinPaths(base, path);
const DEFAULT = '::baseUriPath::'; };
return () => { export const formatAssetPath = (path: string, base = basePath): string => {
if (process.env.NODE_ENV === 'development') { if (import.meta.env.DEV && import.meta.env.BASE_URL !== '/') {
return ''; // Vite will automatically add BASE_URL to imported assets.
return joinPaths(path);
} }
if (basePath !== undefined) { return joinPaths(base, path);
return basePath; };
// Parse the basePath value from the HTML meta tag.
export const parseBasePath = (value = basePathMetaTagContent()): string => {
if (import.meta.env.DEV && import.meta.env.BASE_URL !== '/') {
// Use the `UNLEASH_BASE_PATH` env var instead of the meta tag.
return joinPaths(import.meta.env.BASE_URL);
} }
const baseUriPath = document.querySelector<HTMLMetaElement>(
return value === '::baseUriPath::' ? '' : joinPaths(value);
};
// Join paths with a leading separator and without a trailing separator.
const joinPaths = (...paths: string[]): string => {
return ['', ...paths]
.join('/')
.replace(/\/+$/g, '') // Remove trailing separators.
.replace(/\/+/g, '/'); // Collapse repeated separators.
};
const basePathMetaTagContent = (): string => {
const el = document.querySelector<HTMLMetaElement>(
'meta[name="baseUriPath"]' 'meta[name="baseUriPath"]'
); );
if (baseUriPath?.content) { return el?.content ?? '';
basePath = baseUriPath?.content;
if (basePath === DEFAULT) {
basePath = '';
return '';
}
return basePath;
}
basePath = '';
return basePath;
};
}; };
export const getBasePath = getBasePathGenerator(); export const basePath = parseBasePath();
export const formatApiPath = (path: string) => {
const basePath = getBasePath();
if (basePath) {
return `${basePath}/${path}`;
}
return `/${path}`;
};
export const formatAssetPath = (path: string) => {
const basePath = getBasePath();
if (basePath) {
return `${basePath}/${path}`;
}
return path;
};

View File

@ -1,10 +1,10 @@
import { Configuration, AdminApi } from 'openapi'; import { Configuration, AdminApi } from 'openapi';
import { getBasePath } from 'utils/formatPath'; import { basePath } from 'utils/formatPath';
const createAdminApi = (): AdminApi => { const createAdminApi = (): AdminApi => {
return new AdminApi( return new AdminApi(
new Configuration({ new Configuration({
basePath: getBasePath(), basePath,
}) })
); );
}; };

View File

@ -50,3 +50,4 @@ export const HEADER_USER_AVATAR = 'HEADER_USER_AVATAR';
export const SIDEBAR_MODAL_ID = 'SIDEBAR_MODAL_ID'; export const SIDEBAR_MODAL_ID = 'SIDEBAR_MODAL_ID';
export const AUTH_PAGE_ID = 'AUTH_PAGE_ID'; export const AUTH_PAGE_ID = 'AUTH_PAGE_ID';
export const ANNOUNCER_ELEMENT_TEST_ID = 'ANNOUNCER_ELEMENT_TEST_ID'; export const ANNOUNCER_ELEMENT_TEST_ID = 'ANNOUNCER_ELEMENT_TEST_ID';
export const INSTANCE_STATUS_BAR_ID = 'INSTANCE_STATUS_BAR_ID';

View File

@ -4,10 +4,20 @@ import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr'; import svgr from 'vite-plugin-svgr';
import envCompatible from 'vite-plugin-env-compatible'; import envCompatible from 'vite-plugin-env-compatible';
const API_URL = process.env.UNLEASH_API || 'http://localhost:4242'; const UNLEASH_API = process.env.UNLEASH_API || 'http://localhost:4242';
const UNLEASH_BASE_PATH = process.env.UNLEASH_BASE_PATH || '/';
if (!UNLEASH_BASE_PATH.startsWith('/') || !UNLEASH_BASE_PATH.endsWith('/')) {
console.error('UNLEASH_BASE_PATH must both start and end with /');
process.exit(1);
}
// https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: UNLEASH_BASE_PATH,
build: {
outDir: 'build',
assetsDir: 'static',
},
test: { test: {
globals: true, globals: true,
setupFiles: 'src/setupTests.ts', setupFiles: 'src/setupTests.ts',
@ -17,23 +27,19 @@ export default defineConfig({
server: { server: {
open: true, open: true,
proxy: { proxy: {
'/api': { [`${UNLEASH_BASE_PATH}api`]: {
target: API_URL, target: UNLEASH_API,
changeOrigin: true, changeOrigin: true,
}, },
'/auth': { [`${UNLEASH_BASE_PATH}auth`]: {
target: API_URL, target: UNLEASH_API,
changeOrigin: true, changeOrigin: true,
}, },
'/logout': { [`${UNLEASH_BASE_PATH}logout`]: {
target: API_URL, target: UNLEASH_API,
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, },
build: {
outDir: 'build',
assetsDir: 'static',
},
plugins: [react(), tsconfigPaths(), svgr(), envCompatible()], plugins: [react(), tsconfigPaths(), svgr(), envCompatible()],
}); });