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
8 changes: 8 additions & 0 deletions src/features/dashboard/dashboardSelectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ export const selectRecentDonations = (state) => state.dashboard.recentDonations;
export const selectRecentCampaigns = (state) => state.dashboard.recentCampaigns;
export const selectDashboardLoading = (state) => state.dashboard.isLoading;
export const selectDashboardError = (state) => state.dashboard.error;

/**
* Returns the per-resource `{ stats, donations, campaigns }` cache
* timestamps (ms since epoch) for the dashboard. Each entry is `null` when
* the cache for that resource has been invalidated.
*/
export const selectDashboardLastFetched = (state) =>
state.dashboard.lastFetched;
41 changes: 39 additions & 2 deletions src/features/dashboard/dashboardSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,52 @@ import {
fetchRecentDonations,
fetchRecentCampaigns,
} from './dashboardThunks';
import { logoutUser } from '../auth/authThunks';

const initialState = {
stats: null,
recentDonations: [],
recentCampaigns: [],
isLoading: false,
error: null,
/**
* Per-resource cache timestamps (ms since epoch).
* Used by the dashboard thunks to decide whether to skip the API call.
* A `null` value means the cache for that resource is invalid and a
* request should be issued on the next dispatch.
*/
lastFetched: {
stats: null,
donations: null,
campaigns: null,
},
};

/**
* Invalidate one or every dashboard cache timestamp.
*
* Pass a named resource ('stats' | 'donations' | 'campaigns') to invalidate
* just that entry, or pass `undefined` / `null` / `'all'` to invalidate all.
*/
function applyInvalidation(state, payload) {
const reset = { stats: null, donations: null, campaigns: null };
if (!payload || payload === 'all') {
state.lastFetched = { ...reset };
return;
}
if (payload in reset) {
state.lastFetched[payload] = null;
}
}

const dashboardSlice = createSlice({
name: 'dashboard',
initialState,
reducers: {
clearDashboard: () => initialState,
invalidateDashboardCache: (state, action) => {
applyInvalidation(state, action.payload);
},
},
extraReducers: (builder) => {
builder
Expand All @@ -29,6 +61,7 @@ const dashboardSlice = createSlice({
.addCase(fetchDashboardStats.fulfilled, (state, action) => {
state.isLoading = false;
state.stats = action.payload;
state.lastFetched.stats = Date.now();
})
.addCase(fetchDashboardStats.rejected, (state, action) => {
state.isLoading = false;
Expand All @@ -42,6 +75,7 @@ const dashboardSlice = createSlice({
.addCase(fetchRecentDonations.fulfilled, (state, action) => {
state.isLoading = false;
state.recentDonations = action.payload;
state.lastFetched.donations = Date.now();
})
.addCase(fetchRecentDonations.rejected, (state, action) => {
state.isLoading = false;
Expand All @@ -55,13 +89,16 @@ const dashboardSlice = createSlice({
.addCase(fetchRecentCampaigns.fulfilled, (state, action) => {
state.isLoading = false;
state.recentCampaigns = action.payload;
state.lastFetched.campaigns = Date.now();
})
.addCase(fetchRecentCampaigns.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload;
});
})
// Logout clears the cache so the next user sees fresh data.
.addCase(logoutUser.fulfilled, () => initialState);
},
});

export const { clearDashboard } = dashboardSlice.actions;
export const { clearDashboard, invalidateDashboardCache } = dashboardSlice.actions;
export default dashboardSlice.reducer;
33 changes: 30 additions & 3 deletions src/features/dashboard/dashboardThunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import api from '../../services/api';
import { toastError } from '../../utils/toast';

/**
* Dashboard data is treated as fresh for this many milliseconds after a
* successful fetch. Navigation away from the dashboard and back inside this
* window will not re-issue the network request.
*/
export const DASHBOARD_CACHE_TTL_MS = 2 * 60 * 1000;

const isFresh = (lastFetchedAt) =>
typeof lastFetchedAt === 'number' &&
Date.now() - lastFetchedAt < DASHBOARD_CACHE_TTL_MS;

/**
* Compose a `condition` callback for a cached thunk.
*
* Returns `true` to allow the API call, `false` to short-circuit (skip the
* call). A `force: true` arg bypasses the cache check unconditionally.
*/
const makeCacheCondition = (resource) => (arg, { getState }) => {
if (arg && arg.force) return true;
const last = getState()?.dashboard?.lastFetched?.[resource];
if (isFresh(last)) return false;
return true;
};

export const fetchDashboardStats = createAsyncThunk(
'dashboard/fetchStats',
async (_, { rejectWithValue }) => {
Expand All @@ -12,7 +36,8 @@ export const fetchDashboardStats = createAsyncThunk(
toastError(err);
return rejectWithValue(err.response?.data || err.message);
}
}
},
{ condition: makeCacheCondition('stats') }
);

export const fetchRecentDonations = createAsyncThunk(
Expand All @@ -25,7 +50,8 @@ export const fetchRecentDonations = createAsyncThunk(
toastError(err);
return rejectWithValue(err.response?.data || err.message);
}
}
},
{ condition: makeCacheCondition('donations') }
);

export const fetchRecentCampaigns = createAsyncThunk(
Expand All @@ -38,5 +64,6 @@ export const fetchRecentCampaigns = createAsyncThunk(
toastError(err);
return rejectWithValue(err.response?.data || err.message);
}
}
},
{ condition: makeCacheCondition('campaigns') }
);
194 changes: 194 additions & 0 deletions src/features/dashboard/dashboardThunks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { configureStore } from '@reduxjs/toolkit';
import dashboardReducer, {
clearDashboard,
invalidateDashboardCache,
} from './dashboardSlice';
import authReducer from '../auth/authSlice';
import {
fetchDashboardStats,
fetchRecentDonations,
fetchRecentCampaigns,
DASHBOARD_CACHE_TTL_MS,
} from './dashboardThunks';

// Mock the axios-based api instance: every test records calls on a shared mock.
vi.mock('../../services/api', () => {
const api = {
get: vi.fn(),
post: vi.fn(),
};
return { default: api };
});

// Silence toast calls during tests — we're asserting on network behavior,
// not on toast appearance.
vi.mock('../../utils/toast', () => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastInfo: vi.fn(),
toastLoading: vi.fn(),
}));

import api from '../../services/api';

const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));

const makeStore = () =>
configureStore({
reducer: {
dashboard: dashboardReducer,
auth: authReducer,
},
});

beforeEach(() => {
// Each request resolves with a distinct, traceable payload so we can
// assert on call counts and the resulting state shape.
api.get.mockReset();
api.get.mockImplementation((url) => {
const body = url.split('/').pop();
return Promise.resolve({ data: { marker: `${body}-payload` } });
});
});

afterEach(() => {
vi.useRealTimers();
});

describe('dashboard thunks — caching behavior', () => {
it('issues a network request on the first dispatch of each resource', async () => {
const store = makeStore();
await store.dispatch(fetchDashboardStats());
await store.dispatch(fetchRecentDonations());
await store.dispatch(fetchRecentCampaigns());
await flushPromises();

expect(api.get).toHaveBeenCalledTimes(3);
expect(api.get).toHaveBeenCalledWith('/dashboard/stats');
expect(api.get).toHaveBeenCalledWith('/dashboard/recent-donations');
expect(api.get).toHaveBeenCalledWith('/dashboard/recent-campaigns');
});

it('skips the network call for a resource that was fetched within the TTL', async () => {
const store = makeStore();
await store.dispatch(fetchRecentDonations());
await flushPromises();
expect(api.get).toHaveBeenCalledTimes(1);

// Dispatch again immediately — cache is still fresh, no extra call.
await store.dispatch(fetchRecentDonations());
await flushPromises();
expect(api.get).toHaveBeenCalledTimes(1);
});

it('does not share cache across different resources', async () => {
const store = makeStore();
await store.dispatch(fetchDashboardStats());
await flushPromises();
await store.dispatch(fetchRecentDonations());
await flushPromises();

expect(api.get).toHaveBeenCalledTimes(2);
});

it('refetches when the cache timestamp is older than the TTL', async () => {
const store = makeStore();

// First fetch — clock starts now.
await store.dispatch(fetchRecentDonations());
await flushPromises();

// Advance the clock past the TTL.
const originalNow = Date.now;
Date.now = vi.fn(() => originalNow() + DASHBOARD_CACHE_TTL_MS + 1);

await store.dispatch(fetchRecentDonations());
await flushPromises();

expect(api.get).toHaveBeenCalledTimes(2);

Date.now = originalNow;
});

it('bypasses the cache when { force: true } is passed', async () => {
const store = makeStore();
await store.dispatch(fetchRecentDonations());
await flushPromises();
expect(api.get).toHaveBeenCalledTimes(1);

await store.dispatch(fetchRecentDonations({ force: true }));
await flushPromises();
await store.dispatch(fetchRecentCampaigns({ force: true }));
await flushPromises();
expect(api.get).toHaveBeenCalledTimes(3);
});

it('records lastFetched timestamps only on fulfilled', async () => {
const store = makeStore();
await store.dispatch(fetchRecentDonations());
await flushPromises();

const last = store.getState().dashboard.lastFetched;
expect(last.donations).toBeTypeOf('number');
expect(last.stats).toBeNull();
expect(last.campaigns).toBeNull();
});
});

describe('invalidateDashboardCache action', () => {
it('clears a single named resource without affecting others', async () => {
const store = makeStore();
await store.dispatch(fetchDashboardStats());
await store.dispatch(fetchRecentDonations());
await store.dispatch(fetchRecentCampaigns());
await flushPromises();
expect(api.get).toHaveBeenCalledTimes(3);

store.dispatch(invalidateDashboardCache('donations'));

const last = store.getState().dashboard.lastFetched;
expect(last.donations).toBeNull();
expect(last.stats).toBeTypeOf('number');
expect(last.campaigns).toBeTypeOf('number');

// Donations should re-fetch on next dispatch; others should stay cached.
await store.dispatch(fetchRecentDonations());
await store.dispatch(fetchDashboardStats());
await store.dispatch(fetchRecentCampaigns());
await flushPromises();
expect(api.get).toHaveBeenCalledTimes(4);
});

it('clears every timestamp when called with no payload', async () => {
const store = makeStore();
await store.dispatch(fetchDashboardStats());
await store.dispatch(fetchRecentDonations());
await store.dispatch(fetchRecentCampaigns());
await flushPromises();

store.dispatch(invalidateDashboardCache());

const last = store.getState().dashboard.lastFetched;
expect(last).toEqual({ stats: null, donations: null, campaigns: null });
});
});

describe('clearDashboard action', () => {
it('resets dashboard state including cache timestamps', async () => {
const store = makeStore();
await store.dispatch(fetchRecentDonations());
await flushPromises();

store.dispatch(clearDashboard());

expect(store.getState().dashboard).toMatchObject({
stats: null,
recentDonations: [],
recentCampaigns: [],
isLoading: false,
error: null,
lastFetched: { stats: null, donations: null, campaigns: null },
});
});
});
9 changes: 9 additions & 0 deletions src/pages/campaigns/CreateCampaignPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
selectDraftCampaign,
selectFormStep,
} from "../../features/campaigns/campaignsSlice";
import { invalidateDashboardCache } from "../../features/dashboard/dashboardSlice";
import { submitCampaign } from "../../features/campaigns/campaignsThunks";
import BasicInfoStep from "../../components/campaigns/steps/BasicInfoStep";
import FundingStep from "./steps/FundingStep";
Expand Down Expand Up @@ -67,6 +68,14 @@ const CreateCampaignPage = () => {
const handleBack = () => dispatch(prevStep());

const handleSubmit = () => {
// Placeholder submit; integrates with campaign service in a later issue.
console.log("Submitting campaign draft:", draft);
dispatch(resetCampaignForm());
// A new campaign appears on the dashboard's "Recent Campaigns" list and
// is reflected in summary stats, so invalidate those caches so the next
// dashboard load fetches fresh data.
dispatch(invalidateDashboardCache("campaigns"));
dispatch(invalidateDashboardCache("stats"));
// Submit the draft for review. The slice clears draftCampaign and
// resets formStep on fulfillment (see submitCampaign.extraReducers).
dispatch(submitCampaign(draft));
Expand Down
Loading
Loading