mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
chore: event timeline (#8176)
https://linear.app/unleash/issue/2-2657/implement-a-first-iteration-of-an-horizontal-event-timeline This implements the very first iteration of our event timeline. This is behind a feature flag, which when enabled shows the new timeline at the top of our event log page. It is missing some features, like: - Placement: It should show up as an option in the header, not in the event log; - Tooltip: It should show proper tooltips for all the events that we're displaying; - Grouping: It should group together events that occurred in a short span of time; - Signals: It should show signals along with the events; Here's how it currently looks like, with some example events, in order from left to right: - A flag was disabled more than 30 min ago; - A flag was then enabled; - A segment was updated (didn't have an icon for segments, so I picked one); - A strategy was updated; - A flag was created;   (Time passed since I took the first screenshot, so you can see the events "moved" to the left slightly in the dark theme screenshot) I have some concerns about the low contrast of `neutral` variant events, especially in dark mode. Maybe we should consider using `error` instead, for red? Or maybe add a border to our event circles? I specifically changed my environment to be "development" for the screenshots. The default selection is the first enabled environment that is `type=production`, which in my case is "production". Here are our filters: - Time Span  - Environment  Here are a few more screenshots, with the different time spans (zooming out, since we're increasing the time span):       Again, when zooming out, some events should be grouped together, but that's a task for later.
This commit is contained in:
parent
70e95e66a8
commit
205b59ddee
@ -1,4 +1,4 @@
|
||||
import { Switch, FormControlLabel, useMediaQuery } from '@mui/material';
|
||||
import { Switch, FormControlLabel, useMediaQuery, Box } from '@mui/material';
|
||||
import EventJson from 'component/events/EventJson/EventJson';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
@ -15,6 +15,7 @@ import { useEventLogSearch } from './useEventLogSearch';
|
||||
import { StickyPaginationBar } from 'component/common/Table/StickyPaginationBar/StickyPaginationBar';
|
||||
import { EventActions } from './EventActions';
|
||||
import useLoading from 'hooks/useLoading';
|
||||
import { EventTimeline } from '../EventTimeline/EventTimeline';
|
||||
|
||||
interface IEventLogProps {
|
||||
title: string;
|
||||
@ -50,7 +51,8 @@ const Placeholder = styled('li')({
|
||||
});
|
||||
|
||||
export const EventLog = ({ title, project, feature }: IEventLogProps) => {
|
||||
const { isEnterprise } = useUiConfig();
|
||||
const { isOss, isEnterprise } = useUiConfig();
|
||||
const eventTimeline = useUiFlag('eventTimeline') && !isOss();
|
||||
const showFilters = useUiFlag('newEventSearch') && isEnterprise();
|
||||
const {
|
||||
events,
|
||||
@ -131,55 +133,72 @@ export const EventLog = ({ title, project, feature }: IEventLogProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
bodyClass={'no-padding'}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`${title} (${total})`}
|
||||
actions={
|
||||
<>
|
||||
{showDataSwitch}
|
||||
<EventActions events={events} />
|
||||
{!isSmallScreen && searchInputField}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isSmallScreen && searchInputField}
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<EventResultWrapper ref={ref} withFilters={showFilters}>
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={eventTimeline}
|
||||
show={
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(2),
|
||||
marginBottom: theme.spacing(2),
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
})}
|
||||
>
|
||||
<EventTimeline />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<PageContent
|
||||
bodyClass={'no-padding'}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`${title} (${total})`}
|
||||
actions={
|
||||
<>
|
||||
{showDataSwitch}
|
||||
<EventActions events={events} />
|
||||
{!isSmallScreen && searchInputField}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isSmallScreen && searchInputField}
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<EventResultWrapper ref={ref} withFilters={showFilters}>
|
||||
<ConditionallyRender
|
||||
condition={showFilters}
|
||||
show={
|
||||
<StyledFilters
|
||||
logType={
|
||||
project
|
||||
? 'project'
|
||||
: feature
|
||||
? 'flag'
|
||||
: 'global'
|
||||
}
|
||||
state={filterState}
|
||||
onChange={setTableState}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{resultComponent()}
|
||||
</EventResultWrapper>
|
||||
<ConditionallyRender
|
||||
condition={showFilters}
|
||||
condition={total > 25}
|
||||
show={
|
||||
<StyledFilters
|
||||
logType={
|
||||
project
|
||||
? 'project'
|
||||
: feature
|
||||
? 'flag'
|
||||
: 'global'
|
||||
}
|
||||
state={filterState}
|
||||
onChange={setTableState}
|
||||
<StickyPaginationBar
|
||||
totalItems={total}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.currentPage}
|
||||
fetchPrevPage={pagination.prevPage}
|
||||
fetchNextPage={pagination.nextPage}
|
||||
setPageLimit={pagination.setPageLimit}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{resultComponent()}
|
||||
</EventResultWrapper>
|
||||
<ConditionallyRender
|
||||
condition={total > 25}
|
||||
show={
|
||||
<StickyPaginationBar
|
||||
totalItems={total}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.currentPage}
|
||||
fetchPrevPage={pagination.prevPage}
|
||||
fetchNextPage={pagination.nextPage}
|
||||
setPageLimit={pagination.setPageLimit}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageContent>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
151
frontend/src/component/events/EventTimeline/EventTimeline.tsx
Normal file
151
frontend/src/component/events/EventTimeline/EventTimeline.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { EventSchemaType } from 'openapi';
|
||||
import { useState } from 'react';
|
||||
import { startOfDay, sub } from 'date-fns';
|
||||
import type { IEnvironment } from 'interfaces/environments';
|
||||
import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch';
|
||||
import { EventTimelineEvent } from './EventTimelineEvent/EventTimelineEvent';
|
||||
import {
|
||||
EventTimelineHeader,
|
||||
type TimeSpanOption,
|
||||
timeSpanOptions,
|
||||
} from './EventTimelineHeader/EventTimelineHeader';
|
||||
|
||||
const StyledRow = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
const StyledTimelineContainer = styled('div')(({ theme }) => ({
|
||||
position: 'relative',
|
||||
height: theme.spacing(1),
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(1.5, 0),
|
||||
}));
|
||||
|
||||
const StyledTimeline = styled('div')(({ theme }) => ({
|
||||
backgroundColor: theme.palette.divider,
|
||||
height: theme.spacing(0.5),
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
const StyledMiddleMarkerContainer = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
const StyledMarker = styled('div')(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
height: theme.spacing(1),
|
||||
width: theme.spacing(0.25),
|
||||
backgroundColor: theme.palette.text.secondary,
|
||||
}));
|
||||
|
||||
const StyledMiddleMarker = styled(StyledMarker)(({ theme }) => ({
|
||||
top: theme.spacing(-2),
|
||||
}));
|
||||
|
||||
const StyledMarkerLabel = styled('div')(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallerBody,
|
||||
color: theme.palette.text.secondary,
|
||||
}));
|
||||
|
||||
const StyledStart = styled(StyledMarker)({
|
||||
left: 0,
|
||||
});
|
||||
|
||||
const StyledEnd = styled(StyledMarker)({
|
||||
right: 0,
|
||||
});
|
||||
|
||||
const RELEVANT_EVENT_TYPES: EventSchemaType[] = [
|
||||
'strategy-reactivated',
|
||||
'strategy-updated',
|
||||
'segment-updated',
|
||||
'segment-deleted',
|
||||
'feature-created',
|
||||
'feature-updated',
|
||||
'feature-variants-updated',
|
||||
'feature-archived',
|
||||
'feature-revived',
|
||||
'feature-strategy-update',
|
||||
'feature-strategy-add',
|
||||
'feature-strategy-remove',
|
||||
'feature-environment-enabled',
|
||||
'feature-environment-disabled',
|
||||
];
|
||||
|
||||
const toISODateString = (date: Date) => date.toISOString().split('T')[0];
|
||||
|
||||
export const EventTimeline = () => {
|
||||
const [timeSpan, setTimeSpan] = useState<TimeSpanOption>(
|
||||
timeSpanOptions[0],
|
||||
);
|
||||
const [environment, setEnvironment] = useState<IEnvironment | undefined>();
|
||||
|
||||
const endDate = new Date();
|
||||
const startDate = sub(endDate, timeSpan.value);
|
||||
|
||||
const { events } = useEventSearch(
|
||||
{
|
||||
from: `IS:${toISODateString(startOfDay(startDate))}`,
|
||||
to: `IS:${toISODateString(endDate)}`,
|
||||
type: `IS_ANY_OF:${RELEVANT_EVENT_TYPES.join(',')}`,
|
||||
},
|
||||
{ refreshInterval: 10 * 1000 },
|
||||
);
|
||||
|
||||
const filteredEvents = events.filter(
|
||||
(event) =>
|
||||
new Date(event.createdAt).getTime() >= startDate.getTime() &&
|
||||
new Date(event.createdAt).getTime() <= endDate.getTime() &&
|
||||
RELEVANT_EVENT_TYPES.includes(event.type) &&
|
||||
(!event.environment ||
|
||||
!environment ||
|
||||
event.environment === environment.name),
|
||||
);
|
||||
|
||||
const sortedEvents = [...filteredEvents].reverse();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledRow>
|
||||
<EventTimelineHeader
|
||||
totalEvents={sortedEvents.length}
|
||||
timeSpan={timeSpan}
|
||||
setTimeSpan={setTimeSpan}
|
||||
environment={environment}
|
||||
setEnvironment={setEnvironment}
|
||||
/>
|
||||
</StyledRow>
|
||||
<StyledTimelineContainer>
|
||||
<StyledTimeline />
|
||||
<StyledStart />
|
||||
{sortedEvents.map((event) => (
|
||||
<EventTimelineEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
))}
|
||||
<StyledEnd />
|
||||
</StyledTimelineContainer>
|
||||
<StyledRow>
|
||||
<StyledMarkerLabel>{timeSpan.markers[0]}</StyledMarkerLabel>
|
||||
{timeSpan.markers.slice(1).map((marker) => (
|
||||
<StyledMiddleMarkerContainer key={marker}>
|
||||
<StyledMiddleMarker />
|
||||
<StyledMarkerLabel>{marker}</StyledMarkerLabel>
|
||||
</StyledMiddleMarkerContainer>
|
||||
))}
|
||||
<StyledMarkerLabel>now</StyledMarkerLabel>
|
||||
</StyledRow>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,108 @@
|
||||
import type { EventSchema, EventSchemaType } from 'openapi';
|
||||
import { styled } from '@mui/material';
|
||||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||
import { EventTimelineEventTooltip } from './EventTimelineEventTooltip/EventTimelineEventTooltip';
|
||||
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
|
||||
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
|
||||
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
|
||||
import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined';
|
||||
import SegmentsIcon from '@mui/icons-material/DonutLargeOutlined';
|
||||
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
||||
|
||||
type DefaultEventVariant = 'secondary';
|
||||
type CustomEventVariant = 'success' | 'neutral';
|
||||
type EventVariant = DefaultEventVariant | CustomEventVariant;
|
||||
|
||||
const StyledEvent = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'position',
|
||||
})<{ position: string }>(({ position }) => ({
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
left: position,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
}));
|
||||
|
||||
const StyledEventCircle = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'variant',
|
||||
})<{ variant: EventVariant }>(({ theme, variant }) => ({
|
||||
height: theme.spacing(3),
|
||||
width: theme.spacing(3),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: theme.palette[variant].light,
|
||||
border: `1px solid ${theme.palette[variant].main}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'transform 0.2s',
|
||||
'& svg': {
|
||||
color: theme.palette[variant].main,
|
||||
height: theme.spacing(2.5),
|
||||
width: theme.spacing(2.5),
|
||||
},
|
||||
'&:hover': {
|
||||
transform: 'scale(1.5)',
|
||||
},
|
||||
}));
|
||||
|
||||
const getEventIcon = (type: EventSchemaType) => {
|
||||
if (type === 'feature-environment-enabled') {
|
||||
return <ToggleOnIcon />;
|
||||
}
|
||||
if (type === 'feature-environment-disabled') {
|
||||
return <ToggleOffIcon />;
|
||||
}
|
||||
if (type.startsWith('strategy-') || type.startsWith('feature-strategy-')) {
|
||||
return <ExtensionOutlinedIcon />;
|
||||
}
|
||||
if (type.startsWith('feature-')) {
|
||||
return <FlagOutlinedIcon />;
|
||||
}
|
||||
if (type.startsWith('segment-')) {
|
||||
return <SegmentsIcon />;
|
||||
}
|
||||
|
||||
return <QuestionMarkIcon />;
|
||||
};
|
||||
|
||||
const customEventVariants: Partial<
|
||||
Record<EventSchemaType, CustomEventVariant>
|
||||
> = {
|
||||
'feature-environment-enabled': 'success',
|
||||
'feature-environment-disabled': 'neutral',
|
||||
'feature-archived': 'neutral',
|
||||
};
|
||||
|
||||
interface IEventTimelineEventProps {
|
||||
event: EventSchema;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export const EventTimelineEvent = ({
|
||||
event,
|
||||
startDate,
|
||||
endDate,
|
||||
}: IEventTimelineEventProps) => {
|
||||
const timelineDuration = endDate.getTime() - startDate.getTime();
|
||||
const eventTime = new Date(event.createdAt).getTime();
|
||||
|
||||
const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`;
|
||||
|
||||
const variant = customEventVariants[event.type] || 'secondary';
|
||||
|
||||
return (
|
||||
<StyledEvent position={position}>
|
||||
<HtmlTooltip
|
||||
title={<EventTimelineEventTooltip event={event} />}
|
||||
arrow
|
||||
>
|
||||
<StyledEventCircle variant={variant}>
|
||||
{getEventIcon(event.type)}
|
||||
</StyledEventCircle>
|
||||
</HtmlTooltip>
|
||||
</StyledEvent>
|
||||
);
|
||||
};
|
@ -0,0 +1,50 @@
|
||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||
import type { EventSchema } from 'openapi';
|
||||
import { formatDateYMDHMS } from 'utils/formatDate';
|
||||
|
||||
interface IEventTimelineEventTooltipProps {
|
||||
event: EventSchema;
|
||||
}
|
||||
|
||||
export const EventTimelineEventTooltip = ({
|
||||
event,
|
||||
}: IEventTimelineEventTooltipProps) => {
|
||||
const { locationSettings } = useLocationSettings();
|
||||
const eventDateTime = formatDateYMDHMS(
|
||||
event.createdAt,
|
||||
locationSettings?.locale,
|
||||
);
|
||||
|
||||
if (event.type === 'feature-environment-enabled') {
|
||||
return (
|
||||
<div>
|
||||
<small>{eventDateTime}</small>
|
||||
<p>
|
||||
{event.createdBy} enabled {event.featureName} for the{' '}
|
||||
{event.environment} environment in project {event.project}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (event.type === 'feature-environment-disabled') {
|
||||
return (
|
||||
<div>
|
||||
<small>{eventDateTime}</small>
|
||||
<p>
|
||||
{event.createdBy} disabled {event.featureName} for the{' '}
|
||||
{event.environment} environment in project {event.project}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{eventDateTime}</div>
|
||||
<div>{event.createdBy}</div>
|
||||
<div>{event.type}</div>
|
||||
<div>{event.featureName}</div>
|
||||
<div>{event.environment}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,167 @@
|
||||
import { MenuItem, styled, TextField } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||
import type { IEnvironment } from 'interfaces/environments';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
const StyledCol = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledFilter = styled(TextField)(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
'& > div': {
|
||||
background: 'transparent',
|
||||
'& > .MuiSelect-select': {
|
||||
padding: theme.spacing(0.5, 4, 0.5, 1),
|
||||
background: 'transparent',
|
||||
},
|
||||
'& > fieldset': { borderColor: 'transparent' },
|
||||
},
|
||||
}));
|
||||
|
||||
export type TimeSpanOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: Duration;
|
||||
markers: string[];
|
||||
};
|
||||
|
||||
export const timeSpanOptions: TimeSpanOption[] = [
|
||||
{
|
||||
key: '30m',
|
||||
label: 'last 30 min',
|
||||
value: { minutes: 30 },
|
||||
markers: ['30 min ago'],
|
||||
},
|
||||
{
|
||||
key: '1h',
|
||||
label: 'last hour',
|
||||
value: { hours: 1 },
|
||||
markers: ['1 hour ago', '30 min ago'],
|
||||
},
|
||||
{
|
||||
key: '3h',
|
||||
label: 'last 3 hours',
|
||||
value: { hours: 3 },
|
||||
markers: ['3 hours ago', '2 hours ago', '1 hour ago'],
|
||||
},
|
||||
{
|
||||
key: '12h',
|
||||
label: 'last 12 hours',
|
||||
value: { hours: 12 },
|
||||
markers: ['12 hours ago', '9 hours ago', '6 hours ago', '3 hours ago'],
|
||||
},
|
||||
{
|
||||
key: '24h',
|
||||
label: 'last 24 hours',
|
||||
value: { hours: 24 },
|
||||
markers: [
|
||||
'24 hours ago',
|
||||
'18 hours ago',
|
||||
'12 hours ago',
|
||||
'6 hours ago',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '48h',
|
||||
label: 'last 48 hours',
|
||||
value: { hours: 48 },
|
||||
markers: [
|
||||
'48 hours ago',
|
||||
'36 hours ago',
|
||||
'24 hours ago',
|
||||
'12 hours ago',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface IEventTimelineHeaderProps {
|
||||
totalEvents: number;
|
||||
timeSpan: TimeSpanOption;
|
||||
setTimeSpan: (timeSpan: TimeSpanOption) => void;
|
||||
environment: IEnvironment | undefined;
|
||||
setEnvironment: (environment: IEnvironment) => void;
|
||||
}
|
||||
|
||||
export const EventTimelineHeader = ({
|
||||
totalEvents,
|
||||
timeSpan,
|
||||
setTimeSpan,
|
||||
environment,
|
||||
setEnvironment,
|
||||
}: IEventTimelineHeaderProps) => {
|
||||
const { environments } = useEnvironments();
|
||||
|
||||
const activeEnvironments = useMemo(
|
||||
() => environments.filter(({ enabled }) => enabled),
|
||||
[environments],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeEnvironments.length > 0) {
|
||||
const defaultEnvironment =
|
||||
activeEnvironments.find(({ type }) => type === 'production') ||
|
||||
activeEnvironments[0];
|
||||
setEnvironment(defaultEnvironment);
|
||||
}
|
||||
}, [activeEnvironments]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledCol>
|
||||
<span>
|
||||
{totalEvents} event
|
||||
{totalEvents === 1 ? '' : 's'}
|
||||
</span>
|
||||
<StyledFilter
|
||||
select
|
||||
size='small'
|
||||
variant='outlined'
|
||||
value={timeSpan.key}
|
||||
onChange={(e) =>
|
||||
setTimeSpan(
|
||||
timeSpanOptions.find(
|
||||
({ key }) => key === e.target.value,
|
||||
) || timeSpanOptions[0],
|
||||
)
|
||||
}
|
||||
>
|
||||
{timeSpanOptions.map(({ key, label }) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</StyledFilter>
|
||||
</StyledCol>
|
||||
<StyledCol>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(environment)}
|
||||
show={() => (
|
||||
<StyledFilter
|
||||
select
|
||||
size='small'
|
||||
variant='outlined'
|
||||
value={environment!.name}
|
||||
onChange={(e) =>
|
||||
setEnvironment(
|
||||
environments.find(
|
||||
({ name }) => name === e.target.value,
|
||||
) || environments[0],
|
||||
)
|
||||
}
|
||||
>
|
||||
{environments.map(({ name }) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</StyledFilter>
|
||||
)}
|
||||
/>
|
||||
</StyledCol>
|
||||
</>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user