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
This commit is contained in:
@@ -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' };
|
||||
|
||||
@@ -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<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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 />
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface SearchContextData {
|
||||
hasError: boolean;
|
||||
collectionHits: CollectionHit[];
|
||||
totalCollectionHits: number;
|
||||
usageKey: string;
|
||||
}
|
||||
|
||||
const SearchContext = React.createContext<SearchContextData | undefined>(undefined);
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user