Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: direct link to single block in library [FC-0062] #1392

Merged
merged 6 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 '';

Check warning on line 48 in src/editors/EditorContainer.tsx

View check run for this annotation

Codecov / codecov/patch

src/editors/EditorContainer.tsx#L48

Added line #L48 was not covered by tests
}
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