Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions web/src/components/event-status-icon/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render } from "~~/test-utils";
import { EventStatusIcon } from ".";
import { EventStatus } from "~~/model/event_pb";

describe("EventStatusIcon", () => {
it("renders the correct status icon", () => {
const { getByTestId } = render(
<EventStatusIcon status={EventStatus.EVENT_NOT_HANDLED} />
);
expect(getByTestId("event-not-handled-icon")).toBeInTheDocument();
});
it("renders the success icon", () => {
const { getByTestId } = render(
<EventStatusIcon status={EventStatus.EVENT_SUCCESS} />
);
expect(getByTestId("event-success-icon")).toBeInTheDocument();
});
it("renders the failure icon", () => {
const { getByTestId } = render(
<EventStatusIcon status={EventStatus.EVENT_FAILURE} />
);
expect(getByTestId("event-failure-icon")).toBeInTheDocument();
});
it("renders the outdated icon", () => {
const { getByTestId } = render(
<EventStatusIcon status={EventStatus.EVENT_OUTDATED} />
);
expect(getByTestId("event-outdated-icon")).toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion web/src/components/event-status-icon/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
IndeterminateCheckBox,
Block,
} from "@mui/icons-material";
import { EventStatus } from "~/modules/events";
import { EventStatus } from "pipecd/web/model/event_pb";
import { FC } from "react";

export interface EventStatusIconProps {
Expand Down
23 changes: 10 additions & 13 deletions web/src/components/events-page/event-filter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,23 @@ import Autocomplete from "@mui/material/Autocomplete";
import { FC, memo, useCallback, useState, useEffect } from "react";
import { FilterView } from "~/components/filter-view";
import { EVENT_STATE_TEXT } from "~/constants/event-status-text";
import { useAppSelector } from "~/hooks/redux";
import { Event, EventStatus } from "pipecd/web/model/event_pb";
import {
Event,
EventFilterOptions,
EventStatus,
EventStatusKey,
selectAll as selectAllEvents,
} from "~/modules/events";
} from "~/queries/events/use-get-events-infinite";

const ALL_VALUE = "ALL";

export interface EventFilterProps {
events: Event.AsObject[];
options: EventFilterOptions;
onClear: () => void;
onChange: (options: EventFilterOptions) => void;
}

export const EventFilter: FC<EventFilterProps> = memo(function EventFilter({
events,
options,
onChange,
onClear,
Expand All @@ -38,11 +37,8 @@ export const EventFilter: FC<EventFilterProps> = memo(function EventFilter({
[options, onChange]
);

const events = useAppSelector<Event.AsObject[]>((state) =>
selectAllEvents(state.events)
);

const [allNames, setAllNames] = useState(new Array<string>());

useEffect(() => {
const names = new Set<string>();
events.map((event) => {
Expand All @@ -53,23 +49,24 @@ export const EventFilter: FC<EventFilterProps> = memo(function EventFilter({

const [allLabels, setAllLabels] = useState(new Array<string>());
const [selectedLabels, setSelectedLabels] = useState(new Array<string>());

useEffect(() => {
const labels = new Set<string>();
events
.filter((app) => app.labelsMap.length > 0)
.map((app) => {
app.labelsMap.map((label) => {
.forEach((app) => {
app.labelsMap.forEach((label) => {
labels.add(`${label[0]}:${label[1]}`);
});
});

setAllLabels(Array.from(labels));
}, [events]);

return (
<FilterView
onClear={() => {
onClear();
setSelectedLabels([]);
}}
>
<FormControl sx={{ width: "100%", mt: 4 }} variant="outlined">
Expand All @@ -78,7 +75,7 @@ export const EventFilter: FC<EventFilterProps> = memo(function EventFilter({
id="filter-event-name"
noOptionsText="No selectable name"
options={allNames}
value={options.name}
value={options.name ?? ""}
onInputChange={(_, value) => {
setAllNames([value]);
}}
Expand Down
13 changes: 5 additions & 8 deletions web/src/components/events-page/event-item/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,18 @@ import { Box, ListItem, Typography, Chip } from "@mui/material";
import dayjs from "dayjs";
import { FC, memo } from "react";
import { EVENT_STATE_TEXT } from "~/constants/event-status-text";
import { useAppSelector } from "~/hooks/redux";
import { Event, selectById as selectEventById } from "~/modules/events";
import { EventStatusIcon } from "~/components/event-status-icon";
import { Event } from "pipecd/web/model/event_pb";

export interface EventItemProps {
id: string;
event: Event.AsObject;
}

const NO_DESCRIPTION = "No description.";

export const EventItem: FC<EventItemProps> = memo(function EventItem({ id }) {
const event = useAppSelector<Event.AsObject | undefined>((state) =>
selectEventById(state.events, id)
);

export const EventItem: FC<EventItemProps> = memo(function EventItem({
event,
}) {
if (!event) {
return null;
}
Expand Down
162 changes: 77 additions & 85 deletions web/src/components/events-page/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import CloseIcon from "@mui/icons-material/Close";
import FilterIcon from "@mui/icons-material/FilterList";
import RefreshIcon from "@mui/icons-material/Refresh";
import dayjs from "dayjs";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useInView } from "react-intersection-observer";
import { useNavigate } from "react-router-dom";
import { PAGE_PATH_EVENTS } from "~/constants/path";
Expand All @@ -21,20 +21,8 @@ import {
UI_TEXT_REFRESH,
UI_TEXT_MORE,
} from "~/constants/ui-text";
import {
useAppDispatch,
useAppSelector,
useShallowEqualSelector,
} from "~/hooks/redux";
import { fetchApplications } from "~/modules/applications";
import {
Event,
EventFilterOptions,
fetchEvents,
fetchMoreEvents,
selectById as selectEventById,
selectIds as selectEventIds,
} from "~/modules/events";

import { Event } from "pipecd/web/model/event_pb";
import { SpinnerIcon } from "~/styles/button";
import {
stringifySearchParams,
Expand All @@ -43,64 +31,72 @@ import {
} from "~/utils/search-params";
import { EventFilter } from "./event-filter";
import { EventItem } from "./event-item";
import {
EventFilterOptions,
useGetEventsInfinite,
} from "~/queries/events/use-get-events-infinite";

const sortComp = (a: string | number, b: string | number): number => {
return dayjs(b).valueOf() - dayjs(a).valueOf();
};

function filterUndefined<TValue>(value: TValue | undefined): value is TValue {
return value !== undefined;
}

const useGroupedEvents = (): Record<string, Event.AsObject[]> => {
const events = useShallowEqualSelector<Event.AsObject[]>((state) =>
selectEventIds(state.events)
.map((id) => selectEventById(state.events, id))
.filter(filterUndefined)
);

const result: Record<string, Event.AsObject[]> = {};

events.forEach((event) => {
const dateStr = dayjs(event.createdAt * 1000).format("YYYY/MM/DD");
if (!result[dateStr]) {
result[dateStr] = [];
}
result[dateStr].push(event);
});

return result;
const useNewGroupedEvents = (
events: Event.AsObject[]
): Record<string, Event.AsObject[]> => {
return useMemo(() => {
const result: Record<string, Event.AsObject[]> = {};

events.forEach((event) => {
const dateStr = dayjs(event.createdAt * 1000).format("YYYY/MM/DD");
if (!result[dateStr]) {
result[dateStr] = [];
}
if (!result[dateStr].some((e) => e.id === event.id)) {
result[dateStr].push(event);
}
});

return result;
}, [events]);
};

export const EventIndexPage: FC = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const listRef = useRef(null);
const status = useAppSelector((state) => state.events.status);
const hasMore = useAppSelector((state) => state.events.hasMore);
const groupedEvents = useGroupedEvents();
const filterOptions = useSearchParams();
const [openFilter, setOpenFilter] = useState(true);
const [ref, inView] = useInView({
rootMargin: "400px",
root: listRef.current,
});

const isLoading = status === "loading";

useEffect(() => {
dispatch(fetchApplications());
}, [dispatch]);
const {
data: eventsData,
isFetching,
fetchNextPage: fetchMoreEvents,
refetch: refetchEvents,
isSuccess,
} = useGetEventsInfinite(filterOptions);

const eventsList = useMemo(() => {
return eventsData?.pages.flatMap((item) => item.eventsList) || [];
}, [eventsData]);

const hasMore = useMemo(() => {
if (!eventsData || eventsData.pages.length === 0) {
return false;
}
const lastIndex = eventsData?.pages.length - 1;
return eventsData?.pages?.[lastIndex]?.hasMore || false;
}, [eventsData]);

useEffect(() => {
dispatch(fetchEvents(filterOptions));
}, [dispatch, filterOptions]);
const groupedEvents = useNewGroupedEvents(eventsList || []);

useEffect(() => {
if (inView && hasMore && isLoading === false) {
dispatch(fetchMoreEvents(filterOptions));
if (inView && hasMore && isFetching === false) {
fetchMoreEvents();
}
}, [dispatch, inView, hasMore, isLoading, filterOptions]);
}, [inView, isFetching, filterOptions, fetchMoreEvents, hasMore]);

// filter handlers
const handleFilterChange = useCallback(
Expand All @@ -120,12 +116,12 @@ export const EventIndexPage: FC = () => {
}, [navigate]);

const handleRefreshClick = useCallback(() => {
dispatch(fetchEvents(filterOptions));
}, [dispatch, filterOptions]);
refetchEvents();
}, [refetchEvents]);

const handleMoreClick = useCallback(() => {
dispatch(fetchMoreEvents(filterOptions));
}, [dispatch, filterOptions]);
fetchMoreEvents();
}, [fetchMoreEvents]);

const dates = Object.keys(groupedEvents).sort(sortComp);

Expand All @@ -148,10 +144,10 @@ export const EventIndexPage: FC = () => {
color="primary"
startIcon={<RefreshIcon />}
onClick={handleRefreshClick}
disabled={isLoading}
disabled={isFetching}
>
{UI_TEXT_REFRESH}
{isLoading && <SpinnerIcon />}
{isFetching && <SpinnerIcon />}
</Button>
<Button
color="primary"
Expand Down Expand Up @@ -181,28 +177,21 @@ export const EventIndexPage: FC = () => {
}}
ref={listRef}
>
{dates.length === 0 &&
(isLoading ? (
<Box
sx={{
display: "flex",
justifyContent: "center",
mt: 3,
}}
>
{dates.length === 0 && (
<Box
sx={{
display: "flex",
justifyContent: "center",
mt: 3,
}}
>
{isFetching ? (
<CircularProgress />
</Box>
) : (
<Box
sx={{
display: "flex",
justifyContent: "center",
mt: 3,
}}
>
) : (
<Typography>No events</Typography>
</Box>
))}
)}
</Box>
)}
{dates.map((date) => (
<li key={date}>
<Typography
Expand All @@ -217,29 +206,32 @@ export const EventIndexPage: FC = () => {
<List>
{groupedEvents[date]
.sort((a, b) => sortComp(a.createdAt, b.createdAt))
.map((event) => (
<EventItem id={event.id} key={`event-item-${event.id}`} />
))}
.map((event) => {
return (
<EventItem event={event} key={`event-item-${event.id}`} />
);
})}
</List>
</li>
))}
{status === "succeeded" && <div ref={ref} />}
{!hasMore && (
{isSuccess && <div ref={ref} />}
{!eventsData?.pages?.[0]?.hasMore && (
<Button
color="primary"
variant="outlined"
size="large"
fullWidth
onClick={handleMoreClick}
disabled={isLoading}
disabled={isFetching}
>
{UI_TEXT_MORE}
{isLoading && <SpinnerIcon />}
{isFetching && <SpinnerIcon />}
</Button>
)}
</Box>
{openFilter && (
<EventFilter
events={eventsList}
options={filterOptions}
onChange={handleFilterChange}
onClear={handleFilterClear}
Expand Down
Loading