From c2120cc8bce19ce597b991522aebff33794044c1 Mon Sep 17 00:00:00 2001 From: VaibhavOrbit Date: Sat, 30 May 2026 10:14:34 +0000 Subject: [PATCH] feat: add collection list pagination support --- client/constants.js | 2 + client/modules/IDE/actions/collections.js | 83 +++++--- .../IDE/components/AddToCollectionList.jsx | 54 ++++- .../CollectionList/CollectionList.jsx | 195 +++++++++++------- client/modules/IDE/components/Pagination.jsx | 17 +- client/modules/IDE/reducers/collections.js | 26 ++- .../reducers/collectionsListCollections.js | 48 +++++ client/modules/IDE/selectors/collections.js | 9 +- client/testData/testReduxStore.ts | 1 + .../getCollectionsForUser.js | 107 ++++++++++ .../collection.controller/index.js | 1 + server/routes/collection.routes.ts | 7 +- 12 files changed, 419 insertions(+), 131 deletions(-) create mode 100644 client/modules/IDE/reducers/collectionsListCollections.js create mode 100644 server/controllers/collection.controller/getCollectionsForUser.js diff --git a/client/constants.js b/client/constants.js index cf1ef18431..eb2e099921 100644 --- a/client/constants.js +++ b/client/constants.js @@ -35,6 +35,8 @@ export const SET_PROJECT = 'SET_PROJECT'; export const SET_PROJECTS = 'SET_PROJECTS'; export const SET_PROJECTS_FOR_COLLECTION_LIST = 'SET_PROJECTS_FOR_COLLECTION_LIST'; +export const SET_COLLECTIONS_FOR_COLLECTION_LIST = + 'SET_COLLECTIONS_FOR_COLLECTION_LIST'; export const SET_COLLECTIONS = 'SET_COLLECTIONS'; export const CREATE_COLLECTION = 'CREATED_COLLECTION'; diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js index 68a42f500d..940d4137ec 100644 --- a/client/modules/IDE/actions/collections.js +++ b/client/modules/IDE/actions/collections.js @@ -6,33 +6,62 @@ import { setToastText, showToast } from './toast'; const TOAST_DISPLAY_TIME_MS = 1500; -export function getCollections(username) { - return (dispatch) => { - dispatch(startLoader()); - let url; - if (username) { - url = `/${username}/collections`; - } else { - url = '/collections'; - } - return apiClient - .get(url) - .then((response) => { - dispatch({ - type: ActionTypes.SET_COLLECTIONS, - collections: response.data - }); - dispatch(stopLoader()); - }) - .catch((error) => { - dispatch({ - type: ActionTypes.ERROR, - error: error?.response?.data - }); - dispatch(stopLoader()); - }); - }; -} +const buildCollectionUrl = (username, options = {}) => { + const { + page = 1, + limit = 10, + sortField = 'updatedAt', + sortDir = 'desc', + q = '' + } = options; + + const base = username + ? `/${encodeURIComponent(username)}/collections` + : '/collections'; + + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + sortField, + sortDir + }); + + const trimmed = q.trim(); + + if (trimmed) { + params.set('q', trimmed); + } + + return `${base}?${params.toString()}`; +}; + +const fetchCollections = (username, options, successType) => (dispatch) => { + dispatch(startLoader()); + + const url = buildCollectionUrl(username, options); + return apiClient + .get(url) + .then((response) => { + dispatch({ type: successType, collections: response.data }); + dispatch(stopLoader()); + return response.data; + }) + .catch((error) => { + dispatch({ type: ActionTypes.ERROR, error: error?.response?.data }); + dispatch(stopLoader()); + throw error; + }); +}; + +export const getCollections = (username, options) => + fetchCollections(username, options, ActionTypes.SET_COLLECTIONS); + +export const getCollectionsForCollectionList = (username, options) => + fetchCollections( + username, + options, + ActionTypes.SET_COLLECTIONS_FOR_COLLECTION_LIST + ); export function createCollection(collection) { return (dispatch) => { diff --git a/client/modules/IDE/components/AddToCollectionList.jsx b/client/modules/IDE/components/AddToCollectionList.jsx index 75713367ee..9a81b4bc4c 100644 --- a/client/modules/IDE/components/AddToCollectionList.jsx +++ b/client/modules/IDE/components/AddToCollectionList.jsx @@ -7,12 +7,14 @@ import styled from 'styled-components'; import { Loader } from '../../App/components/Loader'; import { addToCollection, - getCollections, + getCollectionsForCollectionList, removeFromCollection } from '../actions/collections'; import getSortedCollections from '../selectors/collections'; import QuickAddList from './QuickAddList'; import { remSize } from '../../../theme'; +import Pagination from './Pagination'; +import { sketchResponse } from '../../../testData/testServerResponses'; export const CollectionAddSketchWrapper = styled.div` width: ${remSize(600)}; @@ -34,7 +36,19 @@ const AddToCollectionList = ({ projectId }) => { const username = useSelector((state) => state.user.username); - const collections = useSelector(getSortedCollections); + const collections = useSelector( + (state) => state.collectionsListCollections.collections + ); + + const paginationMeta = useSelector( + (state) => state.collectionsListCollections.metadata + ); + + const q = useSelector((state) => state.search.collectionSearchTerm); + const hasCollections = () => collections?.length > 0; + + const [page, setPage] = useState(1); + const limit = 10; // TODO: improve loading state const loading = useSelector((state) => state.loading); @@ -42,8 +56,18 @@ const AddToCollectionList = ({ projectId }) => { const showLoader = loading && !hasLoadedData; useEffect(() => { - dispatch(getCollections(username)).then(() => setHasLoadedData(true)); - }, [dispatch, username]); + dispatch( + getCollectionsForCollectionList(username, { + page, + limit, + q + }) + ).finally(() => setHasLoadedData(true)); + }, [dispatch, username, page, q]); + + useEffect(() => { + setPage(1); + }, [q]); const handleCollectionAdd = (collection) => { dispatch(addToCollection(collection.id, projectId)); @@ -66,11 +90,23 @@ const AddToCollectionList = ({ projectId }) => { return t('AddToCollectionList.Empty'); } return ( - + <> + + {hasCollections() && ( + + )} + ); }; diff --git a/client/modules/IDE/components/CollectionList/CollectionList.jsx b/client/modules/IDE/components/CollectionList/CollectionList.jsx index 9a59c02d8a..53b7dd558c 100644 --- a/client/modules/IDE/components/CollectionList/CollectionList.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionList.jsx @@ -21,14 +21,18 @@ import CollectionListRow from './CollectionListRow'; import ArrowUpIcon from '../../../../images/sort-arrow-up.svg'; import ArrowDownIcon from '../../../../images/sort-arrow-down.svg'; +import Pagination from '../Pagination'; const CollectionList = ({ user, projectId, getCollections, + // getCollectionsForCollectionList, getProject, collections, - username: propsUsername, + search, + paginationMeta, + username, loading, toggleDirectionForField, resetSorting, @@ -36,6 +40,8 @@ const CollectionList = ({ project, mobile }) => { + const [page, setPage] = useState(1); + const limit = mobile ? 7 : 10; const { t } = useTranslation(); const [hasLoadedData, setHasLoadedData] = useState(false); const [ @@ -43,13 +49,27 @@ const CollectionList = ({ setAddingSketchesToCollectionId ] = useState(null); + const sortField = sorting.field || 'updatedAt'; + const sortDir = + sorting.direction === SortingActions.DIRECTION.ASC ? 'asc' : 'desc'; + useEffect(() => { - if (projectId) { - getProject(projectId); - } - getCollections(propsUsername || user.username); resetSorting(); - }, []); + }, [username, resetSorting]); + + useEffect(() => { + setPage(1); + }, [username, search, sortField, sortDir]); + + useEffect(() => { + getCollections(username, { + page, + limit, + sortField, + sortDir, + q: search + }); + }, [getCollections, username, page, limit, sortField, sortDir, search]); useEffect(() => { if (!loading) { @@ -58,13 +78,13 @@ const CollectionList = ({ }, [loading]); const getTitle = useMemo(() => { - if (propsUsername === user.username) { + if (username === user.username) { return t('CollectionList.Title'); } return t('CollectionList.AnothersTitle', { - anotheruser: propsUsername + anotheruser: username }); - }, [propsUsername, user.username, t]); + }, [username, user.username, t]); const showAddSketches = (collectionId) => { setAddingSketchesToCollectionId(collectionId); @@ -74,8 +94,7 @@ const CollectionList = ({ setAddingSketchesToCollectionId(null); }; - const hasCollections = () => - (!loading || hasLoadedData) && collections.length > 0; + const hasCollections = () => collections.length > 0; const renderLoader = () => { if (loading && !hasLoadedData) return ; @@ -150,70 +169,83 @@ const CollectionList = ({ }; return ( -
- - {getTitle} - + <> +
+ + {getTitle} + - {renderLoader()} - {renderEmptyTable()} + {renderLoader()} + {renderEmptyTable()} + {hasCollections() && ( + + + + {renderFieldHeader('name', t('CollectionList.HeaderName'))} + {renderFieldHeader( + 'createdAt', + t('CollectionList.HeaderCreatedAt', { + context: mobile ? 'mobile' : '' + }) + )} + {renderFieldHeader( + 'updatedAt', + t('CollectionList.HeaderUpdatedAt', { + context: mobile ? 'mobile' : '' + }) + )} + {renderFieldHeader( + 'numItems', + t('CollectionList.HeaderNumItems', { + context: mobile ? 'mobile' : '' + }) + )} + + + + + {collections.map((collection) => ( + showAddSketches(collection.id)} + /> + ))} + +
+ )} + {addingSketchesToCollectionId && ( + } + closeOverlay={hideAddSketches} + isFixedHeight + > + + + )} +
{hasCollections() && ( - - - - {renderFieldHeader('name', t('CollectionList.HeaderName'))} - {renderFieldHeader( - 'createdAt', - t('CollectionList.HeaderCreatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - {renderFieldHeader( - 'updatedAt', - t('CollectionList.HeaderUpdatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - {renderFieldHeader( - 'numItems', - t('CollectionList.HeaderNumItems', { - context: mobile ? 'mobile' : '' - }) - )} - - - - - {collections.map((collection) => ( - showAddSketches(collection.id)} - /> - ))} - -
+ )} - {addingSketchesToCollectionId && ( - } - closeOverlay={hideAddSketches} - isFixedHeight - > - - - )} -
+ ); }; @@ -222,8 +254,17 @@ CollectionList.propTypes = { username: PropTypes.string, authenticated: PropTypes.bool.isRequired }).isRequired, + paginationMeta: PropTypes.shape({ + page: PropTypes.number.isRequired, + totalPages: PropTypes.number.isRequired, + totalCollections: PropTypes.number.isRequired, + limit: PropTypes.number.isRequired, + hasPagination: PropTypes.bool.isRequired + }).isRequired, projectId: PropTypes.string, getCollections: PropTypes.func.isRequired, + // getCollectionsForCollectionList: PropTypes.func.isRequired, + search: PropTypes.string.isRequired, getProject: PropTypes.func.isRequired, collections: PropTypes.arrayOf( PropTypes.shape({ @@ -264,9 +305,17 @@ CollectionList.defaultProps = { function mapStateToProps(state, ownProps) { return { user: state.user, - collections: getSortedCollections(state), + collections: state.collections.collections ?? [], + paginationMeta: state.collections.metadata ?? { + page: 1, + totalPages: 1, + totalCollections: 0, + limit: 10, + hasPagination: true + }, sorting: state.sorting, loading: state.loading, + search: state.search.collectionSearchTerm, project: state.project, projectId: ownProps && ownProps.params ? ownProps.params.project_id : null }; diff --git a/client/modules/IDE/components/Pagination.jsx b/client/modules/IDE/components/Pagination.jsx index a3df03a46d..13ab9b5305 100644 --- a/client/modules/IDE/components/Pagination.jsx +++ b/client/modules/IDE/components/Pagination.jsx @@ -9,14 +9,18 @@ const Pagination = ({ onPageChange, limit, totalSketches, + totalCollections, isOverlay }) => { if (totalPages <= 1) return null; const { t } = useTranslation(); - const startSketch = (page - 1) * limit + 1; - const endSketch = Math.min(page * limit, totalSketches); + const totalItems = Number.isFinite(totalSketches) + ? totalSketches + : totalCollections; + const startItem = totalItems > 0 ? (page - 1) * limit + 1 : 0; + const endItem = totalItems > 0 ? Math.min(page * limit, totalItems) : 0; return (
@@ -35,9 +39,9 @@ const Pagination = ({
  • - {startSketch} - {endSketch} + {startItem} - {endItem} {' '} - {t('Pagination.Of')} {totalSketches} + {t('Pagination.Of')} {totalItems}
  • { return action.collections; case ActionTypes.DELETE_COLLECTION: - return state.filter(({ id }) => action.collectionId !== id); - - // The API returns the complete new edited collection - // with any items added or removed + return { + ...state, + collections: state.collections.filter( + ({ id }) => action.collectionId !== id + ) + }; case ActionTypes.EDIT_COLLECTION: case ActionTypes.ADD_TO_COLLECTION: case ActionTypes.REMOVE_FROM_COLLECTION: - return state.map((collection) => { - if (collection.id === action.payload.id) { - return action.payload; - } - - return collection; - }); + return { + ...state, + collections: state.collections.map((collection) => { + if (collection.id === action.payload.id) { + return action.payload; + } + return collection; + }) + }; default: return state; } diff --git a/client/modules/IDE/reducers/collectionsListCollections.js b/client/modules/IDE/reducers/collectionsListCollections.js new file mode 100644 index 0000000000..e0857648df --- /dev/null +++ b/client/modules/IDE/reducers/collectionsListCollections.js @@ -0,0 +1,48 @@ +import { collections } from 'friendly-words'; +import * as ActionTypes from '../../../constants'; + +const initialState = { + collections: [], + metadata: { + page: 1, + totalPages: 1, + totalCollections: 0, + limit: 10, + hasPagination: true + } +}; + +export default function collectionsListCollections( + state = initialState, + action +) { + switch (action.type) { + case ActionTypes.SET_COLLECTIONS_FOR_COLLECTION_LIST: + return { + ...state, + collections: action.collections.collecitons ?? [], + metadata: action.collections.metadata + }; + + case ActionTypes.DELETE_COLLECTION: + return { + ...state, + collections: state.collections.filter( + ({ id }) => action.collectionId !== id + ) + }; + + case ActionTypes.EDIT_COLLECTION: + case ActionTypes.ADD_TO_COLLECTION: + case ActionTypes.REMOVE_FROM_COLLECTION: + return { + ...state, + collections: state.collections.map((collection) => + collection.id === action.payload.id ? action.payload : collection + ) + }; + + default: + return state; + } +} diff --git a/client/modules/IDE/selectors/collections.js b/client/modules/IDE/selectors/collections.js index 02b38305f0..bac70674ec 100644 --- a/client/modules/IDE/selectors/collections.js +++ b/client/modules/IDE/selectors/collections.js @@ -3,7 +3,14 @@ import differenceInMilliseconds from 'date-fns/differenceInMilliseconds'; import { find, orderBy } from 'lodash'; import { DIRECTION } from '../actions/sorting'; -const getCollections = (state) => state.collections; +function collectionsFromSlice(slice) { + if (slice == null) return []; + if (Array.isArray(slice)) return slice; + if (Array.isArray(slice.collections)) return slice.collections; + return []; +} + +const getCollections = (state) => collectionsFromSlice(state.collections); const getField = (state) => state.sorting.field; const getDirection = (state) => state.sorting.direction; const getSearchTerm = (state) => state.search.collectionSearchTerm; diff --git a/client/testData/testReduxStore.ts b/client/testData/testReduxStore.ts index 0a150ea2ef..e267368ac7 100644 --- a/client/testData/testReduxStore.ts +++ b/client/testData/testReduxStore.ts @@ -1,3 +1,4 @@ +import { any } from 'prop-types'; import { initialState as initialFilesState } from '../modules/IDE/reducers/files'; import { initialState as initialPrefState } from '../modules/IDE/reducers/preferences'; import { RootState } from '../reducers'; diff --git a/server/controllers/collection.controller/getCollectionsForUser.js b/server/controllers/collection.controller/getCollectionsForUser.js new file mode 100644 index 0000000000..38c8389910 --- /dev/null +++ b/server/controllers/collection.controller/getCollectionsForUser.js @@ -0,0 +1,107 @@ +import Collection from '../../models/collection'; +import { User } from '../../models/user'; + +/** + * Fetches collections for the username in the request with pagination support. + * Handles errors and returns a success response. + */ +const createCoreHandler = (mapCollectionsToResponse) => async (req, res) => { + try { + const { username } = req.params; + const { page, limit, sortField, sortDir, q } = req.query; + + if (!username) { + return res.status(422).json({ message: 'Username not provided' }); + } + + const user = await User.findByUsername(username); + + if (!user) { + return res + .status(404) + .json({ message: 'User with that username does not exist.' }); + } + + const canViewPrivate = req.user && req.user._id.equals(user._id); + const filter = { owner: user._id }; + + if (q && q.trim()) { + const term = q.trim(); + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + filter.name = { $regex: escaped, $options: 'i' }; + } + + const usePagination = page !== undefined && limit !== undefined; + + const parsedPage = Math.max(parseInt(page, 10) || 1, 1); + const parsedLimit = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 100); + const dir = sortDir === 'desc' ? -1 : 1; + const allowedSortFields = new Set(['name', 'createdAt', 'updatedAt']); + const safeSortField = allowedSortFields.has(sortField) + ? sortField + : 'updatedAt'; + + const query = Collection.find(filter) + .sort({ [safeSortField]: dir, _id: dir }) + .populate([ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug', 'visibility'], + populate: { + path: 'user', + select: ['username'] + } + } + ]); + + if (usePagination) { + query.skip((parsedPage - 1) * parsedLimit).limit(parsedLimit); + } + + const collections = await query.exec(); + + const totalCollections = usePagination + ? await Collection.countDocuments(filter) + : collections.length; + + let processedCollections = collections; + if (!canViewPrivate) { + processedCollections = collections.map((collection) => { + const { items: originalItems } = collection; + const items = originalItems.filter( + (item) => item.project && item.project.visibility === 'Public' + ); + return { + ...collection.toObject(), + items, + id: collection._id + }; + }); + } + + const response = { + collections: mapCollectionsToResponse(processedCollections), + ...(usePagination && { + metadata: { + page: parsedPage, + totalPages: Math.max(Math.ceil(totalCollections / parsedLimit), 1), + totalCollections, + limit: parsedLimit, + hasPagination: true + } + }) + }; + + return res.json(response); + } catch (error) { + return res.status(500).json({ message: 'Error fetching collections' }); + } +}; + +const getCollectionsForUser = createCoreHandler((collections) => collections); +export default getCollectionsForUser; + +export const apiGetCollectionsForUser = createCoreHandler((collections) => ({ + collections +})); diff --git a/server/controllers/collection.controller/index.js b/server/controllers/collection.controller/index.js index 8cb9e368d7..f447935898 100644 --- a/server/controllers/collection.controller/index.js +++ b/server/controllers/collection.controller/index.js @@ -5,3 +5,4 @@ export { default as listCollections } from './listCollections'; export { default as removeCollection } from './removeCollection'; export { default as removeProjectFromCollection } from './removeProjectFromCollection'; export { default as updateCollection } from './updateCollection'; +export { default as getCollectionsForUser } from './getCollectionsForUser'; diff --git a/server/routes/collection.routes.ts b/server/routes/collection.routes.ts index a764f48b2e..e51d7b13cb 100644 --- a/server/routes/collection.routes.ts +++ b/server/routes/collection.routes.ts @@ -4,13 +4,10 @@ import { isAuthenticated } from '../middleware/isAuthenticated'; const router = Router(); -// List collections router.get( - '/collections', - isAuthenticated, - CollectionController.listCollections + '/:username/collections', + CollectionController.getCollectionsForUser ); -router.get('/:username/collections', CollectionController.listCollections); // Create, modify, delete collection router.post(