diff --git a/cypress/support/commands.js b/cypress/support/commands.js index cd2e6f23d4..70bd7683a8 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -849,3 +849,23 @@ Cypress.Commands.add('getTableSlate', (header = false) => { ); return slate; }); + +Cypress.Commands.add('configureListingWith', (contentType) => { + cy.get('.sidebar-container .tabs-wrapper .menu .item') + .contains('Block') + .click(); + cy.get('.querystring-widget .fields').contains('Add criteria').click(); + cy.get( + '.querystring-widget .fields:first-of-type .field:first-of-type .react-select__menu .react-select__option', + ) + .contains('Type') + .click(); + + //insert Page + cy.get('.querystring-widget .fields:first-of-type > .field').click(); + cy.get( + '.querystring-widget .fields:first-of-type > .field .react-select__menu .react-select__option', + ) + .contains(contentType) + .click(); +}); diff --git a/cypress/tests/core/blocks/blocks-listing.js b/cypress/tests/core/blocks/blocks-listing.js index bea65dac47..288909dc79 100644 --- a/cypress/tests/core/blocks/blocks-listing.js +++ b/cypress/tests/core/blocks/blocks-listing.js @@ -21,6 +21,42 @@ describe('Listing Block Tests', () => { cy.waitForResourceToLoad(''); }); + it('Add Listing block with no results', () => { + cy.intercept('PATCH', '/**/my-page').as('save'); + cy.intercept('GET', '/**/my-page').as('content'); + cy.intercept('GET', '/**/@types/Document').as('schema'); + + cy.createContent({ + contentType: 'Document', + contentId: 'my-page-test', + contentTitle: 'My Page Test', + path: 'my-page', + }); + + cy.navigate('/my-page'); + cy.wait('@content'); + + cy.navigate('/my-page/edit'); + cy.wait('@schema'); + + cy.clearSlateTitle().type('My title'); + + //add listing block + cy.addNewBlock('listing'); + + cy.configureListingWith('News Item'); + + //save + cy.get('#toolbar-save').click(); + cy.wait('@save'); + cy.wait('@content'); + + //test after save + cy.get('#page-document .block.listing.default .emptyListing').contains( + 'No results found.', + ); + }); + it('Add Listing block', () => { cy.intercept('PATCH', '/**/my-page').as('save'); cy.intercept('GET', '/**/my-page').as('content'); @@ -841,6 +877,354 @@ describe('Listing Block Tests', () => { cy.get('.listing-item h4').first().contains('My Folder 3'); }); + it('Navigates in listing block pagination and url clears on logo click', () => { + cy.intercept('PATCH', '/**/my-page').as('save'); + cy.intercept('GET', '/**/my-page').as('content'); + cy.intercept('GET', '/**/@types/Document').as('schema'); + + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder', + contentTitle: 'My Folder', + path: 'my-page', + }); + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder2', + contentTitle: 'My Folder 2', + path: 'my-page', + }); + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder3', + contentTitle: 'My Folder 3', + path: 'my-page', + }); + + cy.navigate('/my-page'); + cy.wait('@content'); + + cy.navigate('/my-page/edit'); + cy.wait('@schema'); + + cy.clearSlateTitle().type('Listing block - navigate to second page'); + + //add listing block + cy.addNewBlock('listing'); + + //verify before save + cy.get(`.block.listing .listing-body:first-of-type`).contains('My Folder'); + + cy.get('.sidebar-container .tabs-wrapper .menu .item') + .contains('Block') + .click(); + cy.get('.querystring-widget .fields').contains('Add criteria').click(); + cy.get( + '.querystring-widget .fields:first-of-type .field:first-of-type .react-select__menu .react-select__option', + ) + .contains('Type') + .click(); + + //insert Page + cy.get('.querystring-widget .fields:first-of-type > .field').click(); + cy.get( + '.querystring-widget .fields:first-of-type > .field .react-select__menu .react-select__option', + ) + .contains('Page') + .click(); + + cy.get('#field-limit-3-querystring').click().type('2'); + + cy.get('#field-limit-3-querystring').click().clear().type('0'); + cy.get('#field-b_size-4-querystring').click().type('2'); + cy.get('.ui.pagination.menu a[value="2"]').first().click(); + cy.get('#toolbar-save').click(); + cy.wait('@save'); + cy.wait('@content'); + //test second pagination click + cy.get('.ui.pagination.menu a[value="2"]').first().click({ force: true }); + cy.url().should('include', '?page=2'); + //on logo click go to home page and remove ?page=2 from path + cy.get('.logo').first().click(); + cy.url().should('not.include', '?page=2'); + }); + + // reload url when ?page=2 + it('Reload path when listing page = 2', () => { + cy.intercept('PATCH', '/**/my-page').as('save'); + cy.intercept('GET', '/**/my-page').as('content'); + cy.intercept('GET', '/**/@types/Document').as('schema'); + + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder', + contentTitle: 'My Folder', + path: 'my-page', + }); + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder2', + contentTitle: 'My Folder 2', + path: 'my-page', + }); + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder3', + contentTitle: 'My Folder 3', + path: 'my-page', + }); + + cy.navigate('/my-page'); + cy.wait('@content'); + + cy.navigate('/my-page/edit'); + cy.wait('@schema'); + + cy.clearSlateTitle().type( + 'Listing block - navigate to second page and reload path', + ); + + //add listing block + cy.addNewBlock('listing'); + + //verify before save + cy.get(`.block.listing .listing-body:first-of-type`).contains('My Folder'); + + cy.get('.sidebar-container .tabs-wrapper .menu .item') + .contains('Block') + .click(); + cy.get('.querystring-widget .fields').contains('Add criteria').click(); + cy.get( + '.querystring-widget .fields:first-of-type .field:first-of-type .react-select__menu .react-select__option', + ) + .contains('Type') + .click(); + + //insert Page + cy.get('.querystring-widget .fields:first-of-type > .field').click(); + cy.get( + '.querystring-widget .fields:first-of-type > .field .react-select__menu .react-select__option', + ) + .contains('Page') + .click(); + + cy.get('#field-limit-3-querystring').click().type('2'); + + //save + cy.get('#toolbar-save').click(); + cy.wait('@save'); + cy.wait('@content'); + + //test after save + cy.get('#page-document .listing-item:first-of-type a').should( + 'have.attr', + 'href', + '/my-page/my-folder', + ); + cy.get('.listing-item').should(($els) => { + expect($els).to.have.length(2); + }); + + cy.navigate('/my-page/edit'); + cy.wait('@schema'); + + cy.get('.block-editor-listing').click(); + cy.get('.sidebar-container .tabs-wrapper .menu .item') + .contains('Block') + .click(); + + cy.get('#field-limit-3-querystring').click().clear().type('0'); + cy.get('#field-b_size-4-querystring').click().type('2'); + cy.get('.ui.pagination.menu a[value="2"]').first().click(); + + cy.get('.listing-item h4').first().contains('My Folder 3'); + cy.get('#toolbar-save').click(); + cy.wait('@save'); + cy.wait('@content'); + //test second pagination click + cy.get('.ui.pagination.menu a[value="2"]').first().click(); + + //test f5 + cy.reload(); + cy.url().should('include', '?page=2'); + }); + + // different page in two listings on the same page + it('Navigate to different pages on two different listings', () => { + cy.intercept('PATCH', '/**/my-page').as('save'); + cy.intercept('GET', '/**/my-page').as('content'); + cy.intercept('GET', '/**/@types/Document').as('schema'); + + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder', + contentTitle: 'My Folder', + path: 'my-page', + }); + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder2', + contentTitle: 'My Folder 2', + path: 'my-page', + }); + cy.createContent({ + contentType: 'Document', + contentId: 'my-folder3', + contentTitle: 'My Folder 3', + path: 'my-page', + }); + + cy.navigate('/my-page'); + cy.wait('@content'); + + cy.navigate('/my-page/edit'); + cy.wait('@schema'); + + cy.clearSlateTitle().type( + 'Listing block - navigate to different pages on two different listings', + ); + + //add listing block + cy.addNewBlock('listing'); + + //verify before save + cy.get(`.block.listing .listing-body:first-of-type`).contains('My Folder'); + + cy.get('.sidebar-container .tabs-wrapper .menu .item') + .contains('Block') + .click(); + cy.get('.querystring-widget .fields').contains('Add criteria').click(); + cy.get( + '.querystring-widget .fields:first-of-type .field:first-of-type .react-select__menu .react-select__option', + ) + .contains('Type') + .click(); + + //insert Page + cy.get('.querystring-widget .fields:first-of-type > .field').click(); + cy.get( + '.querystring-widget .fields:first-of-type > .field .react-select__menu .react-select__option', + ) + .contains('Page') + .click(); + + cy.get('#field-limit-3-querystring').click().type('0'); + cy.get('#field-b_size-4-querystring').click().type('2'); + + cy.addNewBlock('listing'); + + //verify before save + cy.get(`.block.listing .listing-body:first-of-type`).contains('My Folder'); + + cy.get('.sidebar-container .tabs-wrapper .menu .item') + .contains('Block') + .click(); + cy.get('.querystring-widget .fields').contains('Add criteria').click(); + cy.get( + '.querystring-widget .fields:first-of-type .field:first-of-type .react-select__menu .react-select__option', + ) + .contains('Type') + .click(); + + //insert Page + cy.get('.querystring-widget .fields:first-of-type > .field').click(); + cy.get( + '.querystring-widget .fields:first-of-type > .field .react-select__menu .react-select__option', + ) + .contains('Page') + .click(); + + cy.get('#field-limit-3-querystring').click().type('0'); + cy.get('#field-b_size-4-querystring').click().type('1'); + cy.get('#toolbar-save').click(); + cy.wait('@save'); + cy.wait('@content'); + + // const listing1 = cy.get('.ui.pagination.menu').first(); + // cy.log('listing1', listing1); + //test second pagination click + cy.get('.ui.pagination.menu a[value="2"]').first().click(); + //test f5 + cy.reload(); + cy.url().should('include', '=2'); + // const listing2 = cy.get('.ui.pagination.menu').last(); + //test third pagination click on second listing + cy.get('.ui.pagination.menu a[value="3"]').first().click(); + //test f5 + cy.reload(); + cy.url().should('include', '=2'); + cy.url().should('include', '=3'); + //on logo click go to home page and remove ?page=2 from path + cy.get('.logo').first().click(); + cy.url().should('not.include', '=2'); + cy.url().should('not.include', '=3'); + //test back button + cy.navigate('/my-page'); + cy.wait('@content'); + cy.get('.ui.pagination.menu a[value="2"]').first().click({ force: true }); + cy.get('.ui.pagination.menu a[value="3"]').first().click({ force: true }); + cy.go(-1); + cy.url().should('not.include', '=3'); + cy.go(-1); + cy.url().should('not.include', '=2'); + cy.url().should('not.include', '=3'); + }); + + it('Add Listing block with no results, navigate to home, add a News Item, go to the listing', () => { + cy.intercept('PATCH', '/**/my-page').as('save'); + cy.intercept('GET', '/**/my-page').as('content'); + cy.intercept('GET', '/**/@types/Document').as('schema'); + + cy.createContent({ + contentType: 'Document', + contentId: 'my-page-test', + contentTitle: 'My Page Test', + path: 'my-page', + }); + + cy.navigate('/my-page'); + cy.wait('@content'); + + cy.navigate('/my-page/edit'); + cy.wait('@schema'); + + cy.clearSlateTitle().type('My title'); + + //add listing block + cy.addNewBlock('listing'); + + cy.configureListingWith('News Item'); + + //save + cy.get('#toolbar-save').click(); + cy.wait('@save'); + cy.wait('@content'); + + //test after save + cy.get('#page-document .block.listing.default .emptyListing').contains( + 'No results found.', + ); + + cy.get('.logo').first().click(); + + cy.createContent({ + contentType: 'News Item', + contentId: 'my-news-item-test', + contentTitle: 'My News Item', + path: 'my-page', + }); + cy.navigate('/my-page'); + + cy.get('#page-document .listing-body:first-of-type').contains( + 'My News Item', + ); + cy.get('#page-document .listing-item:first-of-type a').should( + 'have.attr', + 'href', + '/my-page/my-news-item-test', + ); + }); + // it('Listing block - Test Criteria: Location Navigation', () => { // /*not implemented because Navigation ui is not yet developed in Listing Block sidebar*/ // }); diff --git a/news/4159.bugfix b/news/4159.bugfix new file mode 100644 index 0000000000..d9b9f18dfa --- /dev/null +++ b/news/4159.bugfix @@ -0,0 +1 @@ +Added current page parameter to route in listing and search block pagination - Fix: #3868 @bipoza \ No newline at end of file diff --git a/src/components/manage/Blocks/Listing/ListingBody.test.jsx b/src/components/manage/Blocks/Listing/ListingBody.test.jsx index d52c7df814..8a5c359972 100644 --- a/src/components/manage/Blocks/Listing/ListingBody.test.jsx +++ b/src/components/manage/Blocks/Listing/ListingBody.test.jsx @@ -36,6 +36,26 @@ test('renders a ListingBody component', () => { content: { data: { is_folderish: true, + blocks: { + '839ee00b-013b-4f4a-9b10-8867938fdac3': { + '@type': 'listing', + block: '839ee00b-013b-4f4a-9b10-8867938fdac3', + headlineTag: 'h2', + query: [], + querystring: { + b_size: '2', + query: [ + { + i: 'path', + o: 'plone.app.querystring.operation.string.absolutePath', + v: '/', + }, + ], + sort_order: 'ascending', + }, + variation: 'default', + }, + }, }, }, intl: { diff --git a/src/components/manage/Blocks/Listing/__snapshots__/ListingBody.test.jsx.snap b/src/components/manage/Blocks/Listing/__snapshots__/ListingBody.test.jsx.snap index e81db3c83f..5fa1511b42 100644 --- a/src/components/manage/Blocks/Listing/__snapshots__/ListingBody.test.jsx.snap +++ b/src/components/manage/Blocks/Listing/__snapshots__/ListingBody.test.jsx.snap @@ -5,7 +5,7 @@ exports[`renders a ListingBody component 1`] = ` className="emptyListing" >
state.querystringsearch.subrequests, ); @@ -50,7 +52,7 @@ export default function withQuerystringResults(WrappedComponent) { const folderItems = content?.is_folderish ? content.items : []; const hasQuery = querystring?.query?.length > 0; - const hasLoaded = hasQuery ? !querystringResults?.[id]?.loading : true; + const hasLoaded = hasQuery ? querystringResults?.[id]?.loaded : true; const listingItems = querystring?.query?.length > 0 && querystringResults?.[id] @@ -109,6 +111,8 @@ export default function withQuerystringResults(WrappedComponent) { } else { dispatch(getContent(initialPath, null, null, currentPage)); } + adaptedQueryRef.current = adaptedQuery; + currentPageRef.current = currentPage; }, [ id, isImageGallery, diff --git a/src/components/manage/Blocks/Search/hocs/withSearch.jsx b/src/components/manage/Blocks/Search/hocs/withSearch.jsx index 332d6feb4f..b810cc282f 100644 --- a/src/components/manage/Blocks/Search/hocs/withSearch.jsx +++ b/src/components/manage/Blocks/Search/hocs/withSearch.jsx @@ -148,12 +148,16 @@ const getSearchFields = (searchData) => { }; /** - * A HOC that will mirror the search block state to a hash location + * A hook that will mirror the search block state to a hash location */ const useHashState = () => { const location = useLocation(); const history = useHistory(); + /** + * Required to maintain parameter compatibility. + With this we will maintain support for receiving hash (#) and search (?) type parameters. + */ const oldState = React.useMemo(() => { return { ...qs.parse(location.search), @@ -169,7 +173,7 @@ const useHashState = () => { const setSearchData = React.useCallback( (searchData) => { - const newParams = qs.parse(location.hash); + const newParams = qs.parse(location.search); let changed = false; @@ -186,11 +190,11 @@ const useHashState = () => { if (changed) { history.push({ - hash: qs.stringify(newParams), + search: qs.stringify(newParams), }); } }, - [history, oldState, location.hash], + [history, oldState, location.search], ); return [current, setSearchData]; diff --git a/src/helpers/Utils/usePagination.js b/src/helpers/Utils/usePagination.js index acb12d2ac5..083ec48682 100644 --- a/src/helpers/Utils/usePagination.js +++ b/src/helpers/Utils/usePagination.js @@ -1,25 +1,83 @@ -import React from 'react'; -import { isEqual } from 'lodash'; -import { usePrevious } from './usePrevious'; -import useDeepCompareEffect from 'use-deep-compare-effect'; +import React, { useRef, useEffect } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import qs from 'query-string'; +import { useSelector } from 'react-redux'; +import { slugify } from '@plone/volto/helpers/Utils/Utils'; + +/** + * @function useCreatePageQueryStringKey + * @description A hook that creates a key with an id if there are multiple blocks with pagination. + * @returns {string} Example: page || page_012345678 + */ +const useCreatePageQueryStringKey = (id) => { + const blockTypesWithPagination = ['search', 'listing']; + const blocks = useSelector((state) => state?.content?.data?.blocks) || []; + const blocksLayout = + useSelector((state) => state?.content?.data?.blocks_layout?.items) || []; + const displayedBlocks = blocksLayout?.map((item) => blocks[item]); + const hasMultiplePaginations = + displayedBlocks.filter((item) => + blockTypesWithPagination.includes(item['@type']), + ).length > 1 || false; + + return hasMultiplePaginations ? slugify(`page-${id}`) : 'page'; +}; + +const useGetBlockType = (id) => { + const blocks = useSelector((state) => state?.content?.data?.blocks) || []; + const block = blocks[id]; + return block ? block?.['@type'] : null; +}; /** * A pagination helper that tracks the query and resets pagination in case the * query changes. */ -export const usePagination = (query, defaultPage = 1) => { - const previousQuery = usePrevious(query); - const [currentPage, setCurrentPage] = React.useState(defaultPage); +export const usePagination = (id = null, defaultPage = 1) => { + const location = useLocation(); + const history = useHistory(); + const pageQueryStringKey = useCreatePageQueryStringKey(id); + const block_type = useGetBlockType(id); + const pageQueryParam = + qs.parse(location.search)[pageQueryStringKey] || defaultPage; + const [currentPage, setCurrentPageState] = React.useState( + parseInt(pageQueryParam), + ); + const setCurrentPage = (page) => { + setCurrentPageState(page); + const newParams = { + ...qs.parse(location.search), + [pageQueryStringKey]: page, + }; + history.push({ search: qs.stringify(newParams) }); + }; - useDeepCompareEffect(() => { - setCurrentPage(defaultPage); - }, [query, previousQuery, defaultPage]); + const queryRef = useRef(qs.parse(location.search)?.query); + useEffect(() => { + if ( + queryRef.current !== qs.parse(location.search)?.query && + block_type === 'search' + ) { + setCurrentPageState(defaultPage); + const newParams = { + ...qs.parse(location.search), + [pageQueryStringKey]: defaultPage, + }; + delete newParams[pageQueryStringKey]; + history.replace({ search: qs.stringify(newParams) }); + queryRef.current = qs.parse(location.search)?.query; + } else { + setCurrentPageState( + parseInt( + qs.parse(location.search)?.[pageQueryStringKey] || defaultPage, + ), + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.search, block_type]); return { - currentPage: - previousQuery && !isEqual(previousQuery, query) - ? defaultPage - : currentPage, + currentPage, setCurrentPage, }; }; diff --git a/src/helpers/Utils/usePagination.test.js b/src/helpers/Utils/usePagination.test.js new file mode 100644 index 0000000000..26924d78a0 --- /dev/null +++ b/src/helpers/Utils/usePagination.test.js @@ -0,0 +1,115 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePagination } from './usePagination'; +import * as redux from 'react-redux'; +import routeData from 'react-router'; +import { slugify } from '@plone/volto/helpers/Utils/Utils'; + +const searchBlockId = '545b33de-92cf-4747-969d-68851837b317'; +const searchBlockId2 = '454b33de-92cf-4747-969d-68851837b713'; +const searchBlock = { + '@type': 'search', + query: { + b_size: '4', + query: [ + { + i: 'path', + o: 'plone.app.querystring.operation.string.relativePath', + v: '', + }, + ], + sort_order: 'ascending', + }, + showSearchInput: true, + showTotalResults: true, +}; +let state = { + content: { + data: { + blocks: { + [searchBlockId]: searchBlock, + }, + blocks_layout: { + items: [searchBlockId], + }, + }, + }, +}; + +let mockUseLocationValue = { + pathname: '/testroute', + search: '', +}; + +const setUp = (searchParam, numberOfSearches) => { + mockUseLocationValue.search = searchParam; + if (numberOfSearches > 1) { + state.content.data.blocks[searchBlockId2] = searchBlock; + state.content.data.blocks_layout.items.push(searchBlockId2); + } + return renderHook(({ id, defaultPage }) => usePagination(id, defaultPage), { + initialProps: { + id: searchBlockId, + defaultPage: 1, + }, + }); +}; + +describe(`Tests for usePagination, for the block ${searchBlockId}`, () => { + const useLocation = jest.spyOn(routeData, 'useLocation'); + const useHistory = jest.spyOn(routeData, 'useHistory'); + const useSelector = jest.spyOn(redux, 'useSelector'); + beforeEach(() => { + useLocation.mockReturnValue(mockUseLocationValue); + useHistory.mockReturnValue({ replace: jest.fn() }); + useSelector.mockImplementation((cb) => cb(state)); + }); + + it('1 paginated block with id and defaultPage 1 - shoud be 1', () => { + const { result } = setUp(); + expect(result.current.currentPage).toBe(1); + }); + + it('1 paginated block without params - shoud be 1', () => { + const { result } = setUp(); + expect(result.current.currentPage).toBe(1); + }); + + const param1 = '?page=2'; + it(`1 paginated block with params: ${param1} - shoud be 2`, () => { + const { result } = setUp(param1); + expect(result.current.currentPage).toBe(2); + }); + + const param2 = `?${slugify(`page-${searchBlockId}`)}=2`; + it(`2 paginated blocks with current block in the params: ${param2} - shoud be 2`, () => { + const { result } = setUp(param2, 2); + expect(result.current.currentPage).toBe(2); + }); + + const param3 = `?${slugify(`page-${searchBlockId2}`)}=2`; + it(`2 paginated blocks with the other block in the params: ${param3} - shoud be 1`, () => { + const { result } = setUp(param3, 2); + expect(result.current.currentPage).toBe(1); + }); + + const param4 = `?${slugify(`page-${searchBlockId}`)}=2&${slugify( + `page-${searchBlockId2}`, + )}=1`; + it(`2 paginated blocks with both blocks in the params, current 2: ${param4} - shoud be 2`, () => { + const { result } = setUp(param4, 2); + expect(result.current.currentPage).toBe(2); + }); + + const param5 = `?${slugify(`page-${searchBlockId}`)}=1&${slugify( + `page-${searchBlockId2}`, + )}=2`; + it(`2 paginated blocks with both blocks in the params, current 1: ${param5} - shoud be 1`, () => { + const { result } = setUp(param5, 2); + expect(result.current.currentPage).toBe(1); + }); + + it(`2 paginated blocks with wrong page param: ${param1} - shoud be 1`, () => { + const { result } = setUp(param1, 2); + expect(result.current.currentPage).toBe(1); + }); +});