1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

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
This commit is contained in:
Thomas Heartman 2024-05-29 08:10:47 +02:00 committed by GitHub
parent 2649c8e7cd
commit 06de5de85c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 647 additions and 650 deletions

View File

@ -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',
});

View File

@ -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<HTMLDivElement | null>();
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 (
<ConfigButton
button={button}
{...props}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
>
<TableSearchInput
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
hideLabel
label={search.label}
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
onKeyDown={toggleTopItem}
/>
<ChangeRequestTable
environments={filteredEnvs}
enableEnvironment={onEnable}
disableEnvironment={onDisable}
/>
</ConfigButton>
);
};

View File

@ -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',
}));

View File

@ -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<PropsWithChildren<ConfigButtonProps>> = ({
button,
onOpen = () => {},
onClose = () => {},
description,
children,
preventOpen,
anchorEl,
setAnchorEl,
}) => {
const ref = useRef<HTMLDivElement>(null);
const descriptionId = uuidv4();
const open = () => {
setAnchorEl(ref.current);
onOpen();
};
const handleClose = () => {
setAnchorEl(null);
onClose();
};
return (
<>
<Box ref={ref}>
<Button
variant='outlined'
color='primary'
startIcon={button.icon}
onClick={() => {
if (!preventOpen) {
open();
}
}}
>
<ButtonLabel labelWidth={button.labelWidth}>
{button.label}
</ButtonLabel>
</Button>
</Box>
<StyledPopover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<HiddenDescription id={descriptionId}>
{description}
</HiddenDescription>
<StyledDropdown aria-describedby={descriptionId}>
{children}
</StyledDropdown>
</StyledPopover>
</>
);
};

View File

@ -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),
}));

View File

@ -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<Array<HTMLInputElement | HTMLLIElement | null>>([]);
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<string> };
};
export const DropdownList: FC<DropdownListProps> = ({
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 (
<>
<StyledDropdownSearch
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
label={search.label}
hideLabel
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
inputRef={(el) => {
listRefs.current[0] = el;
}}
onKeyDown={(event) =>
handleSelection(event, 0, filteredOptions)
}
/>
<List sx={{ overflowY: 'auto' }} disablePadding>
{filteredOptions.map((option, index) => {
const labelId = `checkbox-list-label-${option.value}`;
return (
<StyledListItem
aria-describedby={labelId}
key={option.value}
dense
disablePadding
tabIndex={0}
onClick={() => {
onSelection(option.value);
}}
ref={(el) => {
listRefs.current[index + 1] = el;
}}
onKeyDown={(event) =>
handleSelection(
event,
index + 1,
filteredOptions,
)
}
>
{multiselect ? (
<StyledCheckbox
edge='start'
checked={multiselect.selectedOptions.has(
option.value,
)}
tabIndex={-1}
inputProps={{
'aria-labelledby': labelId,
}}
size='small'
disableRipple
/>
) : null}
<ListItemText id={labelId} primary={option.label} />
</StyledListItem>
);
})}
</List>
</>
);
};

View File

@ -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<DropdownListProps, 'search' | 'options'> & {
selectedOptions: Set<string>;
onChange: (values: Set<string>) => void;
};
export const MultiSelectConfigButton: FC<MultiSelectConfigButtonProps> = ({
selectedOptions,
onChange,
...rest
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const handleToggle = (value: string) => {
if (selectedOptions.has(value)) {
selectedOptions.delete(value);
} else {
selectedOptions.add(value);
}
onChange(new Set(selectedOptions));
};
return (
<ConfigButton {...rest} anchorEl={anchorEl} setAnchorEl={setAnchorEl}>
<DropdownList
multiselect={{
selectedOptions,
}}
onChange={handleToggle}
{...rest}
/>
</ConfigButton>
);
};

View File

@ -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<DropdownListProps, 'search' | 'onChange' | 'options'>;
export const SingleSelectConfigButton: FC<SingleSelectConfigButtonProps> = ({
onChange,
...props
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
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 (
<ConfigButton
{...props}
preventOpen={recentlyClosed}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
>
<DropdownList {...props} onChange={handleChange} />
</ConfigButton>
);
};

View File

@ -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,
}
: {}),
}));

View File

@ -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, {

View File

@ -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',
}));

View File

@ -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: <EnvironmentsIcon />,
text: `Each feature flag can have a separate configuration per environment. This setting configures which environments your project should start with.`,
},
stickiness: {
icon: <StickinessIcon />,
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: <ProjectModeIcon />,
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: <ChangeRequestIcon />,
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<FormProps> = ({
children,
handleSubmit,
@ -126,6 +100,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
const { isEnterprise } = useUiConfig();
const { environments: allEnvironments } = useEnvironments();
const activeEnvironments = allEnvironments.filter((env) => env.enabled);
const stickinessOptions = useStickinessOptions(projectStickiness);
const handleProjectNameUpdate = (
e: React.ChangeEvent<HTMLInputElement>,
@ -134,33 +109,6 @@ export const NewProjectForm: React.FC<FormProps> = ({
setProjectName(input);
};
const projectModeOptions = [
{ value: 'open', label: 'open' },
{ value: 'protected', label: 'protected' },
{ value: 'private', label: 'private' },
];
const stickinessOptions = useStickinessOptions(projectStickiness);
const selectionButtonData = {
environments: {
icon: <EnvironmentsIcon />,
text: `Each feature flag can have a separate configuration per environment. This setting configures which environments your project should start with.`,
},
stickiness: {
icon: <StickinessIcon />,
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: <ProjectModeIcon />,
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: <ChangeRequestIcon />,
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<FormProps> = ({
</TopGrid>
<OptionButtons>
<MultiSelectList
description={selectionButtonData.environments.text}
<MultiSelectConfigButton
description={configButtonData.environments.text}
selectedOptions={projectEnvironments}
options={activeEnvironments.map((env) => ({
label: env.name,
@ -252,13 +200,13 @@ export const NewProjectForm: React.FC<FormProps> = ({
placeholder: 'Select project environments',
}}
onOpen={() =>
overrideDocumentation(selectionButtonData.environments)
overrideDocumentation(configButtonData.environments)
}
onClose={clearDocumentationOverride}
/>
<SingleSelectList
description={selectionButtonData.stickiness.text}
<SingleSelectConfigButton
description={configButtonData.stickiness.text}
options={stickinessOptions.map(({ key, ...rest }) => ({
value: key,
...rest,
@ -275,7 +223,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
placeholder: 'Select default stickiness',
}}
onOpen={() =>
overrideDocumentation(selectionButtonData.stickiness)
overrideDocumentation(configButtonData.stickiness)
}
onClose={clearDocumentationOverride}
/>
@ -283,8 +231,8 @@ export const NewProjectForm: React.FC<FormProps> = ({
<ConditionallyRender
condition={isEnterprise()}
show={
<SingleSelectList
description={selectionButtonData.mode.text}
<SingleSelectConfigButton
description={configButtonData.mode.text}
options={projectModeOptions}
onChange={(value: any) => {
setProjectMode(value);
@ -299,7 +247,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
placeholder: 'Select project mode',
}}
onOpen={() =>
overrideDocumentation(selectionButtonData.mode)
overrideDocumentation(configButtonData.mode)
}
onClose={clearDocumentationOverride}
/>
@ -308,10 +256,8 @@ export const NewProjectForm: React.FC<FormProps> = ({
<ConditionallyRender
condition={isEnterprise()}
show={
<TableSelect
description={
selectionButtonData.changeRequests.text
}
<ChangeRequestTableConfigButton
description={configButtonData.changeRequests.text}
activeEnvironments={
availableChangeRequestEnvironments
}
@ -334,7 +280,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
}
onOpen={() =>
overrideDocumentation(
selectionButtonData.changeRequests,
configButtonData.changeRequests,
)
}
onClose={clearDocumentationOverride}

View File

@ -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',
}));

View File

@ -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<string>;
onChange: (values: Set<string>) => void;
}
export type FilterItemParams = {
operator: string;
values: string[];
};
interface UseSelectionManagementProps {
handleToggle: (value: string) => () => void;
}
const useSelectionManagement = ({
handleToggle,
}: UseSelectionManagementProps) => {
const listRefs = useRef<Array<HTMLInputElement | HTMLLIElement | null>>([]);
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<string> };
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<HTMLDivElement>(null);
const descriptionId = uuidv4();
const open = () => {
setAnchorEl(ref.current);
onOpen();
};
const handleClose = () => {
setAnchorEl(null);
onClose();
};
return (
<>
<Box ref={ref}>
<Button
variant='outlined'
color='primary'
startIcon={button.icon}
onClick={() => {
if (!preventOpen) {
open();
}
}}
>
<ButtonLabel labelWidth={button.labelWidth}>
{button.label}
</ButtonLabel>
</Button>
</Box>
<StyledPopover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<HiddenDescription id={descriptionId}>
{description}
</HiddenDescription>
<StyledDropdown aria-describedby={descriptionId}>
{children}
</StyledDropdown>
</StyledPopover>
</>
);
};
const DropdownList: FC<CombinedSelectProps> = ({
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 (
<>
<StyledDropdownSearch
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
label={search.label}
hideLabel
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
inputRef={(el) => {
listRefs.current[0] = el;
}}
onKeyDown={(event) =>
handleSelection(event, 0, filteredOptions)
}
/>
<List sx={{ overflowY: 'auto' }} disablePadding>
{filteredOptions.map((option, index) => {
const labelId = `checkbox-list-label-${option.value}`;
return (
<StyledListItem
aria-describedby={labelId}
key={option.value}
dense
disablePadding
tabIndex={0}
onClick={() => {
onSelection(option.value);
}}
ref={(el) => {
listRefs.current[index + 1] = el;
}}
onKeyDown={(event) =>
handleSelection(
event,
index + 1,
filteredOptions,
)
}
>
{multiselect ? (
<StyledCheckbox
edge='start'
checked={multiselect.selectedOptions.has(
option.value,
)}
tabIndex={-1}
inputProps={{
'aria-labelledby': labelId,
}}
size='small'
disableRipple
/>
) : null}
<ListItemText id={labelId} primary={option.label} />
</StyledListItem>
);
})}
</List>
</>
);
};
type SingleSelectListProps = Pick<
CombinedSelectProps,
| 'options'
| 'button'
| 'search'
| 'onChange'
| 'onOpen'
| 'onClose'
| 'description'
>;
export const SingleSelectList: FC<SingleSelectListProps> = ({
onChange,
...props
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
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 (
<CombinedSelect
{...props}
preventOpen={recentlyClosed}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
>
<DropdownList {...props} onChange={handleChange} />
</CombinedSelect>
);
};
type MultiselectListProps = Pick<
CombinedSelectProps,
'options' | 'button' | 'search' | 'onOpen' | 'onClose' | 'description'
> & {
selectedOptions: Set<string>;
onChange: (values: Set<string>) => void;
};
export const MultiSelectList: FC<MultiselectListProps> = ({
selectedOptions,
onChange,
...rest
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const handleToggle = (value: string) => {
if (selectedOptions.has(value)) {
selectedOptions.delete(value);
} else {
selectedOptions.add(value);
}
onChange(new Set(selectedOptions));
};
return (
<CombinedSelect {...rest} anchorEl={anchorEl} setAnchorEl={setAnchorEl}>
<DropdownList
multiselect={{
selectedOptions,
}}
onChange={handleToggle}
{...rest}
/>
</CombinedSelect>
);
};
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<TableSelectProps> = ({
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<HTMLDivElement | null>();
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 (
<CombinedSelect
button={button}
{...props}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
>
<TableSearchInput
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
hideLabel
label={search.label}
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
onKeyDown={toggleTopItem}
/>
<ChangeRequestTable
environments={filteredEnvs}
enableEnvironment={onEnable}
disableEnvironment={onDisable}
/>
</CombinedSelect>
);
};

View File

@ -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',