Skip to content

Commit d83bc25

Browse files
:bug fix(agenda-loading): fix AgendaSkeleton displaying on every event reload (#1390)
* Initial plan * Add LoadingProgressLine component and update TimedAgendaEvents to use it Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com> * Add tests for LoadingProgressLine and update Agenda tests Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com> * Update LoadingProgressLine component comment for clarity Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com> * Move LoadingProgressLine inside mainGrid with absolute positioning Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com> * Move LoadingProgressLine to top of ID_GRID_EVENTS_TIMED (timedEvents div) Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com> * :bug fix(agenda-loading): make loading animation subtle * refactor(tests): remove absolute positioning test for LoadingProgressLine * feat(hooks): add useHasLoadedOnce hook for tracking initial load state refactor(agenda): integrate useHasLoadedOnce in Agenda and TimedAgendaEvents components * test(agenda): improve loading state handling and progress line rendering * test(hooks): add tests for useHasLoadedOnce hook functionality --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: victor-enogwe <23452630+victor-enogwe@users.noreply.github.com>
1 parent ad64c52 commit d83bc25

8 files changed

Lines changed: 219 additions & 5 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { useHasLoadedOnce } from "./useHasLoadedOnce";
3+
4+
describe("useHasLoadedOnce", () => {
5+
it("should initialize as false", () => {
6+
const { result } = renderHook(() => useHasLoadedOnce(true));
7+
expect(result.current.current).toBe(false);
8+
});
9+
10+
it("should remain false while loading", () => {
11+
const { result } = renderHook(() => useHasLoadedOnce(true));
12+
expect(result.current.current).toBe(false);
13+
});
14+
15+
it("should become true when loading finishes", () => {
16+
const { result, rerender } = renderHook(
17+
({ isLoading }) => useHasLoadedOnce(isLoading),
18+
{
19+
initialProps: { isLoading: true },
20+
},
21+
);
22+
23+
expect(result.current.current).toBe(false);
24+
25+
rerender({ isLoading: false });
26+
27+
expect(result.current.current).toBe(true);
28+
});
29+
30+
it("should remain true if loading starts again", () => {
31+
const { result, rerender } = renderHook(
32+
({ isLoading }) => useHasLoadedOnce(isLoading),
33+
{
34+
initialProps: { isLoading: true },
35+
},
36+
);
37+
38+
rerender({ isLoading: false });
39+
expect(result.current.current).toBe(true);
40+
41+
rerender({ isLoading: true });
42+
expect(result.current.current).toBe(true);
43+
});
44+
45+
it("should respect the condition parameter", () => {
46+
const { result, rerender } = renderHook(
47+
({ isLoading, condition }) => useHasLoadedOnce(isLoading, condition),
48+
{
49+
initialProps: { isLoading: true, condition: false },
50+
},
51+
);
52+
53+
// Loading finishes, but condition is false
54+
rerender({ isLoading: false, condition: false });
55+
expect(result.current.current).toBe(false);
56+
57+
// Condition becomes true
58+
rerender({ isLoading: false, condition: true });
59+
expect(result.current.current).toBe(true);
60+
});
61+
62+
it("should handle condition being true initially", () => {
63+
const { result } = renderHook(() => useHasLoadedOnce(false, true));
64+
expect(result.current.current).toBe(true);
65+
});
66+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useEffect, useRef } from "react";
2+
3+
export const useHasLoadedOnce = (
4+
isLoading: boolean,
5+
condition: boolean = true,
6+
) => {
7+
const hasLoadedOnce = useRef(false);
8+
9+
useEffect(() => {
10+
if (!isLoading && condition) {
11+
hasLoadedOnce.current = true;
12+
}
13+
}, [isLoading, condition]);
14+
15+
return hasLoadedOnce;
16+
};

packages/web/src/index.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@
7575
--color-gray-300: hsl(219 8 46 / 90.2%);
7676
--color-gray-200: hsl(208 13 71 / 54.9%);
7777
--color-white-100: hsl(0 0 100);
78+
79+
/* Animations */
80+
--animate-progress-slide: progressSlide 2s ease-out infinite;
81+
82+
@keyframes progressSlide {
83+
0% {
84+
width: 0%;
85+
}
86+
100% {
87+
width: 600%;
88+
}
89+
}
7890
}
7991

8092
@layer base {

packages/web/src/views/Day/components/Agenda/Agenda.test.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { screen } from "@testing-library/react";
44
import { Schema_Event } from "@core/types/event.types";
55
import { createStoreWithEvents } from "@web/__tests__/utils/state/store.test.util";
66
import { compareEventsByStartDate } from "@web/common/utils/event/event.util";
7+
import { selectIsDayEventsProcessing } from "@web/ducks/events/selectors/event.selectors";
8+
import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice";
79
import { Agenda } from "@web/views/Day/components/Agenda/Agenda";
810
import { renderWithDayProviders } from "@web/views/Day/util/day.test-util";
911
import { useOpenEventForm } from "@web/views/Forms/hooks/useOpenEventForm";
@@ -14,10 +16,23 @@ jest.mock("@web/auth/auth.util", () => ({
1416

1517
jest.mock("@web/views/Forms/hooks/useOpenEventForm");
1618

19+
jest.mock("@web/ducks/events/selectors/event.selectors", () => {
20+
const actual = jest.requireActual(
21+
"@web/ducks/events/selectors/event.selectors",
22+
);
23+
return {
24+
...actual,
25+
selectIsDayEventsProcessing: jest.fn(),
26+
};
27+
});
28+
1729
const renderAgenda = (
1830
events: Schema_Event[] = [],
1931
options?: { isProcessing?: boolean },
2032
) => {
33+
(selectIsDayEventsProcessing as jest.Mock).mockReturnValue(
34+
options?.isProcessing ?? false,
35+
);
2136
const store = createStoreWithEvents(events, options);
2237
const utils = renderWithDayProviders(<Agenda />, { store });
2338
return { ...utils, store };
@@ -84,6 +99,54 @@ describe("CalendarAgenda", () => {
8499
expect(skeleton).toBeInTheDocument();
85100
});
86101

102+
it("should show progress line on subsequent loads after first load", async () => {
103+
const mockEvents: Schema_Event[] = [
104+
{
105+
_id: "event-1",
106+
title: "Test Event",
107+
startDate: "2024-01-15T10:00:00Z",
108+
endDate: "2024-01-15T11:00:00Z",
109+
isAllDay: false,
110+
},
111+
];
112+
113+
// First render with events loaded (not processing)
114+
const { store } = renderAgenda(mockEvents, { isProcessing: false });
115+
116+
// Verify initial load shows events, not skeleton or progress line
117+
expect(await screen.findByText("Test Event")).toBeInTheDocument();
118+
expect(screen.queryByTestId("agenda-skeleton")).not.toBeInTheDocument();
119+
expect(
120+
screen.queryByTestId("loading-progress-line"),
121+
).not.toBeInTheDocument();
122+
123+
// Dispatch request action to simulate reload
124+
act(() => {
125+
(selectIsDayEventsProcessing as jest.Mock).mockReturnValue(true);
126+
store.dispatch(
127+
eventsEntitiesSlice.actions.edit({
128+
_id: "event-1",
129+
event: {
130+
...mockEvents[0],
131+
title: "Updated Title",
132+
startDate: mockEvents[0].startDate!,
133+
endDate: mockEvents[0].endDate!,
134+
user: "user-123",
135+
priority: "high",
136+
origin: "google",
137+
} as any,
138+
}),
139+
);
140+
});
141+
142+
// On subsequent load after component has loaded once,
143+
// should show progress line not skeleton
144+
expect(
145+
await screen.findByTestId("loading-progress-line"),
146+
).toBeInTheDocument();
147+
expect(screen.queryByTestId("agenda-skeleton")).not.toBeInTheDocument();
148+
});
149+
87150
it("should not show skeleton or error when events are loaded", async () => {
88151
const mockEvents: Schema_Event[] = [
89152
{

packages/web/src/views/Day/components/Agenda/Agenda.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ import { useObservable } from "@ngneat/use-observable";
1111
import { Schema_Event, WithCompassId } from "@core/types/event.types";
1212
import { ID_GRID_EVENTS_TIMED } from "@web/common/constants/web.constants";
1313
import { useFloatingAtCursor } from "@web/common/hooks/useFloatingAtCursor";
14+
import { useHasLoadedOnce } from "@web/common/hooks/useHasLoadedOnce";
1415
import { CursorItem, nodeId$ } from "@web/common/hooks/useOpenAtCursor";
1516
import { compareEventsByStartDate } from "@web/common/utils/event/event.util";
1617
import { FloatingEventForm } from "@web/components/FloatingEventForm/FloatingEventForm";
17-
import { selectDayEvents } from "@web/ducks/events/selectors/event.selectors";
18+
import {
19+
selectDayEvents,
20+
selectIsDayEventsProcessing,
21+
} from "@web/ducks/events/selectors/event.selectors";
1822
import {
1923
allDayEvents$,
2024
eventsStore,
@@ -26,6 +30,7 @@ import { useAppSelector } from "@web/store/store.hooks";
2630
import { AgendaEventPreview } from "@web/views/Day/components/Agenda/Events/AgendaEventPreview/AgendaEventPreview";
2731
import { AllDayAgendaEvents } from "@web/views/Day/components/Agenda/Events/AllDayAgendaEvent/AllDayAgendaEvents";
2832
import { TimedAgendaEvents } from "@web/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvents";
33+
import { LoadingProgressLine } from "@web/views/Day/components/Agenda/LoadingProgressLine/LoadingProgressLine";
2934
import { NowLine } from "@web/views/Day/components/Agenda/NowLine/NowLine";
3035
import { TimeLabels } from "@web/views/Day/components/Agenda/TimeLabels/TimeLabels";
3136
import { EventContextMenu } from "@web/views/Day/components/ContextMenu/EventContextMenu";
@@ -38,6 +43,8 @@ export function Agenda() {
3843
const [timedEvents] = useObservable(timedEvents$);
3944
const height = useRef<number>(0);
4045
const timedAgendaRef = useRef<HTMLElement | null>(null);
46+
const isLoading = useAppSelector(selectIsDayEventsProcessing);
47+
const hasLoadedOnce = useHasLoadedOnce(!!isLoading, timedEvents.length >= 0);
4148

4249
const floating = useFloatingAtCursor((open, _e, reason) => {
4350
const dismissed = reason === "escape-key" || reason === "outside-press";
@@ -80,6 +87,8 @@ export function Agenda() {
8087
);
8188
}, [reduxEvents]);
8289

90+
const showProgressLine = isLoading && hasLoadedOnce.current;
91+
8392
return (
8493
<>
8594
<section
@@ -88,6 +97,12 @@ export function Agenda() {
8897
>
8998
<AllDayAgendaEvents events={allDayEvents} interactions={interactions} />
9099

100+
{showProgressLine ? (
101+
<LoadingProgressLine />
102+
) : (
103+
<div className="h-0.5 border-t border-gray-400/20" />
104+
)}
105+
91106
<div
92107
id={ID_GRID_EVENTS_TIMED}
93108
ref={(e) => {
@@ -98,8 +113,7 @@ export function Agenda() {
98113
className={classNames(
99114
"relative flex flex-1 overflow-x-hidden overflow-y-auto",
100115
"focus-visible:rounded focus-visible:ring-2 focus-visible:outline-none",
101-
"focus:outline-none focus-visible:ring-yellow-200",
102-
"border-t border-gray-400/20 pt-2",
116+
"mt-1 focus:outline-none focus-visible:ring-yellow-200",
103117
)}
104118
data-testid="calendar-scroll"
105119
tabIndex={0}

packages/web/src/views/Day/components/Agenda/Events/TimedAgendaEvent/TimedAgendaEvents.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { UseInteractionsReturn, useMergeRefs } from "@floating-ui/react";
66
import { Schema_Event } from "@core/types/event.types";
77
import { ID_GRID_MAIN } from "@web/common/constants/web.constants";
88
import { useGridOrganization } from "@web/common/hooks/useGridOrganization";
9+
import { useHasLoadedOnce } from "@web/common/hooks/useHasLoadedOnce";
910
import { Schema_GridEvent } from "@web/common/types/web.event.types";
1011
import {
1112
CompassDOMEvents,
@@ -43,6 +44,7 @@ export const TimedAgendaEvents = memo(
4344
const openEventForm = useOpenEventForm();
4445
const [ref, setRef] = useState<HTMLElement | null>(null);
4546
const mergedRef = useMergeRefs([setRef, _ref]);
47+
const hasLoadedOnce = useHasLoadedOnce(!!isLoading, ref !== null);
4648

4749
useGridOrganization(ref);
4850

@@ -51,6 +53,9 @@ export const TimedAgendaEvents = memo(
5153
compassEventEmitter.emit(CompassDOMEvents.SCROLL_TO_NOW_LINE);
5254
}, [pathname]);
5355

56+
const showSkeleton =
57+
(isLoading || ref === null) && !hasLoadedOnce.current;
58+
5459
return (
5560
<Droppable
5661
{...interactions?.getReferenceProps({ onClick: openEventForm })}
@@ -65,14 +70,14 @@ export const TimedAgendaEvents = memo(
6570
style={{ height }}
6671
>
6772
{/* Event blocks */}
68-
{isLoading || ref === null ? (
73+
{showSkeleton ? (
6974
<AgendaSkeleton />
7075
) : (
7176
events.map((event) => (
7277
<DraggableTimedAgendaEvent
7378
key={event._id}
7479
event={event as Schema_GridEvent}
75-
bounds={ref}
80+
bounds={ref!}
7681
interactions={interactions}
7782
isDraftEvent={draft?._id === event._id}
7883
isNewDraftEvent={!timedEvents.find((e) => e._id === event._id)}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import "@testing-library/jest-dom";
2+
import { render, screen } from "@testing-library/react";
3+
import { LoadingProgressLine } from "./LoadingProgressLine";
4+
5+
describe("LoadingProgressLine", () => {
6+
it("should render the progress line", () => {
7+
render(<LoadingProgressLine />);
8+
9+
const progressLine = screen.getByTestId("loading-progress-line");
10+
expect(progressLine).toBeInTheDocument();
11+
});
12+
13+
it("should span full width", () => {
14+
render(<LoadingProgressLine />);
15+
16+
const progressLine = screen.getByTestId("loading-progress-line");
17+
expect(progressLine).toHaveClass("w-full");
18+
});
19+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import classNames from "classnames";
2+
3+
/**
4+
* Loading progress line component that shows at the top of the timedEvents section (ID_GRID_EVENTS_TIMED)
5+
* during subsequent event reloads. Displays an animated color-transitioning
6+
* line that indicates loading state without obstructing the user's view.
7+
*/
8+
export function LoadingProgressLine() {
9+
return (
10+
<div
11+
data-testid="loading-progress-line"
12+
className={classNames(
13+
"h-0.5 w-full",
14+
"bg-linear-to-r/longer from-gray-500 to-gray-300",
15+
"motion-safe:animate-progress-slide",
16+
)}
17+
/>
18+
);
19+
}

0 commit comments

Comments
 (0)