diff --git a/assets/js/common/ActivityLogOverview/ActivityLogOverview.jsx b/assets/js/common/ActivityLogOverview/ActivityLogOverview.jsx index 84cf453358..a6327a56cb 100644 --- a/assets/js/common/ActivityLogOverview/ActivityLogOverview.jsx +++ b/assets/js/common/ActivityLogOverview/ActivityLogOverview.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { noop } from 'lodash'; import { EOS_BUG_REPORT_OUTLINED, @@ -21,6 +21,7 @@ import { import ActivityLogDetailModal from '@common/ActivityLogDetailsModal'; import { format } from 'date-fns'; +import { defaultItemsPerPage } from '../Table/Table'; const logLevelToIcon = { [LEVEL_DEBUG]: , @@ -51,11 +52,56 @@ function ActivityLogOverview({ loading = false, onActivityLogEntryClick = noop, onCloseActivityLogEntryDetails = noop, + currentPaginationData, + loadActivityLog = noop, }) { const [selectedEntry, setEntry] = useState({}); + const [currentlyAppliedFilters, setCurrentlyAppliedFilters] = useState({ + first: defaultItemsPerPage, + }); + + const loadFilteredAndPaginatedActivityLog = (filtersAndPagination) => { + setCurrentlyAppliedFilters(filtersAndPagination); + loadActivityLog(filtersAndPagination); + }; + + useEffect(() => { + loadFilteredAndPaginatedActivityLog(currentlyAppliedFilters); + }, []); + + const onChangeItemsPerPage = (itemsPerPage) => + loadFilteredAndPaginatedActivityLog({ + ...currentlyAppliedFilters, + ...(currentlyAppliedFilters?.first + ? { first: itemsPerPage } + : { last: itemsPerPage }), + }); + + const onPreviousPage = () => + loadFilteredAndPaginatedActivityLog({ + last: currentlyAppliedFilters?.first || currentlyAppliedFilters?.last, + before: currentPaginationData?.start_cursor, + }); + + const onNextPage = () => + loadFilteredAndPaginatedActivityLog({ + first: currentlyAppliedFilters?.first || currentlyAppliedFilters?.last, + after: currentPaginationData?.end_cursor, + }); + + const canNavigateToPreviousPage = + !loading && currentPaginationData?.has_previous_page; + const canNavigateToNextPage = + !loading && currentPaginationData?.has_next_page; const activityLogTableConfig = { pagination: true, + cursorPagination: true, + onChangeItemsPerPage, + canNavigateToPreviousPage, + onPreviousPage, + canNavigateToNextPage, + onNextPage, usePadding: false, columns: [ { diff --git a/assets/js/common/ActivityLogOverview/ActivityLogOverview.stories.jsx b/assets/js/common/ActivityLogOverview/ActivityLogOverview.stories.jsx index 007e2418da..362b4240d2 100644 --- a/assets/js/common/ActivityLogOverview/ActivityLogOverview.stories.jsx +++ b/assets/js/common/ActivityLogOverview/ActivityLogOverview.stories.jsx @@ -1,3 +1,4 @@ +import { action } from '@storybook/addon-actions'; import { activityLogEntryFactory } from '@lib/test-utils/factories/activityLog'; import _ from 'lodash'; import ActivityLogOverview from './ActivityLogOverview'; @@ -12,16 +13,78 @@ export default { type: 'array', }, }, + activityLogDetailModalOpen: { + description: 'Whether the activity log entry details modal is open', + control: { type: 'boolean' }, + }, loading: { description: 'Display loading state of the component', control: { type: 'boolean' }, }, + onActivityLogEntryClick: { + description: 'Function to execute when an activity log entry is clicked', + control: { type: 'function' }, + }, + onCloseActivityLogEntryDetails: { + description: + 'Function to execute when the activity log entry details modal is closed', + control: { type: 'function' }, + }, + currentPaginationData: { + description: 'Current pagination data retrieved from the api response', + control: { type: 'object' }, + }, + loadActivityLog: { + description: 'Function to execute when a new pagination is applied', + control: { type: 'function' }, + }, }, }; -export const Default = { +export const FirstPage = { args: { activityLog: activityLogEntryFactory.buildList(20), + currentPaginationData: { + first: 10, + last: null, + start_cursor: 'start_cursor', + end_cursor: 'end_cursor', + has_next_page: true, + has_previous_page: false, + }, + loadActivityLog: action('loadActivityLog'), + }, +}; + +export const IntermediatePage = { + args: { + ...FirstPage.args, + currentPaginationData: { + ...FirstPage.args.currentPaginationData, + has_next_page: true, + has_previous_page: true, + }, + }, +}; +export const LastPage = { + args: { + activityLog: activityLogEntryFactory.buildList(20), + currentPaginationData: { + ...FirstPage.args.currentPaginationData, + has_next_page: false, + has_previous_page: true, + }, + loadActivityLog: action('loadActivityLog'), + }, +}; +export const OnlyOnePage = { + args: { + ...FirstPage.args, + currentPaginationData: { + ...FirstPage.args.currentPaginationData, + has_next_page: false, + has_previous_page: false, + }, }, }; @@ -40,21 +103,21 @@ export const Empty = { export const UnknwonActivityType = { args: { - ...Default.args, + ...FirstPage.args, activityLog: [activityLogEntryFactory.build({ type: 'foo_bar' })], }, }; export const UnknwonLevel = { args: { - ...Default.args, + ...FirstPage.args, activityLog: [activityLogEntryFactory.build({ level: 'foo_bar' })], }, }; export const MissingLevel = { args: { - ...Default.args, + ...FirstPage.args, activityLog: [_.omit(activityLogEntryFactory.build(), 'level')], }, }; diff --git a/assets/js/common/ActivityLogOverview/ActivityLogOverview.test.jsx b/assets/js/common/ActivityLogOverview/ActivityLogOverview.test.jsx index 8dc2011869..12925b92cc 100644 --- a/assets/js/common/ActivityLogOverview/ActivityLogOverview.test.jsx +++ b/assets/js/common/ActivityLogOverview/ActivityLogOverview.test.jsx @@ -72,4 +72,117 @@ describe('Activity Log Overview', () => { await userEvent.click(screen.getByLabelText(`entry-${id}`)); expect(onActivityLogEntryClick).toHaveBeenCalled(); }); + + describe('filtering and pagination', () => { + it('should load the activity log with the default filters', () => { + const loadActivityLog = jest.fn(); + render( + + ); + + expect(loadActivityLog).toHaveBeenCalledTimes(1); + expect(loadActivityLog).toHaveBeenCalledWith({ + first: 10, + }); + }); + + it.each` + name | hasNextPage | hasPreviousPage + ${'only one page'} | ${false} | ${false} + ${'on first page'} | ${true} | ${false} + ${'on intermediate page'} | ${true} | ${true} + ${'on last page'} | ${false} | ${true} + `( + 'should allow relevant pagination given the context: $name', + ({ hasNextPage, hasPreviousPage }) => { + render( + + ); + + const prevPageButton = screen.getByLabelText('prev-page'); + hasPreviousPage + ? expect(prevPageButton).toBeEnabled() + : expect(prevPageButton).toBeDisabled(); + + const nextPageButton = screen.getByLabelText('next-page'); + hasNextPage + ? expect(nextPageButton).toBeEnabled() + : expect(nextPageButton).toBeDisabled(); + } + ); + + it('should apply new pagination to activity log', async () => { + const loadActivityLog = jest.fn(); + + render( + + ); + + // initial load + expect(loadActivityLog).toHaveBeenNthCalledWith(1, { first: 10 }); + + // change items per page from 10 to 20 + await userEvent.click(screen.getByRole('button', { name: '10' })); + await userEvent.click(screen.getByRole('option', { name: '20' })); + + // just change the number of items from 10 to 20 + expect(loadActivityLog).toHaveBeenNthCalledWith(2, { first: 20 }); + + // navigate to next page + const nextPageButton = screen.getByLabelText('next-page'); + await userEvent.click(nextPageButton); + + // keep previously selected number of items (20) + // and look for 20 items after the end cursor (aka the last element in the list) + expect(loadActivityLog).toHaveBeenNthCalledWith(3, { + first: 20, + after: 'end_cursor', + }); + + // change again items per page from 20 to 10 + await userEvent.click(screen.getByRole('button', { name: '20' })); + await userEvent.click(screen.getByRole('option', { name: '10' })); + + // just change the number of items from 20 to 10 and keep previous cursor + expect(loadActivityLog).toHaveBeenNthCalledWith(4, { + first: 10, + after: 'end_cursor', + }); + + // navigate to previous page + const prevPageButton = screen.getByLabelText('prev-page'); + await userEvent.click(prevPageButton); + + // keeps the last selected number of items to show (10) + // and look for 10 items before the start cursor (aka the first element in the list) + expect(loadActivityLog).toHaveBeenNthCalledWith(5, { + last: 10, + before: 'start_cursor', + }); + }); + }); }); diff --git a/assets/js/common/Table/Table.jsx b/assets/js/common/Table/Table.jsx index 48d72c0c64..e87a1e776f 100644 --- a/assets/js/common/Table/Table.jsx +++ b/assets/js/common/Table/Table.jsx @@ -61,6 +61,7 @@ const getFilterFunction = (column, value) => : getDefaultFilterFunction(value, column.key); const itemsPerPageOptions = [10, 20, 50, 75, 100]; +export const defaultItemsPerPage = itemsPerPageOptions[0]; function Table({ config, @@ -89,9 +90,8 @@ function Table({ const [filters, setFilters] = useState([]); const [currentPage, setCurrentPage] = useState(1); - const [currentItemsPerPage, setCurrentItemsPerPage] = useState( - itemsPerPageOptions[0] - ); + const [currentItemsPerPage, setCurrentItemsPerPage] = + useState(defaultItemsPerPage); const searchParamsEnabled = Boolean(searchParams && setSearchParams); diff --git a/assets/js/lib/api/activityLogs.js b/assets/js/lib/api/activityLogs.js index 8a69fb9135..7f58b9c1d3 100644 --- a/assets/js/lib/api/activityLogs.js +++ b/assets/js/lib/api/activityLogs.js @@ -1,3 +1,6 @@ import { networkClient } from '@lib/network'; -export const getActivityLog = () => networkClient.get(`/activity_log`); +export const getActivityLog = (filters = {}) => + networkClient.get(`/activity_log`, { + params: filters, + }); diff --git a/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx b/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx index 9129c4dd3c..2dbafb53fd 100644 --- a/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx +++ b/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import PageHeader from '@common/PageHeader'; import ActivityLogOverview from '@common/ActivityLogOverview'; @@ -10,17 +10,20 @@ function ActivityLogPage() { const [isLoading, setLoading] = useState(true); const [activityLogDetailModalOpen, setActivityLogDetailModalOpen] = useState(false); + const [currentPaginationData, setCurrentPaginationData] = useState({}); - useEffect(() => { - getActivityLog() + const loadActivityLog = (filters) => { + setLoading(true); + getActivityLog(filters) .then((response) => { setActivityLog(response.data?.data ?? []); + setCurrentPaginationData(response.data?.pagination ?? {}); }) .catch(() => setActivityLog([])) .finally(() => { setLoading(false); }); - }, []); + }; return ( <> @@ -33,6 +36,10 @@ function ActivityLogPage() { onCloseActivityLogEntryDetails={() => setActivityLogDetailModalOpen(false) } + currentPaginationData={currentPaginationData} + loadActivityLog={(filtersAndPagination) => { + loadActivityLog(filtersAndPagination); + }} /> );