From 48eee2043fdee8b342ddf86b7241a4345340a401 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:59:41 +0200 Subject: [PATCH] Frontend for additional environments (#8378) --- .../common/FormTemplate/FormTemplate.tsx | 8 +- .../EnvironmentTable/EnvironmentTable.tsx | 60 ++++-- .../OrderEnvironmentsDialog.test.tsx | 151 +++++++++++++++ .../OrderEnvironmentsDialog.tsx | 179 ++++++++++++++++++ .../OrderEnvironmentsDialogPricing.tsx | 59 ++++++ .../PurchasableFeature/PurchasableFeature.tsx | 71 +++++++ .../__snapshots__/routes.test.tsx.snap | 12 ++ frontend/src/component/menu/routes.ts | 9 + frontend/src/interfaces/uiConfig.ts | 1 + 9 files changed, 533 insertions(+), 17 deletions(-) create mode 100644 frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialog.test.tsx create mode 100644 frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialog.tsx create mode 100644 frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialogPricing/OrderEnvironmentsDialogPricing.tsx create mode 100644 frontend/src/component/environments/EnvironmentTable/PurchasableFeature/PurchasableFeature.tsx diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx index dcb9ec6ce3..34aa96b66d 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.tsx +++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx @@ -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 = ({ }; 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; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx index e2cb880a9a..04ec3f4ac1 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx @@ -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 } }) => ( + + ), + 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 = () => { ); - if (!isFeatureEnabled) { + if (!isFeatureEnabled && !isPurchaseAdditionalEnvronmentsEnabled) { return ( @@ -101,6 +131,20 @@ export const EnvironmentTable = () => { return ( + {isPro() && isPurchaseAdditionalEnvronmentsEnabled ? ( + <> + setPurchaseDialogOpen(true)} + /> + setPurchaseDialogOpen(false)} + onSubmit={() => {}} // TODO: API call + /> + + ) : null} 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 } }) => ( - - ), - disableGlobalFilter: true, - }, ]; diff --git a/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialog.test.tsx b/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialog.test.tsx new file mode 100644 index 0000000000..05b89cbef6 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialog.test.tsx @@ -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( + {}} + 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']); + }); +}); diff --git a/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialog.tsx b/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialog.tsx new file mode 100644 index 0000000000..ae6310a8fa --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialog.tsx @@ -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 = ({ + open, + onClose, + onSubmit, +}) => { + const [selectedOption, setSelectedOption] = useState(OPTIONS[0]); + const [costCheckboxChecked, setCostCheckboxChecked] = useState(false); + const [environmentNames, setEnvironmentNames] = useState([]); + + return ( + + ({ + environments: option, + price: option * PRICE, + }))} + /> + } + footer={ + + + + + } + > + + + Order additional environments + + + + With our PRO plan, you have the flexibility to expand your + workspace by adding environments at ${PRICE} per user per + month. + + + + + Select the number of additional environments + + ({ + key: `${option}`, + label: `${option} environment${option > 1 ? 's' : ''}`, + }))} + onChange={(option) => { + const value = Number.parseInt(option, 10); + setSelectedOption(value); + setEnvironmentNames((names) => + names.slice(0, value), + ); + }} + /> + + + + How would you like the environment + {selectedOption > 1 ? 's' : ''} to be named? + + {[...Array(selectedOption)].map((_, i) => ( + { + setEnvironmentNames((names) => { + const newValues = [...names]; + newValues[i] = event.target.value; + return newValues; + }); + }} + /> + ))} + + + + setCostCheckboxChecked((state) => !state) + } + /> + + I understand adding environments leads to extra + costs + + + + + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialogPricing/OrderEnvironmentsDialogPricing.tsx b/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialogPricing/OrderEnvironmentsDialogPricing.tsx new file mode 100644 index 0000000000..c05596abcb --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/OrderEnvironmentsDialog/OrderEnvironmentsDialogPricing/OrderEnvironmentsDialogPricing.tsx @@ -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 }) => ( + + + Pricing + + {pricingOptions.map((option) => ( + + + + + + + {option.environments} additional environment + {option.environments > 1 ? 's' : ''} + + + + ${option.price} per user per month + + + + + ))} + +); diff --git a/frontend/src/component/environments/EnvironmentTable/PurchasableFeature/PurchasableFeature.tsx b/frontend/src/component/environments/EnvironmentTable/PurchasableFeature/PurchasableFeature.tsx new file mode 100644 index 0000000000..d080e1a872 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/PurchasableFeature/PurchasableFeature.tsx @@ -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 = () => ( + } lightmode={} /> +); + +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 = ({ + title, + description, + onClick, +}) => { + return ( + + + + + + + {title} + {description} + + + + + + + ); +}; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 000b37c2dc..bc37598cca 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -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", diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 566d5f696a..b60a63e9fb 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -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 }, }, { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 837999b05e..b390fdefe2 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -89,6 +89,7 @@ export type UiFlags = { onboardingUI?: boolean; eventTimeline?: boolean; personalDashboardUI?: boolean; + purchaseAdditionalEnvironments?: boolean; }; export interface IVersionInfo {