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 useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
import { NewProjectForm } from '../NewProjectForm';
|
import { NewProjectForm } from './NewProjectForm';
|
||||||
import { CreateButton } from 'component/common/CreateButton/CreateButton';
|
import { CreateButton } from 'component/common/CreateButton/CreateButton';
|
||||||
import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||||
import useProjectForm, {
|
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 type { ProjectMode } from '../../hooks/useProjectEnterpriseSettingsForm';
|
||||||
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 { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||||
import StickinessIcon from '@mui/icons-material/FormatPaint';
|
import StickinessIcon from '@mui/icons-material/FormatPaint';
|
||||||
import ProjectModeIcon from '@mui/icons-material/Adjust';
|
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 { ReactComponent as ChangeRequestIcon } from 'assets/icons/merge.svg';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import theme from 'themes/theme';
|
import theme from 'themes/theme';
|
||||||
|
import {
|
||||||
const StyledForm = styled('form')(({ theme }) => ({
|
FormActions,
|
||||||
background: theme.palette.background.default,
|
OptionButtons,
|
||||||
}));
|
ProjectDescriptionContainer,
|
||||||
|
ProjectNameContainer,
|
||||||
const StyledFormSection = styled('div')(({ theme }) => ({
|
StyledForm,
|
||||||
'& + *': {
|
StyledHeader,
|
||||||
borderBlockStart: `1px solid ${theme.palette.divider}`,
|
StyledIcon,
|
||||||
},
|
StyledInput,
|
||||||
|
TopGrid,
|
||||||
padding: theme.spacing(6),
|
} from './NewProjectForm.styles';
|
||||||
}));
|
import { MultiSelectConfigButton } from './ConfigButtons/MultiSelectConfigButton';
|
||||||
|
import { SingleSelectConfigButton } from './ConfigButtons/SingleSelectConfigButton';
|
||||||
const TopGrid = styled(StyledFormSection)(({ theme }) => ({
|
import { ChangeRequestTableConfigButton } from './ConfigButtons/ChangeRequestTableConfigButton';
|
||||||
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',
|
|
||||||
}));
|
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -104,6 +53,31 @@ type FormProps = {
|
|||||||
const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT';
|
const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT';
|
||||||
const PROJECT_DESCRIPTION_INPUT = 'PROJECT_DESCRIPTION_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> = ({
|
export const NewProjectForm: React.FC<FormProps> = ({
|
||||||
children,
|
children,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@ -126,6 +100,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
const { isEnterprise } = useUiConfig();
|
const { isEnterprise } = useUiConfig();
|
||||||
const { environments: allEnvironments } = useEnvironments();
|
const { environments: allEnvironments } = useEnvironments();
|
||||||
const activeEnvironments = allEnvironments.filter((env) => env.enabled);
|
const activeEnvironments = allEnvironments.filter((env) => env.enabled);
|
||||||
|
const stickinessOptions = useStickinessOptions(projectStickiness);
|
||||||
|
|
||||||
const handleProjectNameUpdate = (
|
const handleProjectNameUpdate = (
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
@ -134,33 +109,6 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
setProjectName(input);
|
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(
|
const numberOfConfiguredChangeRequestEnvironments = Object.keys(
|
||||||
projectChangeRequestConfiguration,
|
projectChangeRequestConfiguration,
|
||||||
).length;
|
).length;
|
||||||
@ -231,8 +179,8 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
</TopGrid>
|
</TopGrid>
|
||||||
|
|
||||||
<OptionButtons>
|
<OptionButtons>
|
||||||
<MultiSelectList
|
<MultiSelectConfigButton
|
||||||
description={selectionButtonData.environments.text}
|
description={configButtonData.environments.text}
|
||||||
selectedOptions={projectEnvironments}
|
selectedOptions={projectEnvironments}
|
||||||
options={activeEnvironments.map((env) => ({
|
options={activeEnvironments.map((env) => ({
|
||||||
label: env.name,
|
label: env.name,
|
||||||
@ -252,13 +200,13 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
placeholder: 'Select project environments',
|
placeholder: 'Select project environments',
|
||||||
}}
|
}}
|
||||||
onOpen={() =>
|
onOpen={() =>
|
||||||
overrideDocumentation(selectionButtonData.environments)
|
overrideDocumentation(configButtonData.environments)
|
||||||
}
|
}
|
||||||
onClose={clearDocumentationOverride}
|
onClose={clearDocumentationOverride}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SingleSelectList
|
<SingleSelectConfigButton
|
||||||
description={selectionButtonData.stickiness.text}
|
description={configButtonData.stickiness.text}
|
||||||
options={stickinessOptions.map(({ key, ...rest }) => ({
|
options={stickinessOptions.map(({ key, ...rest }) => ({
|
||||||
value: key,
|
value: key,
|
||||||
...rest,
|
...rest,
|
||||||
@ -275,7 +223,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
placeholder: 'Select default stickiness',
|
placeholder: 'Select default stickiness',
|
||||||
}}
|
}}
|
||||||
onOpen={() =>
|
onOpen={() =>
|
||||||
overrideDocumentation(selectionButtonData.stickiness)
|
overrideDocumentation(configButtonData.stickiness)
|
||||||
}
|
}
|
||||||
onClose={clearDocumentationOverride}
|
onClose={clearDocumentationOverride}
|
||||||
/>
|
/>
|
||||||
@ -283,8 +231,8 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={isEnterprise()}
|
condition={isEnterprise()}
|
||||||
show={
|
show={
|
||||||
<SingleSelectList
|
<SingleSelectConfigButton
|
||||||
description={selectionButtonData.mode.text}
|
description={configButtonData.mode.text}
|
||||||
options={projectModeOptions}
|
options={projectModeOptions}
|
||||||
onChange={(value: any) => {
|
onChange={(value: any) => {
|
||||||
setProjectMode(value);
|
setProjectMode(value);
|
||||||
@ -299,7 +247,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
placeholder: 'Select project mode',
|
placeholder: 'Select project mode',
|
||||||
}}
|
}}
|
||||||
onOpen={() =>
|
onOpen={() =>
|
||||||
overrideDocumentation(selectionButtonData.mode)
|
overrideDocumentation(configButtonData.mode)
|
||||||
}
|
}
|
||||||
onClose={clearDocumentationOverride}
|
onClose={clearDocumentationOverride}
|
||||||
/>
|
/>
|
||||||
@ -308,10 +256,8 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={isEnterprise()}
|
condition={isEnterprise()}
|
||||||
show={
|
show={
|
||||||
<TableSelect
|
<ChangeRequestTableConfigButton
|
||||||
description={
|
description={configButtonData.changeRequests.text}
|
||||||
selectionButtonData.changeRequests.text
|
|
||||||
}
|
|
||||||
activeEnvironments={
|
activeEnvironments={
|
||||||
availableChangeRequestEnvironments
|
availableChangeRequestEnvironments
|
||||||
}
|
}
|
||||||
@ -334,7 +280,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
|
|||||||
}
|
}
|
||||||
onOpen={() =>
|
onOpen={() =>
|
||||||
overrideDocumentation(
|
overrideDocumentation(
|
||||||
selectionButtonData.changeRequests,
|
configButtonData.changeRequests,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClose={clearDocumentationOverride}
|
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 { useProfile } from 'hooks/api/getters/useProfile/useProfile';
|
||||||
import { groupProjects } from './group-projects';
|
import { groupProjects } from './group-projects';
|
||||||
import { ProjectGroup } from './ProjectGroup';
|
import { ProjectGroup } from './ProjectGroup';
|
||||||
import { CreateProjectDialog } from '../Project/CreateProject/CreateProjectDialog/CreateProjectDialog';
|
import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog';
|
||||||
|
|
||||||
const StyledApiError = styled(ApiError)(({ theme }) => ({
|
const StyledApiError = styled(ApiError)(({ theme }) => ({
|
||||||
maxWidth: '500px',
|
maxWidth: '500px',
|
||||||
|
Loading…
Reference in New Issue
Block a user