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 { 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 { UsersChart } from './UsersChart/UsersChart';
|
||||||
import { FlagsChart } from './FlagsChart/FlagsChart';
|
import { FlagsChart } from './FlagsChart/FlagsChart';
|
||||||
import { useExecutiveDashboard } from 'hooks/api/getters/useExecutiveSummary/useExecutiveSummary';
|
import { useExecutiveDashboard } from 'hooks/api/getters/useExecutiveSummary/useExecutiveSummary';
|
||||||
@ -10,7 +16,10 @@ import { FlagsProjectChart } from './FlagsProjectChart/FlagsProjectChart';
|
|||||||
import { ProjectHealthChart } from './ProjectHealthChart/ProjectHealthChart';
|
import { ProjectHealthChart } from './ProjectHealthChart/ProjectHealthChart';
|
||||||
import { TimeToProductionChart } from './TimeToProductionChart/TimeToProductionChart';
|
import { TimeToProductionChart } from './TimeToProductionChart/TimeToProductionChart';
|
||||||
import { TimeToProduction } from './TimeToProduction/TimeToProduction';
|
import { TimeToProduction } from './TimeToProduction/TimeToProduction';
|
||||||
import { ProjectSelect, allOption } from './ProjectSelect/ProjectSelect';
|
import {
|
||||||
|
ProjectSelect,
|
||||||
|
allOption,
|
||||||
|
} from '../common/ProjectSelect/ProjectSelect';
|
||||||
import { MetricsSummaryChart } from './MetricsSummaryChart/MetricsSummaryChart';
|
import { MetricsSummaryChart } from './MetricsSummaryChart/MetricsSummaryChart';
|
||||||
import {
|
import {
|
||||||
ExecutiveSummarySchemaMetricsSummaryTrendsItem,
|
ExecutiveSummarySchemaMetricsSummaryTrendsItem,
|
||||||
@ -26,6 +35,18 @@ const StyledGrid = styled(Box)(({ theme }) => ({
|
|||||||
gap: theme.spacing(2),
|
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 useDashboardGrid = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
||||||
@ -153,7 +174,17 @@ export const ExecutiveDashboard: VFC = () => {
|
|||||||
/>
|
/>
|
||||||
</Widget>
|
</Widget>
|
||||||
</StyledGrid>
|
</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>
|
<StyledGrid>
|
||||||
<Widget
|
<Widget
|
||||||
title='Number of flags per project'
|
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 { useMemo } from 'react';
|
||||||
import { allOption } from '../ProjectSelect/ProjectSelect';
|
import { allOption } from 'component/common/ProjectSelect/ProjectSelect';
|
||||||
|
|
||||||
export const useFilteredTrends = <
|
export const useFilteredTrends = <
|
||||||
T extends {
|
T extends {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ComponentProps, useState, VFC } from 'react';
|
import { ComponentProps, Dispatch, SetStateAction, useState, VFC } from 'react';
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
@ -22,14 +22,15 @@ import {
|
|||||||
validateTokenFormat,
|
validateTokenFormat,
|
||||||
} from '../../playground.utils';
|
} from '../../playground.utils';
|
||||||
import { Clear } from '@mui/icons-material';
|
import { Clear } from '@mui/icons-material';
|
||||||
|
import { ProjectSelect } from '../../../../common/ProjectSelect/ProjectSelect';
|
||||||
|
|
||||||
interface IPlaygroundConnectionFieldsetProps {
|
interface IPlaygroundConnectionFieldsetProps {
|
||||||
environments: string[];
|
environments: string[];
|
||||||
projects: string[];
|
projects: string[];
|
||||||
token?: string;
|
token?: string;
|
||||||
setProjects: (projects: string[]) => void;
|
setProjects: Dispatch<SetStateAction<string[]>>;
|
||||||
setEnvironments: (environments: string[]) => void;
|
setEnvironments: Dispatch<SetStateAction<string[]>>;
|
||||||
setToken?: (token: string) => void;
|
setToken?: Dispatch<SetStateAction<string | undefined>>;
|
||||||
availableEnvironments: string[];
|
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<
|
const onEnvironmentsChange: ComponentProps<
|
||||||
typeof Autocomplete
|
typeof Autocomplete
|
||||||
>['onChange'] = (event, value, reason) => {
|
>['onChange'] = (event, value, reason) => {
|
||||||
@ -120,11 +94,6 @@ export const PlaygroundConnectionFieldset: VFC<
|
|||||||
return setEnvironments([newEnvironments.id]);
|
return setEnvironments([newEnvironments.id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllProjects =
|
|
||||||
projects &&
|
|
||||||
(projects.length === 0 ||
|
|
||||||
(projects.length === 1 && projects[0] === '*'));
|
|
||||||
|
|
||||||
const envValue = environmentOptions.filter(({ id }) =>
|
const envValue = environmentOptions.filter(({ id }) =>
|
||||||
environments.includes(id),
|
environments.includes(id),
|
||||||
);
|
);
|
||||||
@ -235,6 +204,7 @@ export const PlaygroundConnectionFieldset: VFC<
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box flex={1}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
arrow
|
arrow
|
||||||
title={
|
title={
|
||||||
@ -263,6 +233,8 @@ export const PlaygroundConnectionFieldset: VFC<
|
|||||||
data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'}
|
data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box flex={1}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
arrow
|
arrow
|
||||||
title={
|
title={
|
||||||
@ -271,33 +243,15 @@ export const PlaygroundConnectionFieldset: VFC<
|
|||||||
: 'Select projects to use in the playground'
|
: 'Select projects to use in the playground'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Autocomplete
|
<ProjectSelect
|
||||||
disablePortal
|
selectedProjects={projects}
|
||||||
id='projects'
|
onChange={setProjects}
|
||||||
limitTags={3}
|
dataTestId={'PLAYGROUND_PROJECT_SELECT'}
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onChange={onProjectsChange}
|
|
||||||
disabled={Boolean(token)}
|
disabled={Boolean(token)}
|
||||||
data-testid={'PLAYGROUND_PROJECT_SELECT'}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
<Input
|
<Input
|
||||||
sx={{ mt: 2, width: '50%', pr: 1 }}
|
sx={{ mt: 2, width: '50%', pr: 1 }}
|
||||||
label='API token'
|
label='API token'
|
||||||
|
Loading…
Reference in New Issue
Block a user