From f67c3ffc4c99f8f4f94207d7b96a5d2eb6ca1b94 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 23 Oct 2024 20:55:54 +0530 Subject: [PATCH] feat: direct link to single block in library [FC-0062] (#1392) * 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. * feat: show alert while editing library block from course --- src/editors/EditorContainer.test.jsx | 19 +++++-- src/editors/EditorContainer.tsx | 40 ++++++++++++++- .../EditorContainer.test.jsx.snap | 49 +++++++++++++++++++ src/editors/messages.ts | 15 ++++++ .../LibraryAuthoringPage.test.tsx | 40 +++++++++++++++ .../components/LibraryComponents.tsx | 11 ++++- src/search-manager/SearchKeywordsField.tsx | 9 ++-- src/search-manager/SearchManager.ts | 14 ++++++ src/search-manager/messages.ts | 5 ++ 9 files changed, 193 insertions(+), 9 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/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)} + , + ]} + /> + + 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" + /> ', () => { 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 () => { @@ -745,4 +749,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 + 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 c260897b64..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'; @@ -26,8 +28,15 @@ const LibraryComponents = ({ variant }: LibraryComponentsProps) => { fetchNextPage, isLoading, isFiltered, + usageKey, } = useSearchContext(); - const { openAddContentSidebar } = useLibraryContext(); + const { openAddContentSidebar, openComponentInfoSidebar } = useLibraryContext(); + + useEffect(() => { + if (usageKey) { + openComponentInfoSidebar(usageKey); + } + }, [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..297ce53b08 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 = union(extraFilter, [`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',