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

Frontend for additional environments (#8378)

This commit is contained in:
Tymoteusz Czech 2024-10-08 14:59:41 +02:00 committed by GitHub
parent 99021f373f
commit 48eee2043f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 533 additions and 17 deletions

View File

@ -26,7 +26,7 @@ import { relative } from 'themes/themeStyles';
interface ICreateProps { interface ICreateProps {
title?: ReactNode; title?: ReactNode;
description: string; description: ReactNode;
documentationLink?: string; documentationLink?: string;
documentationIcon?: ReactNode; documentationIcon?: ReactNode;
documentationLinkLabel?: string; documentationLinkLabel?: string;
@ -210,7 +210,7 @@ const StyledDescriptionCard = styled('article')(({ theme }) => ({
marginBlockEnd: theme.spacing(3), marginBlockEnd: theme.spacing(3),
})); }));
const StyledDescription = styled('p')(({ theme }) => ({ const StyledDescription = styled('div')(() => ({
width: '100%', width: '100%',
})); }));
@ -370,7 +370,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
}; };
interface IMobileGuidance { interface IMobileGuidance {
description: string; description: ReactNode;
documentationLink?: string; documentationLink?: string;
documentationIcon?: ReactNode; documentationIcon?: ReactNode;
documentationLinkLabel?: string; documentationLinkLabel?: string;
@ -410,7 +410,7 @@ const MobileGuidance = ({
}; };
interface IGuidanceProps { interface IGuidanceProps {
description: string; description: ReactNode;
documentationIcon?: ReactNode; documentationIcon?: ReactNode;
documentationLink?: string; documentationLink?: string;
documentationLinkLabel?: string; documentationLinkLabel?: string;

View File

@ -8,7 +8,7 @@ import {
Table, Table,
TablePlaceholder, TablePlaceholder,
} from 'component/common/Table'; } from 'component/common/Table';
import { useCallback } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Alert, styled, TableBody } from '@mui/material'; import { Alert, styled, TableBody } from '@mui/material';
import type { MoveListItem } from 'hooks/useDragItem'; import type { MoveListItem } from 'hooks/useDragItem';
@ -27,7 +27,10 @@ import { HighlightCell } from 'component/common/Table/cells/HighlightCell/Highli
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import type { IEnvironment } from 'interfaces/environments'; import type { IEnvironment } from 'interfaces/environments';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import { PurchasableFeature } from './PurchasableFeature/PurchasableFeature';
import { OrderEnvironmentsDialog } from './OrderEnvironmentsDialog/OrderEnvironmentsDialog';
const StyledAlert = styled(Alert)(({ theme }) => ({ const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4), marginBottom: theme.spacing(4),
@ -37,7 +40,12 @@ export const EnvironmentTable = () => {
const { changeSortOrder } = useEnvironmentApi(); const { changeSortOrder } = useEnvironmentApi();
const { setToastApiError } = useToast(); const { setToastApiError } = useToast();
const { environments, mutateEnvironments } = useEnvironments(); const { environments, mutateEnvironments } = useEnvironments();
const [purchaseDialogOpen, setPurchaseDialogOpen] = useState(false);
const isFeatureEnabled = useUiFlag('EEA'); const isFeatureEnabled = useUiFlag('EEA');
const isPurchaseAdditionalEnvronmentsEnabled = useUiFlag(
'purchaseAdditionalEnvironments',
);
const { isPro } = useUiConfig();
const moveListItem: MoveListItem = useCallback( const moveListItem: MoveListItem = useCallback(
async (dragIndex: number, dropIndex: number, save = false) => { async (dragIndex: number, dropIndex: number, save = false) => {
@ -58,6 +66,28 @@ export const EnvironmentTable = () => {
[changeSortOrder, environments, mutateEnvironments, setToastApiError], [changeSortOrder, environments, mutateEnvironments, setToastApiError],
); );
const columnsWithActions = useMemo(() => {
if (isFeatureEnabled) {
return [
...COLUMNS,
{
Header: 'Actions',
id: 'Actions',
align: 'center',
width: '1%',
Cell: ({
row: { original },
}: { row: { original: IEnvironment } }) => (
<EnvironmentActionCell environment={original} />
),
disableGlobalFilter: true,
},
];
}
return COLUMNS;
}, [isFeatureEnabled]);
const { const {
getTableProps, getTableProps,
getTableBodyProps, getTableBodyProps,
@ -68,7 +98,7 @@ export const EnvironmentTable = () => {
setGlobalFilter, setGlobalFilter,
} = useTable( } = useTable(
{ {
columns: COLUMNS as any, columns: columnsWithActions as any,
data: environments, data: environments,
disableSortBy: true, disableSortBy: true,
}, },
@ -91,7 +121,7 @@ export const EnvironmentTable = () => {
<PageHeader title={`Environments (${count})`} actions={headerActions} /> <PageHeader title={`Environments (${count})`} actions={headerActions} />
); );
if (!isFeatureEnabled) { if (!isFeatureEnabled && !isPurchaseAdditionalEnvronmentsEnabled) {
return ( return (
<PageContent header={header}> <PageContent header={header}>
<PremiumFeature feature='environments' /> <PremiumFeature feature='environments' />
@ -101,6 +131,20 @@ export const EnvironmentTable = () => {
return ( return (
<PageContent header={header}> <PageContent header={header}>
{isPro() && isPurchaseAdditionalEnvronmentsEnabled ? (
<>
<PurchasableFeature
title='Purchase additional environments'
description='With our Pro plan, you now have the flexibility to expand your workspace by adding up to three additional environments.'
onClick={() => setPurchaseDialogOpen(true)}
/>
<OrderEnvironmentsDialog
open={purchaseDialogOpen}
onClose={() => setPurchaseDialogOpen(false)}
onSubmit={() => {}} // TODO: API call
/>
</>
) : null}
<StyledAlert severity='info'> <StyledAlert severity='info'>
This is the order of environments that you have today in each This is the order of environments that you have today in each
feature flag. Rearranging them here will change also the order feature flag. Rearranging them here will change also the order
@ -185,14 +229,4 @@ const COLUMNS = [
row.apiTokenCount === 1 ? '1 token' : `${row.apiTokenCount} tokens`, row.apiTokenCount === 1 ? '1 token' : `${row.apiTokenCount} tokens`,
Cell: TextCell, Cell: TextCell,
}, },
{
Header: 'Actions',
id: 'Actions',
align: 'center',
width: '1%',
Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => (
<EnvironmentActionCell environment={original} />
),
disableGlobalFilter: true,
},
]; ];

View File

@ -0,0 +1,151 @@
import { vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { OrderEnvironmentsDialog } from './OrderEnvironmentsDialog';
describe('OrderEnvironmentsDialog Component', () => {
const renderComponent = (props = {}) =>
render(
<OrderEnvironmentsDialog
open={true}
onClose={() => {}}
onSubmit={() => {}}
{...props}
/>,
);
test('should disable "Order" button until the checkbox is checked', () => {
renderComponent();
const orderButton = screen.getByRole('button', { name: /order/i });
const checkbox = screen.getByRole('checkbox', {
name: /i understand adding environments leads to extra costs/i,
});
expect(orderButton).toBeDisabled();
fireEvent.click(checkbox);
expect(orderButton).toBeEnabled();
});
test('should render correct number of environment name inputs based on selected environments', () => {
renderComponent();
let environmentInputs =
screen.getAllByLabelText(/environment \d+ name/i);
expect(environmentInputs).toHaveLength(1);
const selectButton = screen.getByRole('combobox');
fireEvent.mouseDown(selectButton);
const option2 = screen.getByRole('option', { name: '2 environments' });
fireEvent.click(option2);
environmentInputs = screen.getAllByLabelText(/environment \d+ name/i);
expect(environmentInputs).toHaveLength(2);
fireEvent.mouseDown(selectButton);
const option3 = screen.getByRole('option', { name: '3 environments' });
fireEvent.click(option3);
environmentInputs = screen.getAllByLabelText(/environment \d+ name/i);
expect(environmentInputs).toHaveLength(3);
});
test('should enable "Order" button only when checkbox is checked', () => {
renderComponent();
const orderButton = screen.getByRole('button', { name: /order/i });
const checkbox = screen.getByRole('checkbox', {
name: /i understand adding environments leads to extra costs/i,
});
expect(orderButton).toBeDisabled();
fireEvent.click(checkbox);
expect(orderButton).toBeEnabled();
fireEvent.click(checkbox);
expect(orderButton).toBeDisabled();
});
test('should output environment names', () => {
const onSubmitMock = vi.fn();
renderComponent({ onSubmit: onSubmitMock });
const selectButton = screen.getByRole('combobox');
fireEvent.mouseDown(selectButton);
const option2 = screen.getByRole('option', { name: '2 environments' });
fireEvent.click(option2);
const environmentInputs =
screen.getAllByLabelText(/environment \d+ name/i);
fireEvent.change(environmentInputs[0], { target: { value: 'Dev' } });
fireEvent.change(environmentInputs[1], {
target: { value: 'Staging' },
});
const checkbox = screen.getByRole('checkbox', {
name: /i understand adding environments leads to extra costs/i,
});
fireEvent.click(checkbox);
const submitButton = screen.getByRole('button', { name: /order/i });
fireEvent.click(submitButton);
expect(onSubmitMock).toHaveBeenCalledTimes(1);
expect(onSubmitMock).toHaveBeenCalledWith(['Dev', 'Staging']);
});
test('should call onClose when "Cancel" button is clicked', () => {
const onCloseMock = vi.fn();
renderComponent({ onClose: onCloseMock });
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
test('should adjust environment name inputs when decreasing environments', () => {
const onSubmitMock = vi.fn();
renderComponent({ onSubmit: onSubmitMock });
const selectButton = screen.getByRole('combobox');
fireEvent.mouseDown(selectButton);
const option3 = screen.getByRole('option', { name: '3 environments' });
fireEvent.click(option3);
let environmentInputs =
screen.getAllByLabelText(/environment \d+ name/i);
expect(environmentInputs).toHaveLength(3);
fireEvent.change(environmentInputs[0], { target: { value: 'Dev' } });
fireEvent.change(environmentInputs[1], {
target: { value: 'Staging' },
});
fireEvent.change(environmentInputs[2], { target: { value: 'Prod' } });
fireEvent.mouseDown(selectButton);
const option2 = screen.getByRole('option', { name: '2 environments' });
fireEvent.click(option2);
environmentInputs = screen.getAllByLabelText(/environment \d+ name/i);
expect(environmentInputs).toHaveLength(2);
const checkbox = screen.getByRole('checkbox', {
name: /i understand adding environments leads to extra costs/i,
});
fireEvent.click(checkbox);
const submitButton = screen.getByRole('button', { name: /order/i });
fireEvent.click(submitButton);
expect(onSubmitMock).toHaveBeenCalledTimes(1);
expect(onSubmitMock).toHaveBeenCalledWith(['Dev', 'Staging']);
});
});

View File

@ -0,0 +1,179 @@
import { useState, type FC } from 'react';
import {
Box,
Button,
Checkbox,
Dialog,
styled,
Typography,
} from '@mui/material';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { OrderEnvironmentsDialogPricing } from './OrderEnvironmentsDialogPricing/OrderEnvironmentsDialogPricing';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import Input from 'component/common/Input/Input';
type OrderEnvironmentsDialogProps = {
open: boolean;
onClose: () => void;
onSubmit: (environments: string[]) => void;
};
const StyledDialog = styled(Dialog)(({ theme }) => ({
maxWidth: '940px',
margin: 'auto',
'& .MuiDialog-paper': {
borderRadius: theme.shape.borderRadiusExtraLarge,
maxWidth: theme.spacing(170),
width: '100%',
backgroundColor: 'transparent',
},
padding: 0,
'& .MuiPaper-root > section': {
overflowX: 'hidden',
},
}));
const StyledTitle = styled('div')(({ theme }) => ({
marginBottom: theme.spacing(3),
}));
const StyledFooter = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(2),
}));
const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({
margin: theme.spacing(1, 0),
}));
const StyledFields = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
paddingTop: theme.spacing(3),
}));
const StyledEnvironmentNameInputs = styled('fieldset')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
border: 'none',
padding: 0,
margin: 0,
gap: theme.spacing(1.5),
}));
const StyledCheckbox = styled(Checkbox)(({ theme }) => ({
marginBottom: theme.spacing(0.4),
}));
const PRICE = 10;
const OPTIONS = [1, 2, 3];
export const OrderEnvironmentsDialog: FC<OrderEnvironmentsDialogProps> = ({
open,
onClose,
onSubmit,
}) => {
const [selectedOption, setSelectedOption] = useState(OPTIONS[0]);
const [costCheckboxChecked, setCostCheckboxChecked] = useState(false);
const [environmentNames, setEnvironmentNames] = useState<string[]>([]);
return (
<StyledDialog open={open} title=''>
<FormTemplate
compact
description={
<OrderEnvironmentsDialogPricing
pricingOptions={OPTIONS.map((option) => ({
environments: option,
price: option * PRICE,
}))}
/>
}
footer={
<StyledFooter>
<Button onClick={onClose}>Cancel</Button>
<Button
variant='contained'
disabled={!costCheckboxChecked}
onClick={() => onSubmit(environmentNames)}
>
Order
</Button>
</StyledFooter>
}
>
<StyledTitle>
<Typography variant='h3' component='div'>
Order additional environments
</Typography>
</StyledTitle>
<Typography variant='body2' color='text.secondary'>
With our PRO plan, you have the flexibility to expand your
workspace by adding environments at ${PRICE} per user per
month.
</Typography>
<StyledFields>
<Box>
<Typography
component='label'
htmlFor='numberOfEnvironments'
>
Select the number of additional environments
</Typography>
<StyledGeneralSelect
id='numberOfEnvironments'
value={`${selectedOption}`}
options={OPTIONS.map((option) => ({
key: `${option}`,
label: `${option} environment${option > 1 ? 's' : ''}`,
}))}
onChange={(option) => {
const value = Number.parseInt(option, 10);
setSelectedOption(value);
setEnvironmentNames((names) =>
names.slice(0, value),
);
}}
/>
</Box>
<StyledEnvironmentNameInputs>
<Typography>
How would you like the environment
{selectedOption > 1 ? 's' : ''} to be named?
</Typography>
{[...Array(selectedOption)].map((_, i) => (
<Input
key={i}
label={`Environment ${i + 1} name`}
value={environmentNames[i]}
onChange={(event) => {
setEnvironmentNames((names) => {
const newValues = [...names];
newValues[i] = event.target.value;
return newValues;
});
}}
/>
))}
</StyledEnvironmentNameInputs>
<Box>
<StyledCheckbox
edge='start'
id='costsCheckbox'
checked={costCheckboxChecked}
onChange={() =>
setCostCheckboxChecked((state) => !state)
}
/>
<Typography component='label' htmlFor='costsCheckbox'>
I understand adding environments leads to extra
costs
</Typography>
</Box>
</StyledFields>
</FormTemplate>
</StyledDialog>
);
};

View File

@ -0,0 +1,59 @@
import type { FC } from 'react';
import { Box, Card, styled, Typography } from '@mui/material';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
type OrderEnvironmentsDialogPricingProps = {
pricingOptions: Array<{ environments: number; price: number }>;
};
const StyledContainer = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
justifyContent: 'center',
height: '100%',
[theme.breakpoints.up('lg')]: {
marginTop: theme.spacing(7.5),
},
}));
const StyledCard = styled(Card)(({ theme }) => ({
borderRadius: `${theme.shape.borderRadiusMedium}px`,
boxShadow: 'none',
}));
const StyledCardContent = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: 'row',
padding: theme.spacing(2),
}));
export const OrderEnvironmentsDialogPricing: FC<
OrderEnvironmentsDialogPricingProps
> = ({ pricingOptions }) => (
<StyledContainer>
<Typography variant='h3' component='div' color='white' gutterBottom>
Pricing
</Typography>
{pricingOptions.map((option) => (
<StyledCard key={option.environments}>
<StyledCardContent>
<EnvironmentIcon enabled />
<Box>
<Box>
<Typography variant='body2' fontWeight='bold'>
{option.environments} additional environment
{option.environments > 1 ? 's' : ''}
</Typography>
</Box>
<Typography variant='body2'>
${option.price} per user per month
</Typography>
</Box>
</StyledCardContent>
</StyledCard>
))}
</StyledContainer>
);

View File

@ -0,0 +1,71 @@
import type { FC, ReactNode } from 'react';
import { Box, Button, styled, Typography } from '@mui/material';
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg';
type PurchasableFeatureProps = {
title: ReactNode;
description: ReactNode;
onClick: () => void;
};
const Icon = () => (
<ThemeMode darkmode={<ProPlanIconLight />} lightmode={<ProPlanIcon />} />
);
const StyledContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(2, 3),
marginBottom: theme.spacing(3),
background: theme.palette.background.elevation2,
borderRadius: `${theme.shape.borderRadiusMedium}px`,
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(3),
[theme.breakpoints.down('md')]: {
flexDirection: 'column',
gap: theme.spacing(2),
},
}));
const StyledIconContainer = styled(Box)(() => ({
width: '36px',
flexShrink: 0,
}));
const StyledMessage = styled(Box)(() => ({
flexGrow: 1,
display: 'flex',
}));
const StyledButtonContainer = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
whiteSpace: 'nowrap',
}));
export const PurchasableFeature: FC<PurchasableFeatureProps> = ({
title,
description,
onClick,
}) => {
return (
<StyledContainer>
<StyledMessage>
<StyledIconContainer>
<Icon />
</StyledIconContainer>
<Box>
<Typography variant='h3'>{title}</Typography>
<Typography>{description}</Typography>
</Box>
</StyledMessage>
<StyledButtonContainer>
<Button variant='contained' onClick={onClick}>
View pricing
</Button>
</StyledButtonContainer>
</StyledContainer>
);
};

View File

@ -252,6 +252,18 @@ exports[`returns all baseRoutes 1`] = `
"advanced": true, "advanced": true,
"mobile": true, "mobile": true,
}, },
"notFlag": "purchaseAdditionalEnvironments",
"path": "/environments",
"title": "Environments",
"type": "protected",
},
{
"component": [Function],
"flag": "purchaseAdditionalEnvironments",
"menu": {
"advanced": true,
"mobile": true,
},
"path": "/environments", "path": "/environments",
"title": "Environments", "title": "Environments",
"type": "protected", "type": "protected",

View File

@ -267,6 +267,15 @@ export const routes: IRoute[] = [
component: EnvironmentTable, component: EnvironmentTable,
type: 'protected', type: 'protected',
flag: EEA, flag: EEA,
notFlag: 'purchaseAdditionalEnvironments',
menu: { mobile: true, advanced: true },
},
{
path: '/environments',
title: 'Environments',
component: EnvironmentTable,
type: 'protected',
flag: 'purchaseAdditionalEnvironments',
menu: { mobile: true, advanced: true }, menu: { mobile: true, advanced: true },
}, },
{ {

View File

@ -89,6 +89,7 @@ export type UiFlags = {
onboardingUI?: boolean; onboardingUI?: boolean;
eventTimeline?: boolean; eventTimeline?: boolean;
personalDashboardUI?: boolean; personalDashboardUI?: boolean;
purchaseAdditionalEnvironments?: boolean;
}; };
export interface IVersionInfo { export interface IVersionInfo {