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:
parent
2649c8e7cd
commit
06de5de85c
@ -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',
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
}));
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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),
|
||||
}));
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
}));
|
@ -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, {
|
@ -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',
|
||||
}));
|
@ -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}
|
@ -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',
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user