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:
parent
06b0a29ea8
commit
2e367b3a04
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -9,5 +9,6 @@ export const useStyles = makeStyles()({
|
|||||||
height: 1,
|
height: 1,
|
||||||
margin: -1,
|
margin: -1,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -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);
|
@ -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();
|
||||||
|
});
|
@ -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],
|
||||||
|
}));
|
@ -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>
|
||||||
|
`;
|
@ -4,6 +4,7 @@ interface IPercentageCircleProps {
|
|||||||
styles?: object;
|
styles?: object;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
secondaryPieColor?: string;
|
secondaryPieColor?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PercentageCircle = ({
|
const PercentageCircle = ({
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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')
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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,
|
||||||
|
@ -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"
|
||||||
/>,
|
/>,
|
||||||
|
@ -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>
|
||||||
|
@ -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');
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
@ -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"
|
||||||
/>,
|
/>,
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
};
|
@ -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()
|
||||||
);
|
);
|
||||||
|
@ -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' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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()
|
||||||
);
|
);
|
||||||
|
@ -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: '' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
16
frontend/src/interfaces/instance.ts
Normal file
16
frontend/src/interfaces/instance.ts
Normal 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',
|
||||||
|
}
|
25
frontend/src/utils/env.test.ts
Normal file
25
frontend/src/utils/env.test.ts
Normal 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);
|
||||||
|
});
|
9
frontend/src/utils/env.ts
Normal file
9
frontend/src/utils/env.ts
Normal 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');
|
48
frontend/src/utils/formatPath.test.ts
Normal file
48
frontend/src/utils/formatPath.test.ts
Normal 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');
|
||||||
|
});
|
@ -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;
|
|
||||||
};
|
|
||||||
|
@ -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,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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';
|
||||||
|
@ -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()],
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user