From 7394deab2c98de978e18f00f0db13c49d8699bb4 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 17 Oct 2024 12:01:07 +0530 Subject: [PATCH 1/6] feat: direct link to single block in library Adds support for displaying single xblock in a library when passed a query param: usageKey. This is required for directing users to a specific block from course. --- .../components/LibraryComponents.tsx | 7 ++++++- src/search-manager/SearchKeywordsField.tsx | 9 +++++---- src/search-manager/SearchManager.ts | 14 ++++++++++++++ src/search-manager/messages.ts | 5 +++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx index c260897b64..c3a7758ec0 100644 --- a/src/library-authoring/components/LibraryComponents.tsx +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -26,8 +26,13 @@ const LibraryComponents = ({ variant }: LibraryComponentsProps) => { fetchNextPage, isLoading, isFiltered, + usageKey, } = useSearchContext(); - const { openAddContentSidebar } = useLibraryContext(); + const { openAddContentSidebar, openComponentInfoSidebar } = useLibraryContext(); + + if (usageKey) { + openComponentInfoSidebar(usageKey); + } const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits; diff --git a/src/search-manager/SearchKeywordsField.tsx b/src/search-manager/SearchKeywordsField.tsx index a60a54cd02..14a6a06dc9 100644 --- a/src/search-manager/SearchKeywordsField.tsx +++ b/src/search-manager/SearchKeywordsField.tsx @@ -9,7 +9,9 @@ import { useSearchContext } from './SearchManager'; */ const SearchKeywordsField: React.FC<{ className?: string, placeholder?: string }> = (props) => { const intl = useIntl(); - const { searchKeywords, setSearchKeywords } = useSearchContext(); + const { searchKeywords, setSearchKeywords, usageKey } = useSearchContext(); + const defaultPlaceholder = usageKey ? messages.clearUsageKeyToSearch : messages.inputPlaceholder; + const { placeholder = intl.formatMessage(defaultPlaceholder) } = props; return ( setSearchKeywords('')} value={searchKeywords} className={props.className} + disabled={!!usageKey} > diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index 413e4ff760..8712376558 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -44,6 +44,7 @@ export interface SearchContextData { hasError: boolean; collectionHits: CollectionHit[]; totalCollectionHits: number; + usageKey: string; } const SearchContext = React.createContext(undefined); @@ -101,7 +102,17 @@ export const SearchContextProvider: React.FC<{ const [blockTypesFilter, setBlockTypesFilter] = React.useState([]); const [problemTypesFilter, setProblemTypesFilter] = React.useState([]); const [tagsFilter, setTagsFilter] = React.useState([]); + const [usageKey, setUsageKey] = useStateWithUrlSearchParam( + '', + 'usageKey', + (value: string) => value, + (value: string) => value, + ); + let extraFilter: string[] = forceArray(props.extraFilter); + if (usageKey) { + extraFilter.push(`usage_key = "${usageKey}"`); + } // The search sort order can be set via the query string // E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA. @@ -131,12 +142,14 @@ export const SearchContextProvider: React.FC<{ blockTypesFilter.length > 0 || problemTypesFilter.length > 0 || tagsFilter.length > 0 + || !!usageKey ); const isFiltered = canClearFilters || (searchKeywords !== ''); const clearFilters = React.useCallback(() => { setBlockTypesFilter([]); setTagsFilter([]); setProblemTypesFilter([]); + setUsageKey(''); }, []); // Initialize a connection to Meilisearch: @@ -176,6 +189,7 @@ export const SearchContextProvider: React.FC<{ defaultSearchSortOrder, closeSearchModal: props.closeSearchModal ?? (() => { }), hasError: hasConnectionError || result.isError, + usageKey, ...result, }, }, props.children); diff --git a/src/search-manager/messages.ts b/src/search-manager/messages.ts index 218b452e1e..aca799f93c 100644 --- a/src/search-manager/messages.ts +++ b/src/search-manager/messages.ts @@ -11,6 +11,11 @@ const messages = defineMessages({ defaultMessage: 'Search', description: 'Placeholder text shown in the keyword input field when the user has not yet entered a keyword', }, + clearUsageKeyToSearch: { + id: 'course-authoring.search-manager.clearUsageKeyToSearch', + defaultMessage: 'Displaying single block, clear filters to search', + description: 'Placeholder text shown in the keyword input field when a single block filtered by usage key is shown', + }, blockTypeFilter: { id: 'course-authoring.search-manager.blockTypeFilter', defaultMessage: 'Type', From b0dd84f72bf78f2fb79e14176f9e31ac9a742cee Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 17 Oct 2024 15:41:44 +0530 Subject: [PATCH 2/6] test: usageKey param tests --- .../LibraryAuthoringPage.test.tsx | 36 +++++++++++++++++++ .../components/LibraryComponents.tsx | 10 ++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 70853f18b6..ce7091dad8 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -745,4 +745,40 @@ describe('', () => { expect(container.queryAllByText('Text').length).toBeGreaterThan(0); expect(container.queryAllByText('Collection').length).toBeGreaterThan(0); }); + + it('shows a single block when usageKey query param is set', async () => { + render(, { + path, + routerProps: { + initialEntries: [ + `/library/${mockContentLibrary.libraryId}/components?usageKey=${mockXBlockFields.usageKeyHtml}`, + ], + }, + }); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.stringContaining(mockXBlockFields.usageKeyHtml), + headers: expect.anything(), + method: 'POST', + }); + }); + expect(screen.queryByPlaceholderText('Displaying single block, clear filters to search')).toBeInTheDocument(); + const { displayName } = mockXBlockFields.dataHtml; + const sidebar = screen.getByTestId('library-sidebar'); + + const { getByText } = within(sidebar); + + // should display the component with passed param: usageKey in the sidebar + await waitFor(() => expect(getByText(displayName)).toBeInTheDocument()); + // clear usageKey filter + const clearFitlersButton = screen.getByRole('button', { name: /clear filters/i }); + fireEvent.click(clearFitlersButton); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.not.stringContaining(mockXBlockFields.usageKeyHtml), + method: 'POST', + headers: expect.anything(), + }); + }); + }); }); diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx index c3a7758ec0..772dd76313 100644 --- a/src/library-authoring/components/LibraryComponents.tsx +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + import { LoadingSpinner } from '../../generic/Loading'; import { useLoadOnScroll } from '../../hooks'; import { useSearchContext } from '../../search-manager'; @@ -30,9 +32,11 @@ const LibraryComponents = ({ variant }: LibraryComponentsProps) => { } = useSearchContext(); const { openAddContentSidebar, openComponentInfoSidebar } = useLibraryContext(); - if (usageKey) { - openComponentInfoSidebar(usageKey); - } + useEffect(() => { + if (usageKey) { + openComponentInfoSidebar(usageKey); + } + }, [usageKey]); const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits; From f64f0c26e4702b14ab924b71b40986abbaeacc23 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 23 Oct 2024 11:53:35 +0530 Subject: [PATCH 3/6] feat: show alert while editing library block from course Displays an alert when user attempts to edit a course block imported from a library. --- src/editors/EditorContainer.tsx | 40 ++++++++++++++++++++++++++++++++- src/editors/messages.ts | 15 +++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/editors/EditorContainer.tsx b/src/editors/EditorContainer.tsx index 90bd248766..c3962cf3b6 100644 --- a/src/editors/EditorContainer.tsx +++ b/src/editors/EditorContainer.tsx @@ -1,8 +1,15 @@ import React from 'react'; -import { useLocation, useParams } from 'react-router-dom'; +import { useLocation, useParams, useSearchParams } from 'react-router-dom'; import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Hyperlink } from '@openedx/paragon'; +import { Warning as WarningIcon } from '@openedx/paragon/icons'; import EditorPage from './EditorPage'; +import AlertMessage from '../generic/alert-message'; +import messages from './messages'; +import { getLibraryId } from '../generic/key-utils'; +import { createCorrectInternalRoute } from '../utils'; interface Props { /** Course ID or Library ID */ @@ -25,15 +32,46 @@ const EditorContainer: React.FC = ({ onClose, returnFunction, }) => { + const intl = useIntl(); const { blockType, blockId } = useParams(); const location = useLocation(); + const [searchParams] = useSearchParams(); + const upstreamLibRef = searchParams.get('upstreamLibRef'); if (blockType === undefined || blockId === undefined) { // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. return
Error: missing URL parameters
; } + + const getLibraryBlockUrl = () => { + if (!upstreamLibRef) { + return ''; + } + const libId = getLibraryId(upstreamLibRef); + return createCorrectInternalRoute(`/library/${libId}/components?usageKey=${upstreamLibRef}`); + }; + return (
+ + {intl.formatMessage(messages.libraryBlockEditWarningLink)} + , + ]} + /> Date: Wed, 23 Oct 2024 12:25:25 +0530 Subject: [PATCH 4/6] test: fix editor container test --- src/editors/EditorContainer.test.jsx | 19 +++++-- .../EditorContainer.test.jsx.snap | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/editors/EditorContainer.test.jsx b/src/editors/EditorContainer.test.jsx index ea5ec4a8b4..436f81c3c4 100644 --- a/src/editors/EditorContainer.test.jsx +++ b/src/editors/EditorContainer.test.jsx @@ -2,13 +2,26 @@ import React from 'react'; import { shallow } from '@edx/react-unit-test-utils'; import EditorContainer from './EditorContainer'; -jest.mock('react-router', () => ({ - ...jest.requireActual('react-router'), // use actual for all non-hook parts +const mockPathname = '/editor/'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts useParams: () => ({ blockId: 'company-id1', blockType: 'html', }), - useLocation: () => {}, + useLocation: () => ({ + pathname: mockPathname, + }), + useSearchParams: () => [{ + get: () => 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', + }], +})); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), })); const props = { learningContextId: 'cOuRsEId' }; diff --git a/src/editors/__snapshots__/EditorContainer.test.jsx.snap b/src/editors/__snapshots__/EditorContainer.test.jsx.snap index c742c7a606..6344ed8bd4 100644 --- a/src/editors/__snapshots__/EditorContainer.test.jsx.snap +++ b/src/editors/__snapshots__/EditorContainer.test.jsx.snap @@ -4,6 +4,55 @@ exports[`Editor Container snapshots rendering correctly with expected Input 1`]
+ + View in Library + , + ] + } + className="m-3" + description="Edits made here will only be reflected in this course. These edits may be overridden later if updates are accepted." + icon={[Function]} + show="lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd" + title="Editing Content from a Library" + variant="warning" + /> Date: Wed, 23 Oct 2024 12:48:03 +0530 Subject: [PATCH 5/6] fix: extraFilter being updated infinitely --- src/library-authoring/LibraryAuthoringPage.test.tsx | 2 +- src/search-manager/SearchManager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index ce7091dad8..6489556f53 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -769,7 +769,7 @@ describe('', () => { const { getByText } = within(sidebar); // should display the component with passed param: usageKey in the sidebar - await waitFor(() => expect(getByText(displayName)).toBeInTheDocument()); + expect(getByText(displayName)).toBeInTheDocument(); // clear usageKey filter const clearFitlersButton = screen.getByRole('button', { name: /clear filters/i }); fireEvent.click(clearFitlersButton); diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index 8712376558..297ce53b08 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -111,7 +111,7 @@ export const SearchContextProvider: React.FC<{ let extraFilter: string[] = forceArray(props.extraFilter); if (usageKey) { - extraFilter.push(`usage_key = "${usageKey}"`); + extraFilter = union(extraFilter, [`usage_key = "${usageKey}"`]); } // The search sort order can be set via the query string From ddb24dcc5587623145109f7dec5ed044d162381e Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 23 Oct 2024 14:30:10 +0530 Subject: [PATCH 6/6] test: close sidebar --- src/library-authoring/LibraryAuthoringPage.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 6489556f53..566a9e9e58 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -496,6 +496,10 @@ describe('', () => { await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage'); + const closeButton = getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); it('should open and close the collection sidebar', async () => {