+ )}
+
+ );
+};
+
+DeleteConfirmationModal.defaultProps = {
+ selectedRows: [],
+};
+
+DeleteConfirmationModal.propTypes = {
+ selectedRows: PropTypes.arrayOf(PropTypes.shape({
+ original: PropTypes.shape({
+ id: PropTypes.string,
+ displayName: PropTypes.string,
+ activeStatus: PropTypes.string,
+ usageLocations: PropTypes.arrayOf(PropTypes.shape({
+ url: PropTypes.string,
+ displayLocation: PropTypes.string,
+ })),
+ }),
+ })),
+ isDeleteConfirmationOpen: PropTypes.bool.isRequired,
+ closeDeleteConfirmation: PropTypes.func.isRequired,
+ handleBulkDelete: PropTypes.func.isRequired,
+ fileType: PropTypes.string.isRequired,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(DeleteConfirmationModal);
diff --git a/src/files-and-videos/generic/DeleteConfirmationModal.test.jsx b/src/files-and-videos/generic/DeleteConfirmationModal.test.jsx
new file mode 100644
index 0000000000..669e497b70
--- /dev/null
+++ b/src/files-and-videos/generic/DeleteConfirmationModal.test.jsx
@@ -0,0 +1,88 @@
+import {
+ render,
+ screen,
+ within,
+} from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import DeleteConfirmationModal from './DeleteConfirmationModal';
+
+const defaultProps = {
+ isDeleteConfirmationOpen: true,
+ closeDeleteConfirmation: jest.fn(),
+ handleBulkDelete: jest.fn(),
+ selectedRows: [{
+ original: {
+ displayName: 'test',
+ activeStatus: 'active',
+ id: 'file-test',
+ usageLocations: [{
+ displayLocation: 'unit', url: 'unit/url',
+ }],
+ },
+ }],
+ fileType: 'file',
+};
+
+const renderComponent = (props) => {
+ render(
+
+
+ ,
+ );
+};
+
+describe('DeleteConfirmationModal', () => {
+ it('should show file name in title', () => {
+ renderComponent(defaultProps);
+ const { displayName } = defaultProps.selectedRows[0].original;
+
+ expect(screen.getByText(`Delete ${displayName}`)).toBeInTheDocument();
+ });
+
+ it('should show number of files in title', () => {
+ const props = {
+ ...defaultProps,
+ selectedRows: [
+ ...defaultProps.selectedRows,
+ { original: { displayName: 'test 2', activeStatus: 'inactive' } },
+ ],
+ };
+
+ renderComponent(props);
+ const numberOfFiles = props.selectedRows.length;
+
+ expect(screen.getByText(`Delete ${numberOfFiles} ${defaultProps.fileType}s`)).toBeInTheDocument();
+ });
+
+ it('should not show delete confirmation usage list', () => {
+ const props = {
+ ...defaultProps,
+ selectedRows: [],
+ };
+ renderComponent(props);
+
+ expect(screen.queryByRole('list')).toBeNull();
+ });
+
+ it('should show test file in delete confirmation usage list', () => {
+ renderComponent(defaultProps);
+ const deleteUsageList = screen.getByRole('list');
+
+ expect(within(deleteUsageList).getByTestId('collapsible-file-test')).toBeVisible();
+ });
+
+ it('should not show test file in delete confirmation usage list', () => {
+ const props = {
+ ...defaultProps,
+ selectedRows: [
+ ...defaultProps.selectedRows,
+ { original: { displayName: 'test 2', activeStatus: 'inactive', id: 'file-test2' } },
+ ],
+ };
+ renderComponent(props);
+ const deleteUsageList = screen.getByRole('list');
+
+ expect(within(deleteUsageList).queryByTestId('collapsible-file-test2')).toBeNull();
+ });
+});
diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx
index a774029224..9e82ab0502 100644
--- a/src/files-and-videos/generic/FileTable.jsx
+++ b/src/files-and-videos/generic/FileTable.jsx
@@ -3,14 +3,11 @@ import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
+ CardView,
DataTable,
- TextFilter,
Dropzone,
- CardView,
+ TextFilter,
useToggle,
- AlertModal,
- ActionRow,
- Button,
} from '@openedx/paragon';
import { RequestStatus } from '../../data/constants';
@@ -28,6 +25,7 @@ import {
Footer,
} from './table-components';
import ApiStatusToast from './ApiStatusToast';
+import DeleteConfirmationModal from './DeleteConfirmationModal';
const FileTable = ({
files,
@@ -275,23 +273,15 @@ const FileTable = ({
sidebar={infoModalSidebar}
/>
)}
-
-
-
-
- )}
- >
- {intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length, fileType })}
-
+
);
};
diff --git a/src/files-and-videos/generic/messages.js b/src/files-and-videos/generic/messages.js
index 6b6054dae9..7e452f5a35 100644
--- a/src/files-and-videos/generic/messages.js
+++ b/src/files-and-videos/generic/messages.js
@@ -4,146 +4,200 @@ const messages = defineMessages({
rowStatusMessage: {
id: 'course-authoring.files-and-upload.rowStatus.message',
defaultMessage: 'Showing {fileCount} of {rowCount}',
+ description: 'This message is showed to notify user of the number of files being shown',
},
apiStatusToastMessage: {
id: 'course-authoring.files-and-upload.apiStatus.message',
defaultMessage: '{actionType} {selectedRowCount} {fileType}(s)',
+ description: 'This message is showed in the toast when action is applied to files',
},
apiStatusAddingAction: {
id: 'course-authoring.files-and-upload.apiStatus.addingAction.message',
defaultMessage: 'Adding',
+ description: 'This message is used in the toast when files are added',
},
apiStatusDeletingAction: {
id: 'course-authoring.files-and-upload.apiStatus.deletingAction.message',
defaultMessage: 'Deleting',
+ description: 'This message is used in the toast when files are deleted',
},
apiStatusDownloadingAction: {
id: 'course-authoring.files-and-upload.apiStatus.downloadingAction.message',
defaultMessage: 'Downloading',
+ description: 'This message is used in the toast when files are downloaded',
},
fileSizeError: {
id: 'course-authoring.files-and-upload.addFiles.error.fileSize',
defaultMessage: 'Uploaded file(s) must be 20 MB or less. Please resize file(s) and try again.',
+ description: 'This error message is shown when user tries to upload a file larger than 20 MB',
},
noResultsFoundMessage: {
id: 'course-authoring.files-and-upload.table.noResultsFound.message',
defaultMessage: 'No results found',
+ description: 'This message is shown when no files are found based on name search',
},
addFilesButtonLabel: {
id: 'course-authoring.files-and-upload.addFiles.button.label',
defaultMessage: 'Add {fileType}s',
+ description: 'Label for add files button, name changes based on page',
},
actionsButtonLabel: {
id: 'course-authoring.files-and-upload.action.button.label',
defaultMessage: 'Actions',
+ description: 'Label for actions dropdown button',
},
errorAlertMessage: {
id: 'course-authoring.files-and-upload.errorAlert.message',
defaultMessage: '{message}',
+ description: 'Message shell for error alert',
},
transcriptionErrorMessage: {
id: 'course-authoring.files-and-uploads.file-info.transcripts.error.alert',
defaultMessage: 'Transcript failed: "{error}"',
+ description: 'Message for transcript error in info modal',
},
usageTitle: {
id: 'course-authoring.files-and-uploads.file-info.usage.title',
defaultMessage: 'Usage',
+ description: 'Title for usage information section in info modal',
},
usageLoadingMessage: {
id: 'course-authoring.files-and-uploads.file-info.usage.loading.message',
defaultMessage: 'Loading',
+ description: 'Screen reader text for loading spinner in usage information section',
},
usageNotInUseMessage: {
id: 'course-authoring.files-and-uploads.file-info.usage.notInUse.message',
defaultMessage: 'Currently not in use',
+ description: 'Message for usage information section when file is not used in course',
},
copyVideoIdTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.copyVideoIdTitle',
defaultMessage: 'Copy video ID',
+ description: 'Label for copy video id button in card menu dropdown',
},
copyStudioUrlTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.copyStudioUrlTitle',
defaultMessage: 'Copy Studio Url',
+ description: 'Label for copy studio url button in card menu dropdown',
},
copyWebUrlTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.copyWebUrlTitle',
defaultMessage: 'Copy Web Url',
+ description: 'Label for copy web url button in card menu dropdown',
},
downloadTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.downloadTitle',
defaultMessage: 'Download',
+ description: 'Label for download button in card menu dropdown',
},
lockMenuTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.lockTitle',
defaultMessage: 'Lock',
+ description: 'Label for lock button in card menu dropdown',
+ },
+ lockFileTooltipContent: {
+ id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content',
+ defaultMessage: `By default, anyone can access a file you upload if
+ they know the web URL, even if they are not enrolled in your course.
+ You can prevent outside access to a file by locking the file. When
+ you lock a file, the web URL only allows learners who are enrolled
+ in your course and signed in to access the file.`,
+ description: 'Tooltip message for the lock icon in the table view of files',
},
unlockMenuTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.unlockTitle',
defaultMessage: 'Unlock',
+ description: 'Label for unlock button in card menu dropdown',
},
infoTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.infoTitle',
defaultMessage: 'Info',
+ description: 'Label for info button in card menu dropdown',
},
downloadEncodingsTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.downloadEncodingsTitle',
defaultMessage: 'Download video list (.csv)',
+ description: 'Label for download video list button in actions dropdown',
},
deleteTitle: {
id: 'course-authoring.files-and-uploads.cardMenu.deleteTitle',
defaultMessage: 'Delete',
+ description: 'Label for delete button in card menu dropdown',
},
deleteConfirmationTitle: {
id: 'course-authoring.files-and-uploads..deleteConfirmation.title',
- defaultMessage: 'Delete {fileType}(s) confirmation',
+ defaultMessage: 'Delete {fileNumber, plural, one {{fileName}} other {{fileNumber} {fileType}s}}',
+ description: 'Title for delete confirmation modal',
},
deleteConfirmationMessage: {
id: 'course-authoring.files-and-uploads..deleteConfirmation.message',
- defaultMessage: 'Are you sure you want to delete {fileNumber} {fileType}(s)? This action cannot be undone.',
+ defaultMessage: `
+ Are you sure you want to delete {fileNumber, plural, one {{fileName}} other {{fileNumber} {fileType}s}}?
+ This action cannot be undone and may break your course if the {fileNumber, plural, one {{fileType} is} other {{fileType}s are}}
+ used in the course content, advanced settings, updates, or schedule and details.
+ `,
+ description: 'Message presented to user listing the number of files they are attempting to delete in the delete confirmation modal',
+ },
+ deleteConfirmationUsageMessage: {
+ id: 'course-authoring.files-and-uploads..deleteConfirmation.message',
+ defaultMessage: 'The following {fileNumber, plural, one {{fileType} is} other {{fileType}s are}} used in course content. Consider updating the content before deleting.',
+ description: 'Message listing where the files the user is attempting to delete are used in the course',
},
deleteFileButtonLabel: {
id: 'course-authoring.files-and-uploads.deleteConfirmation.deleteFile.label',
defaultMessage: 'Delete',
+ description: 'Label for delete button in delete confirmation modal modal',
},
cancelButtonLabel: {
id: 'course-authoring.files-and-uploads.cancelButton.label',
defaultMessage: 'Cancel',
+ description: 'Label for cancel button in modals',
},
sortButtonLabel: {
id: 'course-authoring.files-and-uploads.sortButton.label',
defaultMessage: 'Sort and filter',
+ description: 'Label for button that opens the sort and filter modal',
},
sortModalTitleLabel: {
id: 'course-authoring.files-and-uploads.sortModal.title',
defaultMessage: 'Sort by',
+ description: 'Title for Sort By secition in sort and filter modal',
},
sortByNameAscending: {
id: 'course-authoring.files-and-uploads.sortByNameAscendingButton.label',
defaultMessage: 'Name (A-Z)',
+ description: 'Label for ascending name radio button in sort and filter modal',
},
sortByNewest: {
id: 'course-authoring.files-and-uploads.sortByNewestButton.label',
defaultMessage: 'Newest',
+ description: 'Label for descending date added radio button in sort and filter modal',
},
sortBySizeDescending: {
id: 'course-authoring.files-and-uploads.sortBySizeDescendingButton.label',
defaultMessage: 'File size (High to low)',
+ description: 'Label for descending file size radio button in sort and filter modal',
},
sortByNameDescending: {
id: 'course-authoring.files-and-uploads.sortByNameDescendingButton.label',
defaultMessage: 'Name (Z-A)',
+ description: 'Label for descending name radio button in sort and filter modal',
},
sortByOldest: {
id: 'course-authoring.files-and-uploads.sortByOldestButton.label',
defaultMessage: 'Oldest',
+ description: 'Label for ascending date added radio button in sort and filter modal',
},
sortBySizeAscending: {
id: 'course-authoring.files-and-uploads.sortBySizeAscendingButton.label',
defaultMessage: 'File size(Low to high)',
+ description: 'Label for ascending file size radio button in sort and filter modal',
},
applySortButton: {
id: 'course-authoring.files-and-uploads.applyySortButton.label',
defaultMessage: 'Apply',
+ description: 'Label for apply sort button in sort and filter modal',
},
});
diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx
index 21327140d2..fae17b6a98 100644
--- a/src/files-and-videos/videos-page/VideosPage.test.jsx
+++ b/src/files-and-videos/videos-page/VideosPage.test.jsx
@@ -273,7 +273,7 @@ describe('Videos page', () => {
axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204);
fireEvent.click(deleteButton);
- expect(screen.getByText('Delete video(s) confirmation')).toBeVisible();
+ expect(screen.getByText('Delete mOckID1.mp4')).toBeVisible();
await act(async () => {
userEvent.click(deleteButton);
});
@@ -287,7 +287,7 @@ describe('Videos page', () => {
userEvent.click(confirmDeleteButton);
});
- expect(screen.queryByText('Delete video(s) confirmation')).toBeNull();
+ expect(screen.queryByText('Delete mOckID1.mp4')).toBeNull();
// Check if the video is deleted in the store and UI
const deleteStatus = store.getState().videos.deletingStatus;
@@ -541,10 +541,10 @@ describe('Videos page', () => {
axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204);
fireEvent.click(within(fileMenuButton).getByLabelText('file-menu-toggle'));
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
- expect(screen.getByText('Delete video(s) confirmation')).toBeVisible();
+ expect(screen.getByText('Delete mOckID1.mp4')).toBeVisible();
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
- expect(screen.queryByText('Delete video(s) confirmation')).toBeNull();
+ expect(screen.queryByText('Delete mOckID1.mp4')).toBeNull();
executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch);
});
@@ -640,10 +640,10 @@ describe('Videos page', () => {
axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(404);
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
- expect(screen.getByText('Delete video(s) confirmation')).toBeVisible();
+ expect(screen.getByText('Delete mOckID1.mp4')).toBeVisible();
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
- expect(screen.queryByText('Delete video(s) confirmation')).toBeNull();
+ expect(screen.queryByText('Delete mOckID1.mp4')).toBeNull();
executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch);
});
From fd6b9ae3a6d7e4059ae7687d85afe668e43bdb4b Mon Sep 17 00:00:00 2001
From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
Date: Wed, 10 Apr 2024 14:50:40 -0400
Subject: [PATCH 3/6] fix: referencing activeStatus for undefined (#943)
---
src/files-and-videos/generic/DeleteConfirmationModal.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/files-and-videos/generic/DeleteConfirmationModal.jsx b/src/files-and-videos/generic/DeleteConfirmationModal.jsx
index 087e4665f6..bbcb3ab7e1 100644
--- a/src/files-and-videos/generic/DeleteConfirmationModal.jsx
+++ b/src/files-and-videos/generic/DeleteConfirmationModal.jsx
@@ -25,7 +25,7 @@ const DeleteConfirmationModal = ({
const firstSelectedRow = selectedRows[0]?.original;
let activeContentRows = [];
if (Array.isArray(selectedRows)) {
- activeContentRows = selectedRows.filter(row => row.original.activeStatus === 'active');
+ activeContentRows = selectedRows.filter(row => row.original?.activeStatus === 'active');
}
const isDeletingCourseContent = activeContentRows.length > 0;
From aaf49896103ebe945c044e4276f505e7a627eabd Mon Sep 17 00:00:00 2001
From: Raymond Zhou <56318341+rayzhou-bit@users.noreply.github.com>
Date: Wed, 10 Apr 2024 12:57:46 -0700
Subject: [PATCH 4/6] fix: react datepicker workaround for local time (#944)
---
src/utils.js | 35 +++++++++++++++++++++++++++++++++--
1 file changed, 33 insertions(+), 2 deletions(-)
diff --git a/src/utils.js b/src/utils.js
index 925e780d39..1a16977b1c 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -255,19 +255,50 @@ export function setupYupExtensions() {
}
export const convertToDateFromString = (dateStr) => {
+ /**
+ * Convert UTC to local time for react-datepicker
+ * Note: react-datepicker has a bug where it only interacts with local time
+ * @param {string} dateStr - YYYY-MM-DDTHH:MM:SSZ
+ * @return {Date} date in local time
+ */
if (!dateStr) {
return '';
}
- return moment(dateStr).utc().toDate();
+ const stripTimeZone = (stringValue) => stringValue.substring(0, 19);
+
+ const differenceDueToDST = (date) => {
+ const isNowDST = moment(new Date()).isDST();
+ const isDateDST = moment(date).isDST();
+ if (isNowDST && !isDateDST) {
+ return 1;
+ }
+ if (!isNowDST && isDateDST) {
+ return -1;
+ }
+ return 0;
+ };
+
+ const timeZoneOffset = new Date().getTimezoneOffset();
+ const timeZoneHours = (Math.abs(timeZoneOffset) / 60) + differenceDueToDST(moment(dateStr));
+ const sign = timeZoneOffset < 0 ? '+' : '-';
+ const timeZone = `${sign}${String(timeZoneHours).padStart(2, '0')}00`;
+
+ return moment(stripTimeZone(String(dateStr)) + timeZone).toDate();
};
export const convertToStringFromDate = (date) => {
+ /**
+ * Convert local time to UTC from react-datepicker
+ * Note: react-datepicker has a bug where it only interacts with local time
+ * @param {Date} date - date in local time
+ * @return {string} YYYY-MM-DDTHH:MM:SSZ
+ */
if (!date) {
return '';
}
- return moment(date).utc().format(DATE_TIME_FORMAT);
+ return moment(date).format(DATE_TIME_FORMAT);
};
export const isValidDate = (date) => {
From fc3e38f63b88d7bfbdf03407e13373c548b97e89 Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Wed, 10 Apr 2024 21:31:06 -0700
Subject: [PATCH 5/6] feat: Content Search Modal: Filters [FC-0040] (#918)
Implementation of openedx/modular-learning#201
Implements a modal for searching course content with filters for searching in current or all courses, filtering by content type, content tags and text.
---
package-lock.json | 134 +++++++++++-
package.json | 3 +-
.../ContentTagsDropDownSelector.jsx | 2 +-
src/header/Header.jsx | 2 +-
src/index.scss | 1 +
src/search-modal/BlockTypeLabel.jsx | 25 +++
src/search-modal/ClearFiltersButton.jsx | 25 +++
src/search-modal/EmptyStates.jsx | 30 +++
src/search-modal/FilterByBlockType.jsx | 90 ++++++++
src/search-modal/FilterByTags.jsx | 119 +++++++++++
src/search-modal/SearchEndpointLoader.jsx | 41 ++++
src/search-modal/SearchFilterWidget.jsx | 60 ++++++
src/search-modal/SearchKeywordsField.jsx | 29 +++
src/search-modal/SearchModal.jsx | 42 +---
src/search-modal/SearchModal.scss | 71 +++++++
src/search-modal/SearchModal.test.jsx | 4 +-
src/search-modal/SearchResult.jsx | 38 ++--
src/search-modal/SearchUI.jsx | 102 ++++++---
src/search-modal/SearchUI.test.jsx | 196 ++++++++++++++++++
src/search-modal/Stats.jsx | 27 +++
src/search-modal/__mocks__/search-result.json | 79 +++++++
src/search-modal/data/apiHooks.js | 5 +-
src/search-modal/messages.js | 111 +++++++++-
src/taxonomy/manage-orgs/ManageOrgsModal.jsx | 2 +-
24 files changed, 1129 insertions(+), 109 deletions(-)
mode change 100755 => 100644 src/index.scss
create mode 100644 src/search-modal/BlockTypeLabel.jsx
create mode 100644 src/search-modal/ClearFiltersButton.jsx
create mode 100644 src/search-modal/EmptyStates.jsx
create mode 100644 src/search-modal/FilterByBlockType.jsx
create mode 100644 src/search-modal/FilterByTags.jsx
create mode 100644 src/search-modal/SearchEndpointLoader.jsx
create mode 100644 src/search-modal/SearchFilterWidget.jsx
create mode 100644 src/search-modal/SearchKeywordsField.jsx
create mode 100644 src/search-modal/SearchModal.scss
create mode 100644 src/search-modal/SearchUI.test.jsx
create mode 100644 src/search-modal/Stats.jsx
create mode 100644 src/search-modal/__mocks__/search-result.json
diff --git a/package-lock.json b/package-lock.json
index 595b4a9cfe..ded7637afb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,7 +26,7 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
- "@meilisearch/instant-meilisearch": "^0.16.0",
+ "@meilisearch/instant-meilisearch": "^0.17.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
@@ -83,6 +83,7 @@
"axios": "^0.28.0",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
+ "fetch-mock-jest": "^1.5.1",
"glob": "7.2.3",
"husky": "7.0.4",
"jest-canvas-mock": "^2.5.2",
@@ -4449,11 +4450,11 @@
}
},
"node_modules/@meilisearch/instant-meilisearch": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@meilisearch/instant-meilisearch/-/instant-meilisearch-0.16.0.tgz",
- "integrity": "sha512-JdqG/Wq+8cbzwxz4DKLuUuTetpiAcpaGQaOvi2wJzzXyYfyoiGh3/F12XMY8CA/pfDRLZtrZpDYvYLcsH+QUqw==",
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@meilisearch/instant-meilisearch/-/instant-meilisearch-0.17.0.tgz",
+ "integrity": "sha512-6SDDivDWsmYjX33m2fAUCcBvatjutBbvqV8Eg+CEllz0l6piAiDK/WlukVpYrSmhYN2YGQsJSm62WbMGciPhUg==",
"dependencies": {
- "meilisearch": "^0.37.0"
+ "meilisearch": "^0.38.0"
}
},
"node_modules/@newrelic/publish-sourcemap": {
@@ -10923,6 +10924,95 @@
"bser": "2.1.1"
}
},
+ "node_modules/fetch-mock": {
+ "version": "9.11.0",
+ "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz",
+ "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.0.0",
+ "@babel/runtime": "^7.0.0",
+ "core-js": "^3.0.0",
+ "debug": "^4.1.1",
+ "glob-to-regexp": "^0.4.0",
+ "is-subset": "^0.1.1",
+ "lodash.isequal": "^4.5.0",
+ "path-to-regexp": "^2.2.1",
+ "querystring": "^0.2.0",
+ "whatwg-url": "^6.5.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ },
+ "funding": {
+ "type": "charity",
+ "url": "https://www.justgiving.com/refugee-support-europe"
+ },
+ "peerDependencies": {
+ "node-fetch": "*"
+ },
+ "peerDependenciesMeta": {
+ "node-fetch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fetch-mock-jest": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/fetch-mock-jest/-/fetch-mock-jest-1.5.1.tgz",
+ "integrity": "sha512-+utwzP8C+Pax1GSka3nFXILWMY3Er2L+s090FOgqVNrNCPp0fDqgXnAHAJf12PLHi0z4PhcTaZNTz8e7K3fjqQ==",
+ "dev": true,
+ "dependencies": {
+ "fetch-mock": "^9.11.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "charity",
+ "url": "https://www.justgiving.com/refugee-support-europe"
+ },
+ "peerDependencies": {
+ "node-fetch": "*"
+ },
+ "peerDependenciesMeta": {
+ "node-fetch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fetch-mock/node_modules/path-to-regexp": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz",
+ "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==",
+ "dev": true
+ },
+ "node_modules/fetch-mock/node_modules/tr46": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
+ "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/fetch-mock/node_modules/webidl-conversions": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
+ "dev": true
+ },
+ "node_modules/fetch-mock/node_modules/whatwg-url": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz",
+ "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==",
+ "dev": true,
+ "dependencies": {
+ "lodash.sortby": "^4.7.0",
+ "tr46": "^1.0.1",
+ "webidl-conversions": "^4.0.2"
+ }
+ },
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -13166,6 +13256,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-subset": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
+ "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==",
+ "dev": true
+ },
"node_modules/is-symbol": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
@@ -15107,6 +15203,12 @@
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="
},
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "dev": true
+ },
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -15127,6 +15229,12 @@
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="
},
+ "node_modules/lodash.sortby": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
+ "dev": true
+ },
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
@@ -15325,9 +15433,9 @@
}
},
"node_modules/meilisearch": {
- "version": "0.37.0",
- "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.37.0.tgz",
- "integrity": "sha512-LdbK6JmRghCawrmWKJSEQF0OiE82md+YqJGE/U2JcCD8ROwlhTx0KM6NX4rQt0u0VpV0QZVG9umYiu3CSSIJAQ==",
+ "version": "0.38.0",
+ "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.38.0.tgz",
+ "integrity": "sha512-bHaq8nYxSKw9/Qslq1Zes5g9tHgFkxy/I9o8942wv2PqlNOT0CzptIkh/x98N52GikoSZOXSQkgt6oMjtf5uZw==",
"dependencies": {
"cross-fetch": "^3.1.6"
}
@@ -17785,6 +17893,16 @@
"node": ">=0.10"
}
},
+ "node_modules/querystring": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz",
+ "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==",
+ "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
diff --git a/package.json b/package.json
index 442a2347a1..fc1b9e35c4 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
- "@meilisearch/instant-meilisearch": "^0.16.0",
+ "@meilisearch/instant-meilisearch": "^0.17.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
@@ -110,6 +110,7 @@
"axios": "^0.28.0",
"axios-mock-adapter": "1.22.0",
"eslint-import-resolver-webpack": "^0.13.8",
+ "fetch-mock-jest": "^1.5.1",
"glob": "7.2.3",
"husky": "7.0.4",
"jest-canvas-mock": "^2.5.2",
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx
index 39620bfc2d..4960e30912 100644
--- a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx
+++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx
@@ -283,7 +283,7 @@ const ContentTagsDropDownSelector = ({
clickAndEnterHandler(tagData.value)}
- tabIndex="-1"
+ tabIndex={-1}
/>
)}
diff --git a/src/header/Header.jsx b/src/header/Header.jsx
index 470f088057..7cc1adcb08 100644
--- a/src/header/Header.jsx
+++ b/src/header/Header.jsx
@@ -22,7 +22,7 @@ const Header = ({
const [isShowSearchModalOpen, openSearchModal, closeSearchModal] = useToggle(false);
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
- const meiliSearchEnabled = getConfig().MEILISEARCH_ENABLED || null;
+ const meiliSearchEnabled = [true, 'true'].includes(getConfig().MEILISEARCH_ENABLED);
const mainMenuDropdowns = [
{
id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`,
diff --git a/src/index.scss b/src/index.scss
old mode 100755
new mode 100644
index 4565fba554..27e23358ca
--- a/src/index.scss
+++ b/src/index.scss
@@ -25,4 +25,5 @@
@import "course-checklist/CourseChecklist";
@import "content-tags-drawer/ContentTagsDropDownSelector";
@import "content-tags-drawer/ContentTagsCollapsible";
+@import "search-modal/SearchModal";
@import "certificates/scss/Certificates";
diff --git a/src/search-modal/BlockTypeLabel.jsx b/src/search-modal/BlockTypeLabel.jsx
new file mode 100644
index 0000000000..8a6048df47
--- /dev/null
+++ b/src/search-modal/BlockTypeLabel.jsx
@@ -0,0 +1,25 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+
+/**
+ * Displays a friendly, localized text name for the given XBlock/component type
+ * e.g. `vertical` becomes `"Unit"`
+ * @type {React.FC<{type: string}>}
+ */
+const BlockTypeLabel = ({ type }) => {
+ // TODO: Load the localized list of Component names from Studio REST API?
+ const msg = messages[`blockType.${type}`];
+
+ if (msg) {
+ return ;
+ }
+ // Replace underscores and hypens with spaces, then let the browser capitalize this
+ // in a locale-aware way to get a reasonable display value.
+ // e.g. 'drag-and-drop-v2' -> "Drag And Drop V2"
+ return {type.replace(/[_-]/g, ' ')};
+};
+
+export default BlockTypeLabel;
diff --git a/src/search-modal/ClearFiltersButton.jsx b/src/search-modal/ClearFiltersButton.jsx
new file mode 100644
index 0000000000..2b33c981d2
--- /dev/null
+++ b/src/search-modal/ClearFiltersButton.jsx
@@ -0,0 +1,25 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { useClearRefinements } from 'react-instantsearch';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { Button } from '@openedx/paragon';
+import messages from './messages';
+
+/**
+ * A button that appears when at least one filter is active, and will clear the filters when clicked.
+ * @type {React.FC>}
+ */
+const ClearFiltersButton = () => {
+ const { refine, canRefine } = useClearRefinements();
+ if (canRefine) {
+ return (
+
+ );
+ }
+ return null;
+};
+
+export default ClearFiltersButton;
diff --git a/src/search-modal/EmptyStates.jsx b/src/search-modal/EmptyStates.jsx
new file mode 100644
index 0000000000..a90a294df2
--- /dev/null
+++ b/src/search-modal/EmptyStates.jsx
@@ -0,0 +1,30 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { useStats, useClearRefinements } from 'react-instantsearch';
+
+/**
+ * If the user hasn't put any keywords/filters yet, display an "empty state".
+ * Likewise, if the results are empty (0 results), display a special message.
+ * Otherwise, display the results, which are assumed to be the children prop.
+ * @type {React.FC<{children: React.ReactElement}>}
+ */
+const EmptyStates = ({ children }) => {
+ const { nbHits, query } = useStats();
+ const { canRefine: hasFiltersApplied } = useClearRefinements();
+ const hasQuery = !!query;
+
+ if (!hasQuery && !hasFiltersApplied) {
+ // We haven't started the search yet. Display the "start your search" empty state
+ // Note this isn't localized because it's going to be replaced in a fast-follow PR.
+ return
Enter a keyword or select a filter to begin searching.
;
+ }
+ if (nbHits === 0) {
+ // Note this isn't localized because it's going to be replaced in a fast-follow PR.
+ return
No results found. Change your search and try again.
;
+ }
+
+ return children;
+};
+
+export default EmptyStates;
diff --git a/src/search-modal/FilterByBlockType.jsx b/src/search-modal/FilterByBlockType.jsx
new file mode 100644
index 0000000000..8c7f590259
--- /dev/null
+++ b/src/search-modal/FilterByBlockType.jsx
@@ -0,0 +1,90 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import {
+ Button,
+ Badge,
+ Form,
+ Menu,
+ MenuItem,
+} from '@openedx/paragon';
+import {
+ useCurrentRefinements,
+ useRefinementList,
+} from 'react-instantsearch';
+import SearchFilterWidget from './SearchFilterWidget';
+import messages from './messages';
+import BlockTypeLabel from './BlockTypeLabel';
+
+/**
+ * A button with a dropdown that allows filtering the current search by component type (XBlock type)
+ * e.g. Limit results to "Text" (html) and "Problem" (problem) components.
+ * The button displays the first type selected, and a count of how many other types are selected, if more than one.
+ * @type {React.FC>}
+ */
+const FilterByBlockType = () => {
+ const {
+ items,
+ refine,
+ canToggleShowMore,
+ isShowingMore,
+ toggleShowMore,
+ } = useRefinementList({ attribute: 'block_type', sortBy: ['count:desc', 'name'] });
+
+ // Get the list of applied 'items' (selected block types to filter) in the original order that the user clicked them.
+ // The first choice will be shown on the button, and we don't want it to change as the user selects more options.
+ // (But for the dropdown menu, we always want them sorted by 'count:desc' and 'name'; not in order of selection.)
+ const refinementsData = useCurrentRefinements({ includedAttributes: ['block_type'] });
+ const appliedItems = refinementsData.items[0]?.refinements ?? [];
+ // If we didn't need to preserve the order the user clicked on, the above two lines could be simplified to:
+ // const appliedItems = items.filter(item => item.isRefined);
+
+ const handleCheckboxChange = React.useCallback((e) => {
+ refine(e.target.value);
+ }, [refine]);
+
+ return (
+ ({ label: }))}
+ label={}
+ >
+
+ item.value)}
+ >
+
+
+
+ {
+ canToggleShowMore && !isShowingMore
+ ?
+ : null
+ }
+
+ );
+};
+
+export default FilterByBlockType;
diff --git a/src/search-modal/FilterByTags.jsx b/src/search-modal/FilterByTags.jsx
new file mode 100644
index 0000000000..68bd92ee70
--- /dev/null
+++ b/src/search-modal/FilterByTags.jsx
@@ -0,0 +1,119 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import {
+ Button,
+ Badge,
+ Form,
+ Menu,
+ MenuItem,
+} from '@openedx/paragon';
+import { useHierarchicalMenu } from 'react-instantsearch';
+import SearchFilterWidget from './SearchFilterWidget';
+import messages from './messages';
+
+// eslint-disable-next-line max-len
+/** @typedef {import('instantsearch.js/es/connectors/hierarchical-menu/connectHierarchicalMenu').HierarchicalMenuItem} HierarchicalMenuItem */
+
+/**
+ * A button with a dropdown menu to allow filtering the search using tags.
+ * This version is based on Instantsearch's component, so it only allows selecting one tag at a
+ * time. We will replace it with a custom version that allows multi-select.
+ * @type {React.FC<{
+ * items: HierarchicalMenuItem[],
+ * refine: (value: string) => void,
+ * depth?: number,
+ * }>}
+ */
+const FilterOptions = ({ items, refine, depth = 0 }) => {
+ const handleCheckboxChange = React.useCallback((e) => {
+ refine(e.target.value);
+ }, [refine]);
+
+ return (
+ <>
+ {
+ items.map((item) => (
+
+
+ {item.data && }
+
+ ))
+ }
+ >
+ );
+};
+
+/** @type {React.FC} */
+const FilterByTags = () => {
+ const {
+ items,
+ refine,
+ canToggleShowMore,
+ isShowingMore,
+ toggleShowMore,
+ } = useHierarchicalMenu({
+ attributes: [
+ 'tags.taxonomy',
+ 'tags.level0',
+ 'tags.level1',
+ 'tags.level2',
+ 'tags.level3',
+ ],
+ });
+
+ // Recurse over the 'items' tree and find all the selected leaf tags - (with no children that are checked/"refined")
+ const appliedItems = React.useMemo(() => {
+ /** @type {{label: string}[]} */
+ const result = [];
+ /** @type {(itemSet: HierarchicalMenuItem[]) => void} */
+ const findSelectedLeaves = (itemSet) => {
+ itemSet.forEach(item => {
+ if (item.isRefined && item.data?.find(child => child.isRefined) === undefined) {
+ result.push({ label: item.label });
+ }
+ if (item.data) {
+ findSelectedLeaves(item.data);
+ }
+ });
+ };
+ findSelectedLeaves(items);
+ return result;
+ }, [items]);
+
+ return (
+ }
+ >
+
+
+
+ {
+ canToggleShowMore && !isShowingMore
+ ?
+ : null
+ }
+
+ );
+};
+
+export default FilterByTags;
diff --git a/src/search-modal/SearchEndpointLoader.jsx b/src/search-modal/SearchEndpointLoader.jsx
new file mode 100644
index 0000000000..664a3b5e03
--- /dev/null
+++ b/src/search-modal/SearchEndpointLoader.jsx
@@ -0,0 +1,41 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { ModalDialog } from '@openedx/paragon';
+import { ErrorAlert } from '@edx/frontend-lib-content-components';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { LoadingSpinner } from '../generic/Loading';
+import { useContentSearch } from './data/apiHooks';
+import SearchUI from './SearchUI';
+import messages from './messages';
+
+/** @type {React.FC<{courseId: string}>} */
+const SearchEndpointLoader = ({ courseId }) => {
+ const intl = useIntl();
+
+ // Load the Meilisearch connection details from the LMS: the URL to use, the index name, and an API key specific
+ // to us (to the current user) that allows us to search all content we have permission to view.
+ const {
+ data: searchEndpointData,
+ isLoading,
+ error,
+ } = useContentSearch();
+
+ const title = intl.formatMessage(messages.title);
+
+ if (searchEndpointData) {
+ return ;
+ }
+ return (
+ <>
+ {title}
+
+ {/* @ts-ignore */}
+ {isLoading ? : {error?.message ?? String(error)}}
+
+ >
+ );
+};
+
+export default SearchEndpointLoader;
diff --git a/src/search-modal/SearchFilterWidget.jsx b/src/search-modal/SearchFilterWidget.jsx
new file mode 100644
index 0000000000..27430ca060
--- /dev/null
+++ b/src/search-modal/SearchFilterWidget.jsx
@@ -0,0 +1,60 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { ArrowDropDown } from '@openedx/paragon/icons';
+import {
+ Badge,
+ Button,
+ ModalPopup,
+ useToggle,
+} from '@openedx/paragon';
+
+/**
+ * A button that represents a filter on the search.
+ * If the filter is active, the button displays the currently applied values.
+ * So when no filter is active it may look like:
+ * [ Type ▼ ]
+ * Or when a filter is active and limited to two values, it may look like:
+ * [ Type: HTML, +1 ▼ ]
+ *
+ * When clicked, the button will display a dropdown menu containing this
+ * element's `children`. So use this to wrap a etc.
+ *
+ * @type {React.FC<{appliedFilters: {label: React.ReactNode}[], label: React.ReactNode, children: React.ReactNode}>}
+ */
+const SearchFilterWidget = ({ appliedFilters, ...props }) => {
+ const [isOpen, open, close] = useToggle(false);
+ const [target, setTarget] = React.useState(null);
+
+ return (
+ <>
+
+
+
+
+
+ {props.children}
+
+
+ >
+ );
+};
+
+export default SearchFilterWidget;
diff --git a/src/search-modal/SearchKeywordsField.jsx b/src/search-modal/SearchKeywordsField.jsx
new file mode 100644
index 0000000000..15614d8c8b
--- /dev/null
+++ b/src/search-modal/SearchKeywordsField.jsx
@@ -0,0 +1,29 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { useSearchBox } from 'react-instantsearch';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { SearchField } from '@openedx/paragon';
+import messages from './messages';
+
+/**
+ * The "main" input field where users type in search keywords. The search happens as they type (no need to press enter).
+ * @type {React.FC}
+ */
+const SearchKeywordsField = (props) => {
+ const intl = useIntl();
+ const { query, refine } = useSearchBox(props);
+
+ return (
+ refine('')}
+ value={query}
+ className={props.className}
+ placeholder={intl.formatMessage(messages.inputPlaceholder)}
+ />
+ );
+};
+
+export default SearchKeywordsField;
diff --git a/src/search-modal/SearchModal.jsx b/src/search-modal/SearchModal.jsx
index 1b1bf6e1e5..93fce12720 100644
--- a/src/search-modal/SearchModal.jsx
+++ b/src/search-modal/SearchModal.jsx
@@ -1,46 +1,16 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
-import {
- ModalDialog,
-} from '@openedx/paragon';
-import { ErrorAlert } from '@edx/frontend-lib-content-components';
+import { ModalDialog } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { LoadingSpinner } from '../generic/Loading';
-import SearchUI from './SearchUI';
-import { useContentSearch } from './data/apiHooks';
+import SearchEndpointLoader from './SearchEndpointLoader';
import messages from './messages';
-// Using TypeScript here is blocked until we have frontend-build 14:
-// interface Props {
-// courseId: string;
-// isOpen: boolean;
-// onClose: () => void;
-// }
-
/** @type {React.FC<{courseId: string, isOpen: boolean, onClose: () => void}>} */
const SearchModal = ({ courseId, ...props }) => {
const intl = useIntl();
-
- // Load the Meilisearch connection details from the LMS: the URL to use, the index name, and an API key specific
- // to us (to the current user) that allows us to search all content we have permission to view.
- const {
- data: searchEndpointData,
- isLoading,
- error,
- } = useContentSearch();
-
- const title = intl.formatMessage(messages['courseSearch.title']);
- let body;
- if (searchEndpointData) {
- body = ;
- } else if (isLoading) {
- body = ;
- } else {
- // @ts-ignore
- body = {error?.message ?? String(error)};
- }
+ const title = intl.formatMessage(messages.title);
return (
{
isOpen={props.isOpen}
onClose={props.onClose}
hasCloseButton
+ // We need isOverflowVisible={false} - see the .scss file in this folder
+ isOverflowVisible={false}
isFullscreenOnMobile
+ className="courseware-search-modal"
>
- {title}
- {body}
+
);
};
diff --git a/src/search-modal/SearchModal.scss b/src/search-modal/SearchModal.scss
new file mode 100644
index 0000000000..c67c6ba2a9
--- /dev/null
+++ b/src/search-modal/SearchModal.scss
@@ -0,0 +1,71 @@
+// Simulate Tailwind-style arbitrary value classNames. We have to hard code this one though.
+.h-\[calc\(100vh-200px\)\] {
+ height: calc(100vh - 200px);
+}
+
+// Helper to set a minimum width for the This Course / All Courses toggle
+.pgn__menu-select.with-min-toggle-width {
+ & > button {
+ min-width: 155px;
+ }
+}
+
+.courseware-search-modal {
+ // Fix so the 'This course' / 'All courses' dropdown is not cut off on the right hand side,
+ // But still preserve correct scrolling behavior for the results list (vertical)
+ // (If we set 'isOverflowVisible: true', the scrolling of the results list is messed up)
+ overflow: visible;
+
+ .pgn__modal-header .pgn__menu-select {
+ // The "All courses" / "This course" toggle button
+ & > button {
+ min-width: 155px; // Set a minumum width so it doesn't change size when you change the selection
+ // The current Open edX theme makes the search field square but the button round and it looks bad. We need this
+ // hacky override until the theme is fixed to be more consistent.
+ border-radius: 0;
+ }
+ }
+
+ // Options for the "filter by tag" menu
+ .pgn__menu {
+ $indent-initial: 1.3rem;
+ $indent-each: 1.6rem;
+
+ .tag-option-1 {
+ padding-left: $indent-initial + (1 * $indent-each);
+ }
+
+ .tag-option-2 {
+ padding-left: $indent-initial + (2 * $indent-each);
+ }
+
+ .tag-option-3 {
+ padding-left: $indent-initial + (3 * $indent-each);
+ }
+
+ .tag-option-4 {
+ padding-left: $indent-initial + (4 * $indent-each);
+ }
+ }
+
+ .pgn__menu-item {
+ // Fix a bug in Paragon menu dropdowns: the checkbox currently shrinks if the text is too long.
+ // https://github.com/openedx/paragon/pull/3019
+ // This can be removed once we upgrade Paragon - https://github.com/openedx/frontend-app-course-authoring/pull/933
+ input[type="checkbox"] {
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+ // Fix a bug in Paragon menu dropdowns: very long text is not truncated with an ellipsis
+ // https://github.com/openedx/paragon/pull/3019
+ // This can be removed once we upgrade Paragon - https://github.com/openedx/frontend-app-course-authoring/pull/933
+ > div {
+ overflow: hidden;
+ }
+ }
+
+ .ais-InfiniteHits-loadPrevious,
+ .ais-InfiniteHits-loadMore--disabled {
+ display: none; // temporary; remove this once we implement our own / component.
+ }
+}
diff --git a/src/search-modal/SearchModal.test.jsx b/src/search-modal/SearchModal.test.jsx
index baf366b854..5c1b5a6881 100644
--- a/src/search-modal/SearchModal.test.jsx
+++ b/src/search-modal/SearchModal.test.jsx
@@ -58,8 +58,8 @@ describe('', () => {
index: 'test-index',
apiKey: 'test-api-key',
});
- const { findByTestId } = render();
- expect(await findByTestId('search-ui')).toBeInTheDocument();
+ const { findByText } = render();
+ expect(await findByText('Enter a keyword or select a filter to begin searching.')).toBeInTheDocument();
});
it('should render the spinner while the config is loading', () => {
diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx
index 8f3369c702..cb28172eac 100644
--- a/src/search-modal/SearchResult.jsx
+++ b/src/search-modal/SearchResult.jsx
@@ -2,42 +2,36 @@
// @ts-check
import React from 'react';
import { Highlight } from 'react-instantsearch';
+import BlockTypeLabel from './BlockTypeLabel';
-/* This component will be replaced by a new search UI component that will be developed in the future.
- * See:
- * - https://github.com/openedx/modular-learning/issues/200
- * - https://github.com/openedx/modular-learning/issues/201
- */
-/* istanbul ignore next */
-/** @type {React.FC<{hit: import('instantsearch.js').Hit<{
+/**
+ * A single search result (row), usually represents an XBlock/Component
+ * @type {React.FC<{hit: import('instantsearch.js').Hit<{
* id: string,
* display_name: string,
* block_type: string,
- * content: {
- * html_content: string,
- * capa_content: string
- * },
+ * 'content.html_content'?: string,
+ * 'content.capa_content'?: string,
* breadcrumbs: {display_name: string}[]}>,
- * }>} */
+ * }>}
+ */
const SearchResult = ({ hit }) => (
- <>
-
);
export default SearchResult;
diff --git a/src/search-modal/SearchUI.jsx b/src/search-modal/SearchUI.jsx
index 8e2f46ecbe..8becfbe64d 100644
--- a/src/search-modal/SearchUI.jsx
+++ b/src/search-modal/SearchUI.jsx
@@ -2,51 +2,85 @@
// @ts-check
import React from 'react';
import {
- HierarchicalMenu,
- InfiniteHits,
- InstantSearch,
- RefinementList,
- SearchBox,
- Stats,
-} from 'react-instantsearch';
+ MenuItem,
+ ModalDialog,
+ SelectMenu,
+} from '@openedx/paragon';
+import { Check } from '@openedx/paragon/icons';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { Configure, InfiniteHits, InstantSearch } from 'react-instantsearch';
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
-import 'instantsearch.css/themes/algolia-min.css';
+import ClearFiltersButton from './ClearFiltersButton';
+import EmptyStates from './EmptyStates';
import SearchResult from './SearchResult';
+import SearchKeywordsField from './SearchKeywordsField';
+import FilterByBlockType from './FilterByBlockType';
+import FilterByTags from './FilterByTags';
+import Stats from './Stats';
+import messages from './messages';
-/* This component will be replaced by a new search UI component that will be developed in the future.
- * See:
- * - https://github.com/openedx/modular-learning/issues/200
- * - https://github.com/openedx/modular-learning/issues/201
- */
-/* istanbul ignore next */
-/** @type {React.FC<{url: string, apiKey: string, indexName: string}>} */
+/** @type {React.FC<{courseId: string, url: string, apiKey: string, indexName: string}>} */
const SearchUI = (props) => {
const { searchClient } = React.useMemo(
() => instantMeiliSearch(props.url, props.apiKey, { primaryKey: 'id' }),
[props.url, props.apiKey],
);
+ const hasCourseId = Boolean(props.courseId);
+ const [_searchThisCourseEnabled, setSearchThisCourse] = React.useState(hasCourseId);
+ const switchToThisCourse = React.useCallback(() => setSearchThisCourse(true), []);
+ const switchToAllCourses = React.useCallback(() => setSearchThisCourse(false), []);
+ const searchThisCourse = hasCourseId && _searchThisCourseEnabled;
+
return (
-
-
-
-
- Refine by component type:
-
- Refine by tag:
-
-
-
-
+
+ {/* Add in a filter for the current course, if relevant */}
+
+ {/* We need to override z-index here or the appears behind the
+ * But it can't be more then 9 because the close button has z-index 10. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* If there are no results (yet), EmptyStates displays a friendly messages. Otherwise we see the results. */}
+
+
+
+
+
);
};
diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx
new file mode 100644
index 0000000000..a790d57e5c
--- /dev/null
+++ b/src/search-modal/SearchUI.test.jsx
@@ -0,0 +1,196 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ fireEvent,
+ render,
+ waitFor,
+ getByLabelText as getByLabelTextIn,
+} from '@testing-library/react';
+import fetchMock from 'fetch-mock-jest';
+
+// @ts-ignore
+import mockResult from './__mocks__/search-result.json';
+import SearchUI from './SearchUI';
+
+// mockResult contains only a single result - this one:
+const mockResultDisplayName = 'Test HTML Block';
+
+const queryClient = new QueryClient();
+
+// Default props for
+const defaults = {
+ url: 'http://mock.meilisearch.local/',
+ apiKey: 'test-key',
+ indexName: 'studio',
+ courseId: 'course-v1:org+test+123',
+};
+const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
+
+/** @type {React.FC<{children:React.ReactNode}>} */
+const Wrap = ({ children }) => (
+
+
+
+ {children}
+
+
+
+);
+
+describe('', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ fetchMock.post(searchEndpoint, (_url, req) => {
+ const requestData = JSON.parse(req.body?.toString() ?? '');
+ const query = requestData?.queries[0]?.q ?? '';
+ // We have to replace the query (search keywords) in the mock results with the actual query,
+ // because otherwise Instantsearch will update the UI and change the query,
+ // leading to unexpected results in the test cases.
+ mockResult.results[0].query = query;
+ // And create the required '_formatted' field; not sure why it's there - seems very redundant. But it's required.
+ // eslint-disable-next-line no-underscore-dangle, no-param-reassign
+ mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
+ return mockResult;
+ });
+ });
+
+ afterEach(async () => {
+ fetchMock.mockReset();
+ });
+
+ it('should render an empty state', async () => {
+ const { getByText } = render();
+ // Before the results have even loaded, we see this message:
+ expect(getByText('Enter a keyword or select a filter to begin searching.')).toBeInTheDocument();
+ // When this UI loads, Instantsearch makes two queries. I think one to load the facets and one "blank" search.
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
+ // And that message is still displayed even after the initial results/filters have loaded:
+ expect(getByText('Enter a keyword or select a filter to begin searching.')).toBeInTheDocument();
+ });
+
+ it('defaults to searching "All Courses" if used outside of any particular course', async () => {
+ const { getByText, queryByText, getByRole } = render();
+ // We default to searching all courses:
+ expect(getByText('All courses')).toBeInTheDocument();
+ expect(queryByText('This course')).toBeNull();
+ // Wait for the initial search request that loads all the filter options:
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
+ // Enter a keyword - search for 'giraffe':
+ fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } });
+ // Wait for the new search request to load all the results:
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
+ // Now we should see the results:
+ expect(queryByText('Enter a keyword')).toBeNull();
+ // The result:
+ expect(getByText('1 result found')).toBeInTheDocument();
+ expect(getByText(mockResultDisplayName)).toBeInTheDocument();
+ // Breadcrumbs showing where the result came from:
+ expect(getByText('The Little Unit That Could')).toBeInTheDocument();
+ });
+
+ it('defaults to searching "This Course" if used in a course', async () => {
+ const { getByText, queryByText, getByRole } = render();
+ // We default to searching all courses:
+ expect(getByText('This course')).toBeInTheDocument();
+ expect(queryByText('All courses')).toBeNull();
+ // Wait for the initial search request that loads all the filter options:
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
+ // Enter a keyword - search for 'giraffe':
+ fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } });
+ // Wait for the new search request to load all the results:
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
+ // And make sure the request was limited to this course:
+ expect(fetchMock).toHaveLastFetched((_url, req) => {
+ const requestData = JSON.parse(req.body?.toString() ?? '');
+ const requestedFilter = requestData?.queries[0].filter;
+ return requestedFilter?.[0] === 'context_key = "course-v1:org+test+123"';
+ });
+ // Now we should see the results:
+ expect(queryByText('Enter a keyword')).toBeNull();
+ // The result:
+ expect(getByText('1 result found')).toBeInTheDocument();
+ expect(getByText(mockResultDisplayName)).toBeInTheDocument();
+ // Breadcrumbs showing where the result came from:
+ expect(getByText('The Little Unit That Could')).toBeInTheDocument();
+ });
+
+ describe('filters', () => {
+ /** @type {import('@testing-library/react').RenderResult} */
+ let rendered;
+ beforeEach(async () => {
+ rendered = render();
+ const { getByRole, getByText } = rendered;
+ // Wait for the initial search request that loads all the filter options:
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
+ // Enter a keyword - search for 'giraffe':
+ fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } });
+ // Wait for the new search request to load all the results and the filter options, based on the search so far:
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
+ // And make sure the request was limited to this course:
+ expect(fetchMock).toHaveLastFetched((_url, req) => {
+ const requestData = JSON.parse(req.body?.toString() ?? '');
+ const requestedFilter = requestData?.queries[0].filter;
+ return (requestedFilter?.length === 1); // the filter is: 'context_key = "course-v1:org+test+123"'
+ });
+ // Now we should see the results:
+ expect(getByText('1 result found')).toBeInTheDocument();
+ expect(getByText(mockResultDisplayName)).toBeInTheDocument();
+ });
+
+ it('can filter results by component/XBlock type', async () => {
+ const { getByRole } = rendered;
+ // Now open the filters menu:
+ fireEvent.click(getByRole('button', { name: 'Type' }), {});
+ // The dropdown menu has role="group"
+ await waitFor(() => { expect(getByRole('group')).toBeInTheDocument(); });
+ const popupMenu = getByRole('group');
+ const problemFilterCheckbox = getByLabelTextIn(popupMenu, /Problem/i);
+ fireEvent.click(problemFilterCheckbox, {});
+ // Now wait for the filter to be applied and the new results to be fetched.
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(4, searchEndpoint, 'post'); });
+ // Because we're mocking the results, there's no actual changes to the mock results,
+ // but we can verify that the filter was sent in the request
+ expect(fetchMock).toHaveLastFetched((_url, req) => {
+ const requestData = JSON.parse(req.body?.toString() ?? '');
+ const requestedFilter = requestData?.queries[0].filter;
+ return JSON.stringify(requestedFilter) === JSON.stringify([
+ 'context_key = "course-v1:org+test+123"',
+ ['"block_type"="problem"'], // <-- the newly added filter, sent with the request
+ ]);
+ });
+ });
+
+ it('can filter results by tag', async () => {
+ const { getByRole, getByLabelText } = rendered;
+ // Now open the filters menu:
+ fireEvent.click(getByRole('button', { name: 'Tags' }), {});
+ // The dropdown menu in this case doesn't have a role; let's just assume it's displayed.
+ const competentciesCheckbox = getByLabelText(/ESDC Skills and Competencies/i);
+ fireEvent.click(competentciesCheckbox, {});
+ // Now wait for the filter to be applied and the new results to be fetched.
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(4, searchEndpoint, 'post'); });
+ // Because we're mocking the results, there's no actual changes to the mock results,
+ // but we can verify that the filter was sent in the request
+ expect(fetchMock).toHaveLastFetched((_url, req) => {
+ const requestData = JSON.parse(req.body?.toString() ?? '');
+ const requestedFilter = requestData?.queries[0].filter;
+ return JSON.stringify(requestedFilter) === JSON.stringify([
+ 'context_key = "course-v1:org+test+123"',
+ ['"tags.taxonomy"="ESDC Skills and Competencies"'], // <-- the newly added filter, sent with the request
+ ]);
+ });
+ });
+ });
+});
diff --git a/src/search-modal/Stats.jsx b/src/search-modal/Stats.jsx
new file mode 100644
index 0000000000..fabfe76a4f
--- /dev/null
+++ b/src/search-modal/Stats.jsx
@@ -0,0 +1,27 @@
+/* eslint-disable react/prop-types */
+// @ts-check
+import React from 'react';
+import { useStats, useClearRefinements } from 'react-instantsearch';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+
+/**
+ * Simple component that displays the # of matching results
+ * @type {React.FC>}
+ */
+const Stats = (props) => {
+ const { nbHits, query } = useStats(props);
+ const { canRefine: hasFiltersApplied } = useClearRefinements();
+ const hasQuery = !!query;
+
+ if (!hasQuery && !hasFiltersApplied) {
+ // We haven't started the search yet.
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default Stats;
diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json
new file mode 100644
index 0000000000..ad57f3ca78
--- /dev/null
+++ b/src/search-modal/__mocks__/search-result.json
@@ -0,0 +1,79 @@
+{
+ "comment": "This is a mock of the response from Meilisearch, based on an actual search in Studio.",
+ "results": [
+ {
+ "indexUid": "studio",
+ "hits": [
+ {
+ "type": "course_block",
+ "display_name": "Test HTML Block",
+ "block_id": "test_html",
+ "content": {
+ "html_content": "This is the content of the test HTML block. You can do a keyword search and it will find matches within this text."
+ },
+ "id": "block-v1edxTestCourse24typehtmlblocktest_html-e47ff4c0",
+ "usage_key": "block-v1:edx+TestCourse+24+type@html+block@test_html",
+ "block_type": "html",
+ "context_key": "course-v1:edx+TestCourse+24",
+ "org": "edx",
+ "breadcrumbs": [
+ { "display_name": "TheCourse" },
+ { "display_name": "Section 2" },
+ { "display_name": "Subsection 3" },
+ { "display_name": "The Little Unit That Could" }
+ ],
+ "tags": {
+ "taxonomy": [
+ "ESDC Skills and Competencies",
+ "FlatTaxonomy",
+ "TwoLevelTaxonomy"
+ ],
+ "level0": [
+ "ESDC Skills and Competencies > Personal Attributes",
+ "ESDC Skills and Competencies > Work Context",
+ "FlatTaxonomy > flat taxonomy tag 589",
+ "TwoLevelTaxonomy > two level tag 1"
+ ],
+ "level1": [
+ "ESDC Skills and Competencies > Personal Attributes > Self-Improvement",
+ "ESDC Skills and Competencies > Work Context > Physical Work Environment",
+ "TwoLevelTaxonomy > two level tag 1 > two level tag 1.1"
+ ],
+ "level2": [
+ "ESDC Skills and Competencies > Personal Attributes > Self-Improvement > Adjustment",
+ "ESDC Skills and Competencies > Personal Attributes > Self-Improvement > Learning Orientation",
+ "ESDC Skills and Competencies > Work Context > Physical Work Environment > Environmental Conditions"
+ ],
+ "level3": [
+ "ESDC Skills and Competencies > Personal Attributes > Self-Improvement > Adjustment > Adaptability",
+ "ESDC Skills and Competencies > Personal Attributes > Self-Improvement > Learning Orientation > Active Learning",
+ "ESDC Skills and Competencies > Work Context > Physical Work Environment > Environmental Conditions > Biological Agents"
+ ]
+ }
+ }
+ ],
+ "query": "learn",
+ "processingTimeMs": 1,
+ "limit": 2,
+ "offset": 0,
+ "estimatedTotalHits": 1,
+ "facetDistribution": {
+ "block_type": {
+ "html": 1,
+ "problem": 16,
+ "vertical": 2,
+ "video": 1
+ },
+ "tags.taxonomy": {
+ "ESDC Skills and Competencies": 1,
+ "FlatTaxonomy": 2,
+ "HierarchicalTaxonomy": 1,
+ "Lightcast Open Skills Taxonomy": 1,
+ "MultiOrgTaxonomy": 1,
+ "TwoLevelTaxonomy": 2
+ }
+ },
+ "facetStats": {}
+ }
+ ]
+}
diff --git a/src/search-modal/data/apiHooks.js b/src/search-modal/data/apiHooks.js
index 4169cb3af3..36e5c2e12f 100644
--- a/src/search-modal/data/apiHooks.js
+++ b/src/search-modal/data/apiHooks.js
@@ -14,7 +14,10 @@ export const useContentSearch = () => (
useQuery({
queryKey: ['content_search'],
queryFn: getContentSearchConfig,
- staleTime: 60 * 60, // If cache is up to one hour old, no need to re-fetch
+ cacheTime: 60 * 60_000, // Even if we're not actively using the search modal, keep it in memory up to an hour
+ staleTime: 60 * 60_000, // If cache is up to one hour old, no need to re-fetch
+ refetchInterval: 60 * 60_000,
refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab.
+ refetchOnMount: false,
})
);
diff --git a/src/search-modal/messages.js b/src/search-modal/messages.js
index b0dc782732..4f85c472e7 100644
--- a/src/search-modal/messages.js
+++ b/src/search-modal/messages.js
@@ -2,14 +2,119 @@
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
// frontend-platform currently doesn't provide types... do it ourselves.
-const defineMessages = /** @type {(x: T) => x} */(_defineMessages);
+const defineMessages = /** @type {import('react-intl').defineMessages} */(_defineMessages);
const messages = defineMessages({
- 'courseSearch.title': {
- id: 'courseSearch.title',
+ blockTypeFilter: {
+ id: 'course-authoring.course-search.blockTypeFilter',
+ defaultMessage: 'Type',
+ description: 'Label for the filter that allows limiting results to a specific component type',
+ },
+ 'blockTypeFilter.empty': {
+ id: 'course-authoring.course-search.blockTypeFilter.empty',
+ defaultMessage: 'No matching components',
+ description: 'Label shown when there are no options available to filter by component type',
+ },
+ blockTagsFilter: {
+ id: 'course-authoring.course-search.blockTagsFilter',
+ defaultMessage: 'Tags',
+ description: 'Label for the filter that allows finding components with specific tags',
+ },
+ 'blockTagsFilter.empty': {
+ id: 'course-authoring.course-search.blockTagsFilter.empty',
+ defaultMessage: 'No tags in current results',
+ description: 'Label shown when there are no options available to filter by tags',
+ },
+ 'blockType.annotatable': {
+ id: 'course-authoring.course-search.blockType.annotatable',
+ defaultMessage: 'Annotation',
+ description: 'Name of the "Annotation" component type in Studio',
+ },
+ 'blockType.chapter': {
+ id: 'course-authoring.course-search.blockType.chapter',
+ defaultMessage: 'Section',
+ description: 'Name of the "Section" course outline level in Studio',
+ },
+ 'blockType.discussion': {
+ id: 'course-authoring.course-search.blockType.discussion',
+ defaultMessage: 'Discussion',
+ description: 'Name of the "Discussion" component type in Studio',
+ },
+ 'blockType.drag-and-drop-v2': {
+ id: 'course-authoring.course-search.blockType.drag-and-drop-v2',
+ defaultMessage: 'Drag and Drop',
+ description: 'Name of the "Drag and Drop" component type in Studio',
+ },
+ 'blockType.html': {
+ id: 'course-authoring.course-search.blockType.html',
+ defaultMessage: 'Text',
+ description: 'Name of the "Text" component type in Studio',
+ },
+ 'blockType.library_content': {
+ id: 'course-authoring.course-search.blockType.library_content',
+ defaultMessage: 'Library Content',
+ description: 'Name of the "Library Content" component type in Studio',
+ },
+ 'blockType.openassessment': {
+ id: 'course-authoring.course-search.blockType.openassessment',
+ defaultMessage: 'Open Response Assessment',
+ description: 'Name of the "Open Response Assessment" component type in Studio',
+ },
+ 'blockType.problem': {
+ id: 'course-authoring.course-search.blockType.problem',
+ defaultMessage: 'Problem',
+ description: 'Name of the "Problem" component type in Studio',
+ },
+ 'blockType.sequential': {
+ id: 'course-authoring.course-search.blockType.sequential',
+ defaultMessage: 'Subsection',
+ description: 'Name of the "Subsection" course outline level in Studio',
+ },
+ 'blockType.vertical': {
+ id: 'course-authoring.course-search.blockType.vertical',
+ defaultMessage: 'Unit',
+ description: 'Name of the "Unit" course outline level in Studio',
+ },
+ 'blockType.video': {
+ id: 'course-authoring.course-search.blockType.video',
+ defaultMessage: 'Video',
+ description: 'Name of the "Video" component type in Studio',
+ },
+ clearFilters: {
+ id: 'course-authoring.course-search.clearFilters',
+ defaultMessage: 'Clear Filters',
+ description: 'Label for the button that removes all applied search filters',
+ },
+ numResults: {
+ id: 'course-authoring.course-search.num-results',
+ defaultMessage: '{numResults, plural, one {# result} other {# results}} found',
+ description: 'This count displays how many matching results were found from the user\'s search',
+ },
+ searchAllCourses: {
+ id: 'course-authoring.course-search.searchAllCourses',
+ defaultMessage: 'All courses',
+ description: 'Option to get search results from all courses.',
+ },
+ searchThisCourse: {
+ id: 'course-authoring.course-search.searchThisCourse',
+ defaultMessage: 'This course',
+ description: 'Option to limit search results to the current course only.',
+ },
+ title: {
+ id: 'course-authoring.course-search.title',
defaultMessage: 'Search',
description: 'Title for the course search dialog',
},
+ inputPlaceholder: {
+ id: 'course-authoring.course-search.inputPlaceholder',
+ defaultMessage: 'Search',
+ description: 'Placeholder text shown in the keyword input field when the user has not yet entered a keyword',
+ },
+ showMore: {
+ id: 'course-authoring.course-search.showMore',
+ defaultMessage: 'Show more',
+ description: 'Show more tags / filter options',
+ },
});
export default messages;
diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx
index 6c2ace49e2..1d8db279e5 100644
--- a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx
+++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx
@@ -180,7 +180,7 @@ const ManageOrgsModal = ({
key={org}
iconAfter={Close}
onIconAfterClick={() => setSelectedOrgs(selectedOrgs.filter((o) => o !== org))}
- disabled={allOrgs}
+ disabled={!!allOrgs}
>
{org}
From 21e7cabe9b74838d440adcc0447f6c01e8a9ef84 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=B4mulo=20Penido?=
Date: Thu, 11 Apr 2024 14:59:29 -0300
Subject: [PATCH 6/6] feat: improve search modal results
---
src/course-outline/CourseOutline.scss | 18 ++
.../section-card/SectionCard.jsx | 11 +-
.../subsection-card/SubsectionCard.jsx | 8 +-
src/course-outline/unit-card/UnitCard.jsx | 10 +-
src/search-modal/EmptyStates.jsx | 28 ++-
src/search-modal/SearchEndpointLoader.jsx | 6 +-
src/search-modal/SearchModal.jsx | 4 +-
src/search-modal/SearchModal.scss | 10 ++
src/search-modal/SearchResult.jsx | 164 +++++++++++++++---
src/search-modal/SearchUI.jsx | 32 +++-
src/search-modal/images/empty-search.svg | 51 ++++++
src/search-modal/images/no-results.svg | 43 +++++
src/search-modal/messages.js | 20 +++
13 files changed, 356 insertions(+), 49 deletions(-)
create mode 100644 src/search-modal/images/empty-search.svg
create mode 100644 src/search-modal/images/no-results.svg
diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss
index 1dad6b4a51..e84b40860f 100644
--- a/src/course-outline/CourseOutline.scss
+++ b/src/course-outline/CourseOutline.scss
@@ -11,3 +11,21 @@
@import "./drag-helper/SortableItem";
@import "./xblock-status/XBlockStatus";
@import "./paste-button/PasteButton";
+
+div.row:has(> div > div.highlight) {
+ animation: 5s glow;
+}
+
+@keyframes glow {
+ 0% {
+ box-shadow: 0 0 5px 5px $primary-500;
+ }
+
+ 90% {
+ box-shadow: 0 0 5px 5px $primary-500;
+ }
+
+ 100% {
+ box-shadow: unset;
+ }
+}
diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx
index 6d3f3f490b..914d201ab8 100644
--- a/src/course-outline/section-card/SectionCard.jsx
+++ b/src/course-outline/section-card/SectionCard.jsx
@@ -7,6 +7,7 @@ import { useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Bubble, Button, useToggle } from '@openedx/paragon';
import { Add as IconAdd } from '@openedx/paragon/icons';
+import { useSearchParams } from 'react-router-dom';
import classNames from 'classnames';
import { setCurrentItem, setCurrentSection } from '../data/slice';
@@ -42,6 +43,9 @@ const SectionCard = ({
const dispatch = useDispatch();
const { activeId, overId } = useContext(DragContext);
const [isExpanded, setIsExpanded] = useState(isSectionsExpanded);
+ const [searchParams] = useSearchParams();
+ const locatorId = searchParams.get('show');
+ const isScrolledToElement = locatorId === section.id;
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'section';
@@ -70,11 +74,10 @@ const SectionCard = ({
}, [activeId, overId]);
useEffect(() => {
- // if this items has been newly added, scroll to it.
- if (currentRef.current && section.shouldScroll) {
+ if (currentRef.current && (section.shouldScroll || isScrolledToElement)) {
scrollToElement(currentRef.current);
}
- }, []);
+ }, [isScrolledToElement]);
// re-create actions object for customizations
const actions = { ...sectionActions };
@@ -155,7 +158,7 @@ const SectionCard = ({
}}
>
{isHeaderVisible && (
<>
{
const currentRef = useRef(null);
const dispatch = useDispatch();
+ const [searchParams] = useSearchParams();
+ const locatorId = searchParams.get('show');
+ const isScrolledToElement = locatorId === unit.id;
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'unit';
@@ -109,10 +113,10 @@ const UnitCard = ({
// if this items has been newly added, scroll to it.
// we need to check section.shouldScroll as whole section is fetched when a
// unit is duplicated under it.
- if (currentRef.current && (section.shouldScroll || unit.shouldScroll)) {
+ if (currentRef.current && (section.shouldScroll || unit.shouldScroll || isScrolledToElement)) {
scrollToElement(currentRef.current);
}
- }, []);
+ }, [isScrolledToElement]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
@@ -139,7 +143,7 @@ const UnitCard = ({
}}
>
diff --git a/src/search-modal/EmptyStates.jsx b/src/search-modal/EmptyStates.jsx
index a90a294df2..a1714b1da5 100644
--- a/src/search-modal/EmptyStates.jsx
+++ b/src/search-modal/EmptyStates.jsx
@@ -1,8 +1,30 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { Stack } from '@openedx/paragon';
import { useStats, useClearRefinements } from 'react-instantsearch';
+import EmptySearchImage from './images/empty-search.svg';
+import NoResultImage from './images/no-results.svg';
+import messages from './messages';
+
+const EmptySearch = () => (
+
+
+
+
+
+);
+
+const NoResults = () => (
+
+
+
+
+
+);
+
/**
* If the user hasn't put any keywords/filters yet, display an "empty state".
* Likewise, if the results are empty (0 results), display a special message.
@@ -16,12 +38,10 @@ const EmptyStates = ({ children }) => {
if (!hasQuery && !hasFiltersApplied) {
// We haven't started the search yet. Display the "start your search" empty state
- // Note this isn't localized because it's going to be replaced in a fast-follow PR.
- return
Enter a keyword or select a filter to begin searching.
;
+ return ;
}
if (nbHits === 0) {
- // Note this isn't localized because it's going to be replaced in a fast-follow PR.
- return
No results found. Change your search and try again.
;
+ return ;
}
return children;
diff --git a/src/search-modal/SearchEndpointLoader.jsx b/src/search-modal/SearchEndpointLoader.jsx
index 664a3b5e03..104ee3ef3f 100644
--- a/src/search-modal/SearchEndpointLoader.jsx
+++ b/src/search-modal/SearchEndpointLoader.jsx
@@ -10,8 +10,8 @@ import { useContentSearch } from './data/apiHooks';
import SearchUI from './SearchUI';
import messages from './messages';
-/** @type {React.FC<{courseId: string}>} */
-const SearchEndpointLoader = ({ courseId }) => {
+/** @type {React.FC<{courseId: string, closeSearch: () => void}>} */
+const SearchEndpointLoader = ({ courseId, closeSearch }) => {
const intl = useIntl();
// Load the Meilisearch connection details from the LMS: the URL to use, the index name, and an API key specific
@@ -25,7 +25,7 @@ const SearchEndpointLoader = ({ courseId }) => {
const title = intl.formatMessage(messages.title);
if (searchEndpointData) {
- return ;
+ return ;
}
return (
<>
diff --git a/src/search-modal/SearchModal.jsx b/src/search-modal/SearchModal.jsx
index 93fce12720..d7cd9ea91b 100644
--- a/src/search-modal/SearchModal.jsx
+++ b/src/search-modal/SearchModal.jsx
@@ -1,8 +1,8 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
-import { ModalDialog } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
+import { ModalDialog } from '@openedx/paragon';
import SearchEndpointLoader from './SearchEndpointLoader';
import messages from './messages';
@@ -24,7 +24,7 @@ const SearchModal = ({ courseId, ...props }) => {
isFullscreenOnMobile
className="courseware-search-modal"
>
-
+
);
};
diff --git a/src/search-modal/SearchModal.scss b/src/search-modal/SearchModal.scss
index c67c6ba2a9..3054909dab 100644
--- a/src/search-modal/SearchModal.scss
+++ b/src/search-modal/SearchModal.scss
@@ -68,4 +68,14 @@
.ais-InfiniteHits-loadMore--disabled {
display: none; // temporary; remove this once we implement our own / component.
}
+
+ .search-result {
+ &:hover {
+ background-color: $gray-100 !important;
+ }
+ }
+
+ .fs-large {
+ font-size: $font-size-base * 1.25;
+ }
}
diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx
index cb28172eac..4992ee8459 100644
--- a/src/search-modal/SearchResult.jsx
+++ b/src/search-modal/SearchResult.jsx
@@ -1,37 +1,147 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
-import { Highlight } from 'react-instantsearch';
-import BlockTypeLabel from './BlockTypeLabel';
+import { getConfig, getPath } from '@edx/frontend-platform';
+import {
+ Icon,
+ IconButton,
+ Stack,
+} from '@openedx/paragon';
+import {
+ Article,
+ Folder,
+ OpenInNew,
+ Question,
+ TextFields,
+ Videocam,
+} from '@openedx/paragon/icons';
+import {
+ Highlight,
+ Snippet,
+} from 'react-instantsearch';
+import { useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+
+import { getStudioHomeData } from '../studio-home/data/selectors';
/**
- * A single search result (row), usually represents an XBlock/Component
- * @type {React.FC<{hit: import('instantsearch.js').Hit<{
- * id: string,
- * display_name: string,
- * block_type: string,
- * 'content.html_content'?: string,
- * 'content.capa_content'?: string,
- * breadcrumbs: {display_name: string}[]}>,
+ * @typedef {import('instantsearch.js').Hit<{
+ * id: string,
+ * usage_key: string,
+ * context_key: string,
+ * display_name: string,
+ * block_type: string,
+ * 'content.html_content'?: string,
+ * 'content.capa_content'?: string,
+ * breadcrumbs: {display_name: string}[]
+ * breadcrumbsNames: string[],
+ * }>} CustomHit
+ */
+
+/**
+ * Custom Highlight component that uses the tag for highlighting
+ * @type {React.FC<{
+ * attribute: keyof CustomHit | string[],
+ * hit: CustomHit,
+ * separator?: string,
* }>}
*/
-const SearchResult = ({ hit }) => (
-