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(