chore: update with latest master
This commit is contained in:
1
.env
1
.env
@@ -34,6 +34,7 @@ ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
|
||||
@@ -35,6 +35,7 @@ ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
|
||||
@@ -31,6 +31,7 @@ ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
|
||||
11
.github/workflows/validate.yml
vendored
11
.github/workflows/validate.yml
vendored
@@ -9,13 +9,16 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [18, 20]
|
||||
continue-on-error: ${{ matrix.node == 20 }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
|
||||
@@ -124,7 +124,7 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="certificates"
|
||||
element={<PageWrap><Certificates courseId={courseId} /></PageWrap>}
|
||||
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="textbooks"
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Badge, Icon } from '@openedx/paragon';
|
||||
import { Settings as IconSettings } from '@openedx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
|
||||
const ProcessingNotification = ({ isShow, title }) => (
|
||||
<Badge
|
||||
className={classNames('processing-notification', {
|
||||
@@ -24,7 +22,7 @@ const ProcessingNotification = ({ isShow, title }) => (
|
||||
|
||||
ProcessingNotification.propTypes = {
|
||||
isShow: PropTypes.bool.isRequired,
|
||||
title: PropTypes.oneOf(Object.values(NOTIFICATION_MESSAGES)).isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ProcessingNotification;
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('<ToastProvider />', () => {
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -61,6 +62,13 @@ describe('<ToastProvider />', () => {
|
||||
expect(await screen.findByText('This is the toast!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close toast after 5000ms', async () => {
|
||||
render(<RootWrapper><TestComponentToShow /></RootWrapper>);
|
||||
expect(await screen.findByText('This is the toast!')).toBeInTheDocument();
|
||||
jest.advanceTimersByTime(6000);
|
||||
expect(screen.queryByText('This is the toast!')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close toast', async () => {
|
||||
render(<RootWrapper><TestComponentToClose /></RootWrapper>);
|
||||
expect(await screen.findByText('Content')).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Toast } from '@openedx/paragon';
|
||||
|
||||
import ProcessingNotification from '../processing-notification';
|
||||
|
||||
export interface ToastContextData {
|
||||
toastMessage: string | null;
|
||||
@@ -35,7 +36,13 @@ export const ToastProvider = (props: ToastProviderProps) => {
|
||||
setToastMessage(null);
|
||||
}, []);
|
||||
|
||||
const showToast = React.useCallback((message) => setToastMessage(message), [setToastMessage]);
|
||||
const showToast = React.useCallback((message) => {
|
||||
setToastMessage(message);
|
||||
// Close the toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
setToastMessage(null);
|
||||
}, 5000);
|
||||
}, [setToastMessage]);
|
||||
const closeToast = React.useCallback(() => setToastMessage(null), [setToastMessage]);
|
||||
|
||||
const context = React.useMemo(() => ({
|
||||
@@ -48,9 +55,7 @@ export const ToastProvider = (props: ToastProviderProps) => {
|
||||
<ToastContext.Provider value={context}>
|
||||
{props.children}
|
||||
{ toastMessage && (
|
||||
<Toast show={toastMessage !== null} onClose={closeToast}>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
<ProcessingNotification isShow={toastMessage !== null} title={toastMessage} />
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
|
||||
@@ -31,32 +31,37 @@ export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/details/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/grading/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.grading']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/course_team/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.courseTeam']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/group_configurations/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.groupConfigurations']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.advancedSettings']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/certificates/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.certificates']),
|
||||
},
|
||||
]);
|
||||
export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => {
|
||||
const items = [
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/details/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/grading/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.grading']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/course_team/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.courseTeam']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/group_configurations/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.groupConfigurations']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.advancedSettings']),
|
||||
},
|
||||
];
|
||||
if (getConfig().ENABLE_CERTIFICATE_PAGE === 'true') {
|
||||
items.push({
|
||||
href: `${studioBaseUrl}/certificates/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.certificates']),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { getContentMenuItems, getToolsMenuItems } from './utils';
|
||||
import { getContentMenuItems, getToolsMenuItems, getSettingMenuItems } from './utils';
|
||||
|
||||
const props = {
|
||||
studioBaseUrl: 'UrLSTuiO',
|
||||
@@ -29,6 +29,25 @@ describe('header utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSettingsMenuitems', () => {
|
||||
it('should include certificates option', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_CERTIFICATE_PAGE: 'true',
|
||||
});
|
||||
const actualItems = getSettingMenuItems(props);
|
||||
expect(actualItems).toHaveLength(6);
|
||||
});
|
||||
it('should not include certificates option', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_CERTIFICATE_PAGE: 'false',
|
||||
});
|
||||
const actualItems = getSettingMenuItems(props);
|
||||
expect(actualItems).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolsMenuItems', () => {
|
||||
it('should include export tags option', () => {
|
||||
setConfig({
|
||||
|
||||
@@ -126,6 +126,7 @@ initialize({
|
||||
ENABLE_UNIT_PAGE: process.env.ENABLE_UNIT_PAGE || 'false',
|
||||
ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_PAGE || 'false',
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
|
||||
ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false',
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2: process.env.ENABLE_HOME_PAGE_COURSE_API_V2 === 'true',
|
||||
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
|
||||
|
||||
11
src/library-authoring/LibraryAuthoringPage.scss
Normal file
11
src/library-authoring/LibraryAuthoringPage.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.library-authoring-page {
|
||||
.header-actions {
|
||||
.normal-border {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.open-border {
|
||||
border: 2px solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,33 +189,33 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
|
||||
const {
|
||||
getByRole, getByText, queryByText, findByText, findAllByText,
|
||||
getByRole, getAllByText, getByText, queryByText, findByText, findAllByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
|
||||
expect(await findByText('Content library')).toBeInTheDocument();
|
||||
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
||||
|
||||
expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
||||
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
// "Recently Modified" header + sort shown
|
||||
expect(getAllByText('Recently Modified').length).toEqual(2);
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
expect((await findAllByText('Test HTML Block'))[0]).toBeInTheDocument();
|
||||
|
||||
// Navigate to the components tab
|
||||
fireEvent.click(getByRole('tab', { name: 'Components' }));
|
||||
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
|
||||
// "Recently Modified" default sort shown
|
||||
expect(getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
|
||||
expect(queryByText('Components (6)')).not.toBeInTheDocument();
|
||||
|
||||
// Navigate to the collections tab
|
||||
fireEvent.click(getByRole('tab', { name: 'Collections' }));
|
||||
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
|
||||
// "Recently Modified" default sort shown
|
||||
expect(getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
|
||||
expect(queryByText('Components (6)')).not.toBeInTheDocument();
|
||||
expect(queryByText('There are 6 components in this library')).not.toBeInTheDocument();
|
||||
@@ -224,7 +224,8 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
// Go back to Home tab
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(getByRole('tab', { name: 'Home' }));
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
// "Recently Modified" header + sort shown
|
||||
expect(getAllByText('Recently Modified').length).toEqual(2);
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
});
|
||||
@@ -239,10 +240,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(await findByText('Content library')).toBeInTheDocument();
|
||||
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
||||
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
||||
});
|
||||
@@ -304,16 +302,13 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(await findByText('Content library')).toBeInTheDocument();
|
||||
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
||||
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
|
||||
fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } });
|
||||
|
||||
// Ensure the search endpoint is called again, only once more since the recently modified call
|
||||
// should not be impacted by the search
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('No matching components found in this library.')).toBeInTheDocument();
|
||||
|
||||
@@ -375,17 +370,22 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument();
|
||||
|
||||
// Open by default; close the library info sidebar
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
|
||||
|
||||
// Open library info sidebar with 'Library info' button
|
||||
const libraryInfoButton = screen.getByRole('button', { name: /library info/i });
|
||||
fireEvent.click(libraryInfoButton);
|
||||
|
||||
expect(screen.getByText('Draft')).toBeInTheDocument();
|
||||
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
|
||||
|
||||
// CLose library info sidebar with 'Library info' button
|
||||
fireEvent.click(libraryInfoButton);
|
||||
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show the "View All" button when viewing library with many components', async () => {
|
||||
@@ -396,15 +396,13 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
getByRole, getByText, queryByText, getAllByText, findAllByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('Content library')).toBeInTheDocument();
|
||||
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); });
|
||||
// "Recently Modified" header + sort shown
|
||||
await waitFor(() => { expect(getAllByText('Recently Modified').length).toEqual(2); });
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
|
||||
@@ -416,7 +414,8 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
|
||||
// Clicking on "View All" button should navigate to the Components tab
|
||||
fireEvent.click(getByText('View All'));
|
||||
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
|
||||
// "Recently Modified" default sort shown
|
||||
expect(getAllByText('Recently Modified').length).toEqual(1);
|
||||
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
|
||||
expect(queryByText('Components (6)')).not.toBeInTheDocument();
|
||||
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
|
||||
@@ -424,7 +423,8 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
// Go back to Home tab
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(getByRole('tab', { name: 'Home' }));
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
// "Recently Modified" header + sort shown
|
||||
expect(getAllByText('Recently Modified').length).toEqual(2);
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
});
|
||||
@@ -438,15 +438,13 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
getByText, queryByText, getAllByText, findAllByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('Content library')).toBeInTheDocument();
|
||||
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); });
|
||||
// "Recently Modified" header + sort shown
|
||||
await waitFor(() => { expect(getAllByText('Recently Modified').length).toEqual(2); });
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (2)')).toBeInTheDocument();
|
||||
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
|
||||
@@ -463,18 +461,25 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
|
||||
|
||||
const {
|
||||
findByTitle, getAllByText, getByText, getByTitle,
|
||||
findByTitle, getAllByText, getByRole, getByTitle,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
expect(await findByTitle('Sort search results')).toBeInTheDocument();
|
||||
|
||||
const testSortOption = (async (optionText, sortBy) => {
|
||||
if (optionText) {
|
||||
fireEvent.click(getByTitle('Sort search results'));
|
||||
fireEvent.click(getByText(optionText));
|
||||
}
|
||||
const testSortOption = (async (optionText, sortBy, isDefault) => {
|
||||
// Open the drop-down menu
|
||||
fireEvent.click(getByTitle('Sort search results'));
|
||||
|
||||
// Click the option with the given text
|
||||
// Since the sort drop-down also shows the selected sort
|
||||
// option in its toggle button, we need to make sure we're
|
||||
// clicking on the last one found.
|
||||
const options = getAllByText(optionText);
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
fireEvent.click(options[options.length - 1]);
|
||||
|
||||
// Did the search happen with the expected sort option?
|
||||
const bodyText = sortBy ? `"sort":["${sortBy}"]` : '"sort":[]';
|
||||
const searchText = sortBy ? `?sort=${encodeURIComponent(sortBy)}` : '';
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
|
||||
body: expect.stringContaining(bodyText),
|
||||
@@ -482,16 +487,23 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
// Is the sort option stored in the query string?
|
||||
const searchText = isDefault ? '' : `?sort=${encodeURIComponent(sortBy)}`;
|
||||
expect(window.location.search).toEqual(searchText);
|
||||
|
||||
// Is the selected sort option shown in the toggle button (if not default)
|
||||
// as well as in the drop-down menu?
|
||||
expect(getAllByText(optionText).length).toEqual(isDefault ? 1 : 2);
|
||||
});
|
||||
|
||||
await testSortOption('Title, A-Z', 'display_name:asc');
|
||||
await testSortOption('Title, Z-A', 'display_name:desc');
|
||||
await testSortOption('Newest', 'created:desc');
|
||||
await testSortOption('Oldest', 'created:asc');
|
||||
await testSortOption('Title, A-Z', 'display_name:asc', false);
|
||||
await testSortOption('Title, Z-A', 'display_name:desc', false);
|
||||
await testSortOption('Newest', 'created:desc', false);
|
||||
await testSortOption('Oldest', 'created:asc', false);
|
||||
|
||||
// Sorting by Recently Published also excludes unpublished components
|
||||
await testSortOption('Recently Published', 'last_published:desc');
|
||||
await testSortOption('Recently Published', 'last_published:desc', false);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
|
||||
body: expect.stringContaining('last_published IS NOT NULL'),
|
||||
@@ -500,8 +512,22 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Clearing filters clears the url search param and uses default sort
|
||||
fireEvent.click(getAllByText('Clear Filters')[0]);
|
||||
await testSortOption('', '');
|
||||
// Re-selecting the previous sort option resets sort to default "Recently Modified"
|
||||
await testSortOption('Recently Published', 'modified:desc', true);
|
||||
expect(getAllByText('Recently Modified').length).toEqual(2);
|
||||
|
||||
// Enter a keyword into the search box
|
||||
const searchBox = getByRole('searchbox');
|
||||
fireEvent.change(searchBox, { target: { value: 'words to find' } });
|
||||
|
||||
// Default sort option changes to "Most Relevant"
|
||||
expect(getAllByText('Most Relevant').length).toEqual(2);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
|
||||
body: expect.stringContaining('"sort":[]'),
|
||||
method: 'POST',
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ import LibraryCollections from './LibraryCollections';
|
||||
import LibraryHome from './LibraryHome';
|
||||
import { useContentLibrary } from './data/apiHooks';
|
||||
import { LibrarySidebar } from './library-sidebar';
|
||||
import { LibraryContext } from './common/context';
|
||||
import { LibraryContext, SidebarBodyComponentId } from './common/context';
|
||||
import messages from './messages';
|
||||
|
||||
enum TabList {
|
||||
@@ -51,22 +52,41 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
|
||||
const {
|
||||
openAddContentSidebar,
|
||||
openInfoSidebar,
|
||||
closeLibrarySidebar,
|
||||
sidebarBodyComponent,
|
||||
} = useContext(LibraryContext);
|
||||
|
||||
if (!canEditLibrary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const infoSidebarIsOpen = () => (
|
||||
sidebarBodyComponent === SidebarBodyComponentId.Info
|
||||
);
|
||||
|
||||
const handleOnClickInfoSidebar = () => {
|
||||
if (infoSidebarIsOpen()) {
|
||||
closeLibrarySidebar();
|
||||
} else {
|
||||
openInfoSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
className={classNames('mr-1', {
|
||||
'normal-border': !infoSidebarIsOpen(),
|
||||
'open-border': infoSidebarIsOpen(),
|
||||
})}
|
||||
iconBefore={InfoOutline}
|
||||
variant="outline-primary rounded-0"
|
||||
onClick={openInfoSidebar}
|
||||
onClick={handleOnClickInfoSidebar}
|
||||
>
|
||||
{intl.formatMessage(messages.libraryInfoButton)}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-1"
|
||||
iconBefore={Add}
|
||||
variant="primary rounded-0"
|
||||
onClick={openAddContentSidebar}
|
||||
@@ -74,7 +94,7 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
|
||||
>
|
||||
{intl.formatMessage(messages.newContentButton)}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -132,7 +152,7 @@ const LibraryAuthoringPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container className="library-authoring-page">
|
||||
<Row>
|
||||
<Col>
|
||||
<Header
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useQuery, useMutation, useQueryClient, Query,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
type GetLibrariesV2CustomParams,
|
||||
@@ -122,6 +124,22 @@ export const useRevertLibraryChanges = () => {
|
||||
mutationFn: revertLibraryChanges,
|
||||
onSettled: (_data, _error, libraryId) => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
|
||||
queryClient.invalidateQueries({
|
||||
// Invalidate all content queries related to this library.
|
||||
// If we allow searching "all courses and libraries" in the future,
|
||||
// then we'd have to invalidate all `["content_search", "results"]`
|
||||
// queries, and not just the ones for this library, because items from
|
||||
// this library could be included in an "all courses and libraries"
|
||||
// search. For now we only allow searching individual libraries.
|
||||
predicate: /* istanbul ignore next */ (query: Query): boolean => {
|
||||
// extraFilter contains library id
|
||||
const extraFilter = query.queryKey[5];
|
||||
if (!(Array.isArray(extraFilter) || typeof extraFilter === 'string')) {
|
||||
return false;
|
||||
}
|
||||
return query.queryKey[0] === 'content_search' && extraFilter?.includes(`context_key = "${libraryId}"`);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
@import "library-authoring/components/ComponentCard";
|
||||
@import "library-authoring/library-info/LibraryPublishStatus";
|
||||
@import "library-authoring/LibraryAuthoringPage";
|
||||
|
||||
@@ -204,4 +204,44 @@ describe('<LibraryInfo />', () => {
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||
});
|
||||
|
||||
it('should discard changes', async () => {
|
||||
const url = getCommitLibraryChangesUrl(libraryData.id);
|
||||
axiosMock.onDelete(url).reply(200);
|
||||
|
||||
render(<RootWrapper data={libraryData} />);
|
||||
const discardButton = screen.getByRole('button', { name: /discard changes/i });
|
||||
fireEvent.click(discardButton);
|
||||
|
||||
expect(await screen.findByText('Library changes reverted successfully')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.delete[0].url).toEqual(url));
|
||||
});
|
||||
|
||||
it('should show error on discard changes', async () => {
|
||||
const url = getCommitLibraryChangesUrl(libraryData.id);
|
||||
axiosMock.onDelete(url).reply(500);
|
||||
|
||||
render(<RootWrapper data={libraryData} />);
|
||||
const discardButton = screen.getByRole('button', { name: /discard changes/i });
|
||||
fireEvent.click(discardButton);
|
||||
|
||||
expect(await screen.findByText('There was an error reverting changes in the library.')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.delete[0].url).toEqual(url));
|
||||
});
|
||||
|
||||
it('discard changes btn should be disabled for new libraries', async () => {
|
||||
render(<RootWrapper data={{ ...libraryData, lastPublished: null, numBlocks: 0 }} />);
|
||||
const discardButton = screen.getByRole('button', { name: /discard changes/i });
|
||||
|
||||
expect(discardButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('discard changes btn should be enabled for new libraries if components are added', async () => {
|
||||
render(<RootWrapper data={{ ...libraryData, lastPublished: null, numBlocks: 2 }} />);
|
||||
const discardButton = screen.getByRole('button', { name: /discard changes/i });
|
||||
|
||||
expect(discardButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => {
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span className="font-weight-bold m-1.5">
|
||||
<span className="font-weight-bold mt-1.5 ml-1.5">
|
||||
{library.title}
|
||||
</span>
|
||||
{library.canEditLibrary && (
|
||||
@@ -75,6 +75,8 @@ const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => {
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.editNameButtonAlt)}
|
||||
onClick={handleClick}
|
||||
className="mt-1"
|
||||
size="inline"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Button, Container, Stack } from '@openedx/paragon';
|
||||
import { FormattedDate, FormattedTime, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useCommitLibraryChanges } from '../data/apiHooks';
|
||||
import { useCommitLibraryChanges, useRevertLibraryChanges } from '../data/apiHooks';
|
||||
import { ContentLibrary } from '../data/api';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import messages from './messages';
|
||||
@@ -14,6 +14,7 @@ type LibraryPublishStatusProps = {
|
||||
const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
|
||||
const intl = useIntl();
|
||||
const commitLibraryChanges = useCommitLibraryChanges();
|
||||
const revertLibraryChanges = useRevertLibraryChanges();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
@@ -25,9 +26,6 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* TODO, the discard changes breaks the library.
|
||||
* Discomment this when discard changes is fixed.
|
||||
const revert = useCallback(() => {
|
||||
revertLibraryChanges.mutateAsync(library.id)
|
||||
.then(() => {
|
||||
@@ -36,15 +34,16 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
|
||||
showToast(intl.formatMessage(messages.revertErrorMsg));
|
||||
});
|
||||
}, []);
|
||||
*/
|
||||
|
||||
const {
|
||||
isPublished,
|
||||
isNew,
|
||||
statusMessage,
|
||||
extraStatusMessage,
|
||||
bodyMessage,
|
||||
} = useMemo(() => {
|
||||
let isPublishedResult: boolean;
|
||||
let isNewResult = false;
|
||||
let statusMessageResult : string;
|
||||
let extraStatusMessageResult : string | undefined;
|
||||
let bodyMessageResult : string | undefined;
|
||||
@@ -94,6 +93,7 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
|
||||
|
||||
if (!library.lastPublished) {
|
||||
// Library is never published (new)
|
||||
isNewResult = library.numBlocks === 0; // allow discarding if components are added
|
||||
isPublishedResult = false;
|
||||
statusMessageResult = intl.formatMessage(messages.draftStatusLabel);
|
||||
extraStatusMessageResult = intl.formatMessage(messages.neverPublishedLabel);
|
||||
@@ -123,6 +123,7 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
|
||||
}
|
||||
return {
|
||||
isPublished: isPublishedResult,
|
||||
isNew: isNewResult,
|
||||
statusMessage: statusMessageResult,
|
||||
extraStatusMessage: extraStatusMessageResult,
|
||||
bodyMessage: bodyMessageResult,
|
||||
@@ -153,15 +154,11 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
|
||||
<Button disabled={isPublished} onClick={commit}>
|
||||
{intl.formatMessage(messages.publishButtonLabel)}
|
||||
</Button>
|
||||
{ /*
|
||||
* TODO, the discard changes breaks the library.
|
||||
* Discomment this when discard changes is fixed.
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button disabled={isPublished} variant="link" onClick={revert}>
|
||||
{intl.formatMessage(messages.discardChangesButtonLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
*/ }
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button disabled={isPublished || isNew} variant="link" onClick={revert}>
|
||||
{intl.formatMessage(messages.discardChangesButtonLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Stack>
|
||||
|
||||
@@ -49,11 +49,13 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
|
||||
<Stack direction="horizontal" className="d-flex justify-content-between">
|
||||
{buildHeader()}
|
||||
<IconButton
|
||||
className="mt-1"
|
||||
src={Close}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.closeButtonAlt)}
|
||||
onClick={closeLibrarySidebar}
|
||||
variant="black"
|
||||
size="inline"
|
||||
/>
|
||||
</Stack>
|
||||
<div>
|
||||
|
||||
@@ -81,16 +81,17 @@ const FilterByBlockType: React.FC<Record<never, never>> = () => {
|
||||
<Menu className="block-type-refinement-menu" style={{ boxShadow: 'none' }}>
|
||||
{
|
||||
Object.entries(sortedBlockTypes).map(([blockType, count]) => (
|
||||
<MenuItem
|
||||
key={blockType}
|
||||
as={Form.Checkbox}
|
||||
value={blockType}
|
||||
checked={blockTypesFilter.includes(blockType)}
|
||||
onChange={handleCheckboxChange}
|
||||
>
|
||||
<BlockTypeLabel type={blockType} />{' '}
|
||||
<Badge variant="light" pill>{count}</Badge>
|
||||
</MenuItem>
|
||||
<label key={blockType} className="d-inline">
|
||||
<MenuItem
|
||||
as={Form.Checkbox}
|
||||
value={blockType}
|
||||
checked={blockTypesFilter.includes(blockType)}
|
||||
onChange={handleCheckboxChange}
|
||||
>
|
||||
<BlockTypeLabel type={blockType} />{' '}
|
||||
<Badge variant="light" pill>{count}</Badge>
|
||||
</MenuItem>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
{
|
||||
|
||||
@@ -49,38 +49,43 @@ const TagMenuItem: React.FC<{
|
||||
const randomNumber = React.useMemo(() => Math.floor(Math.random() * 1000), []);
|
||||
const checkboxId = tagPath.replace(/[\W]/g, '_') + randomNumber;
|
||||
|
||||
const expandChildrenClick = React.useCallback((e) => {
|
||||
e.preventDefault();
|
||||
onToggleChildren?.(tagPath);
|
||||
}, [onToggleChildren, tagPath]);
|
||||
|
||||
return (
|
||||
<div className="pgn__menu-item pgn__form-checkbox tag-toggle-item" role="group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={checkboxId}
|
||||
checked={isChecked}
|
||||
onChange={onClickCheckbox}
|
||||
className="pgn__form-checkbox-input flex-shrink-0"
|
||||
/>
|
||||
<label htmlFor={checkboxId} className="flex-shrink-1 mb-0">
|
||||
{label}{' '}
|
||||
<Badge variant="light" pill>{tagCount}</Badge>
|
||||
</label>
|
||||
{
|
||||
hasChildren
|
||||
? (
|
||||
<IconButton
|
||||
src={isExpanded ? ArrowDropUp : ArrowDropDown}
|
||||
iconAs={Icon}
|
||||
alt={
|
||||
intl.formatMessage(
|
||||
isExpanded ? messages.childTagsCollapse : messages.childTagsExpand,
|
||||
{ tagName: label },
|
||||
)
|
||||
}
|
||||
onClick={() => onToggleChildren?.(tagPath)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
<label className="d-inline">
|
||||
<div className="pgn__menu-item pgn__form-checkbox tag-toggle-item" role="group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={checkboxId}
|
||||
checked={isChecked}
|
||||
onChange={onClickCheckbox}
|
||||
className="pgn__form-checkbox-input flex-shrink-0"
|
||||
/>
|
||||
{label}
|
||||
<Badge variant="light" pill className="ml-1">{tagCount}</Badge>
|
||||
{
|
||||
hasChildren
|
||||
? (
|
||||
<IconButton
|
||||
src={isExpanded ? ArrowDropUp : ArrowDropDown}
|
||||
iconAs={Icon}
|
||||
alt={
|
||||
intl.formatMessage(
|
||||
isExpanded ? messages.childTagsCollapse : messages.childTagsExpand,
|
||||
{ tagName: label },
|
||||
)
|
||||
}
|
||||
onClick={expandChildrenClick}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -125,7 +130,6 @@ const TagOptions: React.FC<{
|
||||
return (
|
||||
<React.Fragment key={tagName}>
|
||||
<TagMenuItem
|
||||
key={tagName}
|
||||
label={tagName}
|
||||
tagCount={t.tagCount}
|
||||
tagPath={tagPath}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface SearchContextData {
|
||||
isFiltered: boolean;
|
||||
searchSortOrder: SearchSortOption;
|
||||
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
|
||||
defaultSearchSortOrder: SearchSortOption;
|
||||
hits: ContentHit[];
|
||||
totalHits: number;
|
||||
isFetching: boolean;
|
||||
@@ -65,14 +66,11 @@ function useStateWithUrlSearchParam<Type>(
|
||||
setSearchParams((prevParams) => {
|
||||
const paramValue: string = toString(value) ?? '';
|
||||
const newSearchParams = new URLSearchParams(prevParams);
|
||||
if (paramValue) {
|
||||
newSearchParams.set(paramName, paramValue);
|
||||
} else {
|
||||
// If no paramValue, remove it from the search params, so
|
||||
// we don't get dangling parameter values like ?paramName=
|
||||
// Another way to decide this would be to check value === defaultValue,
|
||||
// and ensure that default values are never stored in the search string.
|
||||
// If using the default paramValue, remove it from the search params.
|
||||
if (paramValue === defaultValue) {
|
||||
newSearchParams.delete(paramName);
|
||||
} else {
|
||||
newSearchParams.set(paramName, paramValue);
|
||||
}
|
||||
return newSearchParams;
|
||||
}, { replace: true });
|
||||
@@ -95,9 +93,10 @@ export const SearchContextProvider: React.FC<{
|
||||
|
||||
// The search sort order can be set via the query string
|
||||
// E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA.
|
||||
const defaultSortOption = SearchSortOption.RELEVANCE;
|
||||
// Default sort by Most Relevant if there's search keyword(s), else by Recently Modified.
|
||||
const defaultSearchSortOrder = searchKeywords ? SearchSortOption.RELEVANCE : SearchSortOption.RECENTLY_MODIFIED;
|
||||
const [searchSortOrder, setSearchSortOrder] = useStateWithUrlSearchParam<SearchSortOption>(
|
||||
defaultSortOption,
|
||||
defaultSearchSortOrder,
|
||||
'sort',
|
||||
(value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue),
|
||||
(value: SearchSortOption) => value.toString(),
|
||||
@@ -105,7 +104,7 @@ export const SearchContextProvider: React.FC<{
|
||||
// SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we
|
||||
// send it to useContentSearchResults as an empty array.
|
||||
const searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder;
|
||||
const sort: SearchSortOption[] = (searchSortOrderToUse === defaultSortOption ? [] : [searchSortOrderToUse]);
|
||||
const sort: SearchSortOption[] = (searchSortOrderToUse === SearchSortOption.RELEVANCE ? [] : [searchSortOrderToUse]);
|
||||
// Selecting SearchSortOption.RECENTLY_PUBLISHED also excludes unpublished components.
|
||||
if (searchSortOrderToUse === SearchSortOption.RECENTLY_PUBLISHED) {
|
||||
extraFilter.push('last_published IS NOT NULL');
|
||||
@@ -114,13 +113,11 @@ export const SearchContextProvider: React.FC<{
|
||||
const canClearFilters = (
|
||||
blockTypesFilter.length > 0
|
||||
|| tagsFilter.length > 0
|
||||
|| searchSortOrderToUse !== defaultSortOption
|
||||
);
|
||||
const isFiltered = canClearFilters || (searchKeywords !== '');
|
||||
const clearFilters = React.useCallback(() => {
|
||||
setBlockTypesFilter([]);
|
||||
setTagsFilter([]);
|
||||
setSearchSortOrder(defaultSortOption);
|
||||
}, []);
|
||||
|
||||
// Initialize a connection to Meilisearch:
|
||||
@@ -160,6 +157,7 @@ export const SearchContextProvider: React.FC<{
|
||||
clearFilters,
|
||||
searchSortOrder,
|
||||
setSearchSortOrder,
|
||||
defaultSearchSortOrder,
|
||||
closeSearchModal: props.closeSearchModal ?? (() => {}),
|
||||
hasError: hasConnectionError || result.isError,
|
||||
...result,
|
||||
|
||||
@@ -9,47 +9,71 @@ import { useSearchContext } from './SearchManager';
|
||||
|
||||
export const SearchSortWidget: React.FC<Record<never, never>> = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
searchSortOrder,
|
||||
setSearchSortOrder,
|
||||
defaultSearchSortOrder,
|
||||
} = useSearchContext();
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'search-sort-option-title-az',
|
||||
name: intl.formatMessage(messages.searchSortTitleAZ),
|
||||
value: SearchSortOption.TITLE_AZ,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-title-za',
|
||||
name: intl.formatMessage(messages.searchSortTitleZA),
|
||||
value: SearchSortOption.TITLE_ZA,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-newest',
|
||||
name: intl.formatMessage(messages.searchSortNewest),
|
||||
value: SearchSortOption.NEWEST,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-oldest',
|
||||
name: intl.formatMessage(messages.searchSortOldest),
|
||||
value: SearchSortOption.OLDEST,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-recently-published',
|
||||
name: intl.formatMessage(messages.searchSortRecentlyPublished),
|
||||
value: SearchSortOption.RECENTLY_PUBLISHED,
|
||||
id: 'search-sort-option-most-relevant',
|
||||
name: intl.formatMessage(messages.searchSortMostRelevant),
|
||||
value: SearchSortOption.RELEVANCE,
|
||||
show: (defaultSearchSortOrder === SearchSortOption.RELEVANCE),
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-recently-modified',
|
||||
name: intl.formatMessage(messages.searchSortRecentlyModified),
|
||||
value: SearchSortOption.RECENTLY_MODIFIED,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-recently-published',
|
||||
name: intl.formatMessage(messages.searchSortRecentlyPublished),
|
||||
value: SearchSortOption.RECENTLY_PUBLISHED,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-title-az',
|
||||
name: intl.formatMessage(messages.searchSortTitleAZ),
|
||||
value: SearchSortOption.TITLE_AZ,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-title-za',
|
||||
name: intl.formatMessage(messages.searchSortTitleZA),
|
||||
value: SearchSortOption.TITLE_ZA,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-newest',
|
||||
name: intl.formatMessage(messages.searchSortNewest),
|
||||
value: SearchSortOption.NEWEST,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-oldest',
|
||||
name: intl.formatMessage(messages.searchSortOldest),
|
||||
value: SearchSortOption.OLDEST,
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
[intl],
|
||||
[intl, defaultSearchSortOrder],
|
||||
);
|
||||
|
||||
const { searchSortOrder, setSearchSortOrder } = useSearchContext();
|
||||
const selectedSortOption = menuItems.find((menuItem) => menuItem.value === searchSortOrder);
|
||||
const searchSortLabel = (
|
||||
selectedSortOption ? selectedSortOption.name : intl.formatMessage(messages.searchSortWidgetLabel)
|
||||
const menuHeader = intl.formatMessage(messages.searchSortWidgetLabel);
|
||||
const defaultSortOption = menuItems.find(
|
||||
({ value }) => (value === defaultSearchSortOrder),
|
||||
);
|
||||
const shownMenuItems = menuItems.filter(({ show }) => show);
|
||||
|
||||
// Show the currently selected sort option as the toggle button label.
|
||||
const selectedSortOption = shownMenuItems.find(
|
||||
({ value }) => (value === searchSortOrder),
|
||||
) ?? defaultSortOption;
|
||||
const toggleLabel = selectedSortOption ? selectedSortOption.name : menuHeader;
|
||||
|
||||
return (
|
||||
<Dropdown id="search-sort-dropdown">
|
||||
@@ -62,13 +86,18 @@ export const SearchSortWidget: React.FC<Record<never, never>> = () => {
|
||||
size="sm"
|
||||
>
|
||||
<Icon src={SwapVert} className="d-inline" />
|
||||
{searchSortLabel}
|
||||
<div className="py-0 px-1">{toggleLabel}</div>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{menuItems.map(({ id, name, value }) => (
|
||||
<Dropdown.Header>{menuHeader}</Dropdown.Header>
|
||||
{shownMenuItems.map(({ id, name, value }) => (
|
||||
<Dropdown.Item
|
||||
key={id}
|
||||
onClick={() => setSearchSortOrder(value)}
|
||||
onClick={() => {
|
||||
// If the selected sort option was re-clicked, de-select it (reset to default)
|
||||
const searchOrder = value === searchSortOrder ? defaultSearchSortOrder : value;
|
||||
setSearchSortOrder(searchOrder);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
{(value === searchSortOrder) && <Icon src={Check} className="ml-2" />}
|
||||
|
||||
@@ -132,7 +132,7 @@ const messages = defineMessages({
|
||||
},
|
||||
searchSortWidgetLabel: {
|
||||
id: 'course-authoring.course-search.searchSortWidget.label',
|
||||
defaultMessage: 'Sort',
|
||||
defaultMessage: 'Sort By',
|
||||
description: 'Label displayed to users when default sorting is used by the content search drop-down menu',
|
||||
},
|
||||
searchSortWidgetAltTitle: {
|
||||
@@ -170,6 +170,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Recently Modified',
|
||||
description: 'Label for the content search sort drop-down which sorts by modified date, descending',
|
||||
},
|
||||
searchSortMostRelevant: {
|
||||
id: 'course-authoring.course-search.searchSort.mostRelevant',
|
||||
defaultMessage: 'Most Relevant',
|
||||
description: 'Label for the content search sort drop-down which sorts keyword searches by relevance',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user