feat: Add filters/sorting for the libraries v2 tab on studio home (#1117)
This commit is contained in:
66
src/library/data/api.ts
Normal file
66
src/library/data/api.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
getStudioHomeCourses,
|
||||
getStudioHomeCoursesV2,
|
||||
getStudioHomeLibraries,
|
||||
getStudioHomeLibrariesV2,
|
||||
} from './api';
|
||||
import { getStudioHomeLibrariesV2 } from '../../library/data/api';
|
||||
import {
|
||||
generateGetStudioCoursesApiResponse,
|
||||
generateGetStudioHomeDataApiResponse,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user