From 06de5de85c8165cb06514105632df36f102f76d3 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Wed, 29 May 2024 08:10:47 +0200 Subject: [PATCH] chore: code cleanup for new project form pt 2 (#7190) This is the second part of the code cleanup job. It primarily consists of breaking apart large files and organizing the code better --- .../ConfigButtons}/ChangeRequestTable.tsx | 0 .../ChangeRequestTableConfigButton.styles.tsx | 11 + .../ChangeRequestTableConfigButton.tsx | 128 +++++ .../ConfigButtons/ConfigButton.styles.tsx | 27 ++ .../ConfigButtons/ConfigButton.tsx | 84 ++++ .../ConfigButtons/DropdownList.styles.tsx | 15 + .../ConfigButtons/DropdownList.tsx | 151 ++++++ .../ConfigButtons/MultiSelectConfigButton.tsx | 42 ++ .../SingleSelectConfigButton.tsx | 40 ++ .../ConfigButtons/shared.styles.tsx | 34 ++ .../CreateProjectDialog.tsx | 2 +- .../NewProjectForm.styles.tsx | 60 +++ .../NewProjectForm.tsx | 160 ++---- .../CreateProject/SelectionButton.styles.tsx | 82 ---- .../Project/CreateProject/SelectionButton.tsx | 459 ------------------ .../project/ProjectList/ProjectList.tsx | 2 +- 16 files changed, 647 insertions(+), 650 deletions(-) rename frontend/src/component/project/Project/CreateProject/{ => NewCreateProjectForm/ConfigButtons}/ChangeRequestTable.tsx (100%) create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTableConfigButton.styles.tsx create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTableConfigButton.tsx create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ConfigButton.styles.tsx create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ConfigButton.tsx create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/DropdownList.styles.tsx create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/DropdownList.tsx create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/MultiSelectConfigButton.tsx create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/SingleSelectConfigButton.tsx create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/shared.styles.tsx rename frontend/src/component/project/Project/CreateProject/{CreateProjectDialog => NewCreateProjectForm}/CreateProjectDialog.tsx (99%) create mode 100644 frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/NewProjectForm.styles.tsx rename frontend/src/component/project/Project/CreateProject/{ => NewCreateProjectForm}/NewProjectForm.tsx (72%) delete mode 100644 frontend/src/component/project/Project/CreateProject/SelectionButton.styles.tsx delete mode 100644 frontend/src/component/project/Project/CreateProject/SelectionButton.tsx diff --git a/frontend/src/component/project/Project/CreateProject/ChangeRequestTable.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTable.tsx similarity index 100% rename from frontend/src/component/project/Project/CreateProject/ChangeRequestTable.tsx rename to frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTable.tsx diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTableConfigButton.styles.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTableConfigButton.styles.tsx new file mode 100644 index 0000000000..f38aabb57b --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTableConfigButton.styles.tsx @@ -0,0 +1,11 @@ +import { styled } from '@mui/material'; +import { StyledDropdownSearch } from './shared.styles'; + +export const TableSearchInput = styled(StyledDropdownSearch)({ + maxWidth: '30ch', +}); + +export const ScrollContainer = styled('div')({ + width: '100%', + overflow: 'auto', +}); diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTableConfigButton.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTableConfigButton.tsx new file mode 100644 index 0000000000..28c3f9c935 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ChangeRequestTableConfigButton.tsx @@ -0,0 +1,128 @@ +import { type FC, useState, useMemo } from 'react'; +import { ConfigButton, type ConfigButtonProps } from './ConfigButton'; +import { InputAdornment } from '@mui/material'; +import Search from '@mui/icons-material/Search'; +import { ChangeRequestTable } from './ChangeRequestTable'; +import { TableSearchInput } from './ChangeRequestTableConfigButton.styles'; + +type ChangeRequestTableConfigButtonProps = Pick< + ConfigButtonProps, + 'button' | 'onOpen' | 'onClose' | 'description' +> & { + search: { + label: string; + placeholder: string; + }; + updateProjectChangeRequestConfiguration: { + disableChangeRequests: (env: string) => void; + enableChangeRequests: (env: string, requiredApprovals: number) => void; + }; + activeEnvironments: { + name: string; + type: string; + }[]; + projectChangeRequestConfiguration: Record< + string, + { requiredApprovals: number } + >; +}; + +export const ChangeRequestTableConfigButton: FC< + ChangeRequestTableConfigButtonProps +> = ({ + button, + search, + projectChangeRequestConfiguration, + updateProjectChangeRequestConfiguration, + activeEnvironments, + onOpen = () => {}, + onClose = () => {}, + ...props +}) => { + 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 [anchorEl, setAnchorEl] = useState(); + const [searchText, setSearchText] = useState(''); + + 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)} + hideLabel + label={search.label} + placeholder={search.placeholder} + autoFocus + InputProps={{ + startAdornment: ( + + + + ), + }} + onKeyDown={toggleTopItem} + /> + + + ); +}; diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ConfigButton.styles.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ConfigButton.styles.tsx new file mode 100644 index 0000000000..14be4832f9 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ConfigButton.styles.tsx @@ -0,0 +1,27 @@ +import { Popover, styled } from '@mui/material'; +import { visuallyHiddenStyles } from './shared.styles'; + +export const StyledDropdown = styled('div')(({ theme }) => ({ + padding: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + maxHeight: '70vh', +})); + +export const StyledPopover = styled(Popover)(({ theme }) => ({ + '& .MuiPaper-root': { + borderRadius: `${theme.shape.borderRadiusMedium}px`, + }, +})); + +export const HiddenDescription = styled('p')(() => ({ + ...visuallyHiddenStyles, + position: 'absolute', +})); + +export const ButtonLabel = styled('span', { + shouldForwardProp: (prop) => prop !== 'labelWidth', +})<{ labelWidth?: string }>(({ labelWidth }) => ({ + width: labelWidth || 'unset', +})); diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ConfigButton.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ConfigButton.tsx new file mode 100644 index 0000000000..06aaa4d8b5 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/ConfigButton.tsx @@ -0,0 +1,84 @@ +import { v4 as uuidv4 } from 'uuid'; +import { type FC, type ReactNode, useRef, type PropsWithChildren } from 'react'; +import { Box, Button } from '@mui/material'; +import { + StyledDropdown, + StyledPopover, + HiddenDescription, + ButtonLabel, +} from './ConfigButton.styles'; + +export type ConfigButtonProps = { + button: { label: string; icon: ReactNode; labelWidth?: string }; + onOpen?: () => void; + onClose?: () => void; + description: string; + preventOpen?: boolean; + anchorEl: HTMLDivElement | null | undefined; + setAnchorEl: (el: HTMLDivElement | null | undefined) => void; +}; + +export const ConfigButton: FC> = ({ + button, + onOpen = () => {}, + onClose = () => {}, + description, + children, + preventOpen, + anchorEl, + setAnchorEl, +}) => { + const ref = useRef(null); + const descriptionId = uuidv4(); + + const open = () => { + setAnchorEl(ref.current); + onOpen(); + }; + + const handleClose = () => { + setAnchorEl(null); + onClose(); + }; + + return ( + <> + + + + + + {description} + + + {children} + + + + ); +}; diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/DropdownList.styles.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/DropdownList.styles.tsx new file mode 100644 index 0000000000..ca73fd0151 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/DropdownList.styles.tsx @@ -0,0 +1,15 @@ +import { Checkbox, ListItem, styled } from '@mui/material'; + +export const StyledListItem = styled(ListItem)(({ theme }) => ({ + paddingLeft: theme.spacing(1), + cursor: 'pointer', + '&:hover, &:focus': { + backgroundColor: theme.palette.action.hover, + outline: 'none', + }, + minHeight: theme.spacing(4.5), +})); + +export const StyledCheckbox = styled(Checkbox)(({ theme }) => ({ + padding: theme.spacing(1, 1, 1, 1.5), +})); diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/DropdownList.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/DropdownList.tsx new file mode 100644 index 0000000000..c95ad1f08f --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/DropdownList.tsx @@ -0,0 +1,151 @@ +import Search from '@mui/icons-material/Search'; +import { type FC, useRef, useState } from 'react'; +import { InputAdornment, List, ListItemText } from '@mui/material'; +import { StyledDropdownSearch } from './shared.styles'; +import { StyledCheckbox, StyledListItem } from './DropdownList.styles'; + +const useSelectionManagement = ( + handleToggle: (value: string) => () => void, +) => { + const listRefs = useRef>([]); + + const handleSelection = ( + event: React.KeyboardEvent, + index: number, + filteredOptions: { label: string; value: string }[], + ) => { + // we have to be careful not to prevent other keys e.g tab + if (event.key === 'ArrowDown' && index < listRefs.current.length - 1) { + event.preventDefault(); + listRefs.current[index + 1]?.focus(); + } else if (event.key === 'ArrowUp' && index > 0) { + event.preventDefault(); + listRefs.current[index - 1]?.focus(); + } else if ( + event.key === 'Enter' && + index === 0 && + listRefs.current[0]?.value && + filteredOptions.length > 0 + ) { + // if the search field is not empty and the user presses + // enter from the search field, toggle the topmost item in + // the filtered list event.preventDefault(); + handleToggle(filteredOptions[0].value)(); + } else if ( + event.key === 'Enter' || + // allow selection with space when not in the search field + (index !== 0 && event.key === ' ') + ) { + event.preventDefault(); + if (index > 0) { + const listItemIndex = index - 1; + handleToggle(filteredOptions[listItemIndex].value)(); + } + } + }; + + return { listRefs, handleSelection }; +}; + +export type DropdownListProps = { + options: Array<{ label: string; value: string }>; + onChange: (value: string) => void; + search: { + label: string; + placeholder: string; + }; + multiselect?: { selectedOptions: Set }; +}; + +export const DropdownList: FC = ({ + options, + onChange, + search, + multiselect, +}) => { + const [searchText, setSearchText] = useState(''); + + const onSelection = (selected: string) => { + onChange(selected); + }; + + const { listRefs, handleSelection } = useSelectionManagement( + (selected: string) => () => onSelection(selected), + ); + + const filteredOptions = options?.filter((option) => + option.label.toLowerCase().includes(searchText.toLowerCase()), + ); + + return ( + <> + setSearchText(event.target.value)} + label={search.label} + hideLabel + placeholder={search.placeholder} + autoFocus + InputProps={{ + startAdornment: ( + + + + ), + }} + inputRef={(el) => { + listRefs.current[0] = el; + }} + onKeyDown={(event) => + handleSelection(event, 0, filteredOptions) + } + /> + + {filteredOptions.map((option, index) => { + const labelId = `checkbox-list-label-${option.value}`; + + return ( + { + onSelection(option.value); + }} + ref={(el) => { + listRefs.current[index + 1] = el; + }} + onKeyDown={(event) => + handleSelection( + event, + index + 1, + filteredOptions, + ) + } + > + {multiselect ? ( + + ) : null} + + + ); + })} + + + ); +}; diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/MultiSelectConfigButton.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/MultiSelectConfigButton.tsx new file mode 100644 index 0000000000..4888cfcbaa --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/MultiSelectConfigButton.tsx @@ -0,0 +1,42 @@ +import { type FC, useState } from 'react'; +import { ConfigButton, type ConfigButtonProps } from './ConfigButton'; +import { DropdownList, type DropdownListProps } from './DropdownList'; + +type MultiSelectConfigButtonProps = Pick< + ConfigButtonProps, + 'button' | 'onOpen' | 'onClose' | 'description' +> & + Pick & { + selectedOptions: Set; + onChange: (values: Set) => void; + }; + +export const MultiSelectConfigButton: FC = ({ + selectedOptions, + onChange, + ...rest +}) => { + const [anchorEl, setAnchorEl] = useState(); + + const handleToggle = (value: string) => { + if (selectedOptions.has(value)) { + selectedOptions.delete(value); + } else { + selectedOptions.add(value); + } + + onChange(new Set(selectedOptions)); + }; + + return ( + + + + ); +}; diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/SingleSelectConfigButton.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/SingleSelectConfigButton.tsx new file mode 100644 index 0000000000..e47d175418 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/SingleSelectConfigButton.tsx @@ -0,0 +1,40 @@ +import { type FC, useState } from 'react'; +import { ConfigButton, type ConfigButtonProps } from './ConfigButton'; +import { DropdownList, type DropdownListProps } from './DropdownList'; + +type SingleSelectConfigButtonProps = Pick< + ConfigButtonProps, + 'button' | 'onOpen' | 'onClose' | 'description' +> & + Pick; + +export const SingleSelectConfigButton: FC = ({ + onChange, + ...props +}) => { + const [anchorEl, setAnchorEl] = useState(); + const [recentlyClosed, setRecentlyClosed] = useState(false); + + const handleChange = (value: any) => { + onChange(value); + setAnchorEl(null); + props.onClose && props.onClose(); + + setRecentlyClosed(true); + // this is a hack to prevent the button from being + // auto-clicked after you select an item by pressing enter + // in the search bar for single-select lists. + setTimeout(() => setRecentlyClosed(false), 1); + }; + + return ( + + + + ); +}; diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/shared.styles.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/shared.styles.tsx new file mode 100644 index 0000000000..735261af66 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/ConfigButtons/shared.styles.tsx @@ -0,0 +1,34 @@ +import { TextField, styled } from '@mui/material'; + +export const visuallyHiddenStyles = { + border: 0, + clip: 'rect(0 0 0 0)', + height: 'auto', + margin: 0, + overflow: 'hidden', + padding: 0, + position: 'absolute', + width: '1px', + whiteSpace: 'nowrap', +}; + +export const StyledDropdownSearch = styled(TextField, { + shouldForwardProp: (prop) => prop !== 'hideLabel', +})<{ hideLabel?: boolean }>(({ theme, hideLabel }) => ({ + '& .MuiInputBase-root': { + padding: theme.spacing(0, 1.5), + borderRadius: `${theme.shape.borderRadiusMedium}px`, + }, + '& .MuiInputBase-input': { + padding: theme.spacing(0.75, 0), + fontSize: theme.typography.body2.fontSize, + }, + + ...(hideLabel + ? { + label: visuallyHiddenStyles, + + 'fieldset > legend > span': visuallyHiddenStyles, + } + : {}), +})); diff --git a/frontend/src/component/project/Project/CreateProject/CreateProjectDialog/CreateProjectDialog.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/CreateProjectDialog.tsx similarity index 99% rename from frontend/src/component/project/Project/CreateProject/CreateProjectDialog/CreateProjectDialog.tsx rename to frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/CreateProjectDialog.tsx index 808d00a05c..e1b2111a17 100644 --- a/frontend/src/component/project/Project/CreateProject/CreateProjectDialog/CreateProjectDialog.tsx +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/CreateProjectDialog.tsx @@ -2,7 +2,7 @@ import { formatUnknownError } from 'utils/formatUnknownError'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import useToast from 'hooks/useToast'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; -import { NewProjectForm } from '../NewProjectForm'; +import { NewProjectForm } from './NewProjectForm'; import { CreateButton } from 'component/common/CreateButton/CreateButton'; import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions'; import useProjectForm, { diff --git a/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/NewProjectForm.styles.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/NewProjectForm.styles.tsx new file mode 100644 index 0000000000..8bc7d6dad9 --- /dev/null +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/NewProjectForm.styles.tsx @@ -0,0 +1,60 @@ +import { Typography, styled } from '@mui/material'; +import Input from 'component/common/Input/Input'; +import { ReactComponent as ProjectIcon } from 'assets/icons/projectIconSmall.svg'; + +export const StyledForm = styled('form')(({ theme }) => ({ + background: theme.palette.background.default, +})); + +export const StyledFormSection = styled('div')(({ theme }) => ({ + '& + *': { + borderBlockStart: `1px solid ${theme.palette.divider}`, + }, + + padding: theme.spacing(6), +})); + +export const TopGrid = styled(StyledFormSection)(({ theme }) => ({ + display: 'grid', + gridTemplateAreas: + '"icon header" "icon project-name" "icon project-description"', + gridTemplateColumns: 'auto 1fr', + gap: theme.spacing(4), +})); + +export const StyledIcon = styled(ProjectIcon)(({ theme }) => ({ + fill: theme.palette.primary.main, + stroke: theme.palette.primary.main, +})); + +export const StyledHeader = styled(Typography)({ + gridArea: 'header', + alignSelf: 'center', + fontWeight: 'lighter', +}); + +export const ProjectNameContainer = styled('div')({ + gridArea: 'project-name', +}); + +export const ProjectDescriptionContainer = styled('div')({ + gridArea: 'project-description', +}); + +export const StyledInput = styled(Input)({ + width: '100%', + fieldset: { border: 'none' }, +}); + +export const OptionButtons = styled(StyledFormSection)(({ theme }) => ({ + display: 'flex', + flexFlow: 'row wrap', + gap: theme.spacing(2), +})); + +export const FormActions = styled(StyledFormSection)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(5), + justifyContent: 'flex-end', + flexFlow: 'row wrap', +})); diff --git a/frontend/src/component/project/Project/CreateProject/NewProjectForm.tsx b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/NewProjectForm.tsx similarity index 72% rename from frontend/src/component/project/Project/CreateProject/NewProjectForm.tsx rename to frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/NewProjectForm.tsx index b5ed31dbb1..5c22df7568 100644 --- a/frontend/src/component/project/Project/CreateProject/NewProjectForm.tsx +++ b/frontend/src/component/project/Project/CreateProject/NewCreateProjectForm/NewProjectForm.tsx @@ -1,12 +1,4 @@ -import { Typography, styled } from '@mui/material'; -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, - TableSelect, -} from './SelectionButton'; +import type { ProjectMode } from '../../hooks/useProjectEnterpriseSettingsForm'; import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; import StickinessIcon from '@mui/icons-material/FormatPaint'; import ProjectModeIcon from '@mui/icons-material/Adjust'; @@ -17,63 +9,20 @@ import { useStickinessOptions } from 'hooks/useStickinessOptions'; import { ReactComponent as ChangeRequestIcon } from 'assets/icons/merge.svg'; import type { ReactNode } from 'react'; import theme from 'themes/theme'; - -const StyledForm = styled('form')(({ theme }) => ({ - background: theme.palette.background.default, -})); - -const StyledFormSection = styled('div')(({ theme }) => ({ - '& + *': { - borderBlockStart: `1px solid ${theme.palette.divider}`, - }, - - padding: theme.spacing(6), -})); - -const TopGrid = styled(StyledFormSection)(({ theme }) => ({ - display: 'grid', - gridTemplateAreas: - '"icon header" "icon project-name" "icon project-description"', - gridTemplateColumns: 'auto 1fr', - gap: theme.spacing(4), -})); - -const StyledIcon = styled(ProjectIcon)(({ theme }) => ({ - fill: theme.palette.primary.main, - stroke: theme.palette.primary.main, -})); - -const StyledHeader = styled(Typography)(({ theme }) => ({ - gridArea: 'header', - alignSelf: 'center', - fontWeight: 'lighter', -})); - -const ProjectNameContainer = styled('div')(({ theme }) => ({ - gridArea: 'project-name', -})); - -const ProjectDescriptionContainer = styled('div')(({ theme }) => ({ - gridArea: 'project-description', -})); - -const StyledInput = styled(Input)(({ theme }) => ({ - width: '100%', - fieldset: { border: 'none' }, -})); - -const OptionButtons = styled(StyledFormSection)(({ theme }) => ({ - display: 'flex', - flexFlow: 'row wrap', - gap: theme.spacing(2), -})); - -const FormActions = styled(StyledFormSection)(({ theme }) => ({ - display: 'flex', - gap: theme.spacing(5), - justifyContent: 'flex-end', - flexFlow: 'row wrap', -})); +import { + FormActions, + OptionButtons, + ProjectDescriptionContainer, + ProjectNameContainer, + StyledForm, + StyledHeader, + StyledIcon, + StyledInput, + TopGrid, +} from './NewProjectForm.styles'; +import { MultiSelectConfigButton } from './ConfigButtons/MultiSelectConfigButton'; +import { SingleSelectConfigButton } from './ConfigButtons/SingleSelectConfigButton'; +import { ChangeRequestTableConfigButton } from './ConfigButtons/ChangeRequestTableConfigButton'; type FormProps = { projectId: string; @@ -104,6 +53,31 @@ type FormProps = { const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT'; const PROJECT_DESCRIPTION_INPUT = 'PROJECT_DESCRIPTION_INPUT'; +const projectModeOptions = [ + { value: 'open', label: 'open' }, + { value: 'protected', label: 'protected' }, + { value: 'private', label: 'private' }, +]; + +const configButtonData = { + environments: { + icon: , + text: `Each feature flag can have a separate configuration per environment. This setting configures which environments your project should start with.`, + }, + stickiness: { + icon: , + text: 'Stickiness is used to guarantee that your users see the same result when using a gradual rollout. Default stickiness allows you to choose which field is used by default in this project.', + }, + mode: { + icon: , + text: 'Mode defines who should be allowed to interact and see your project. Private mode hides the project from anyone except the project owner and members.', + }, + changeRequests: { + icon: , + text: 'Change requests can be configured per environment and require changes to go through an approval process before being applied.', + }, +}; + export const NewProjectForm: React.FC = ({ children, handleSubmit, @@ -126,6 +100,7 @@ export const NewProjectForm: React.FC = ({ const { isEnterprise } = useUiConfig(); const { environments: allEnvironments } = useEnvironments(); const activeEnvironments = allEnvironments.filter((env) => env.enabled); + const stickinessOptions = useStickinessOptions(projectStickiness); const handleProjectNameUpdate = ( e: React.ChangeEvent, @@ -134,33 +109,6 @@ export const NewProjectForm: React.FC = ({ setProjectName(input); }; - const projectModeOptions = [ - { value: 'open', label: 'open' }, - { value: 'protected', label: 'protected' }, - { value: 'private', label: 'private' }, - ]; - - const stickinessOptions = useStickinessOptions(projectStickiness); - - const selectionButtonData = { - environments: { - icon: , - text: `Each feature flag can have a separate configuration per environment. This setting configures which environments your project should start with.`, - }, - stickiness: { - icon: , - text: 'Stickiness is used to guarantee that your users see the same result when using a gradual rollout. Default stickiness allows you to choose which field is used by default in this project.', - }, - mode: { - icon: , - text: 'Mode defines who should be allowed to interact and see your project. Private mode hides the project from anyone except the project owner and members.', - }, - changeRequests: { - icon: , - text: 'Change requests can be configured per environment and require changes to go through an approval process before being applied.', - }, - }; - const numberOfConfiguredChangeRequestEnvironments = Object.keys( projectChangeRequestConfiguration, ).length; @@ -231,8 +179,8 @@ export const NewProjectForm: React.FC = ({ - ({ label: env.name, @@ -252,13 +200,13 @@ export const NewProjectForm: React.FC = ({ placeholder: 'Select project environments', }} onOpen={() => - overrideDocumentation(selectionButtonData.environments) + overrideDocumentation(configButtonData.environments) } onClose={clearDocumentationOverride} /> - ({ value: key, ...rest, @@ -275,7 +223,7 @@ export const NewProjectForm: React.FC = ({ placeholder: 'Select default stickiness', }} onOpen={() => - overrideDocumentation(selectionButtonData.stickiness) + overrideDocumentation(configButtonData.stickiness) } onClose={clearDocumentationOverride} /> @@ -283,8 +231,8 @@ export const NewProjectForm: React.FC = ({ { setProjectMode(value); @@ -299,7 +247,7 @@ export const NewProjectForm: React.FC = ({ placeholder: 'Select project mode', }} onOpen={() => - overrideDocumentation(selectionButtonData.mode) + overrideDocumentation(configButtonData.mode) } onClose={clearDocumentationOverride} /> @@ -308,10 +256,8 @@ export const NewProjectForm: React.FC = ({ = ({ } onOpen={() => overrideDocumentation( - selectionButtonData.changeRequests, + configButtonData.changeRequests, ) } onClose={clearDocumentationOverride} diff --git a/frontend/src/component/project/Project/CreateProject/SelectionButton.styles.tsx b/frontend/src/component/project/Project/CreateProject/SelectionButton.styles.tsx deleted file mode 100644 index f58d97e116..0000000000 --- a/frontend/src/component/project/Project/CreateProject/SelectionButton.styles.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Checkbox, ListItem, Popover, TextField, styled } from '@mui/material'; - -export const StyledDropdown = styled('div')(({ theme }) => ({ - padding: theme.spacing(2), - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(1), - maxHeight: '70vh', -})); - -export const StyledListItem = styled(ListItem)(({ theme }) => ({ - paddingLeft: theme.spacing(1), - cursor: 'pointer', - '&:hover, &:focus': { - backgroundColor: theme.palette.action.hover, - outline: 'none', - }, - minHeight: theme.spacing(4.5), -})); - -export const StyledCheckbox = styled(Checkbox)(({ theme }) => ({ - padding: theme.spacing(1, 1, 1, 1.5), -})); - -export const StyledPopover = styled(Popover)(({ theme }) => ({ - '& .MuiPaper-root': { - borderRadius: `${theme.shape.borderRadiusMedium}px`, - }, -})); - -const visuallyHiddenStyles = { - border: 0, - clip: 'rect(0 0 0 0)', - height: 'auto', - margin: 0, - overflow: 'hidden', - padding: 0, - position: 'absolute', - width: '1px', - whiteSpace: 'nowrap', -}; - -export const HiddenDescription = styled('p')(() => ({ - ...visuallyHiddenStyles, - position: 'absolute', -})); - -export const StyledDropdownSearch = styled(TextField, { - shouldForwardProp: (prop) => prop !== 'hideLabel', -})<{ hideLabel?: boolean }>(({ theme, hideLabel }) => ({ - '& .MuiInputBase-root': { - padding: theme.spacing(0, 1.5), - borderRadius: `${theme.shape.borderRadiusMedium}px`, - }, - '& .MuiInputBase-input': { - padding: theme.spacing(0.75, 0), - fontSize: theme.typography.body2.fontSize, - }, - - ...(hideLabel - ? { - label: visuallyHiddenStyles, - - 'fieldset > legend > span': visuallyHiddenStyles, - } - : {}), -})); - -export const TableSearchInput = styled(StyledDropdownSearch)(({ theme }) => ({ - maxWidth: '30ch', -})); - -export const ScrollContainer = styled('div')(({ theme }) => ({ - width: '100%', - overflow: 'auto', -})); - -export const ButtonLabel = styled('span', { - shouldForwardProp: (prop) => prop !== 'labelWidth', -})<{ labelWidth?: string }>(({ labelWidth }) => ({ - width: labelWidth || 'unset', -})); diff --git a/frontend/src/component/project/Project/CreateProject/SelectionButton.tsx b/frontend/src/component/project/Project/CreateProject/SelectionButton.tsx deleted file mode 100644 index 2539542e38..0000000000 --- a/frontend/src/component/project/Project/CreateProject/SelectionButton.tsx +++ /dev/null @@ -1,459 +0,0 @@ -import Search from '@mui/icons-material/Search'; -import { v4 as uuidv4 } from 'uuid'; -import { - type FC, - type ReactNode, - useRef, - useState, - useMemo, - type PropsWithChildren, -} from 'react'; -import { Box, Button, InputAdornment, List, ListItemText } from '@mui/material'; -import { - StyledCheckbox, - StyledDropdown, - StyledListItem, - StyledPopover, - StyledDropdownSearch, - TableSearchInput, - HiddenDescription, - ButtonLabel, -} from './SelectionButton.styles'; -import { ChangeRequestTable } from './ChangeRequestTable'; - -export interface IFilterItemProps { - label: ReactNode; - options: Array<{ label: string; value: string }>; - selectedOptions: Set; - onChange: (values: Set) => void; -} - -export type FilterItemParams = { - operator: string; - values: string[]; -}; - -interface UseSelectionManagementProps { - handleToggle: (value: string) => () => void; -} - -const useSelectionManagement = ({ - handleToggle, -}: UseSelectionManagementProps) => { - const listRefs = useRef>([]); - - const handleSelection = ( - event: React.KeyboardEvent, - index: number, - filteredOptions: { label: string; value: string }[], - ) => { - // we have to be careful not to prevent other keys e.g tab - if (event.key === 'ArrowDown' && index < listRefs.current.length - 1) { - event.preventDefault(); - listRefs.current[index + 1]?.focus(); - } else if (event.key === 'ArrowUp' && index > 0) { - event.preventDefault(); - listRefs.current[index - 1]?.focus(); - } else if ( - event.key === 'Enter' && - index === 0 && - listRefs.current[0]?.value && - filteredOptions.length > 0 - ) { - // if the search field is not empty and the user presses - // enter from the search field, toggle the topmost item in - // the filtered list event.preventDefault(); - handleToggle(filteredOptions[0].value)(); - } else if ( - event.key === 'Enter' || - // allow selection with space when not in the search field - (index !== 0 && event.key === ' ') - ) { - event.preventDefault(); - if (index > 0) { - const listItemIndex = index - 1; - handleToggle(filteredOptions[listItemIndex].value)(); - } - } - }; - - return { listRefs, handleSelection }; -}; - -type CombinedSelectProps = { - options: Array<{ label: string; value: string }>; - onChange: (value: string) => void; - button: { label: string; icon: ReactNode; labelWidth?: string }; - search: { - label: string; - placeholder: string; - }; - multiselect?: { selectedOptions: Set }; - onOpen?: () => void; - onClose?: () => void; - description: string; // visually hidden, for assistive tech -}; - -const CombinedSelect: FC< - PropsWithChildren<{ - button: { label: string; icon: ReactNode; labelWidth?: string }; - onOpen?: () => void; - onClose?: () => void; - description: string; - preventOpen?: boolean; - anchorEl: HTMLDivElement | null | undefined; - setAnchorEl: (el: HTMLDivElement | null | undefined) => void; - }> -> = ({ - button, - onOpen = () => {}, - onClose = () => {}, - description, - children, - preventOpen, - anchorEl, - setAnchorEl, -}) => { - const ref = useRef(null); - const descriptionId = uuidv4(); - - const open = () => { - setAnchorEl(ref.current); - onOpen(); - }; - - const handleClose = () => { - setAnchorEl(null); - onClose(); - }; - - return ( - <> - - - - - - {description} - - - {children} - - - - ); -}; - -const DropdownList: FC = ({ - options, - onChange, - search, - multiselect, -}) => { - const [searchText, setSearchText] = useState(''); - - const onSelection = (selected: string) => { - onChange(selected); - }; - - const { listRefs, handleSelection } = useSelectionManagement({ - handleToggle: (selected: string) => () => onSelection(selected), - }); - - const filteredOptions = options?.filter((option) => - option.label.toLowerCase().includes(searchText.toLowerCase()), - ); - - return ( - <> - setSearchText(event.target.value)} - label={search.label} - hideLabel - placeholder={search.placeholder} - autoFocus - InputProps={{ - startAdornment: ( - - - - ), - }} - inputRef={(el) => { - listRefs.current[0] = el; - }} - onKeyDown={(event) => - handleSelection(event, 0, filteredOptions) - } - /> - - {filteredOptions.map((option, index) => { - const labelId = `checkbox-list-label-${option.value}`; - - return ( - { - onSelection(option.value); - }} - ref={(el) => { - listRefs.current[index + 1] = el; - }} - onKeyDown={(event) => - handleSelection( - event, - index + 1, - filteredOptions, - ) - } - > - {multiselect ? ( - - ) : null} - - - ); - })} - - - ); -}; - -type SingleSelectListProps = Pick< - CombinedSelectProps, - | 'options' - | 'button' - | 'search' - | 'onChange' - | 'onOpen' - | 'onClose' - | 'description' ->; - -export const SingleSelectList: FC = ({ - onChange, - ...props -}) => { - const [anchorEl, setAnchorEl] = useState(); - const [recentlyClosed, setRecentlyClosed] = useState(false); - - const handleChange = (value: any) => { - onChange(value); - setAnchorEl(null); - props.onClose && props.onClose(); - - setRecentlyClosed(true); - // this is a hack to prevent the button from being - // auto-clicked after you select an item by pressing enter - // in the search bar for single-select lists. - setTimeout(() => setRecentlyClosed(false), 1); - }; - - return ( - - - - ); -}; - -type MultiselectListProps = Pick< - CombinedSelectProps, - 'options' | 'button' | 'search' | 'onOpen' | 'onClose' | 'description' -> & { - selectedOptions: Set; - onChange: (values: Set) => void; -}; - -export const MultiSelectList: FC = ({ - selectedOptions, - onChange, - ...rest -}) => { - const [anchorEl, setAnchorEl] = useState(); - - const handleToggle = (value: string) => { - if (selectedOptions.has(value)) { - selectedOptions.delete(value); - } else { - selectedOptions.add(value); - } - - onChange(new Set(selectedOptions)); - }; - - return ( - - - - ); -}; - -type TableSelectProps = Pick< - CombinedSelectProps, - 'button' | 'search' | 'onOpen' | 'onClose' | 'description' -> & { - updateProjectChangeRequestConfiguration: { - disableChangeRequests: (env: string) => void; - enableChangeRequests: (env: string, requiredApprovals: number) => void; - }; - activeEnvironments: { - name: string; - type: string; - }[]; - projectChangeRequestConfiguration: Record< - string, - { requiredApprovals: number } - >; -}; - -export const TableSelect: FC = ({ - button, - search, - projectChangeRequestConfiguration, - updateProjectChangeRequestConfiguration, - activeEnvironments, - onOpen = () => {}, - onClose = () => {}, - ...props -}) => { - 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 [anchorEl, setAnchorEl] = useState(); - const [searchText, setSearchText] = useState(''); - - 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)} - hideLabel - label={search.label} - placeholder={search.placeholder} - autoFocus - InputProps={{ - startAdornment: ( - - - - ), - }} - onKeyDown={toggleTopItem} - /> - - - ); -}; diff --git a/frontend/src/component/project/ProjectList/ProjectList.tsx b/frontend/src/component/project/ProjectList/ProjectList.tsx index 3865a5bbfe..1093cdefa5 100644 --- a/frontend/src/component/project/ProjectList/ProjectList.tsx +++ b/frontend/src/component/project/ProjectList/ProjectList.tsx @@ -24,7 +24,7 @@ import { useUiFlag } from 'hooks/useUiFlag'; import { useProfile } from 'hooks/api/getters/useProfile/useProfile'; import { groupProjects } from './group-projects'; import { ProjectGroup } from './ProjectGroup'; -import { CreateProjectDialog } from '../Project/CreateProject/CreateProjectDialog/CreateProjectDialog'; +import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog'; const StyledApiError = styled(ApiError)(({ theme }) => ({ maxWidth: '500px',