mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-04 01:18:20 +02:00
fix: Project select bug with duplicate values (#6405)
Project select fix for Executive Dashboard and Playground Extract the select to it's own component and move to `common` Re-use for both Dashboard and Playground Adds the id in parenthesis when there are duplicate names <img width="1406" alt="Screenshot 2024-03-01 at 12 04 22" src="https://github.com/Unleash/unleash/assets/104830839/379ea11f-d627-493e-8088-a739d58fba61"> <img width="1434" alt="Screenshot 2024-03-01 at 12 36 46" src="https://github.com/Unleash/unleash/assets/104830839/9c5cf863-002c-4630-ac3a-4a869303a308"> --------- Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
parent
a4a604aebb
commit
7b67f218eb
112
frontend/src/component/common/ProjectSelect/ProjectSelect.tsx
Normal file
112
frontend/src/component/common/ProjectSelect/ProjectSelect.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { ComponentProps, Dispatch, SetStateAction, VFC } from 'react';
|
||||
import { Autocomplete, SxProps, TextField } from '@mui/material';
|
||||
import { renderOption } from 'component/playground/Playground/PlaygroundForm/renderOption';
|
||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||
|
||||
interface IOption {
|
||||
label: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const allOption = { label: 'ALL', id: '*' };
|
||||
|
||||
interface IProjectSelectProps {
|
||||
selectedProjects: string[];
|
||||
onChange: Dispatch<SetStateAction<string[]>>;
|
||||
dataTestId?: string;
|
||||
sx?: SxProps;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function findAllIndexes(arr: string[], name: string): number[] {
|
||||
const indexes: number[] = [];
|
||||
arr.forEach((currentValue, index) => {
|
||||
if (currentValue === name) {
|
||||
indexes.push(index);
|
||||
}
|
||||
});
|
||||
return indexes;
|
||||
}
|
||||
|
||||
export const ProjectSelect: VFC<IProjectSelectProps> = ({
|
||||
selectedProjects,
|
||||
onChange,
|
||||
dataTestId,
|
||||
sx,
|
||||
disabled,
|
||||
}) => {
|
||||
const { projects: availableProjects } = useProjects();
|
||||
|
||||
const projectNames = availableProjects.map(({ name }) => name);
|
||||
|
||||
const projectsOptions = [
|
||||
allOption,
|
||||
...availableProjects.map(({ name, id }) => {
|
||||
const indexes = findAllIndexes(projectNames, name);
|
||||
const isDuplicate = indexes.length > 1;
|
||||
|
||||
return {
|
||||
label: isDuplicate ? `${name} - (${id})` : name,
|
||||
id,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const isAllProjects =
|
||||
selectedProjects &&
|
||||
(selectedProjects.length === 0 ||
|
||||
(selectedProjects.length === 1 && selectedProjects[0] === '*'));
|
||||
|
||||
const onProjectsChange: ComponentProps<typeof Autocomplete>['onChange'] = (
|
||||
event,
|
||||
value,
|
||||
reason,
|
||||
) => {
|
||||
const newProjects = value as IOption | IOption[];
|
||||
if (reason === 'clear' || newProjects === null) {
|
||||
return onChange([allOption.id]);
|
||||
}
|
||||
if (Array.isArray(newProjects)) {
|
||||
if (newProjects.length === 0) {
|
||||
return onChange([allOption.id]);
|
||||
}
|
||||
if (
|
||||
newProjects.find(({ id }) => id === allOption.id) !== undefined
|
||||
) {
|
||||
return onChange([allOption.id]);
|
||||
}
|
||||
return onChange(newProjects.map(({ id }) => id));
|
||||
}
|
||||
if (newProjects.id === allOption.id) {
|
||||
return onChange([allOption.id]);
|
||||
}
|
||||
|
||||
return onChange([newProjects.id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
id='projects'
|
||||
limitTags={3}
|
||||
multiple={!isAllProjects}
|
||||
options={projectsOptions}
|
||||
sx={sx}
|
||||
renderInput={(params) => <TextField {...params} label='Projects' />}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect
|
||||
size='small'
|
||||
disabled={disabled}
|
||||
value={
|
||||
isAllProjects
|
||||
? allOption
|
||||
: projectsOptions.filter(({ id }) =>
|
||||
selectedProjects.includes(id),
|
||||
)
|
||||
}
|
||||
onChange={onProjectsChange}
|
||||
data-testid={dataTestId ? dataTestId : 'PROJECT_SELECT'}
|
||||
/>
|
||||
);
|
||||
};
|
@ -1,5 +1,11 @@
|
||||
import { useMemo, useState, VFC } from 'react';
|
||||
import { Box, styled, useMediaQuery, useTheme } from '@mui/material';
|
||||
import {
|
||||
Box,
|
||||
styled,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { UsersChart } from './UsersChart/UsersChart';
|
||||
import { FlagsChart } from './FlagsChart/FlagsChart';
|
||||
import { useExecutiveDashboard } from 'hooks/api/getters/useExecutiveSummary/useExecutiveSummary';
|
||||
@ -10,7 +16,10 @@ import { FlagsProjectChart } from './FlagsProjectChart/FlagsProjectChart';
|
||||
import { ProjectHealthChart } from './ProjectHealthChart/ProjectHealthChart';
|
||||
import { TimeToProductionChart } from './TimeToProductionChart/TimeToProductionChart';
|
||||
import { TimeToProduction } from './TimeToProduction/TimeToProduction';
|
||||
import { ProjectSelect, allOption } from './ProjectSelect/ProjectSelect';
|
||||
import {
|
||||
ProjectSelect,
|
||||
allOption,
|
||||
} from '../common/ProjectSelect/ProjectSelect';
|
||||
import { MetricsSummaryChart } from './MetricsSummaryChart/MetricsSummaryChart';
|
||||
import {
|
||||
ExecutiveSummarySchemaMetricsSummaryTrendsItem,
|
||||
@ -26,6 +35,18 @@ const StyledGrid = styled(Box)(({ theme }) => ({
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
marginBottom: theme.spacing(4),
|
||||
marginTop: theme.spacing(4),
|
||||
[theme.breakpoints.down('lg')]: {
|
||||
width: '100%',
|
||||
marginLeft: 0,
|
||||
},
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const useDashboardGrid = () => {
|
||||
const theme = useTheme();
|
||||
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
@ -153,7 +174,17 @@ export const ExecutiveDashboard: VFC = () => {
|
||||
/>
|
||||
</Widget>
|
||||
</StyledGrid>
|
||||
<ProjectSelect selectedProjects={projects} onChange={setProjects} />
|
||||
<StyledBox>
|
||||
<Typography variant='h2' component='span'>
|
||||
Insights per project
|
||||
</Typography>
|
||||
<ProjectSelect
|
||||
selectedProjects={projects}
|
||||
onChange={setProjects}
|
||||
dataTestId={'DASHBOARD_PROJECT_SELECT'}
|
||||
sx={{ flex: 1, maxWidth: '360px' }}
|
||||
/>
|
||||
</StyledBox>
|
||||
<StyledGrid>
|
||||
<Widget
|
||||
title='Number of flags per project'
|
||||
|
@ -1,113 +0,0 @@
|
||||
import { ComponentProps, Dispatch, SetStateAction, VFC } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
styled,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { renderOption } from '../../playground/Playground/PlaygroundForm/renderOption';
|
||||
import useProjects from '../../../hooks/api/getters/useProjects/useProjects';
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
marginBottom: theme.spacing(4),
|
||||
marginTop: theme.spacing(4),
|
||||
[theme.breakpoints.down('lg')]: {
|
||||
width: '100%',
|
||||
marginLeft: 0,
|
||||
},
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
interface IOption {
|
||||
label: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const allOption = { label: 'ALL', id: '*' };
|
||||
|
||||
interface IProjectSelectProps {
|
||||
selectedProjects: string[];
|
||||
onChange: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export const ProjectSelect: VFC<IProjectSelectProps> = ({
|
||||
selectedProjects,
|
||||
onChange,
|
||||
}) => {
|
||||
const { projects: availableProjects } = useProjects();
|
||||
|
||||
const projectsOptions = [
|
||||
allOption,
|
||||
...availableProjects.map(({ name: label, id }) => ({
|
||||
label,
|
||||
id,
|
||||
})),
|
||||
];
|
||||
|
||||
const isAllProjects =
|
||||
selectedProjects &&
|
||||
(selectedProjects.length === 0 ||
|
||||
(selectedProjects.length === 1 && selectedProjects[0] === '*'));
|
||||
|
||||
const onProjectsChange: ComponentProps<typeof Autocomplete>['onChange'] = (
|
||||
event,
|
||||
value,
|
||||
reason,
|
||||
) => {
|
||||
const newProjects = value as IOption | IOption[];
|
||||
if (reason === 'clear' || newProjects === null) {
|
||||
return onChange([allOption.id]);
|
||||
}
|
||||
if (Array.isArray(newProjects)) {
|
||||
if (newProjects.length === 0) {
|
||||
return onChange([allOption.id]);
|
||||
}
|
||||
if (
|
||||
newProjects.find(({ id }) => id === allOption.id) !== undefined
|
||||
) {
|
||||
return onChange([allOption.id]);
|
||||
}
|
||||
return onChange(newProjects.map(({ id }) => id));
|
||||
}
|
||||
if (newProjects.id === allOption.id) {
|
||||
return onChange([allOption.id]);
|
||||
}
|
||||
|
||||
return onChange([newProjects.id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledBox>
|
||||
<Typography variant='h2' component='span'>
|
||||
Insights per project
|
||||
</Typography>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
id='projects'
|
||||
limitTags={3}
|
||||
multiple={!isAllProjects}
|
||||
options={projectsOptions}
|
||||
sx={{ flex: 1, maxWidth: 360 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label='Projects' />
|
||||
)}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect
|
||||
size='small'
|
||||
value={
|
||||
isAllProjects
|
||||
? allOption
|
||||
: projectsOptions.filter(({ id }) =>
|
||||
selectedProjects.includes(id),
|
||||
)
|
||||
}
|
||||
onChange={onProjectsChange}
|
||||
data-testid={'DASHBOARD_PROJECT_SELECT'}
|
||||
/>
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { allOption } from '../ProjectSelect/ProjectSelect';
|
||||
import { allOption } from 'component/common/ProjectSelect/ProjectSelect';
|
||||
|
||||
export const useFilteredTrends = <
|
||||
T extends {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ComponentProps, useState, VFC } from 'react';
|
||||
import { ComponentProps, Dispatch, SetStateAction, useState, VFC } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
@ -22,14 +22,15 @@ import {
|
||||
validateTokenFormat,
|
||||
} from '../../playground.utils';
|
||||
import { Clear } from '@mui/icons-material';
|
||||
import { ProjectSelect } from '../../../../common/ProjectSelect/ProjectSelect';
|
||||
|
||||
interface IPlaygroundConnectionFieldsetProps {
|
||||
environments: string[];
|
||||
projects: string[];
|
||||
token?: string;
|
||||
setProjects: (projects: string[]) => void;
|
||||
setEnvironments: (environments: string[]) => void;
|
||||
setToken?: (token: string) => void;
|
||||
setProjects: Dispatch<SetStateAction<string[]>>;
|
||||
setEnvironments: Dispatch<SetStateAction<string[]>>;
|
||||
setToken?: Dispatch<SetStateAction<string | undefined>>;
|
||||
availableEnvironments: string[];
|
||||
}
|
||||
|
||||
@ -76,33 +77,6 @@ export const PlaygroundConnectionFieldset: VFC<
|
||||
})),
|
||||
];
|
||||
|
||||
const onProjectsChange: ComponentProps<typeof Autocomplete>['onChange'] = (
|
||||
event,
|
||||
value,
|
||||
reason,
|
||||
) => {
|
||||
const newProjects = value as IOption | IOption[];
|
||||
if (reason === 'clear' || newProjects === null) {
|
||||
return setProjects([allOption.id]);
|
||||
}
|
||||
if (Array.isArray(newProjects)) {
|
||||
if (newProjects.length === 0) {
|
||||
return setProjects([allOption.id]);
|
||||
}
|
||||
if (
|
||||
newProjects.find(({ id }) => id === allOption.id) !== undefined
|
||||
) {
|
||||
return setProjects([allOption.id]);
|
||||
}
|
||||
return setProjects(newProjects.map(({ id }) => id));
|
||||
}
|
||||
if (newProjects.id === allOption.id) {
|
||||
return setProjects([allOption.id]);
|
||||
}
|
||||
|
||||
return setProjects([newProjects.id]);
|
||||
};
|
||||
|
||||
const onEnvironmentsChange: ComponentProps<
|
||||
typeof Autocomplete
|
||||
>['onChange'] = (event, value, reason) => {
|
||||
@ -120,11 +94,6 @@ export const PlaygroundConnectionFieldset: VFC<
|
||||
return setEnvironments([newEnvironments.id]);
|
||||
};
|
||||
|
||||
const isAllProjects =
|
||||
projects &&
|
||||
(projects.length === 0 ||
|
||||
(projects.length === 1 && projects[0] === '*'));
|
||||
|
||||
const envValue = environmentOptions.filter(({ id }) =>
|
||||
environments.includes(id),
|
||||
);
|
||||
@ -235,68 +204,53 @@ export const PlaygroundConnectionFieldset: VFC<
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Tooltip
|
||||
arrow
|
||||
title={
|
||||
token
|
||||
? 'Environment is automatically selected because you are using a token'
|
||||
: 'Select environments to use in the playground'
|
||||
}
|
||||
>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
limitTags={3}
|
||||
id='environment'
|
||||
multiple={true}
|
||||
options={environmentOptions}
|
||||
sx={{ flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label='Environments' />
|
||||
)}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect={false}
|
||||
size='small'
|
||||
value={envValue}
|
||||
onChange={onEnvironmentsChange}
|
||||
disabled={Boolean(token)}
|
||||
data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
arrow
|
||||
title={
|
||||
token
|
||||
? 'Project is automatically selected because you are using a token'
|
||||
: 'Select projects to use in the playground'
|
||||
}
|
||||
>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
id='projects'
|
||||
limitTags={3}
|
||||
multiple={!isAllProjects}
|
||||
options={projectsOptions}
|
||||
sx={{ flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label='Projects' />
|
||||
)}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect
|
||||
size='small'
|
||||
value={
|
||||
isAllProjects
|
||||
? allOption
|
||||
: projectsOptions.filter(({ id }) =>
|
||||
projects.includes(id),
|
||||
)
|
||||
<Box flex={1}>
|
||||
<Tooltip
|
||||
arrow
|
||||
title={
|
||||
token
|
||||
? 'Environment is automatically selected because you are using a token'
|
||||
: 'Select environments to use in the playground'
|
||||
}
|
||||
onChange={onProjectsChange}
|
||||
disabled={Boolean(token)}
|
||||
data-testid={'PLAYGROUND_PROJECT_SELECT'}
|
||||
/>
|
||||
</Tooltip>
|
||||
>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
limitTags={3}
|
||||
id='environment'
|
||||
multiple={true}
|
||||
options={environmentOptions}
|
||||
sx={{ flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label='Environments' />
|
||||
)}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect={false}
|
||||
size='small'
|
||||
value={envValue}
|
||||
onChange={onEnvironmentsChange}
|
||||
disabled={Boolean(token)}
|
||||
data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Tooltip
|
||||
arrow
|
||||
title={
|
||||
token
|
||||
? 'Project is automatically selected because you are using a token'
|
||||
: 'Select projects to use in the playground'
|
||||
}
|
||||
>
|
||||
<ProjectSelect
|
||||
selectedProjects={projects}
|
||||
onChange={setProjects}
|
||||
dataTestId={'PLAYGROUND_PROJECT_SELECT'}
|
||||
disabled={Boolean(token)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Input
|
||||
sx={{ mt: 2, width: '50%', pr: 1 }}
|
||||
|
Loading…
Reference in New Issue
Block a user