From 74eaaa1f9ef14c7df84b21dc6b8f89f82c9e7b49 Mon Sep 17 00:00:00 2001 From: Jesper Hodge <19345795+jesperhodge@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:25:08 -0400 Subject: [PATCH 1/6] chore: change github workflow file to not fail pipeline for codecov (#942) --- .github/workflows/validate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1b5cdb109d..9e10274cd8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -20,4 +20,4 @@ jobs: - name: Upload coverage uses: codecov/codecov-action@v3 with: - fail_ci_if_error: true + fail_ci_if_error: false From fdcda9833fe19344e6353ef9bfdf464415d5a8dd Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:34:06 -0400 Subject: [PATCH 2/6] feat: include usage locations in delete modal (#938) --- .../files-page/FilesPage.test.jsx | 12 +- src/files-and-videos/files-page/messages.js | 26 ++-- .../generic/DeleteConfirmationModal.jsx | 132 ++++++++++++++++++ .../generic/DeleteConfirmationModal.test.jsx | 88 ++++++++++++ src/files-and-videos/generic/FileTable.jsx | 34 ++--- src/files-and-videos/generic/messages.js | 58 +++++++- .../videos-page/VideosPage.test.jsx | 12 +- 7 files changed, 318 insertions(+), 44 deletions(-) create mode 100644 src/files-and-videos/generic/DeleteConfirmationModal.jsx create mode 100644 src/files-and-videos/generic/DeleteConfirmationModal.test.jsx diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index beda688193..1a91280bc6 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -331,7 +331,7 @@ describe('FilesAndUploads', () => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204); fireEvent.click(deleteButton); - expect(screen.getByText('Delete file(s) confirmation')).toBeVisible(); + expect(screen.getByText('Delete mOckID1')).toBeVisible(); await act(async () => { userEvent.click(deleteButton); }); @@ -345,7 +345,7 @@ describe('FilesAndUploads', () => { userEvent.click(confirmDeleteButton); }); - expect(screen.queryByText('Delete file(s) confirmation')).toBeNull(); + expect(screen.queryByText('Delete mOckID1')).toBeNull(); // Check if the asset is deleted in the store and UI const deleteStatus = store.getState().assets.deletingStatus; @@ -554,10 +554,10 @@ describe('FilesAndUploads', () => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204); fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); - expect(screen.getByText('Delete file(s) confirmation')).toBeVisible(); + expect(screen.getByText('Delete mOckID1')).toBeVisible(); fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); - expect(screen.queryByText('Delete file(s) confirmation')).toBeNull(); + expect(screen.queryByText('Delete mOckID1')).toBeNull(); executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch); }); @@ -644,10 +644,10 @@ describe('FilesAndUploads', () => { axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID3`).reply(404); fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); - expect(screen.getByText('Delete file(s) confirmation')).toBeVisible(); + expect(screen.getByText('Delete mOckID3')).toBeVisible(); fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage)); - expect(screen.queryByText('Delete file(s) confirmation')).toBeNull(); + expect(screen.queryByText('Delete mOckID3')).toBeNull(); executeThunk(deleteAssetFile(courseId, 'mOckID3', 5), store.dispatch); }); diff --git a/src/files-and-videos/files-page/messages.js b/src/files-and-videos/files-page/messages.js index 646c4d2c58..2f827e5c01 100644 --- a/src/files-and-videos/files-page/messages.js +++ b/src/files-and-videos/files-page/messages.js @@ -4,82 +4,92 @@ const messages = defineMessages({ heading: { id: 'course-authoring.files-and-uploads.heading', defaultMessage: 'Files', + description: 'Title for the page', }, thumbnailAltMessage: { id: 'course-authoring.files-and-uploads.thumbnail.alt', defaultMessage: '{displayName} file preview', + description: 'Alternative text for thumbnail', }, copyStudioUrlTitle: { id: 'course-authoring.files-and-uploads.file-info.copyStudioUrl.title', defaultMessage: 'Copy Studio Url', + description: 'Label for Copy Studio URL button in info modal', }, copyWebUrlTitle: { id: 'course-authoring.files-and-uploads.file-info.copyWebUrl.title', defaultMessage: 'Copy Web Url', + description: 'Label for Copy Web URL button in info modal', }, dateAddedTitle: { id: 'course-authoring.files-and-uploads.file-info.dateAdded.title', defaultMessage: 'Date added', + description: 'Title for date added section in info modal', }, fileSizeTitle: { id: 'course-authoring.files-and-uploads.file-info.fileSize.title', defaultMessage: 'File size', + description: 'Title for file size section in info modal', }, studioUrlTitle: { id: 'course-authoring.files-and-uploads.file-info.studioUrl.title', defaultMessage: 'Studio URL', + description: 'Title for studio url section in info modal', }, webUrlTitle: { id: 'course-authoring.files-and-uploads.file-info.webUrl.title', defaultMessage: 'Web URL', + description: 'Title for web url section in info modal', }, lockFileTitle: { id: 'course-authoring.files-and-uploads.file-info.lockFile.title', defaultMessage: 'Lock file', - }, - 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: 'Label for lock file checkbox in info modal', }, activeCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.activeCheckbox.label', defaultMessage: 'Active', + description: 'Label for active checkbox in filter section of sort and filter modal', }, inactiveCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.inactiveCheckbox.label', defaultMessage: 'Inactive', + description: 'Label for inactive checkbox in filter section of sort and filter modal', }, lockedCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.lockedCheckbox.label', defaultMessage: 'Locked', + description: 'Label for locked checkbox in filter section of sort and filter modal', }, publicCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.publicCheckbox.label', defaultMessage: 'Public', + description: 'Label for public checkbox in filter section of sort and filter modal', }, codeCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.codeCheckbox.label', defaultMessage: 'Code', + description: 'Label for code checkbox in filter section of sort and filter modal', }, imageCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.imageCheckbox.label', defaultMessage: 'Images', + description: 'Label for images checkbox in filter section of sort and filter modal', }, documentCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.documentCheckbox.label', defaultMessage: 'Documents', + description: 'Label for documents checkbox in filter section of sort and filter modal', }, audioCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.audioCheckbox.label', defaultMessage: 'Audio', + description: 'Label for audio checkbox in filter section of sort and filter modal', }, otherCheckboxLabel: { id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.otherCheckbox.label', defaultMessage: 'Other', + description: 'Label for other checkbox in filter section of sort and filter modal', }, overwriteConfirmMessage: { id: 'course-authoring.files-and-videos.overwrite.modal.confirmation-message', diff --git a/src/files-and-videos/generic/DeleteConfirmationModal.jsx b/src/files-and-videos/generic/DeleteConfirmationModal.jsx new file mode 100644 index 0000000000..087e4665f6 --- /dev/null +++ b/src/files-and-videos/generic/DeleteConfirmationModal.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import { + ActionRow, + AlertModal, + Button, + Collapsible, + Hyperlink, + Truncate, +} from '@openedx/paragon'; + +import messages from './messages'; + +const DeleteConfirmationModal = ({ + isDeleteConfirmationOpen, + closeDeleteConfirmation, + handleBulkDelete, + selectedRows, + fileType, + // injected + intl, +}) => { + const firstSelectedRow = selectedRows[0]?.original; + let activeContentRows = []; + if (Array.isArray(selectedRows)) { + activeContentRows = selectedRows.filter(row => row.original.activeStatus === 'active'); + } + const isDeletingCourseContent = activeContentRows.length > 0; + + const deletedCourseContent = activeContentRows.map(({ original }) => ( +
  • + + + {original.displayName} + + + )} + data-testid={`collapsible-${original.id}`} + > + + +
  • + )); + + return ( + + + + + )} + > + {intl.formatMessage( + messages.deleteConfirmationMessage, + { + fileName: firstSelectedRow?.displayName, + fileNumber: selectedRows.length, + fileType, + }, + )} + {isDeletingCourseContent && ( +
    + {intl.formatMessage( + messages.deleteConfirmationUsageMessage, + { + fileNumber: activeContentRows.length, + fileType, + }, + )} + +
    + )} +
    + ); +}; + +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)} + > + + { + items.map((item) => ( + + {' '} + {item.count} + + )) + } + { + // Show a message if there are no options at all to avoid the impression that the dropdown isn't working + items.length === 0 ? ( + + ) : null + } + + + + { + 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.label}{' '} + {item.count} + + {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 ( + } + > + + + + { + // Show a message if there are no options at all to avoid the impression that the dropdown isn't working + items.length === 0 ? ( + + ) : null + } + + + { + 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 }) => ( - <> -
    - +
    +
    + {' '} + ()
    -

    -
    - { /* @ts-ignore Wrong type definition upstream */ } +
    - { /* @ts-ignore Wrong type definition upstream */ }
    -
    +
    {hit.breadcrumbs.map((bc, i) => ( // eslint-disable-next-line react/no-array-index-key - {bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '>' : ''} + {bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '/' : ''} ))}
    - +
    ); 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 = ({ }} >
    diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 441a4e34f3..7814eb99a1 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -134,7 +134,7 @@ const SubsectionCard = ({ if (currentRef.current && (section.shouldScroll || subsection.shouldScroll || isScrolledToElement)) { scrollToElement(currentRef.current); } - }, []); + }, [isScrolledToElement]); useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { @@ -160,7 +160,11 @@ const SubsectionCard = ({ ...borderStyle, }} > -
    +
    {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 }) => ( -
    -
    - {' '} - () -
    -
    - - -
    -
    - {hit.breadcrumbs.map((bc, i) => ( - // eslint-disable-next-line react/no-array-index-key - {bc.display_name} {i !== hit.breadcrumbs.length - 1 ? '/' : ''} - ))} -
    -
    +const CustomHighlight = ({ attribute, hit, separator }) => ( + ); +const ItemIcon = { + vertical: Folder, + sequential: Folder, + chapter: Folder, + problem: Question, + video: Videocam, + html: TextFields, +}; + +/** + * Returns the URL for the context of the hit + * @param {CustomHit} hit + * @param {boolean} newWindow + * @param {string} libraryAuthoringMfeUrl + * @returns {string} + */ +const getContextUrl = (hit, newWindow, libraryAuthoringMfeUrl) => { + const { context_key: contextKey, usage_key: usageKey } = hit; + if (contextKey.startsWith('course-v1:')) { + if (newWindow) { + return `${getPath(getConfig().PUBLIC_PATH)}/course/${contextKey}?show=${encodeURIComponent(usageKey)}`; + } + return `/course/${contextKey}?show=${encodeURIComponent(usageKey)}`; + } + if (usageKey.includes('lb:')) { + return `${libraryAuthoringMfeUrl}library/${contextKey}`; + } + return '#'; +}; + +/** + * A single search result (row), usually represents an XBlock/Component + * @type {React.FC<{ hit: CustomHit, closeSearch: () => void}>} + */ +const SearchResult = ({ hit, closeSearch }) => { + const navigate = useNavigate(); + const { libraryAuthoringMfeUrl } = useSelector(getStudioHomeData); + + /** + * Navigates to the context of the hit + * @param {React.MouseEvent} e + * @param {boolean} newWindow + * @returns {void} + * */ + const navigateToContext = (e, newWindow) => { + e.stopPropagation(); + const url = getContextUrl(hit, newWindow, libraryAuthoringMfeUrl); + if (newWindow) { + window.open(url, '_blank'); + return; + } + + if (url.startsWith('http')) { + window.location.href = url; + return; + } + + navigate(url); + closeSearch(); + }; + + return ( + +
    + +
    + +
    + +
    +
    + + +
    +
    + +
    +
    + navigateToContext(e, true)} /> +
    + ); +}; + export default SearchResult; diff --git a/src/search-modal/SearchUI.jsx b/src/search-modal/SearchUI.jsx index 8becfbe64d..65e504edf7 100644 --- a/src/search-modal/SearchUI.jsx +++ b/src/search-modal/SearchUI.jsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ // @ts-check -import React from 'react'; +import React, { useCallback } from 'react'; import { MenuItem, ModalDialog, @@ -20,7 +20,7 @@ import FilterByTags from './FilterByTags'; import Stats from './Stats'; import messages from './messages'; -/** @type {React.FC<{courseId: string, url: string, apiKey: string, indexName: string}>} */ +/** @type {React.FC<{courseId: string, url: string, apiKey: string, indexName: string, closeSearch: () => void}>} */ const SearchUI = (props) => { const { searchClient } = React.useMemo( () => instantMeiliSearch(props.url, props.apiKey, { primaryKey: 'id' }), @@ -33,6 +33,11 @@ const SearchUI = (props) => { const switchToAllCourses = React.useCallback(() => setSearchThisCourse(false), []); const searchThisCourse = hasCourseId && _searchThisCourseEnabled; + const HitComponent = useCallback( + ({ hit }) => , + [], + ); + return ( { future={{ preserveSharedStateOnUnmount: true }} > {/* 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. */} @@ -77,7 +86,22 @@ const SearchUI = (props) => { {/* If there are no results (yet), EmptyStates displays a friendly messages. Otherwise we see the results. */} - + items.map((item) => ({ + ...item, + breadcrumbsNames: item.breadcrumbs.map((bc) => bc.display_name), + _highlightResult: { + // eslint-disable-next-line no-underscore-dangle + ...item._highlightResult, + // eslint-disable-next-line no-underscore-dangle + breadcrumbsNames: item._highlightResult.breadcrumbs.map((bc) => bc.display_name), + }, + }))} + /> diff --git a/src/search-modal/images/empty-search.svg b/src/search-modal/images/empty-search.svg new file mode 100644 index 0000000000..09d41ba50b --- /dev/null +++ b/src/search-modal/images/empty-search.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/search-modal/images/no-results.svg b/src/search-modal/images/no-results.svg new file mode 100644 index 0000000000..e6d72bed5b --- /dev/null +++ b/src/search-modal/images/no-results.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/search-modal/messages.js b/src/search-modal/messages.js index 4f85c472e7..4a19caf655 100644 --- a/src/search-modal/messages.js +++ b/src/search-modal/messages.js @@ -115,6 +115,26 @@ const messages = defineMessages({ defaultMessage: 'Show more', description: 'Show more tags / filter options', }, + emptySearchTitle: { + id: 'course-authoring.course-search.emptySearchTitle', + defaultMessage: 'Start searching to find content', + description: 'Title shown when the user has not yet entered a keyword', + }, + emptySearchSubtitle: { + id: 'course-authoring.course-search.emptySearchSubtitle', + defaultMessage: 'Find sections, subsections, units and components', + description: 'Subtitle shown when the user has not yet entered a keyword', + }, + noResultsTitle: { + id: 'course-authoring.course-search.noResultsTitle', + defaultMessage: 'We didn\'t find anything matching your search', + description: 'Title shown when the search returned no results', + }, + noResultsSubtitle: { + id: 'course-authoring.course-search.noResultsSubtitle', + defaultMessage: 'Please try a different search term or filter', + description: 'Subtitle shown when the search returned no results', + }, }); export default messages;