From 5f25fb6a1160d2d60f6f9b4080bbee7e7397d25d Mon Sep 17 00:00:00 2001 From: kypham Date: Mon, 9 Jun 2025 10:01:46 +0700 Subject: [PATCH] Replace redux modules/events with react-query Signed-off-by: kypham --- .../event-status-icon/index.test.tsx | 30 ++++ .../components/event-status-icon/index.tsx | 2 +- .../events-page/event-filter/index.tsx | 23 ++- .../events-page/event-item/index.tsx | 13 +- web/src/components/events-page/index.tsx | 162 +++++++++-------- web/src/modules/events/index.ts | 163 ------------------ web/src/modules/index.ts | 2 - .../events/use-get-events-infinite.tsx | 130 ++++++++++++++ 8 files changed, 253 insertions(+), 272 deletions(-) create mode 100644 web/src/components/event-status-icon/index.test.tsx delete mode 100644 web/src/modules/events/index.ts create mode 100644 web/src/queries/events/use-get-events-infinite.tsx diff --git a/web/src/components/event-status-icon/index.test.tsx b/web/src/components/event-status-icon/index.test.tsx new file mode 100644 index 0000000000..14c0fc628f --- /dev/null +++ b/web/src/components/event-status-icon/index.test.tsx @@ -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( + + ); + expect(getByTestId("event-not-handled-icon")).toBeInTheDocument(); + }); + it("renders the success icon", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("event-success-icon")).toBeInTheDocument(); + }); + it("renders the failure icon", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("event-failure-icon")).toBeInTheDocument(); + }); + it("renders the outdated icon", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("event-outdated-icon")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/event-status-icon/index.tsx b/web/src/components/event-status-icon/index.tsx index 2f5cb605f3..16a936a9ae 100644 --- a/web/src/components/event-status-icon/index.tsx +++ b/web/src/components/event-status-icon/index.tsx @@ -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 { diff --git a/web/src/components/events-page/event-filter/index.tsx b/web/src/components/events-page/event-filter/index.tsx index 39d7cbb616..0c15281ed8 100644 --- a/web/src/components/events-page/event-filter/index.tsx +++ b/web/src/components/events-page/event-filter/index.tsx @@ -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 = memo(function EventFilter({ + events, options, onChange, onClear, @@ -38,11 +37,8 @@ export const EventFilter: FC = memo(function EventFilter({ [options, onChange] ); - const events = useAppSelector((state) => - selectAllEvents(state.events) - ); - const [allNames, setAllNames] = useState(new Array()); + useEffect(() => { const names = new Set(); events.map((event) => { @@ -53,15 +49,17 @@ export const EventFilter: FC = memo(function EventFilter({ const [allLabels, setAllLabels] = useState(new Array()); const [selectedLabels, setSelectedLabels] = useState(new Array()); + useEffect(() => { const labels = new Set(); 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]); @@ -69,7 +67,6 @@ export const EventFilter: FC = memo(function EventFilter({ { onClear(); - setSelectedLabels([]); }} > @@ -78,7 +75,7 @@ export const EventFilter: FC = memo(function EventFilter({ id="filter-event-name" noOptionsText="No selectable name" options={allNames} - value={options.name} + value={options.name ?? ""} onInputChange={(_, value) => { setAllNames([value]); }} diff --git a/web/src/components/events-page/event-item/index.tsx b/web/src/components/events-page/event-item/index.tsx index 95f8ed4652..59280ab866 100644 --- a/web/src/components/events-page/event-item/index.tsx +++ b/web/src/components/events-page/event-item/index.tsx @@ -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 = memo(function EventItem({ id }) { - const event = useAppSelector((state) => - selectEventById(state.events, id) - ); - +export const EventItem: FC = memo(function EventItem({ + event, +}) { if (!event) { return null; } diff --git a/web/src/components/events-page/index.tsx b/web/src/components/events-page/index.tsx index 954da42603..05b1123b34 100644 --- a/web/src/components/events-page/index.tsx +++ b/web/src/components/events-page/index.tsx @@ -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"; @@ -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, @@ -43,42 +31,38 @@ 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(value: TValue | undefined): value is TValue { - return value !== undefined; -} - -const useGroupedEvents = (): Record => { - const events = useShallowEqualSelector((state) => - selectEventIds(state.events) - .map((id) => selectEventById(state.events, id)) - .filter(filterUndefined) - ); - - const result: Record = {}; - - 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 => { + return useMemo(() => { + const result: Record = {}; + + 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({ @@ -86,21 +70,33 @@ export const EventIndexPage: FC = () => { 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( @@ -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); @@ -148,10 +144,10 @@ export const EventIndexPage: FC = () => { color="primary" startIcon={} onClick={handleRefreshClick} - disabled={isLoading} + disabled={isFetching} > {UI_TEXT_REFRESH} - {isLoading && } + {isFetching && } )} {openFilter && ( ; -} - -export const eventsAdapter = createEntityAdapter({ - sortComparer: (a, b) => b.updatedAt - a.updatedAt, -}); - -const initialState = eventsAdapter.getInitialState<{ - status: LoadingStatus; - loading: Record; - hasMore: boolean; - cursor: string; - minUpdatedAt: number; -}>({ - status: "idle", - loading: {}, - hasMore: true, - cursor: "", - minUpdatedAt: Math.round(Date.now() / 1000 - TIME_RANGE_LIMIT_IN_SECONDS), -}); - -const convertFilterOptions = ( - options: EventFilterOptions -): ListEventsRequest.Options.AsObject => { - const labels = new Array<[string, string]>(); - if (options.labels) { - for (const label of options.labels) { - const pair = label.split(":"); - if (pair.length === 2) labels.push([pair[0], pair[1]]); - } - } - return { - name: options.name ?? "", - statusesList: options.status - ? [parseInt(options.status, 10) as EventStatus] - : [], - labelsMap: labels, - }; -}; - -/** - * This action will clear old items and add items. - */ -export const fetchEvents = createAsyncThunk< - { events: Event.AsObject[]; cursor: string }, - EventFilterOptions, - { state: AppState } ->(`${MODULE_NAME}/fetchList`, async (options, thunkAPI) => { - const { events } = thunkAPI.getState(); - const { eventsList, cursor } = await eventsApi.getEvents({ - options: convertFilterOptions({ ...options }), - pageSize: ITEMS_PER_PAGE, - cursor: "", - pageMinUpdatedAt: events.minUpdatedAt, - }); - - return { - events: (eventsList as Event.AsObject[]) || [], - cursor, - }; -}); - -/** - * This action will add items to current state. - */ -export const fetchMoreEvents = createAsyncThunk< - { events: Event.AsObject[]; cursor: string }, - EventFilterOptions, - { state: AppState } ->(`${MODULE_NAME}/fetchMoreList`, async (options, thunkAPI) => { - const { events } = thunkAPI.getState(); - const { eventsList, cursor } = await eventsApi.getEvents({ - options: convertFilterOptions({ ...options }), - pageSize: FETCH_MORE_ITEMS_PER_PAGE, - cursor: events.cursor, - pageMinUpdatedAt: events.minUpdatedAt, - }); - - return { - events: (eventsList as Event.AsObject[]) || [], - cursor, - }; -}); - -export const eventsSlice = createSlice({ - name: MODULE_NAME, - initialState, - reducers: {}, - extraReducers: (builder) => { - builder - .addCase(fetchEvents.pending, (state) => { - state.status = "loading"; - state.hasMore = true; - state.cursor = ""; - }) - .addCase(fetchEvents.fulfilled, (state, action) => { - state.status = "succeeded"; - eventsAdapter.removeAll(state); - if (action.payload.events.length > 0) { - eventsAdapter.upsertMany(state, action.payload.events); - } - if (action.payload.events.length < ITEMS_PER_PAGE) { - state.hasMore = false; - } - state.cursor = action.payload.cursor; - }) - .addCase(fetchEvents.rejected, (state) => { - state.status = "failed"; - }) - .addCase(fetchMoreEvents.pending, (state) => { - state.status = "loading"; - }) - .addCase(fetchMoreEvents.fulfilled, (state, action) => { - state.status = "succeeded"; - eventsAdapter.upsertMany(state, action.payload.events); - const events = action.payload.events; - if (events.length < FETCH_MORE_ITEMS_PER_PAGE) { - state.hasMore = false; - state.minUpdatedAt = state.minUpdatedAt - TIME_RANGE_LIMIT_IN_SECONDS; - } else { - state.hasMore = true; - } - state.cursor = action.payload.cursor; - }) - .addCase(fetchMoreEvents.rejected, (state) => { - state.status = "failed"; - }); - }, -}); - -export const { - selectById, - selectAll, - selectEntities, - selectIds, -} = eventsAdapter.getSelectors(); - -export { Event, EventStatus } from "pipecd/web/model/event_pb"; diff --git a/web/src/modules/index.ts b/web/src/modules/index.ts index 6fccc2d9a0..f7bced8245 100644 --- a/web/src/modules/index.ts +++ b/web/src/modules/index.ts @@ -14,7 +14,6 @@ import { stageLogsSlice } from "./stage-logs"; import { toastsSlice } from "./toasts"; import { updateApplicationSlice } from "./update-application"; import { unregisteredApplicationsSlice } from "./unregistered-applications"; -import { eventsSlice } from "./events"; import { deploymentTraceSlice } from "./deploymentTrace"; export const reducers = combineReducers({ @@ -34,5 +33,4 @@ export const reducers = combineReducers({ apiKeys: apiKeysSlice.reducer, applicationCounts: applicationCountsSlice.reducer, unregisteredApplications: unregisteredApplicationsSlice.reducer, - events: eventsSlice.reducer, }); diff --git a/web/src/queries/events/use-get-events-infinite.tsx b/web/src/queries/events/use-get-events-infinite.tsx new file mode 100644 index 0000000000..395102a99c --- /dev/null +++ b/web/src/queries/events/use-get-events-infinite.tsx @@ -0,0 +1,130 @@ +import { + useInfiniteQuery, + UseInfiniteQueryResult, +} from "@tanstack/react-query"; +import { ListEventsRequest } from "~~/api_client/service_pb"; +import { Event, EventStatus } from "pipecd/web/model/event_pb"; +import * as eventsApi from "~/api/events"; +import { useCallback, useState } from "react"; + +// 30 days +const TIME_RANGE_LIMIT_IN_SECONDS = 2592000; +const FIRST_PAGE_SIZE = 50; +const FETCH_MORE_PAGE_SIZE = 30; + +export type EventStatusKey = keyof typeof EventStatus; + +export type EventFilterOptions = { + name?: string; + status?: string; + // Suppose to be like ["key-1:value-1"] + // sindresorhus/query-string doesn't support multidimensional arrays, that's why the format is a bit tricky. + labels?: Array; +}; + +const convertFilterOptions = ( + options: EventFilterOptions +): ListEventsRequest.Options.AsObject => { + const labels = new Array<[string, string]>(); + if (options.labels) { + for (const label of options.labels) { + const pair = label.split(":"); + if (pair.length === 2) labels.push([pair[0], pair[1]]); + } + } + return { + name: options.name ?? "", + statusesList: options.status + ? [parseInt(options.status, 10) as EventStatus] + : [], + labelsMap: labels, + }; +}; + +type QueryType = UseInfiniteQueryResult<{ + eventsList: Event.AsObject[]; + cursor: string; + minUpdatedAt: number; + hasMore: boolean; +}>; + +export const useGetEventsInfinite = ( + options: EventFilterOptions +): { + data: QueryType["data"]; + isFetching: QueryType["isFetching"]; + fetchNextPage: () => void; + refetch: () => void; + isSuccess: boolean; +} => { + const [localMinUpdatedAt, setLocalMinUpdatedAt] = useState( + Math.round(Date.now() / 1000 - TIME_RANGE_LIMIT_IN_SECONDS) + ); + + const { fetchNextPage, data, ...otherParams } = useInfiniteQuery({ + queryKey: ["events", options], + queryFn: async ({ + pageParam, + }: { + pageParam?: { + cursor?: string; + minUpdatedAt?: number; + }; + }) => { + const isFirstPage = !pageParam; + const minUpdatedAt = pageParam?.minUpdatedAt ?? localMinUpdatedAt; + + const pageSize = isFirstPage ? FIRST_PAGE_SIZE : FETCH_MORE_PAGE_SIZE; + + const { eventsList, cursor } = await eventsApi.getEvents({ + options: convertFilterOptions({ ...options }), + pageSize: pageSize, + cursor: pageParam?.cursor || "", + pageMinUpdatedAt: minUpdatedAt, + }); + + return { + eventsList, + cursor: cursor || pageParam?.cursor || "", + minUpdatedAt, + hasMore: eventsList.length >= pageSize, + }; + }, + refetchOnWindowFocus: false, + }); + + const fetchMoreEvents = useCallback(() => { + const lastPage = data?.pages[data.pages.length - 1]; + const isFirstPage = data?.pages.length === 0; + const PAGE_SIZE = isFirstPage ? FIRST_PAGE_SIZE : FETCH_MORE_PAGE_SIZE; + + if (!lastPage) { + fetchNextPage(); + return; + } + + const isHasMore = lastPage.eventsList.length >= PAGE_SIZE; + if (isHasMore) { + fetchNextPage(); + return; + } + + // Update local state to ensure the next fetch will use the correct minUpdatedAt + const newMinUpdatedAt = lastPage.minUpdatedAt - TIME_RANGE_LIMIT_IN_SECONDS; + setLocalMinUpdatedAt(newMinUpdatedAt); + fetchNextPage({ + pageParam: { + cursor: lastPage.cursor, + minUpdatedAt: newMinUpdatedAt, + }, + }); + }, [data?.pages, fetchNextPage]); + + return { + isFetching: otherParams.isFetching, + refetch: otherParams.refetch, + isSuccess: otherParams.isSuccess, + fetchNextPage: fetchMoreEvents, + data: data, + }; +};