diff --git a/backend/src/apps/common/management/commands/algolia_update_replicas.py b/backend/src/apps/common/management/commands/algolia_update_replicas.py index 6d331a1553..69b4f93fe0 100644 --- a/backend/src/apps/common/management/commands/algolia_update_replicas.py +++ b/backend/src/apps/common/management/commands/algolia_update_replicas.py @@ -2,6 +2,7 @@ from django.core.management.base import BaseCommand +from apps.mentorship.index import ProgramIndex from apps.owasp.index import ChapterIndex, ProjectIndex @@ -12,5 +13,6 @@ def handle(self, *_args, **_options) -> None: """Update replicas for Algolia indices.""" self.stdout.write("\n Starting replica configuration...\n") ChapterIndex.configure_replicas() + ProgramIndex.configure_replicas() ProjectIndex.configure_replicas() self.stdout.write(self.style.SUCCESS("\n Replicas have been successfully created.\n")) diff --git a/backend/src/apps/mentorship/index/registry/program.py b/backend/src/apps/mentorship/index/registry/program.py index da7675943e..4e81f5910e 100644 --- a/backend/src/apps/mentorship/index/registry/program.py +++ b/backend/src/apps/mentorship/index/registry/program.py @@ -11,6 +11,7 @@ class ProgramIndex(IndexBase): index_name = "programs" fields = ( + "idx_created_at", "idx_description", "idx_ended_at", "idx_experience_levels", @@ -18,6 +19,7 @@ class ProgramIndex(IndexBase): "idx_name", "idx_started_at", "idx_status", + "idx_updated_at", ) settings = { @@ -45,6 +47,22 @@ class ProgramIndex(IndexBase): should_index = "is_indexable" + @staticmethod + def configure_replicas() -> None: # type: ignore[override] + """Configure the settings for program replicas.""" + replicas = { + "name_asc": ["asc(idx_name)"], + "name_desc": ["desc(idx_name)"], + "created_at_asc": ["asc(idx_created_at)"], + "created_at_desc": ["desc(idx_created_at)"], + "updated_at_asc": ["asc(idx_updated_at)"], + "updated_at_desc": ["desc(idx_updated_at)"], + "ended_at_asc": ["asc(idx_ended_at)"], + "ended_at_desc": ["desc(idx_ended_at)"], + } + + IndexBase.configure_replicas("programs", replicas) + def get_entities(self): """Return only published programs for indexing.""" return Program.objects.filter(status=Program.ProgramStatus.PUBLISHED) diff --git a/backend/src/apps/mentorship/models/mixins/program.py b/backend/src/apps/mentorship/models/mixins/program.py index fd843159a8..0ce315d7b8 100644 --- a/backend/src/apps/mentorship/models/mixins/program.py +++ b/backend/src/apps/mentorship/models/mixins/program.py @@ -21,11 +21,21 @@ def idx_key(self) -> str: """Unique key for Algolia indexing.""" return self.key + @property + def idx_created_at(self) -> str | None: + """Formatted created datetime for sorting.""" + return self.nest_created_at.isoformat() if self.nest_created_at else None + @property def idx_status(self) -> str: """Status for Algolia indexing.""" return self.status + @property + def idx_updated_at(self) -> str | None: + """Formatted updated datetime for sorting.""" + return self.nest_updated_at.isoformat() if self.nest_updated_at else None + @property def idx_description(self) -> str: """Description for Algolia indexing.""" diff --git a/backend/tests/unit/apps/mentorship/model/program_mixin_test.py b/backend/tests/unit/apps/mentorship/model/program_mixin_test.py index dc6b0bf744..bcedf179cd 100644 --- a/backend/tests/unit/apps/mentorship/model/program_mixin_test.py +++ b/backend/tests/unit/apps/mentorship/model/program_mixin_test.py @@ -88,6 +88,28 @@ def test_idx_started_at_none(self): mock = self._make_program_mock(started_at=None) assert ProgramIndexMixin.idx_started_at.fget(mock) is None + def test_idx_created_at(self): + """Test idx_created_at returns ISO formatted created datetime.""" + created = datetime(2025, 1, 1, 9, 0, 0, tzinfo=UTC) + mock = self._make_program_mock(nest_created_at=created) + assert ProgramIndexMixin.idx_created_at.fget(mock) == created.isoformat() + + def test_idx_created_at_none(self): + """Test idx_created_at returns None when nest_created_at is None.""" + mock = self._make_program_mock(nest_created_at=None) + assert ProgramIndexMixin.idx_created_at.fget(mock) is None + + def test_idx_updated_at(self): + """Test idx_updated_at returns ISO formatted updated datetime.""" + updated = datetime(2025, 6, 15, 12, 30, 0, tzinfo=UTC) + mock = self._make_program_mock(nest_updated_at=updated) + assert ProgramIndexMixin.idx_updated_at.fget(mock) == updated.isoformat() + + def test_idx_updated_at_none(self): + """Test idx_updated_at returns None when nest_updated_at is None.""" + mock = self._make_program_mock(nest_updated_at=None) + assert ProgramIndexMixin.idx_updated_at.fget(mock) is None + def test_idx_ended_at(self): """Test idx_ended_at returns ISO formatted end datetime.""" ended = datetime(2025, 12, 31, 18, 0, 0, tzinfo=UTC) diff --git a/e2e/pages/MentorshipPrograms.spec.ts b/e2e/pages/MentorshipPrograms.spec.ts index 1cbd8c6ff3..9a61c00c25 100644 --- a/e2e/pages/MentorshipPrograms.spec.ts +++ b/e2e/pages/MentorshipPrograms.spec.ts @@ -41,6 +41,38 @@ test.describe('Mentorship Programs Page', () => { await expect(page.getByPlaceholder('Search for programs...')).toBeVisible() }) + test('sort dropdown exposes all sort options', async ({ page }) => { + await page.getByRole('button', { name: 'Sort by' }).click() + for (const option of ['Relevancy', 'Name', 'Date Created', 'Last Updated', 'End Date']) { + await expect(page.getByRole('option', { name: option })).toBeVisible() + } + }) + + test('sorting by name requests the name replica index', async ({ page }) => { + const requestedIndexes: string[] = [] + page.on('request', (request) => { + if (request.url().includes('/idx/')) { + requestedIndexes.push(request.postDataJSON()?.indexName) + } + }) + + await page.getByRole('button', { name: 'Sort by' }).click() + await page.getByRole('option', { name: 'Name' }).click() + await expect.poll(() => requestedIndexes).toContain('programs_name_desc') + + await page.getByRole('button', { name: /Sort in descending order/i }).click() + await expect.poll(() => requestedIndexes).toContain('programs_name_asc') + + await expect(page).toHaveURL(/sortBy=name/) + await expect(page.getByRole('heading', { name: 'Program 1' })).toBeVisible() + + await page.goBack() + await expect(page).toHaveURL(/sortBy=name&order=desc/) + + await page.goForward() + await expect(page).toHaveURL(/sortBy=name&order=asc/) + }) + test('displays "No programs found" when there are no programs', async ({ page }) => { await page.route('**/idx/', async (route) => { await route.fulfill({ diff --git a/frontend/__tests__/unit/hooks/useSearchPage.test.tsx b/frontend/__tests__/unit/hooks/useSearchPage.test.tsx index c8dadcfb31..2fd3c8386d 100644 --- a/frontend/__tests__/unit/hooks/useSearchPage.test.tsx +++ b/frontend/__tests__/unit/hooks/useSearchPage.test.tsx @@ -13,6 +13,7 @@ jest.mock('app/global-error', () => ({ })) const mockFetchAlgoliaData = fetchAlgoliaData as jest.Mock +const mockHandleAppError = jest.requireMock('app/global-error').handleAppError as jest.Mock const mockUseSearchParams = useSearchParams as jest.Mock const mockUseRouter = useRouter as jest.Mock @@ -23,6 +24,14 @@ const defaultOptions = { defaultOrder: 'desc', } as const +const programsOptions = { + indexName: 'programs', + pageTitle: 'OWASP Programs', + defaultSortBy: 'default', + defaultOrder: 'desc', + hitsPerPage: 24, +} as const + describe('useSearchPage', () => { const push = jest.fn() @@ -63,6 +72,7 @@ describe('useSearchPage', () => { hits: [{ objectID: '1' }], totalPages: 5, }) + window.scrollTo = jest.fn() }) it('preserves the page query param on initial load', async () => { @@ -221,6 +231,27 @@ describe('useSearchPage', () => { await waitFor(() => { expect(result.current.currentPage).toBe(1) }) + + mockUseSearchParams.mockReturnValue(new URLSearchParams('page=2foo')) + rerender() + + await waitFor(() => { + expect(result.current.currentPage).toBe(1) + }) + + mockUseSearchParams.mockReturnValue(new URLSearchParams(`page=${'9'.repeat(400)}`)) + rerender() + + await waitFor(() => { + expect(result.current.currentPage).toBe(1) + }) + + mockUseSearchParams.mockReturnValue(new URLSearchParams('page=12')) + rerender() + + await waitFor(() => { + expect(result.current.currentPage).toBe(12) + }) }) it('still pushes URL updates for user-driven page changes after back/forward sync', async () => { @@ -320,4 +351,151 @@ describe('useSearchPage', () => { expect(push).toHaveBeenCalledWith('?q=nest') }) }) + + it('resets to page 1 and fetches the replica index when sort changes on a later page', async () => { + mockUseSearchParams.mockReturnValue(new URLSearchParams()) + + const { result } = renderHook(() => useSearchPage<{ key: string }>(programsOptions)) + + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(1) + }) + expect(push).not.toHaveBeenCalled() + + act(() => { + result.current.handlePageChange(2) + }) + + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenLastCalledWith('programs', '', 2, 24, []) + }) + expect(push).toHaveBeenLastCalledWith('?page=2') + + act(() => { + result.current.handleSortChange('name') + }) + + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenLastCalledWith('programs_name_desc', '', 1, 24, []) + }) + expect(mockFetchAlgoliaData).not.toHaveBeenCalledWith('programs_name_desc', '', 2, 24, []) + expect(result.current.sortBy).toBe('name') + expect(result.current.currentPage).toBe(1) + }) + + it('adopts sort and order params from the URL when it changes', async () => { + mockUseSearchParams.mockReturnValue(new URLSearchParams('sortBy=name&order=desc')) + + const { result, rerender } = renderHook(() => useSearchPage<{ key: string }>(programsOptions)) + + await waitFor(() => { + expect(result.current.sortBy).toBe('name') + }) + expect(result.current.order).toBe('desc') + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenLastCalledWith('programs_name_desc', '', 1, 24, []) + }) + expect(push).not.toHaveBeenCalled() + + mockUseSearchParams.mockReturnValue(new URLSearchParams()) + rerender() + + await waitFor(() => { + expect(result.current.sortBy).toBe('default') + }) + expect(result.current.order).toBe('desc') + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenLastCalledWith('programs', '', 1, 24, []) + }) + expect(push).not.toHaveBeenCalled() + + await act(async () => { + result.current.handleSortChange('date_created') + }) + expect(push).toHaveBeenCalledWith('?sortBy=date_created&order=desc') + expect(result.current.sortBy).toBe('date_created') + }) + + it('ignores stale responses superseded by a newer request', async () => { + let resolveInitial!: (value: { hits: { key: string }[]; totalPages: number }) => void + let resolveNewer!: (value: { hits: { key: string }[]; totalPages: number }) => void + const initial = new Promise<{ hits: { key: string }[]; totalPages: number }>((resolve) => { + resolveInitial = resolve + }) + const newer = new Promise<{ hits: { key: string }[]; totalPages: number }>((resolve) => { + resolveNewer = resolve + }) + + mockFetchAlgoliaData.mockReturnValueOnce(initial).mockReturnValueOnce(newer) + mockUseSearchParams.mockReturnValue(new URLSearchParams()) + + const { result } = renderHook(() => useSearchPage<{ key: string }>(programsOptions)) + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(1) + }) + + act(() => { + result.current.handleSearch('owasp') + }) + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(2) + }) + + await act(async () => { + resolveNewer({ hits: [{ key: 'new' }], totalPages: 1 }) + }) + await waitFor(() => { + expect(result.current.items).toEqual([{ key: 'new' }]) + }) + + await act(async () => { + resolveInitial({ hits: [{ key: 'stale' }], totalPages: 1 }) + }) + await waitFor(() => { + expect(result.current.items).toEqual([{ key: 'new' }]) + }) + }) + + it('ignores stale failures superseded by a newer request', async () => { + let rejectInitial!: (reason?: object) => void + let resolveNewer!: (value: { hits: { key: string }[]; totalPages: number }) => void + const initial = new Promise<{ hits: { key: string }[]; totalPages: number }>( + (_resolve, reject) => { + rejectInitial = reject + } + ) + const newer = new Promise<{ hits: { key: string }[]; totalPages: number }>((resolve) => { + resolveNewer = resolve + }) + + mockFetchAlgoliaData.mockReturnValueOnce(initial).mockReturnValueOnce(newer) + mockUseSearchParams.mockReturnValue(new URLSearchParams()) + + const { result } = renderHook(() => useSearchPage<{ key: string }>(programsOptions)) + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(1) + }) + + act(() => { + result.current.handleSearch('owasp') + }) + await waitFor(() => { + expect(mockFetchAlgoliaData).toHaveBeenCalledTimes(2) + }) + + await act(async () => { + resolveNewer({ hits: [{ key: 'new' }], totalPages: 1 }) + }) + await waitFor(() => { + expect(result.current.items).toEqual([{ key: 'new' }]) + }) + expect(result.current.isLoaded).toBe(true) + + await act(async () => { + rejectInitial(new Error('stale failure')) + }) + expect(mockHandleAppError).not.toHaveBeenCalled() + expect(result.current.items).toEqual([{ key: 'new' }]) + expect(result.current.isLoaded).toBe(true) + }) }) diff --git a/frontend/src/app/mentorship/programs/page.tsx b/frontend/src/app/mentorship/programs/page.tsx index a987299769..593bbb66d3 100644 --- a/frontend/src/app/mentorship/programs/page.tsx +++ b/frontend/src/app/mentorship/programs/page.tsx @@ -3,8 +3,10 @@ import { useSearchPage } from 'hooks/useSearchPage' import { ProgramStatusEnum } from 'types/__generated__/graphql' import { Program } from 'types/mentorship' +import { sortOptionsProgram } from 'utils/sortingOptions' import ProgramCard from 'components/ProgramCard' import SearchPageLayout from 'components/SearchPageLayout' +import SortBy from 'components/SortBy' const ProgramsPage = () => { const { @@ -13,12 +15,18 @@ const ProgramsPage = () => { currentPage, totalPages, searchQuery, + sortBy, + order, handleSearch, handlePageChange, + handleSortChange, + handleOrderChange, } = useSearchPage({ indexName: 'programs', pageTitle: 'OWASP Programs', hitsPerPage: 24, + defaultSortBy: 'default', + defaultOrder: 'desc', }) const renderProgramCard = (program: Program) => { @@ -43,6 +51,15 @@ const ProgramsPage = () => { onSearch={handleSearch} searchPlaceholder="Search for programs..." searchQuery={searchQuery} + sortChildren={ + + } totalPages={totalPages} >
diff --git a/frontend/src/hooks/useSearchPage.ts b/frontend/src/hooks/useSearchPage.ts index 6131d47731..fd3297c672 100644 --- a/frontend/src/hooks/useSearchPage.ts +++ b/frontend/src/hooks/useSearchPage.ts @@ -36,8 +36,9 @@ interface SearchUrlState { } const parsePageParam = (value: string | null): number => { - const page = Number.parseInt(value || '1', 10) - return Number.isFinite(page) && page > 0 ? page : 1 + const page = (value || '').trim() + const parsed = Number(page) + return /^[1-9]\d*$/.test(page) && Number.isFinite(parsed) ? parsed : 1 } const normalizedPageParam = (params: URLSearchParams): string => { @@ -110,6 +111,7 @@ export function useSearchPage({ order, }) stateRef.current = { currentPage, searchQuery, sortBy, order } + const fetchVersion = useRef(0) useEffect(() => { // Only reset when filters actually change (Strict Mode safe — skips mount / re-invoke). @@ -188,6 +190,7 @@ export function useSearchPage({ // Fetch data when state changes useEffect(() => { + const requestVersion = ++fetchVersion.current setIsLoaded(false) const fetchData = async () => { @@ -211,6 +214,8 @@ export function useSearchPage({ [...stableFacetFilters] ) + if (requestVersion !== fetchVersion.current) return + if ('hits' in response) { setItems(response.hits) setTotalPages(response.totalPages ?? 0) @@ -218,6 +223,7 @@ export function useSearchPage({ handleAppError(response) } } catch (error) { + if (requestVersion !== fetchVersion.current) return handleAppError(error) } setIsLoaded(true) diff --git a/frontend/src/utils/sortingOptions.ts b/frontend/src/utils/sortingOptions.ts index b2f73183e0..d24d8f4344 100644 --- a/frontend/src/utils/sortingOptions.ts +++ b/frontend/src/utils/sortingOptions.ts @@ -4,6 +4,14 @@ export const sortOptionsChapter = [ { label: 'Last Updated', key: 'updated_at' }, ] +export const sortOptionsProgram = [ + { label: 'Relevancy', key: 'default' }, + { label: 'Name', key: 'name' }, + { label: 'Date Created', key: 'created_at' }, + { label: 'Last Updated', key: 'updated_at' }, + { label: 'End Date', key: 'ended_at' }, +] + export const sortOptionsProject = [ { label: 'Relevancy', key: 'default' }, { label: 'Contributors', key: 'contributors_count' },