diff --git a/frontend/src/component/project/Project/CreateProject/ChangeRequestTable.tsx b/frontend/src/component/project/Project/CreateProject/ChangeRequestTable.tsx new file mode 100644 index 0000000000..417c3945f5 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/ChangeRequestTable.tsx @@ -0,0 +1,207 @@ +import { useMemo } from 'react'; +import { type HeaderGroup, useGlobalFilter, useTable } from 'react-table'; +import { Box, Switch, styled } from '@mui/material'; +import { + SortableTableHeader, + Table, + TableBody, + TableCell, + TableRow, +} from 'component/common/Table'; +import { sortTypes } from 'utils/sortTypes'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; +import KeyboardArrowDownOutlined from '@mui/icons-material/KeyboardArrowDownOutlined'; +import { useTheme } from '@mui/material/styles'; +// import { PROJECT_CHANGE_REQUEST_WRITE } from '../../../../providers/AccessProvider/permissions'; + +const StyledBox = styled(Box)(({ theme }) => ({ + padding: theme.spacing(1), + display: 'flex', + justifyContent: 'center', + '& .MuiInputBase-input': { + fontSize: theme.fontSizes.smallBody, + }, +})); + +const StyledTable = styled(Table)(({ theme }) => ({ + th: { whiteSpace: 'nowrap' }, + width: '50rem', + maxWidth: '90vw', + 'tr:last-of-type > td': { + borderBottom: 'none', + }, +})); + +type TableProps = { + environments: { + name: string; + type: string; + requiredApprovals: number; + changeRequestEnabled: boolean; + }[]; + enableEnvironment: (name: string, requiredApprovals: number) => void; + disableEnvironment: (name: string) => void; +}; + +export const ChangeRequestTable = (props: TableProps) => { + const theme = useTheme(); + + const onToggleEnvironment = + ( + environmentName: string, + previousState: boolean, + requiredApprovals: number, + ) => + () => { + const newState = !previousState; + if (newState) { + props.enableEnvironment(environmentName, requiredApprovals); + } else { + props.disableEnvironment(environmentName); + } + }; + + const approvalOptions = Array.from(Array(10).keys()) + .map((key) => String(key + 1)) + .map((key) => { + const labelText = key === '1' ? 'approval' : 'approvals'; + return { + key, + label: `${key} ${labelText}`, + sx: { fontSize: theme.fontSizes.smallBody }, + }; + }); + + function onRequiredApprovalsChange(original: any, approvals: string) { + props.enableEnvironment(original.environment, Number(approvals)); + } + + const columns = useMemo( + () => [ + { + Header: 'Environment', + accessor: 'environment', + disableSortBy: true, + }, + { + Header: 'Type', + accessor: 'type', + disableGlobalFilter: true, + disableSortBy: true, + }, + { + Header: 'Required approvals', + Cell: ({ row: { original } }: any) => { + return ( + + { + onRequiredApprovalsChange( + original, + approvals, + ); + }} + IconComponent={ + KeyboardArrowDownOutlined + } + fullWidth + /> + + } + /> + ); + }, + width: 100, + disableGlobalFilter: true, + disableSortBy: true, + }, + { + Header: 'Status', + accessor: 'changeRequestEnabled', + id: 'changeRequestEnabled', + align: 'center', + + Cell: ({ value, row: { original } }: any) => { + return ( + + + + ); + }, + width: 100, + disableGlobalFilter: true, + disableSortBy: true, + }, + ], + [], + ); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + useTable( + { + // @ts-ignore + columns, + data: props.environments.map((env) => { + return { + environment: env.name, + type: env.type, + changeRequestEnabled: env.changeRequestEnabled, + requiredApprovals: env.requiredApprovals ?? 1, + }; + }), + + sortTypes, + autoResetGlobalFilter: false, + disableSortRemove: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useGlobalFilter, + ); + return ( + + []} + /> + + {rows.map((row) => { + prepareRow(row); + return ( + + {row.cells.map((cell) => ( + + {cell.render('Cell')} + + ))} + + ); + })} + + + ); +}; diff --git a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx index 7d3389480e..c79dc0b0dd 100644 --- a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx +++ b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx @@ -35,11 +35,13 @@ const CreateProject = () => { projectDesc, projectMode, projectEnvironments, + projectChangeRequestConfiguration, setProjectMode, setProjectId, setProjectName, setProjectDesc, setProjectEnvironments, + updateProjectChangeRequestConfig, getCreateProjectPayload, clearErrors, validateProjectId, @@ -114,6 +116,12 @@ const CreateProject = () => { setProjectId={setProjectId} projectName={projectName} projectStickiness={projectStickiness} + projectChangeRequestConfiguration={ + projectChangeRequestConfiguration + } + updateProjectChangeRequestConfig={ + updateProjectChangeRequestConfig + } projectMode={projectMode} setProjectMode={setProjectMode} setProjectStickiness={setProjectStickiness} diff --git a/frontend/src/component/project/Project/CreateProject/NewProjectForm.tsx b/frontend/src/component/project/Project/CreateProject/NewProjectForm.tsx index 8d65678ddd..18b3bd9ab7 100644 --- a/frontend/src/component/project/Project/CreateProject/NewProjectForm.tsx +++ b/frontend/src/component/project/Project/CreateProject/NewProjectForm.tsx @@ -1,9 +1,13 @@ -import { Button, Typography, styled } from '@mui/material'; +import { Typography, styled } from '@mui/material'; import { v4 as uuidv4 } from 'uuid'; import Input from 'component/common/Input/Input'; import type { ProjectMode } from '../hooks/useProjectEnterpriseSettingsForm'; import { ReactComponent as ProjectIcon } from 'assets/icons/projectIconSmall.svg'; -import { MultiselectList, SingleSelectList } from './SelectionButton'; +import { + MultiselectList, + SingleSelectList, + TableSelect, +} from './SelectionButton'; import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; import StickinessIcon from '@mui/icons-material/FormatPaint'; import ProjectModeIcon from '@mui/icons-material/Adjust'; @@ -11,6 +15,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import EnvironmentsIcon from '@mui/icons-material/CloudCircle'; import { useStickinessOptions } from 'hooks/useStickinessOptions'; +import { ReactComponent as ChangeRequestIcon } from 'assets/icons/merge.svg'; const StyledForm = styled('form')(({ theme }) => ({ background: theme.palette.background.default, @@ -65,6 +70,7 @@ const StyledProjectDescription = styled(StyledInput)(({ theme }) => ({ const OptionButtons = styled(StyledFormSection)(({ theme }) => ({ display: 'flex', + flexFlow: 'row wrap', gap: theme.spacing(2), })); @@ -83,13 +89,21 @@ type FormProps = { featureCount?: number; projectMode: string; projectEnvironments: Set; + projectChangeRequestConfiguration: Record< + string, + { requiredApprovals: number } + >; setProjectStickiness: React.Dispatch>; - setProjectEnvironments: React.Dispatch>>; + setProjectEnvironments: (envs: Set) => void; setProjectId: React.Dispatch>; setProjectName: React.Dispatch>; setProjectDesc: React.Dispatch>; setFeatureLimit?: React.Dispatch>; setProjectMode: React.Dispatch>; + updateProjectChangeRequestConfig: { + disableChangeRequests: (env: string) => void; + enableChangeRequests: (env: string, requiredApprovals: number) => void; + }; handleSubmit: (e: any) => void; errors: { [key: string]: string }; mode: 'Create' | 'Edit'; @@ -107,6 +121,7 @@ export const NewProjectForm: React.FC = ({ projectDesc, projectStickiness, projectEnvironments, + projectChangeRequestConfiguration, featureLimit, featureCount, projectMode, @@ -116,6 +131,8 @@ export const NewProjectForm: React.FC = ({ setProjectName, setProjectDesc, setProjectStickiness, + // setProjectChangeRequestConfiguration, + updateProjectChangeRequestConfig, setFeatureLimit, errors, mode, @@ -138,10 +155,6 @@ export const NewProjectForm: React.FC = ({ setProjectId(maybeProjectId); }; - const handleFilterChange = (envs: Set) => { - setProjectEnvironments(envs); - }; - const projectModeOptions = [ { value: 'open', label: 'open' }, { value: 'protected', label: 'protected' }, @@ -194,7 +207,7 @@ export const NewProjectForm: React.FC = ({ label: env.name, value: env.name, }))} - onChange={handleFilterChange} + onChange={setProjectEnvironments} button={{ label: projectEnvironments.size > 0 @@ -245,7 +258,45 @@ export const NewProjectForm: React.FC = ({ /> } /> - + + projectEnvironments.has(env.name), + ) + .map((env) => ({ + name: env.name, + type: env.type, + }))} + updateProjectChangeRequestConfiguration={ + updateProjectChangeRequestConfig + } + button={{ + label: + Object.keys( + projectChangeRequestConfiguration, + ).length > 0 + ? `${ + Object.keys( + projectChangeRequestConfiguration, + ).length + } selected` + : 'Configure change requests', + icon: , + }} + search={{ + label: 'Filter environments', + placeholder: 'Filter environments', + }} + projectChangeRequestConfiguration={ + projectChangeRequestConfiguration + } + /> + } + /> {children} diff --git a/frontend/src/component/project/Project/CreateProject/SelectionButton.styles.tsx b/frontend/src/component/project/Project/CreateProject/SelectionButton.styles.tsx index 2098cc8d38..271ff192e0 100644 --- a/frontend/src/component/project/Project/CreateProject/SelectionButton.styles.tsx +++ b/frontend/src/component/project/Project/CreateProject/SelectionButton.styles.tsx @@ -1,7 +1,7 @@ import { Checkbox, ListItem, Popover, TextField, styled } from '@mui/material'; export const StyledDropdown = styled('div')(({ theme }) => ({ - padding: theme.spacing(1.5), + padding: theme.spacing(2), display: 'flex', flexDirection: 'column', gap: theme.spacing(1), @@ -36,16 +36,8 @@ export const StyledTextField = styled(TextField)(({ theme }) => ({ padding: theme.spacing(0.75, 0), fontSize: theme.typography.body2.fontSize, }, - - '& label': { - border: 0, - clip: 'rect(0 0 0 0)', - height: 'auto', - margin: 0, - overflow: 'hidden', - padding: 0, - position: 'absolute', - width: '1px', - whiteSpace: 'nowrap', - }, +})); + +export const TableSearchInput = styled(StyledTextField)(({ theme }) => ({ + maxWidth: '30ch', })); diff --git a/frontend/src/component/project/Project/CreateProject/SelectionButton.tsx b/frontend/src/component/project/Project/CreateProject/SelectionButton.tsx index aab91b8fe5..2b09a6f49d 100644 --- a/frontend/src/component/project/Project/CreateProject/SelectionButton.tsx +++ b/frontend/src/component/project/Project/CreateProject/SelectionButton.tsx @@ -1,13 +1,15 @@ import Search from '@mui/icons-material/Search'; import { Box, Button, InputAdornment, List, ListItemText } from '@mui/material'; -import { type FC, type ReactNode, useRef, useState } from 'react'; +import { type FC, type ReactNode, useRef, useState, useMemo } from 'react'; import { StyledCheckbox, StyledDropdown, StyledListItem, StyledPopover, StyledTextField, + TableSearchInput, } from './SelectionButton.styles'; +import { ChangeRequestTable } from './ChangeRequestTable'; export interface IFilterItemProps { label: ReactNode; @@ -262,3 +264,147 @@ type SingleSelectListProps = Pick< export const SingleSelectList: FC = (props) => { return ; }; + +type TableSelectProps = Pick & { + updateProjectChangeRequestConfiguration: { + disableChangeRequests: (env: string) => void; + enableChangeRequests: (env: string, requiredApprovals: number) => void; + }; + activeEnvironments: { + name: string; + type: string; + }[]; + projectChangeRequestConfiguration: Record< + string, + { requiredApprovals: number } + >; + disabled: boolean; +}; +export const TableSelect: FC = ({ + button, + disabled, + search, + projectChangeRequestConfiguration, + updateProjectChangeRequestConfiguration, + activeEnvironments, +}) => { + const configured = useMemo(() => { + return Object.fromEntries( + Object.entries(projectChangeRequestConfiguration).map( + ([name, config]) => [ + name, + { ...config, changeRequestEnabled: true }, + ], + ), + ); + }, [projectChangeRequestConfiguration]); + + const tableEnvs = useMemo( + () => + activeEnvironments.map(({ name, type }) => ({ + name, + type, + ...(configured[name] ?? { changeRequestEnabled: false }), + })), + [configured, activeEnvironments], + ); + + const onEnable = (name: string, requiredApprovals: number) => { + updateProjectChangeRequestConfiguration.enableChangeRequests( + name, + requiredApprovals, + ); + }; + + const onDisable = (name: string) => { + updateProjectChangeRequestConfiguration.disableChangeRequests(name); + }; + + const ref = useRef(null); + const [anchorEl, setAnchorEl] = useState(); + const [searchText, setSearchText] = useState(''); + + const open = () => { + setSearchText(''); + setAnchorEl(ref.current); + }; + + const onClose = () => { + setAnchorEl(null); + }; + + const filteredEnvs = tableEnvs.filter((env) => + env.name.toLowerCase().includes(searchText.toLowerCase()), + ); + + const toggleTopItem = (event: React.KeyboardEvent) => { + if ( + event.key === 'Enter' && + searchText.trim().length > 0 && + filteredEnvs.length > 0 + ) { + const firstEnv = filteredEnvs[0]; + if (firstEnv.name in configured) { + onDisable(firstEnv.name); + } else { + onEnable(firstEnv.name, 1); + } + } + }; + + return ( + <> + + + + + + setSearchText(event.target.value)} + label={search.label} + placeholder={search.placeholder} + autoFocus + InputProps={{ + startAdornment: ( + + + + ), + }} + onKeyDown={toggleTopItem} + /> + + + + + ); +}; diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 71a7c928f4..0c890c6c60 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -13,6 +13,10 @@ const useProjectForm = ( initialFeatureLimit = '', initialProjectMode: ProjectMode = 'open', initialProjectEnvironments: Set = new Set(), + initialProjectChangeRequestConfiguration: Record< + string, + { requiredApprovals: number } + > = {}, ) => { const { isEnterprise } = useUiConfig(); const [projectId, setProjectId] = useState(initialProjectId); @@ -28,6 +32,39 @@ const useProjectForm = ( const [projectEnvironments, setProjectEnvironments] = useState>( initialProjectEnvironments, ); + const [ + projectChangeRequestConfiguration, + setProjectChangeRequestConfiguration, + ] = useState(initialProjectChangeRequestConfiguration); + + // todo: write tests for this + // also: disallow adding a project to cr config that isn't in envs + const updateProjectEnvironments = (newState: Set) => { + const filteredChangeRequestEnvs = Object.fromEntries( + Object.entries(projectChangeRequestConfiguration).filter(([env]) => + newState.has(env), + ), + ); + + setProjectChangeRequestConfiguration(filteredChangeRequestEnvs); + setProjectEnvironments(newState); + }; + + const crConfig = { + disableChangeRequests: (env: string) => { + setProjectChangeRequestConfiguration((previousState) => { + const { [env]: _, ...rest } = previousState; + return rest; + }); + }, + + enableChangeRequests: (env: string, approvals: number) => { + setProjectChangeRequestConfiguration((previousState) => ({ + ...previousState, + [env]: { requiredApprovals: approvals }, + })); + }, + }; const [errors, setErrors] = useState({}); @@ -63,6 +100,13 @@ const useProjectForm = ( ? { environments: [...projectEnvironments] } : {}; + const changeRequestEnvironments = Object.entries( + projectChangeRequestConfiguration, + ).map(([env, { requiredApprovals }]) => ({ + name: env, + requiredApprovals, + })); + return isEnterprise() ? { id: projectId, @@ -71,6 +115,7 @@ const useProjectForm = ( defaultStickiness: projectStickiness, mode: projectMode, ...environmentsPayload, + changeRequestEnvironments, } : { id: projectId, @@ -133,13 +178,15 @@ const useProjectForm = ( projectStickiness, featureLimit, projectEnvironments, + projectChangeRequestConfiguration, setProjectId, setProjectName, setProjectDesc, setProjectStickiness, setFeatureLimit, setProjectMode, - setProjectEnvironments, + setProjectEnvironments: updateProjectEnvironments, + updateProjectChangeRequestConfig: crConfig, getCreateProjectPayload, getEditProjectPayload, validateName,