1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01: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 {
title?: ReactNode;
description: string;
description: ReactNode;
documentationLink?: string;
documentationIcon?: ReactNode;
documentationLinkLabel?: string;
@ -210,7 +210,7 @@ const StyledDescriptionCard = styled('article')(({ theme }) => ({
marginBlockEnd: theme.spacing(3),
}));
const StyledDescription = styled('p')(({ theme }) => ({
const StyledDescription = styled('div')(() => ({
width: '100%',
}));
@ -370,7 +370,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
};
interface IMobileGuidance {
description: string;
description: ReactNode;
documentationLink?: string;
documentationIcon?: ReactNode;
documentationLinkLabel?: string;
@ -410,7 +410,7 @@ const MobileGuidance = ({
};
interface IGuidanceProps {
description: string;
description: ReactNode;
documentationIcon?: ReactNode;
documentationLink?: string;
documentationLinkLabel?: string;

View File

@ -8,7 +8,7 @@ import {
Table,
TablePlaceholder,
} from 'component/common/Table';
import { useCallback } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Alert, styled, TableBody } from '@mui/material';
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 type { IEnvironment } from 'interfaces/environments';
import { useUiFlag } from 'hooks/useUiFlag';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import { PurchasableFeature } from './PurchasableFeature/PurchasableFeature';
import { OrderEnvironmentsDialog } from './OrderEnvironmentsDialog/OrderEnvironmentsDialog';
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
@ -37,7 +40,12 @@ export const EnvironmentTable = () => {
const { changeSortOrder } = useEnvironmentApi();
const { setToastApiError } = useToast();
const { environments, mutateEnvironments } = useEnvironments();
const [purchaseDialogOpen, setPurchaseDialogOpen] = useState(false);
const isFeatureEnabled = useUiFlag('EEA');
const isPurchaseAdditionalEnvronmentsEnabled = useUiFlag(
'purchaseAdditionalEnvironments',
);
const { isPro } = useUiConfig();
const moveListItem: MoveListItem = useCallback(
async (dragIndex: number, dropIndex: number, save = false) => {
@ -58,6 +66,28 @@ export const EnvironmentTable = () => {
[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 {
getTableProps,
getTableBodyProps,
@ -68,7 +98,7 @@ export const EnvironmentTable = () => {
setGlobalFilter,
} = useTable(
{
columns: COLUMNS as any,
columns: columnsWithActions as any,
data: environments,
disableSortBy: true,
},
@ -91,7 +121,7 @@ export const EnvironmentTable = () => {
<PageHeader title={`Environments (${count})`} actions={headerActions} />
);
if (!isFeatureEnabled) {
if (!isFeatureEnabled && !isPurchaseAdditionalEnvronmentsEnabled) {
return (
<PageContent header={header}>
<PremiumFeature feature='environments' />
@ -101,6 +131,20 @@ export const EnvironmentTable = () => {
return (
<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'>
This is the order of environments that you have today in each
feature flag. Rearranging them here will change also the order
@ -185,14 +229,4 @@ const COLUMNS = [
row.apiTokenCount === 1 ? '1 token' : `${row.apiTokenCount} tokens`,
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,
"mobile": true,
},
"notFlag": "purchaseAdditionalEnvironments",
"path": "/environments",
"title": "Environments",
"type": "protected",
},
{
"component": [Function],
"flag": "purchaseAdditionalEnvironments",
"menu": {
"advanced": true,
"mobile": true,
},
"path": "/environments",
"title": "Environments",
"type": "protected",

View File

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

View File

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