Skip to content

Commit

Permalink
feat: direct link to single block in library [FC-0062] (#1392)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
navinkarkera authored Oct 23, 2024
1 parent 11470f2 commit f67c3ff
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 9 deletions.
19 changes: 16 additions & 3 deletions src/editors/EditorContainer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
40 changes: 39 additions & 1 deletion src/editors/EditorContainer.tsx
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -25,15 +32,46 @@ const EditorContainer: React.FC<Props> = ({
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 <div>Error: missing URL parameters</div>;
}

const getLibraryBlockUrl = () => {
if (!upstreamLibRef) {
return '';
}
const libId = getLibraryId(upstreamLibRef);
return createCorrectInternalRoute(`/library/${libId}/components?usageKey=${upstreamLibRef}`);
};

return (
<div className="editor-page">
<AlertMessage
className="m-3"
show={upstreamLibRef}
variant="warning"
icon={WarningIcon}
title={intl.formatMessage(messages.libraryBlockEditWarningTitle)}
description={intl.formatMessage(messages.libraryBlockEditWarningDescription)}
actions={[
<Button
destination={getLibraryBlockUrl()}
target="_blank"
rel="noopener noreferrer"
showLaunchIcon
as={Hyperlink}
>
{intl.formatMessage(messages.libraryBlockEditWarningLink)}
</Button>,
]}
/>
<EditorPage
courseId={learningContextId}
blockType={blockType}
Expand Down
49 changes: 49 additions & 0 deletions src/editors/__snapshots__/EditorContainer.test.jsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,55 @@ exports[`Editor Container snapshots rendering correctly with expected Input 1`]
<div
className="editor-page"
>
<AlertMessage
actions={
[
<ForwardRef
as={
{
"$$typeof": Symbol(react.forward_ref),
"defaultProps": {
"className": undefined,
"externalLinkAlternativeText": "in a new tab",
"externalLinkTitle": "Opens in a new tab",
"isInline": false,
"onClick": [Function],
"showLaunchIcon": true,
"target": "_self",
"variant": "default",
},
"propTypes": {
"children": [Function],
"className": [Function],
"destination": [Function],
"externalLinkAlternativeText": [Function],
"externalLinkTitle": [Function],
"isInline": [Function],
"onClick": [Function],
"showLaunchIcon": [Function],
"target": [Function],
"variant": [Function],
},
"render": [Function],
}
}
destination="/library/lib:Axim:TEST/components?usageKey=lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd"
disabled={false}
rel="noopener noreferrer"
showLaunchIcon={true}
target="_blank"
>
View in Library
</ForwardRef>,
]
}
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"
/>
<EditorPage
blockId="company-id1"
blockType="html"
Expand Down
15 changes: 15 additions & 0 deletions src/editors/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ const messages = defineMessages({
defaultMessage: 'Upload MP4 or MOV files (5 GB max)',
description: 'Info message for supported formats',
},
libraryBlockEditWarningTitle: {
id: 'authoring.editorpage.libraryBlockEditWarningTitle',
defaultMessage: 'Editing Content from a Library',
description: 'Title text for Warning users editing library content in a course.',
},
libraryBlockEditWarningDescription: {
id: 'authoring.editorpage.libraryBlockEditWarningDescription',
defaultMessage: 'Edits made here will only be reflected in this course. These edits may be overridden later if updates are accepted.',
description: 'Description text for Warning users editing library content in a course.',
},
libraryBlockEditWarningLink: {
id: 'authoring.editorpage.libraryBlockEditWarningLink',
defaultMessage: 'View in Library',
description: 'Link text for opening library block in another tab.',
},
});

export default messages;
40 changes: 40 additions & 0 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,10 @@ describe('<LibraryAuthoringPage />', () => {

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 () => {
Expand Down Expand Up @@ -745,4 +749,40 @@ describe('<LibraryAuthoringPage />', () => {
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(<LibraryLayout />, {
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(),
});
});
});
});
11 changes: 10 additions & 1 deletion src/library-authoring/components/LibraryComponents.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useEffect } from 'react';

import { LoadingSpinner } from '../../generic/Loading';
import { useLoadOnScroll } from '../../hooks';
import { useSearchContext } from '../../search-manager';
Expand Down Expand Up @@ -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;

Expand Down
9 changes: 5 additions & 4 deletions src/search-manager/SearchKeywordsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SearchField.Advanced
Expand All @@ -18,13 +20,12 @@ const SearchKeywordsField: React.FC<{ className?: string, placeholder?: string }
onClear={() => setSearchKeywords('')}
value={searchKeywords}
className={props.className}
disabled={!!usageKey}
>
<SearchField.Label />
<SearchField.Input
autoFocus
placeholder={props.placeholder ? props.placeholder : intl.formatMessage(
messages.inputPlaceholder,
)}
placeholder={placeholder}
/>
<SearchField.ClearButton />
<SearchField.SubmitButton />
Expand Down
14 changes: 14 additions & 0 deletions src/search-manager/SearchManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface SearchContextData {
hasError: boolean;
collectionHits: CollectionHit[];
totalCollectionHits: number;
usageKey: string;
}

const SearchContext = React.createContext<SearchContextData | undefined>(undefined);
Expand Down Expand Up @@ -101,7 +102,17 @@ export const SearchContextProvider: React.FC<{
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -176,6 +189,7 @@ export const SearchContextProvider: React.FC<{
defaultSearchSortOrder,
closeSearchModal: props.closeSearchModal ?? (() => { }),
hasError: hasConnectionError || result.isError,
usageKey,
...result,
},
}, props.children);
Expand Down
5 changes: 5 additions & 0 deletions src/search-manager/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit f67c3ff

Please sign in to comment.