feat: Add filters/sorting for the libraries v2 tab on studio home (#1117)

This commit is contained in:
Yusuf Musleh
2024-07-08 17:35:43 +03:00
committed by GitHub
parent 8cf26e1a75
commit 83489b0983
11 changed files with 535 additions and 52 deletions

66
src/library/data/api.ts Normal file
View File

@@ -0,0 +1,66 @@
import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export interface LibraryV2 {
id: string,
type: string,
org: string,
slug: string,
title: string,
description: string,
numBlocks: number,
version: number,
lastPublished: string | null,
allowLti: boolean,
allowPublicLearning: boolean,
allowPublicRead: boolean,
hasUnpublishedChanges: boolean,
hasUnpublishedDeletes: boolean,
license: string,
}
export interface LibrariesV2Response {
next: string | null,
previous: string | null,
count: number,
numPages: number,
currentPage: number,
start: number,
results: LibraryV2[],
}
/* Additional custom parameters for the API request. */
export interface GetLibrariesV2CustomParams {
/* (optional) Library type, default `complex` */
type?: string,
/* (optional) Page number of results */
page?: number,
/* (optional) The number of results on each page, default `50` */
pageSize?: number,
/* (optional) Whether pagination is supported, default `true` */
pagination?: boolean,
/* (optional) Library field to order results by. Prefix with '-' for descending */
order?: string,
/* (optional) Search query to filter v2 Libraries by */
search?: string,
}
/**
* Get's studio home v2 Libraries.
*/
export async function getStudioHomeLibrariesV2(customParams: GetLibrariesV2CustomParams): Promise<LibrariesV2Response> {
// Set default params if not passed in
const customParamsDefaults = {
type: customParams.type || 'complex',
page: customParams.page || 1,
pageSize: customParams.pageSize || 50,
pagination: customParams.pagination !== undefined ? customParams.pagination : true,
order: customParams.order || 'title',
textSearch: customParams.search,
};
const customParamsFormat = snakeCaseObject(customParamsDefaults);
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/libraries/v2/`, { params: customParamsFormat });
return camelCaseObject(data);
}

View File

@@ -1,3 +1,4 @@
// @ts-check
import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -8,7 +9,6 @@ export const getCourseNotificationUrl = (url) => new URL(url, getApiBaseUrl()).h
/**
* Get's studio home data.
* @param {string} search
* @returns {Promise<Object>}
*/
export async function getStudioHomeData() {
@@ -40,28 +40,6 @@ export async function getStudioHomeLibraries() {
return camelCaseObject(data);
}
/**
* Get's studio home v2 Libraries.
* @param {object} customParams - Additional custom paramaters for the API request.
* @param {string} [customParams.type] - (optional) Library type, default `complex`
* @param {number} [customParams.page] - (optional) Page number of results
* @param {number} [customParams.pageSize] - (optional) The number of results on each page, default `50`
* @param {boolean} [customParams.pagination] - (optional) Whether pagination is supported, default `true`
* @returns {Promise<Object>} - A Promise that resolves to the response data container the studio home v2 libraries.
*/
export async function getStudioHomeLibrariesV2(customParams) {
// Set default params if not passed in
const customParamsDefaults = {
type: customParams.type || 'complex',
page: customParams.page || 1,
pageSize: customParams.pageSize || 50,
pagination: customParams.pagination !== undefined ? customParams.pagination : true,
};
const customParamsFormat = snakeCaseObject(customParamsDefaults);
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/libraries/v2/`, { params: customParamsFormat });
return camelCaseObject(data);
}
/**
* Handle course notification requests.
* @param {string} url

View File

@@ -13,8 +13,8 @@ import {
getStudioHomeCourses,
getStudioHomeCoursesV2,
getStudioHomeLibraries,
getStudioHomeLibrariesV2,
} from './api';
import { getStudioHomeLibrariesV2 } from '../../library/data/api';
import {
generateGetStudioCoursesApiResponse,
generateGetStudioHomeDataApiResponse,

View File

@@ -1,14 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { getStudioHomeLibrariesV2 } from './api';
import { GetLibrariesV2CustomParams, getStudioHomeLibrariesV2 } from '../../library/data/api';
/**
* Builds the query to fetch list of V2 Libraries
*/
const useListStudioHomeV2Libraries = (customParams) => (
const useListStudioHomeV2Libraries = (customParams: GetLibrariesV2CustomParams) => (
useQuery({
queryKey: ['listV2Libraries', customParams],
queryFn: () => getStudioHomeLibrariesV2(customParams),
keepPreviousData: true,
})
);

View File

@@ -1,8 +1,14 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Icon, Row, Pagination } from '@openedx/paragon';
import {
Icon,
Row,
Pagination,
Alert,
Button,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig, getPath } from '@edx/frontend-platform';
import { Error } from '@openedx/paragon/icons';
import { constructLibraryAuthoringURL } from '../../../utils';
import useListStudioHomeV2Libraries from '../../data/apiHooks';
@@ -10,26 +16,38 @@ import { LoadingSpinner } from '../../../generic/Loading';
import AlertMessage from '../../../generic/alert-message';
import CardItem from '../../card-item';
import messages from '../messages';
import LibrariesV2Filters from './libraries-v2-filters';
const LibrariesV2Tab = ({
const LibrariesV2Tab: React.FC<{
libraryAuthoringMfeUrl: string,
redirectToLibraryAuthoringMfe: boolean
}> = ({
libraryAuthoringMfeUrl,
redirectToLibraryAuthoringMfe,
}) => {
const intl = useIntl();
const [currentPage, setCurrentPage] = useState(1);
const [filterParams, setFilterParams] = useState({});
const handlePageSelect = (page) => {
const isFiltered = Object.keys(filterParams).length > 0;
const handlePageSelect = (page: number) => {
setCurrentPage(page);
};
const handleClearFilters = () => {
setFilterParams({});
setCurrentPage(1);
};
const {
data,
isLoading,
isError,
} = useListStudioHomeV2Libraries({ page: currentPage });
} = useListStudioHomeV2Libraries({ page: currentPage, ...filterParams });
if (isLoading) {
if (isLoading && !isFiltered) {
return (
<Row className="m-0 mt-4 justify-content-center">
<LoadingSpinner />
@@ -37,7 +55,7 @@ const LibrariesV2Tab = ({
);
}
const libURL = (id) => (
const libURL = (id: string) => (
libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe
? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${id}`)
// Redirection to the placeholder is done in the MFE rather than
@@ -46,6 +64,8 @@ const LibrariesV2Tab = ({
: `${window.location.origin}${getPath(getConfig().PUBLIC_PATH)}library/${id}`
);
const hasV2Libraries = !isLoading && ((data!.results.length || 0) > 0);
return (
isError ? (
<AlertMessage
@@ -61,18 +81,26 @@ const LibrariesV2Tab = ({
) : (
<div className="courses-tab-container">
<div className="d-flex flex-row justify-content-between my-4">
{/* Temporary div to add spacing. This will be replaced with lib search/filters */}
<div className="d-flex" />
<p data-testid="pagination-info">
{intl.formatMessage(messages.coursesPaginationInfo, {
length: data.results.length,
total: data.count,
})}
</p>
<LibrariesV2Filters
isLoading={isLoading}
isFiltered={isFiltered}
filterParams={filterParams}
setFilterParams={setFilterParams}
setCurrentPage={setCurrentPage}
/>
{ !isLoading
&& (
<p>
{intl.formatMessage(messages.coursesPaginationInfo, {
length: data!.results.length,
total: data!.count,
})}
</p>
)}
</div>
{
data.results.map(({
{ hasV2Libraries
? data!.results.map(({
id, org, slug, title,
}) => (
<CardItem
@@ -83,16 +111,27 @@ const LibrariesV2Tab = ({
number={slug}
url={libURL(id)}
/>
))
}
)) : isFiltered && !isLoading && (
<Alert className="mt-4">
<Alert.Heading>
{intl.formatMessage(messages.librariesV2TabLibraryNotFoundAlertTitle)}
</Alert.Heading>
<p>
{intl.formatMessage(messages.librariesV2TabLibraryNotFoundAlertMessage)}
</p>
<Button variant="primary" onClick={handleClearFilters}>
{intl.formatMessage(messages.coursesTabCourseNotFoundAlertCleanFiltersButton)}
</Button>
</Alert>
)}
{
data.numPages > 1
hasV2Libraries && (data!.numPages || 0) > 1
&& (
<Pagination
className="d-flex justify-content-center"
paginationLabel="pagination navigation"
pageCount={data.numPages}
pageCount={data!.numPages}
currentPage={currentPage}
onPageSelect={handlePageSelect}
/>
@@ -103,9 +142,4 @@ const LibrariesV2Tab = ({
);
};
LibrariesV2Tab.propTypes = {
libraryAuthoringMfeUrl: PropTypes.string.isRequired,
redirectToLibraryAuthoringMfe: PropTypes.bool.isRequired,
};
export default LibrariesV2Tab;

View File

@@ -0,0 +1,144 @@
import React from 'react';
import {
screen, fireEvent, render, waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import LibrariesV2Filters, { LibrariesV2FiltersProps } from '.';
describe('LibrariesV2Filters', () => {
const setFilterParamsMock = jest.fn();
const setCurrentPageMock = jest.fn();
const IntlProviderWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
);
const renderComponent = (overrideProps: Partial<LibrariesV2FiltersProps> = {}) => render(
<IntlProviderWrapper>
<LibrariesV2Filters
filterParams={{}}
setFilterParams={setFilterParamsMock}
setCurrentPage={setCurrentPageMock}
{...overrideProps}
/>
</IntlProviderWrapper>,
);
beforeEach(() => {
jest.clearAllMocks();
});
it('should render search field and order filter', () => {
renderComponent();
const searchInput = screen.getByRole('searchbox');
expect(searchInput).toBeInTheDocument();
const orderFilter = screen.getByText('Name A-Z');
expect(orderFilter).toBeInTheDocument();
});
it('should call setFilterParams and setCurrentPage when search input changes', async () => {
renderComponent();
const searchInput = screen.getByRole('searchbox');
fireEvent.change(searchInput, { target: { value: 'test' } });
await waitFor(() => expect(setFilterParamsMock).toHaveBeenCalled());
await waitFor(() => expect(setCurrentPageMock).toHaveBeenCalled());
});
it('should call setFilterParams and setCurrentPage when a menu item order menu is selected', async () => {
renderComponent();
const libraryV2OrderMenuFilter = screen.getByText('Name A-Z');
fireEvent.click(libraryV2OrderMenuFilter);
const newestLibV2sMenuItem = screen.getByText('Newest');
fireEvent.click(newestLibV2sMenuItem);
expect(setFilterParamsMock).toHaveBeenCalled();
expect(setCurrentPageMock).toHaveBeenCalled();
});
it('should clear the search input when filters cleared', async () => {
const { rerender } = renderComponent({ isFiltered: true });
const searchInput = screen.getByRole('searchbox');
fireEvent.change(searchInput, { target: { value: 'test' } });
await waitFor(() => expect(setFilterParamsMock).toHaveBeenCalled());
await waitFor(() => expect(setCurrentPageMock).toHaveBeenCalled());
rerender(
<IntlProviderWrapper>
<LibrariesV2Filters
isFiltered={false}
filterParams={{}}
setFilterParams={setFilterParamsMock}
setCurrentPage={setCurrentPageMock}
/>
</IntlProviderWrapper>,
);
await waitFor(() => expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe(''));
});
it('should update states with the correct parameters when a order menu item is selected', () => {
renderComponent();
const libraryV2OrderMenuFilter = screen.getByText('Name A-Z');
fireEvent.click(libraryV2OrderMenuFilter);
const oldestLibV2sMenuItem = screen.getByText('Oldest');
fireEvent.click(oldestLibV2sMenuItem);
// Check that setFilterParams is called with the correct payload
expect(setFilterParamsMock).toHaveBeenCalledWith(expect.objectContaining({
search: undefined,
order: 'created',
}));
// Check that setCurrentPage is called with `1`
expect(setCurrentPageMock).toHaveBeenCalledWith(1);
});
it('should call setFilterParams after debounce delay when the search input changes', async () => {
renderComponent();
const searchInput = screen.getByRole('searchbox');
fireEvent.change(searchInput, { target: { value: 'test' } });
await waitFor(() => expect(setFilterParamsMock).toHaveBeenCalled(), { timeout: 500 });
expect(setFilterParamsMock).toHaveBeenCalledWith(expect.anything());
});
it('should not call setFilterParams with only spaces when search only spaces', async () => {
renderComponent();
const searchInput = screen.getByRole('searchbox');
fireEvent.change(searchInput, { target: { value: ' ' } });
await waitFor(() => expect(setFilterParamsMock).not.toHaveBeenCalledWith(expect.objectContaining({
search: ' ',
order: 'created',
})), { timeout: 500 });
});
it('should display the loading spinner when isLoading is true', () => {
renderComponent({ isLoading: true });
const spinner = screen.getByText('Loading...');
expect(spinner).toBeInTheDocument();
});
it('should not display the loading spinner when isLoading is false', () => {
renderComponent({ isLoading: false });
const spinner = screen.queryByText('Loading...');
expect(spinner).not.toBeInTheDocument();
});
it('should clear the search input and call dispatch when the reset button is clicked', async () => {
renderComponent();
const searchInput = screen.getByRole('searchbox') as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: 'test' } });
const form = searchInput.closest('form');
if (!form) {
throw new Error('Form not found');
}
const resetButton = form.querySelector('button');
if (!resetButton || !(resetButton instanceof HTMLButtonElement)) {
throw new Error('Reset button not found');
}
fireEvent.click(resetButton);
expect(searchInput.value).toBe('');
});
});

View File

@@ -0,0 +1,109 @@
/* eslint-disable react/require-default-props */
import React, { useState, useCallback, useEffect } from 'react';
import { SearchField } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { LoadingSpinner } from '../../../../generic/Loading';
import LibrariesV2OrderFilterMenu from './libraries-v2-order-filter-menu';
import messages from '../../messages';
export interface LibrariesV2FiltersProps {
isLoading?: boolean;
isFiltered?: boolean;
filterParams: { search?: string | undefined, order?: string };
setFilterParams: React.Dispatch<React.SetStateAction<{ search: string | undefined, order: string }>>;
setCurrentPage: React.Dispatch<React.SetStateAction<number>>;
}
const LibrariesV2Filters: React.FC<LibrariesV2FiltersProps> = ({
isLoading = false,
isFiltered = false,
filterParams,
setFilterParams,
setCurrentPage,
}) => {
const intl = useIntl();
const [search, setSearch] = useState<string | undefined>('');
const [order, setOrder] = useState('title');
// Reset search & order when filters cleared
useEffect(() => {
if (!isFiltered) {
setSearch(filterParams.search);
setOrder('title');
}
}, [isFiltered, setSearch, search, setOrder, filterParams.search]);
const getOrderFromFilterType = (filterType: string) => {
const orders = {
sortLibrariesV2AZ: 'title',
sortLibrariesV2ZA: '-title',
sortLibrariesV2Newest: '-created',
sortLibrariesV2Oldest: 'created',
};
// Default to 'A-Z` if invalid filtertype
return orders[filterType] || 'title';
};
const getFilterTypeData = (baseFilters: { search: string | undefined; order: string; }) => ({
sortLibrariesV2AZ: { ...baseFilters, order: 'title' },
sortLibrariesV2ZA: { ...baseFilters, order: '-title' },
sortLibrariesV2Newest: { ...baseFilters, order: '-created' },
sortLibrariesV2Oldest: { ...baseFilters, order: 'created' },
});
const handleMenuFilterItemSelected = (filterType: string) => {
setOrder(getOrderFromFilterType(filterType));
const baseFilters = {
search,
order,
};
const menuFilterParams = getFilterTypeData(baseFilters);
const filterParamsFormat = menuFilterParams[filterType] || baseFilters;
setFilterParams(filterParamsFormat);
setCurrentPage(1);
};
const handleSearchLibrariesV2 = useCallback((searchValue: string) => {
const valueFormatted = searchValue.trim();
const updatedFilterParams = {
search: valueFormatted.length > 0 ? valueFormatted : undefined,
order,
};
// Check if the search is different from the current search and it's not only spaces
if (valueFormatted !== search || valueFormatted) {
setSearch(valueFormatted);
setFilterParams(updatedFilterParams);
setCurrentPage(1);
}
}, [order, search]);
return (
<div className="d-flex">
<div className="d-flex flex-row">
<SearchField
onSubmit={() => {}}
onChange={handleSearchLibrariesV2}
value={search}
className="mr-4"
placeholder={intl.formatMessage(messages.librariesV2TabLibrarySearchPlaceholder)}
/>
{isLoading && (
<span className="search-field-loading">
<LoadingSpinner size="sm" />
</span>
)}
</div>
<LibrariesV2OrderFilterMenu onItemMenuSelected={handleMenuFilterItemSelected} isFiltered={isFiltered} />
</div>
);
};
export default LibrariesV2Filters;

View File

@@ -0,0 +1,58 @@
import React, { useState, useEffect } from 'react';
import { Icon, Dropdown } from '@openedx/paragon';
import { Check } from '@openedx/paragon/icons';
const LibrariesV2FilterMenu: React.FC<{
id: string;
menuItems: { id: string, name: string, value: string }[];
onItemMenuSelected: (value: string) => void;
defaultItemSelectedText: string;
isFiltered: boolean;
}> = ({
id: idProp,
menuItems = [],
onItemMenuSelected,
defaultItemSelectedText = '',
isFiltered,
}) => {
const [itemMenuSelected, setItemMenuSelected] = useState(defaultItemSelectedText);
const handleOrderSelected = (name: string, value: string) => {
setItemMenuSelected(name);
onItemMenuSelected(value);
};
const libraryV2OrderSelectedIcon = (itemValue: string) => (itemValue === itemMenuSelected ? (
<Icon src={Check} className="ml-2" />
) : null);
useEffect(() => {
if (!isFiltered) {
setItemMenuSelected(defaultItemSelectedText);
}
}, [isFiltered]);
return (
<Dropdown id={`dropdown-toggle-${idProp}`}>
<Dropdown.Toggle
alt="dropdown-toggle-menu-items"
id={idProp}
variant="none"
className="dropdown-toggle-menu-items"
>
{itemMenuSelected}
</Dropdown.Toggle>
<Dropdown.Menu>
{menuItems.map(({ id, name, value }) => (
<Dropdown.Item
key={id}
onClick={() => handleOrderSelected(name, value)}
>
{name} {libraryV2OrderSelectedIcon(name)}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
export default LibrariesV2FilterMenu;

View File

@@ -0,0 +1,55 @@
import React, { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import LibrariesV2FilterMenu from '../libraries-v2-filter-menu';
const LibrariesV2OrderFilterMenu: React.FC<{
onItemMenuSelected: (value: string) => void;
isFiltered: boolean;
}> = ({ onItemMenuSelected, isFiltered }) => {
const intl = useIntl();
const libraryV2Orders = useMemo(
() => [
{
id: 'sort-libraries-v2-az',
name: intl.formatMessage(messages.librariesV2OrderFilterMenuAscendantLibrariesV2),
value: 'sortLibrariesV2AZ',
},
{
id: 'sort-libraries-v2-za',
name: intl.formatMessage(messages.librariesV2OrderFilterMenuDescendantLibrariesV2),
value: 'sortLibrariesV2ZA',
},
{
id: 'sort-libraries-v2-newest',
name: intl.formatMessage(messages.librariesV2OrderFilterMenuNewestLibrariesV2),
value: 'sortLibrariesV2Newest',
},
{
id: 'sort-libraries-v2-oldest',
name: intl.formatMessage(messages.librariesV2OrderFilterMenuOldestLibrariesV2),
value: 'sortLibrariesV2Oldest',
},
],
[intl],
);
const handleLibraryV2OrderSelected = (libraryV2Order: string) => {
onItemMenuSelected(libraryV2Order);
};
return (
<LibrariesV2FilterMenu
id="dropdown-toggle-libraries-v2-order-menu"
menuItems={libraryV2Orders}
onItemMenuSelected={handleLibraryV2OrderSelected}
defaultItemSelectedText={intl.formatMessage(messages.librariesV2OrderFilterMenuAscendantLibrariesV2)}
isFiltered={isFiltered}
/>
);
};
export default LibrariesV2OrderFilterMenu;

View File

@@ -0,0 +1,26 @@
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
import type { defineMessages as defineMessagesType } from 'react-intl';
// frontend-platform currently doesn't provide types... do it ourselves.
const defineMessages = _defineMessages as typeof defineMessagesType;
const messages = defineMessages({
librariesV2OrderFilterMenuAscendantLibrariesV2: {
id: 'course-authoring.studio-home.libraries.tab.order-filter-menu.ascendant-librariesv2',
defaultMessage: 'Name A-Z',
},
librariesV2OrderFilterMenuDescendantLibrariesV2: {
id: 'course-authoring.studio-home.libraries.tab.order-filter-menu.descendant-librariesv2',
defaultMessage: 'Name Z-A',
},
librariesV2OrderFilterMenuNewestLibrariesV2: {
id: 'course-authoring.studio-home.libraries.tab.order-filter-menu.newest-librariesv2',
defaultMessage: 'Newest',
},
librariesV2OrderFilterMenuOldestLibrariesV2: {
id: 'course-authoring.studio-home.libraries.tab.order-filter-menu.oldest-librariesv2',
defaultMessage: 'Oldest',
},
});
export default messages;

View File

@@ -58,6 +58,18 @@ const messages = defineMessages({
id: 'course-authoring.studio-home.libraries.placeholder.body',
defaultMessage: 'This is a placeholder page, as the Library Authoring MFE is not enabled.',
},
librariesV2TabLibrarySearchPlaceholder: {
id: 'course-authoring.studio-home.libraries.tab.library.search-placeholder',
defaultMessage: 'Search',
},
librariesV2TabLibraryNotFoundAlertTitle: {
id: 'course-authoring.studio-home.libraries.tab.library.not.found.alert.title',
defaultMessage: 'We could not find any result',
},
librariesV2TabLibraryNotFoundAlertMessage: {
id: 'course-authoring.studio-home.libraries.tab.library.not.found.alert.message',
defaultMessage: 'There are no libraries with the current filters.',
},
});
export default messages;