chore: update with master

This commit is contained in:
Braden MacDonald
2024-08-29 10:03:55 -07:00
28 changed files with 4011 additions and 5271 deletions

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

View File

@@ -12,7 +12,6 @@ jobs:
strategy:
matrix:
node: [18, 20]
continue-on-error: ${{ matrix.node == 20 }}
steps:
- uses: actions/checkout@v4

2
.nvmrc
View File

@@ -1 +1 @@
18
20

8003
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,3 +9,9 @@
}
}
}
.library-authoring-sidebar {
min-width: 300px;
max-width: map-get($grid-breakpoints, "sm");
z-index: 1001; // to appear over header
}

View File

@@ -10,13 +10,14 @@ import {
render,
waitFor,
screen,
within,
} from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';
import initializeStore from '../store';
import { getContentSearchConfigUrl } from '../search-manager/data/api';
import mockResult from '../search-modal/__mocks__/search-result.json';
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
import { getContentLibraryApiUrl, type ContentLibrary } from './data/api';
import { getContentLibraryApiUrl, getXBlockFieldsApiUrl, type ContentLibrary } from './data/api';
import { LibraryLayout } from '.';
let store;
@@ -61,16 +62,17 @@ const returnEmptyResult = (_url, req) => {
const returnLowNumberResults = (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[0]?.q ?? '';
const newMockResult = { ...mockResult };
// We have to replace the query (search keywords) in the mock results with the actual query,
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
mockResult.results[0].query = query;
newMockResult.results[0].query = query;
// Limit number of results to just 2
mockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
mockResult.results[0].estimatedTotalHits = 2;
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
newMockResult.results[0].estimatedTotalHits = 2;
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return mockResult;
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return newMockResult;
};
const libraryData: ContentLibrary = {
@@ -97,6 +99,13 @@ const libraryData: ContentLibrary = {
updated: '2024-07-20',
};
const xBlockFields = {
display_name: 'Test HTML Block',
metadata: {
display_name: 'Test HTML Block',
},
};
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
@@ -158,6 +167,19 @@ describe('<LibraryAuthoringPage />', () => {
queryClient.clear();
});
const renderLibraryPage = async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const result = render(<RootWrapper />);
// Ensure the search endpoint is called:
// Call 1: To fetch searchable/filterable/sortable library data
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
return result;
};
it('shows the spinner before the query is complete', () => {
mockUseParams.mockReturnValue({ libraryId: '1' });
// @ts-ignore Use unresolved promise to keep the Loading visible
@@ -185,12 +207,9 @@ describe('<LibraryAuthoringPage />', () => {
});
it('show library data', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const {
getByRole, getAllByText, getByText, queryByText, findByText, findAllByText,
} = render(<RootWrapper />);
} = await renderLibraryPage();
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
@@ -263,10 +282,7 @@ describe('<LibraryAuthoringPage />', () => {
});
it('show new content button', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
render(<RootWrapper />);
await renderLibraryPage();
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
@@ -322,10 +338,7 @@ describe('<LibraryAuthoringPage />', () => {
});
it('should open and close new content sidebar', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
render(<RootWrapper />);
await renderLibraryPage();
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
@@ -342,10 +355,7 @@ describe('<LibraryAuthoringPage />', () => {
});
it('should open Library Info by default', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
render(<RootWrapper />);
await renderLibraryPage();
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
@@ -361,10 +371,7 @@ describe('<LibraryAuthoringPage />', () => {
});
it('should close and open Library Info', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
render(<RootWrapper />);
await renderLibraryPage();
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
@@ -389,14 +396,9 @@ describe('<LibraryAuthoringPage />', () => {
});
it('show the "View All" button when viewing library with many components', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const {
getByRole, getByText, queryByText, getAllByText, findAllByText,
} = render(<RootWrapper />);
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
} = await renderLibraryPage();
expect(getByText('Content library')).toBeInTheDocument();
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
@@ -456,13 +458,9 @@ describe('<LibraryAuthoringPage />', () => {
});
it('sort library components', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
const {
findByTitle, getAllByText, getByRole, getByTitle,
} = render(<RootWrapper />);
} = await renderLibraryPage();
expect(await findByTitle('Sort search results')).toBeInTheDocument();
@@ -514,7 +512,7 @@ describe('<LibraryAuthoringPage />', () => {
// Re-selecting the previous sort option resets sort to default "Recently Modified"
await testSortOption('Recently Published', 'modified:desc', true);
expect(getAllByText('Recently Modified').length).toEqual(2);
expect(getAllByText('Recently Modified').length).toEqual(3);
// Enter a keyword into the search box
const searchBox = getByRole('searchbox');
@@ -530,4 +528,129 @@ describe('<LibraryAuthoringPage />', () => {
});
});
});
it('should open and close the component sidebar', async () => {
const usageKey = mockResult.results[0].hits[0].usage_key;
const { getAllByText, queryByTestId, queryByText } = await renderLibraryPage();
axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields);
// Click on the first component
waitFor(() => expect(queryByText('Test HTML Block')).toBeInTheDocument());
fireEvent.click(getAllByText('Test HTML Block')[0]);
const sidebar = screen.getByTestId('library-sidebar');
const { getByRole, getByText } = within(sidebar);
await waitFor(() => expect(getByText('Test HTML Block')).toBeInTheDocument());
const closeButton = getByRole('button', { name: /close/i });
fireEvent.click(closeButton);
await waitFor(() => expect(queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
it('filter by capa problem type', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const problemTypes = {
'Multiple Choice': 'choiceresponse',
Checkboxes: 'multiplechoiceresponse',
'Numerical Input': 'numericalresponse',
Dropdown: 'optionresponse',
'Text Input': 'stringresponse',
};
render(<RootWrapper />);
// Ensure the search endpoint is called
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
const filterButton = screen.getByRole('button', { name: /type/i });
fireEvent.click(filterButton);
const openProblemItem = screen.getByTestId('open-problem-item-button');
fireEvent.click(openProblemItem);
const validateSubmenu = async (submenuText : string) => {
const submenu = screen.getByText(submenuText);
expect(submenu).toBeInTheDocument();
fireEvent.click(submenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`),
method: 'POST',
headers: expect.anything(),
});
});
fireEvent.click(submenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.not.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`),
method: 'POST',
headers: expect.anything(),
});
});
};
// Validate per submenu
// eslint-disable-next-line no-restricted-syntax
for (const key of Object.keys(problemTypes)) {
// eslint-disable-next-line no-await-in-loop
await validateSubmenu(key);
}
// Validate click on Problem type
const problemMenu = screen.getByText('Problem');
expect(problemMenu).toBeInTheDocument();
fireEvent.click(problemMenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('block_type = problem'),
method: 'POST',
headers: expect.anything(),
});
});
fireEvent.click(problemMenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.not.stringContaining('block_type = problem'),
method: 'POST',
headers: expect.anything(),
});
});
// Validate clear filters
const submenu = screen.getByText('Checkboxes');
expect(submenu).toBeInTheDocument();
fireEvent.click(submenu);
const clearFitlersButton = screen.getByRole('button', { name: /clear filters/i });
fireEvent.click(clearFitlersButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.not.stringContaining(`content.problem_types = ${problemTypes.Checkboxes}`),
method: 'POST',
headers: expect.anything(),
});
});
});
it('empty type filter', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
render(<RootWrapper />);
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
const filterButton = screen.getByRole('button', { name: /type/i });
fireEvent.click(filterButton);
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
});
});

View File

@@ -5,9 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import {
Badge,
Button,
Col,
Container,
Row,
Stack,
Tab,
Tabs,
@@ -152,78 +150,76 @@ const LibraryAuthoringPage = () => {
};
return (
<Container className="library-authoring-page">
<Row>
<Col>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
/>
<div className="d-flex overflow-auto">
<div className="flex-grow-1 align-content-center">
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
/>
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
<SearchContextProvider
extraFilter={`context_key = "${libraryId}"`}
>
<Container size="xl" className="p-4 mt-3">
<SubHeader
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
<SubHeader
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
/>
<SearchKeywordsField className="w-50" />
<div className="d-flex mt-3 align-items-center">
<FilterByTags />
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<Tabs
variant="tabs"
activeKey={activeKey}
onSelect={handleTabChange}
className="my-3"
>
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
</Tabs>
<Routes>
<Route
path={TabList.home}
element={(
<LibraryHome
libraryId={libraryId}
tabList={TabList}
handleTabChange={handleTabChange}
/>
)}
/>
<SearchKeywordsField className="w-50" />
<div className="d-flex mt-3 align-items-center">
<FilterByTags />
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<Tabs
variant="tabs"
activeKey={activeKey}
onSelect={handleTabChange}
className="my-3"
>
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
</Tabs>
<Routes>
<Route
path={TabList.home}
element={(
<LibraryHome
libraryId={libraryId}
tabList={TabList}
handleTabChange={handleTabChange}
/>
)}
/>
<Route
path={TabList.components}
element={<LibraryComponents libraryId={libraryId} variant="full" />}
/>
<Route
path={TabList.collections}
element={<LibraryCollections />}
/>
<Route
path="*"
element={<NotFoundAlert />}
/>
</Routes>
</Container>
<Route
path={TabList.components}
element={<LibraryComponents libraryId={libraryId} variant="full" />}
/>
<Route
path={TabList.collections}
element={<LibraryCollections />}
/>
<Route
path="*"
element={<NotFoundAlert />}
/>
</Routes>
</SearchContextProvider>
<StudioFooter />
</Col>
{ sidebarBodyComponent !== null && (
<Col xs={3} md={3} className="box-shadow-left-1">
<LibrarySidebar library={libraryData} />
</Col>
)}
</Row>
</Container>
</Container>
<StudioFooter />
</div>
{ !!sidebarBodyComponent && (
<div className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar">
<LibrarySidebar library={libraryData} />
</div>
)}
</div>
);
};

View File

@@ -4,6 +4,7 @@ import React from 'react';
export enum SidebarBodyComponentId {
AddContent = 'add-content',
Info = 'info',
ComponentInfo = 'component-info',
}
export interface LibraryContextData {
@@ -11,6 +12,8 @@ export interface LibraryContextData {
closeLibrarySidebar: () => void;
openAddContentSidebar: () => void;
openInfoSidebar: () => void;
openComponentInfoSidebar: (usageKey: string) => void;
currentComponentUsageKey?: string;
}
export const LibraryContext = React.createContext({
@@ -18,6 +21,7 @@ export const LibraryContext = React.createContext({
closeLibrarySidebar: () => {},
openAddContentSidebar: () => {},
openInfoSidebar: () => {},
openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars
} as LibraryContextData);
/**
@@ -25,21 +29,42 @@ export const LibraryContext = React.createContext({
*/
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []);
const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []);
const closeLibrarySidebar = React.useCallback(() => {
setSidebarBodyComponent(null);
setCurrentComponentUsageKey(undefined);
}, []);
const openAddContentSidebar = React.useCallback(() => {
setCurrentComponentUsageKey(undefined);
setSidebarBodyComponent(SidebarBodyComponentId.AddContent);
}, []);
const openInfoSidebar = React.useCallback(() => {
setCurrentComponentUsageKey(undefined);
setSidebarBodyComponent(SidebarBodyComponentId.Info);
}, []);
const openComponentInfoSidebar = React.useCallback(
(usageKey: string) => {
setCurrentComponentUsageKey(usageKey);
setSidebarBodyComponent(SidebarBodyComponentId.ComponentInfo);
},
[],
);
const context = React.useMemo(() => ({
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
}), [
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
]);
return (

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Tab,
Tabs,
Stack,
} from '@openedx/paragon';
import { ComponentMenu } from '../components';
import messages from './messages';
interface ComponentInfoProps {
usageKey: string;
}
const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
const intl = useIntl();
return (
<Stack>
<div className="d-flex flex-wrap">
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
{intl.formatMessage(messages.publishComponentButtonTitle)}
</Button>
<ComponentMenu usageKey={usageKey} />
</div>
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey="preview"
>
<Tab eventKey="preview" title={intl.formatMessage(messages.previewTabTitle)}>
Preview tab placeholder
</Tab>
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
Manage tab placeholder
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
Details tab placeholder
</Tab>
</Tabs>
</Stack>
);
};
export default ComponentInfo;

View File

@@ -0,0 +1,180 @@
/* eslint-disable react/require-default-props */
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
render,
fireEvent,
screen,
waitFor,
} from '@testing-library/react';
import { ContentLibrary, getXBlockFieldsApiUrl } from '../data/api';
import initializeStore from '../../store';
import { ToastProvider } from '../../generic/toast-context';
import ComponentInfoHeader from './ComponentInfoHeader';
let store;
let axiosMock;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const libraryData: ContentLibrary = {
id: 'lib:org1:lib1',
type: 'complex',
org: 'org1',
slug: 'lib1',
title: 'lib1',
description: 'lib1',
numBlocks: 2,
version: 0,
lastPublished: null,
lastDraftCreated: '2024-07-22',
publishedBy: 'staff',
lastDraftCreatedBy: 'staff',
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
hasUnpublishedChanges: true,
hasUnpublishedDeletes: false,
canEditLibrary: true,
license: '',
created: '2024-06-26',
updated: '2024-07-20',
};
interface WrapperProps {
library?: ContentLibrary,
}
const usageKey = 'lb:org1:library:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d';
const xBlockFields = {
display_name: 'Test HTML Block',
metadata: {
display_name: 'Test HTML Block',
},
};
const RootWrapper = ({ library } : WrapperProps) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ComponentInfoHeader library={library || libraryData} usageKey={usageKey} />
</ToastProvider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
describe('<ComponentInfoHeader />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields);
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
});
it('should render component info Header', async () => {
render(<RootWrapper />);
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit component name/i })).toBeInTheDocument();
});
it('should not render edit title button without permission', () => {
const library = {
...libraryData,
canEditLibrary: false,
};
render(<RootWrapper library={library} />);
expect(screen.queryByRole('button', { name: /edit component name/i })).not.toBeInTheDocument();
});
it('should edit component title', async () => {
const url = getXBlockFieldsApiUrl(usageKey);
axiosMock.onPost(url).reply(200);
render(<RootWrapper />);
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
const textBox = screen.getByRole('textbox', { name: /display name input/i });
fireEvent.change(textBox, { target: { value: 'New component name' } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
expect(textBox).not.toBeInTheDocument();
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(url);
expect(axiosMock.history.post[0].data).toStrictEqual(JSON.stringify({
metadata: { display_name: 'New component name' },
}));
expect(screen.getByText('Component updated successfully.')).toBeInTheDocument();
});
});
it('should close edit library title on press Escape', async () => {
const url = getXBlockFieldsApiUrl(usageKey);
axiosMock.onPost(url).reply(200);
render(<RootWrapper />);
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
const textBox = screen.getByRole('textbox', { name: /display name input/i });
fireEvent.change(textBox, { target: { value: 'New component name' } });
fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 });
expect(textBox).not.toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.post.length).toEqual(0));
});
it('should show error on edit library tittle', async () => {
const url = getXBlockFieldsApiUrl(usageKey);
axiosMock.onPatch(url).reply(500);
render(<RootWrapper />);
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
const textBox = screen.getByRole('textbox', { name: /display name input/i });
fireEvent.change(textBox, { target: { value: 'New component name' } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(url);
expect(axiosMock.history.post[0].data).toStrictEqual(JSON.stringify({
metadata: { display_name: 'New component name' },
}));
expect(screen.getByText('There was an error updating the component.')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,97 @@
import React, { useState, useContext, useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Icon,
IconButton,
Stack,
Form,
} from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons';
import { ToastContext } from '../../generic/toast-context';
import type { ContentLibrary } from '../data/api';
import { useUpdateXBlockFields, useXBlockFields } from '../data/apiHooks';
import messages from './messages';
interface ComponentInfoHeaderProps {
library: ContentLibrary;
usageKey: string;
}
const ComponentInfoHeader = ({ library, usageKey }: ComponentInfoHeaderProps) => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
const {
data: xblockFields,
} = useXBlockFields(library.id, usageKey);
const updateMutation = useUpdateXBlockFields(library.id, usageKey);
const { showToast } = useContext(ToastContext);
const handleSaveDisplayName = useCallback(
(event) => {
const newDisplayName = event.target.value;
if (newDisplayName && newDisplayName !== xblockFields?.displayName) {
updateMutation.mutateAsync({
metadata: {
display_name: newDisplayName,
},
}).then(() => {
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
});
}
setIsActive(false);
},
[xblockFields, showToast, intl],
);
const handleClick = () => {
setIsActive(true);
};
const hanldeOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSaveDisplayName(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
return (
<Stack direction="horizontal">
{ inputIsActive
? (
<Form.Control
autoFocus
name="displayName"
id="displayName"
type="text"
aria-label="Display name input"
defaultValue={xblockFields?.displayName}
onBlur={handleSaveDisplayName}
onKeyDown={hanldeOnKeyDown}
/>
)
: (
<>
<span className="font-weight-bold m-1.5">
{xblockFields?.displayName}
</span>
{library.canEditLibrary && (
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editNameButtonAlt)}
onClick={handleClick}
/>
)}
</>
)}
</Stack>
);
};
export default ComponentInfoHeader;

View File

@@ -0,0 +1,2 @@
export { default as ComponentInfo } from './ComponentInfo';
export { default as ComponentInfoHeader } from './ComponentInfoHeader';

View File

@@ -0,0 +1,50 @@
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
import type { defineMessages as defineMessagesType } from 'react-intl';
// frontend-platform currently doesn't provide types... do it ourselves.
const defineMessages = _defineMessages as typeof defineMessagesType;
const messages = defineMessages({
editNameButtonAlt: {
id: 'course-authoring.library-authoring.component.edit-name.alt',
defaultMessage: 'Edit component name',
description: 'Alt text for edit component name icon button',
},
updateComponentSuccessMsg: {
id: 'course-authoring.library-authoring.component.update.success',
defaultMessage: 'Component updated successfully.',
description: 'Message when the component is updated successfully',
},
updateComponentErrorMsg: {
id: 'course-authoring.library-authoring.component.update.error',
defaultMessage: 'There was an error updating the component.',
description: 'Message when there is an error when updating the component',
},
editComponentButtonTitle: {
id: 'course-authoring.library-authoring.component.edit.title',
defaultMessage: 'Edit component',
description: 'Title for edit component button',
},
publishComponentButtonTitle: {
id: 'course-authoring.library-authoring.component.publish.title',
defaultMessage: 'Publish component',
description: 'Title for publish component button',
},
previewTabTitle: {
id: 'course-authoring.library-authoring.component.preview-tab.title',
defaultMessage: 'Preview',
description: 'Title for preview tab',
},
manageTabTitle: {
id: 'course-authoring.library-authoring.component.manage-tab.title',
defaultMessage: 'Manage',
description: 'Title for manage tab',
},
detailsTabTitle: {
id: 'course-authoring.library-authoring.component.details-tab.title',
defaultMessage: 'Details',
description: 'Title for details tab',
},
});
export default messages;

View File

@@ -16,6 +16,7 @@ import { updateClipboard } from '../../generic/data/api';
import TagCount from '../../generic/tag-count';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit, Highlight } from '../../search-manager';
import { LibraryContext } from '../common/context';
import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
@@ -24,7 +25,7 @@ type ComponentCardProps = {
blockTypeDisplayName: string,
};
const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
@@ -38,7 +39,7 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
};
return (
<Dropdown id="component-card-dropdown">
<Dropdown id="component-card-dropdown" onClick={(e) => e.stopPropagation()}>
<Dropdown.Toggle
id="component-card-menu-toggle"
as={IconButton}
@@ -64,6 +65,10 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
};
const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
const {
openComponentInfoSidebar,
} = useContext(LibraryContext);
const {
blockType,
formatted,
@@ -84,7 +89,15 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
return (
<Container className="library-component-card">
<Card>
<Card
isClickable
onClick={() => openComponentInfoSidebar(usageKey)}
onKeyDown={(e: React.KeyboardEvent) => {
if (['Enter', ' '].includes(e.key)) {
openComponentInfoSidebar(usageKey);
}
}}
>
<Card.Header
className={`library-component-header ${getComponentStyleColor(blockType)}`}
title={
@@ -92,7 +105,7 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
}
actions={(
<ActionRow>
<ComponentCardMenu usageKey={usageKey} />
<ComponentMenu usageKey={usageKey} />
</ActionRow>
)}
/>

View File

@@ -72,7 +72,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
<CardGrid
columnSizes={{
sm: 12,
md: 5,
md: 6,
lg: 4,
xl: 3,
}}

View File

@@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as LibraryComponents } from './LibraryComponents';
export { ComponentMenu } from './ComponentCard';

View File

@@ -24,6 +24,10 @@ export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUr
* Get the URL for paste clipboard content into library.
*/
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
/**
* Get the URL for the xblock metadata API.
*/
export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`;
export interface ContentLibrary {
id: string;
@@ -64,6 +68,12 @@ export interface LibrariesV2Response {
results: ContentLibrary[],
}
export interface XBlockFields {
displayName: string;
metadata: Record<string, unknown>;
data: string;
}
/* Additional custom parameters for the API request. */
export interface GetLibrariesV2CustomParams {
/* (optional) Library type, default `complex` */
@@ -110,6 +120,13 @@ export interface LibraryPasteClipboardRequest {
blockId: string;
}
export interface UpdateXBlockFieldsRequest {
data?: unknown;
metadata?: {
display_name?: string;
};
}
/**
* Fetch block types of a library
*/
@@ -211,3 +228,19 @@ export async function libraryPasteClipboard({
);
return data;
}
/**
* Fetch xblock fields.
*/
export async function getXBlockFields(usageKey: string): Promise<XBlockFields> {
const { data } = await getAuthenticatedHttpClient().get(getXBlockFieldsApiUrl(usageKey));
return camelCaseObject(data);
}
/**
* Update xblock fields.
*/
export async function updateXBlockFields(usageKey:string, xblockData: UpdateXBlockFieldsRequest) {
const client = getAuthenticatedHttpClient();
await client.post(getXBlockFieldsApiUrl(usageKey), xblockData);
}

View File

@@ -1,9 +1,13 @@
import { camelCaseObject } from '@edx/frontend-platform';
import {
useQuery, useMutation, useQueryClient, Query,
useQuery, useMutation, useQueryClient, type Query,
} from '@tanstack/react-query';
import {
type GetLibrariesV2CustomParams,
type ContentLibrary,
type XBlockFields,
type UpdateXBlockFieldsRequest,
getContentLibrary,
getLibraryBlockTypes,
createLibraryBlock,
@@ -11,10 +15,26 @@ import {
commitLibraryChanges,
revertLibraryChanges,
updateLibraryMetadata,
ContentLibrary,
libraryPasteClipboard,
getXBlockFields,
updateXBlockFields,
} from './api';
const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
// Invalidate all content queries related to this library.
// If we allow searching "all courses and libraries" in the future,
// then we'd have to invalidate all `["content_search", "results"]`
// queries, and not just the ones for this library, because items from
// this library could be included in an "all courses and libraries"
// search. For now we only allow searching individual libraries.
const extraFilter = query.queryKey[5]; // extraFilter contains library id
if (!(Array.isArray(extraFilter) || typeof extraFilter === 'string')) {
return false;
}
return query.queryKey[0] === 'content_search' && extraFilter?.includes(`context_key = "${libraryId}"`);
};
export const libraryAuthoringQueryKeys = {
all: ['contentLibrary'],
/**
@@ -32,6 +52,13 @@ export const libraryAuthoringQueryKeys = {
'content',
'libraryBlockTypes',
],
xblockFields: (contentLibraryId: string, usageKey: string) => [
...libraryAuthoringQueryKeys.all,
...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId),
'content',
'xblockFields',
usageKey,
],
};
/**
@@ -124,22 +151,7 @@ export const useRevertLibraryChanges = () => {
mutationFn: revertLibraryChanges,
onSettled: (_data, _error, libraryId) => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
queryClient.invalidateQueries({
// Invalidate all content queries related to this library.
// If we allow searching "all courses and libraries" in the future,
// then we'd have to invalidate all `["content_search", "results"]`
// queries, and not just the ones for this library, because items from
// this library could be included in an "all courses and libraries"
// search. For now we only allow searching individual libraries.
predicate: /* istanbul ignore next */ (query: Query): boolean => {
// extraFilter contains library id
const extraFilter = query.queryKey[5];
if (!(Array.isArray(extraFilter) || typeof extraFilter === 'string')) {
return false;
}
return query.queryKey[0] === 'content_search' && extraFilter?.includes(`context_key = "${libraryId}"`);
},
});
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
},
});
};
@@ -150,7 +162,50 @@ export const useLibraryPasteClipboard = () => {
mutationFn: libraryPasteClipboard,
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) });
queryClient.invalidateQueries({ queryKey: ['content_search'] });
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, variables.libraryId) });
},
});
};
export const useXBlockFields = (contentLibrayId: string, usageKey: string) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.xblockFields(contentLibrayId, usageKey),
queryFn: () => getXBlockFields(usageKey),
enabled: !!usageKey,
})
);
export const useUpdateXBlockFields = (contentLibraryId: string, usageKey: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateXBlockFieldsRequest) => updateXBlockFields(usageKey, data),
onMutate: async (data) => {
const queryKey = libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey);
const previousBlockData = queryClient.getQueriesData(queryKey)[0][1] as XBlockFields;
const formatedData = camelCaseObject(data);
const newBlockData = {
...previousBlockData,
...(formatedData.metadata?.displayName && { displayName: formatedData.metadata.displayName }),
metadata: {
...previousBlockData.metadata,
...formatedData.metadata,
},
};
queryClient.setQueryData(queryKey, newBlockData);
return { previousBlockData, newBlockData };
},
onError: (_err, _data, context) => {
queryClient.setQueryData(
libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey),
context?.previousBlockData,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey) });
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
},
});
};

View File

@@ -8,7 +8,7 @@ import {
import { Edit } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { ContentLibrary } from '../data/api';
import type { ContentLibrary } from '../data/api';
import { useUpdateLibraryMetadata } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';

View File

@@ -10,6 +10,7 @@ import messages from '../messages';
import { AddContentContainer, AddContentHeader } from '../add-content';
import { LibraryContext, SidebarBodyComponentId } from '../common/context';
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
import { ContentLibrary } from '../data/api';
type LibrarySidebarProps = {
@@ -27,25 +28,35 @@ type LibrarySidebarProps = {
*/
const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
const intl = useIntl();
const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext);
const {
sidebarBodyComponent,
closeLibrarySidebar,
currentComponentUsageKey,
} = useContext(LibraryContext);
const bodyComponentMap = {
[SidebarBodyComponentId.AddContent]: <AddContentContainer />,
[SidebarBodyComponentId.Info]: <LibraryInfo library={library} />,
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey && <ComponentInfo usageKey={currentComponentUsageKey} />
),
unknown: null,
};
const headerComponentMap = {
'add-content': <AddContentHeader />,
info: <LibraryInfoHeader library={library} />,
[SidebarBodyComponentId.AddContent]: <AddContentHeader />,
[SidebarBodyComponentId.Info]: <LibraryInfoHeader library={library} />,
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey && <ComponentInfoHeader library={library} usageKey={currentComponentUsageKey} />
),
unknown: null,
};
const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown'];
const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown'];
const buildBody = () : React.ReactNode => bodyComponentMap[sidebarBodyComponent || 'unknown'];
const buildHeader = (): React.ReactNode => headerComponentMap[sidebarBodyComponent || 'unknown'];
return (
<Stack gap={4} className="p-2 vh-100 text-primary-700">
<Stack gap={4} className="p-2 text-primary-700">
<Stack direction="horizontal" className="d-flex justify-content-between">
{buildHeader()}
<IconButton

View File

@@ -9,3 +9,19 @@
.clear-filter-button:hover {
color: $info-900 !important;
}
.problem-menu-item {
.pgn__menu-item-text {
width: 100%;
}
.pgn__form-checkbox > div:first-of-type {
width: 100%;
}
.problem-sub-menu-item {
position: absolute;
left: 3.8rem;
top: -3rem;
}
}

View File

@@ -1,17 +1,212 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import React, { useCallback, useEffect } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Badge,
Form,
Icon,
IconButton,
Menu,
MenuItem,
ModalPopup,
useToggle,
} from '@openedx/paragon';
import { FilterList } from '@openedx/paragon/icons';
import { KeyboardArrowRight, FilterList } from '@openedx/paragon/icons';
import SearchFilterWidget from './SearchFilterWidget';
import messages from './messages';
import BlockTypeLabel from './BlockTypeLabel';
import { useSearchContext } from './SearchManager';
interface ProblemFilterItemProps {
count: number,
handleCheckboxChange: Function,
}
interface FilterItemProps {
blockType: string,
count: number,
}
const ProblemFilterItem = ({ count, handleCheckboxChange } : ProblemFilterItemProps) => {
const blockType = 'problem';
const {
setBlockTypesFilter,
problemTypes,
problemTypesFilter,
blockTypesFilter,
setProblemTypesFilter,
} = useSearchContext();
const intl = useIntl();
const problemTypesLength = Object.values(problemTypes).length;
const [isProblemItemOpen, openProblemItem, closeProblemItem] = useToggle(false);
const [isProblemIndeterminate, setIsProblemIndeterminate] = React.useState(false);
const [problemItemTarget, setProblemItemTarget] = React.useState<HTMLButtonElement | null>(null);
useEffect(() => {
/* istanbul ignore next */
if (problemTypesFilter.length !== 0
&& !blockTypesFilter.includes(blockType)) {
setIsProblemIndeterminate(true);
}
}, []);
const handleCheckBoxChangeOnProblem = React.useCallback((e) => {
handleCheckboxChange(e);
if (e.target.checked) {
setProblemTypesFilter(Object.keys(problemTypes));
} else {
setProblemTypesFilter([]);
}
}, [handleCheckboxChange, setProblemTypesFilter]);
const handleProblemCheckboxChange = React.useCallback((e) => {
setProblemTypesFilter(currentFiltersProblem => {
let result;
if (currentFiltersProblem.includes(e.target.value)) {
result = currentFiltersProblem.filter(x => x !== e.target.value);
} else {
result = [...currentFiltersProblem, e.target.value];
}
if (e.target.checked) {
/* istanbul ignore next */
if (result.length === problemTypesLength) {
// Add 'problem' to type filter if all problem types are selected.
setIsProblemIndeterminate(false);
setBlockTypesFilter(currentFilters => [...currentFilters, 'problem']);
} else {
setIsProblemIndeterminate(true);
}
} /* istanbul ignore next */ else {
// Delete 'problem' filter if a problem is deselected.
setBlockTypesFilter(currentFilters => {
/* istanbul ignore next */
if (currentFilters.includes('problem')) {
return currentFilters.filter(x => x !== 'problem');
}
return [...currentFilters];
});
setIsProblemIndeterminate(result.length !== 0);
}
return result;
});
}, [
setProblemTypesFilter,
problemTypesFilter,
setBlockTypesFilter,
problemTypesLength,
]);
return (
<div className="problem-menu-item">
<MenuItem
key={blockType}
as={Form.Checkbox}
value={blockType}
onChange={handleCheckBoxChangeOnProblem}
isIndeterminate={isProblemIndeterminate}
>
<div className="d-flex justify-content-between align-items-center">
<div>
<BlockTypeLabel type={blockType} />{' '}
<Badge variant="light" pill>{count}</Badge>
</div>
{ Object.keys(problemTypes).length !== 0 && (
<IconButton
ref={setProblemItemTarget}
variant="dark"
iconAs={Icon}
src={KeyboardArrowRight}
onClick={openProblemItem}
data-testid="open-problem-item-button"
alt={intl.formatMessage(messages.openProblemSubmenuAlt)}
/>
)}
</div>
</MenuItem>
<ModalPopup
positionRef={problemItemTarget}
isOpen={isProblemItemOpen}
onClose={closeProblemItem}
>
<div
className="bg-white rounded shadow problem-sub-menu-item"
>
<Form.Group className="mb-0">
<Form.CheckboxSet
name="block-type-filter"
value={problemTypesFilter}
>
<Menu>
{ Object.entries(problemTypes).map(([problemType, problemTypeCount]) => (
<MenuItem
key={problemType}
as={Form.Checkbox}
value={problemType}
onChange={handleProblemCheckboxChange}
>
<div style={{ textAlign: 'start' }}>
<BlockTypeLabel type={problemType} />{' '}
<Badge variant="light" pill>{problemTypeCount}</Badge>
</div>
</MenuItem>
))}
{
// Show a message if there are no options at all to avoid the
// impression that the dropdown isn't working
Object.keys(problemTypes).length === 0 ? (
/* istanbul ignore next */
<MenuItem disabled><FormattedMessage {...messages['blockTypeFilter.empty']} /></MenuItem>
) : null
}
</Menu>
</Form.CheckboxSet>
</Form.Group>
</div>
</ModalPopup>
</div>
);
};
const FilterItem = ({ blockType, count } : FilterItemProps) => {
const {
setBlockTypesFilter,
} = useSearchContext();
const handleCheckboxChange = React.useCallback((e) => {
setBlockTypesFilter(currentFilters => {
if (currentFilters.includes(e.target.value)) {
return currentFilters.filter(x => x !== e.target.value);
}
return [...currentFilters, e.target.value];
});
}, [setBlockTypesFilter]);
if (blockType === 'problem') {
// Build Capa Problem types filter submenu
return (
<ProblemFilterItem
count={count}
handleCheckboxChange={handleCheckboxChange}
/>
);
}
return (
<MenuItem
key={blockType}
as={Form.Checkbox}
value={blockType}
onChange={handleCheckboxChange}
>
<div>
<BlockTypeLabel type={blockType} />{' '}
<Badge variant="light" pill>{count}</Badge>
</div>
</MenuItem>
);
};
/**
* A button with a dropdown that allows filtering the current search by component type (XBlock type)
* e.g. Limit results to "Text" (html) and "Problem" (problem) components.
@@ -21,9 +216,16 @@ const FilterByBlockType: React.FC<Record<never, never>> = () => {
const {
blockTypes,
blockTypesFilter,
problemTypesFilter,
setBlockTypesFilter,
setProblemTypesFilter,
} = useSearchContext();
const clearFilters = useCallback(/* istanbul ignore next */ () => {
setBlockTypesFilter([]);
setProblemTypesFilter([]);
}, []);
// Sort blocktypes in order of hierarchy followed by alphabetically for components
const sortedBlockTypeKeys = Object.keys(blockTypes).sort((a, b) => {
const order = {
@@ -57,41 +259,26 @@ const FilterByBlockType: React.FC<Record<never, never>> = () => {
sortedBlockTypes[key] = blockTypes[key];
});
const handleCheckboxChange = React.useCallback((e) => {
setBlockTypesFilter(currentFilters => {
if (currentFilters.includes(e.target.value)) {
return currentFilters.filter(x => x !== e.target.value);
}
return [...currentFilters, e.target.value];
});
}, [setBlockTypesFilter]);
const appliedFilters = [...blockTypesFilter, ...problemTypesFilter].map(
blockType => ({ label: <BlockTypeLabel type={blockType} /> }),
);
return (
<SearchFilterWidget
appliedFilters={blockTypesFilter.map(blockType => ({ label: <BlockTypeLabel type={blockType} /> }))}
appliedFilters={appliedFilters}
label={<FormattedMessage {...messages.blockTypeFilter} />}
clearFilter={() => setBlockTypesFilter([])}
clearFilter={clearFilters}
icon={FilterList}
>
<Form.Group className="mb-0">
<Form.CheckboxSet
name="block-type-filter"
defaultValue={blockTypesFilter}
value={blockTypesFilter}
>
<Menu className="block-type-refinement-menu" style={{ boxShadow: 'none' }}>
{
Object.entries(sortedBlockTypes).map(([blockType, count]) => (
<label key={blockType} className="d-inline">
<MenuItem
as={Form.Checkbox}
value={blockType}
checked={blockTypesFilter.includes(blockType)}
onChange={handleCheckboxChange}
>
<BlockTypeLabel type={blockType} />{' '}
<Badge variant="light" pill>{count}</Badge>
</MenuItem>
</label>
<FilterItem blockType={blockType} count={count} />
))
}
{

View File

@@ -19,9 +19,12 @@ export interface SearchContextData {
setSearchKeywords: React.Dispatch<React.SetStateAction<string>>;
blockTypesFilter: string[];
setBlockTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
problemTypesFilter: string[];
setProblemTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
tagsFilter: string[];
setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>;
blockTypes: Record<string, number>;
problemTypes: Record<string, number>;
extraFilter?: Filter;
canClearFilters: boolean;
clearFilters: () => void;
@@ -88,6 +91,7 @@ export const SearchContextProvider: React.FC<{
}> = ({ overrideSearchSortOrder, ...props }) => {
const [searchKeywords, setSearchKeywords] = React.useState('');
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
const extraFilter: string[] = forceArray(props.extraFilter);
@@ -112,12 +116,14 @@ export const SearchContextProvider: React.FC<{
const canClearFilters = (
blockTypesFilter.length > 0
|| problemTypesFilter.length > 0
|| tagsFilter.length > 0
);
const isFiltered = canClearFilters || (searchKeywords !== '');
const clearFilters = React.useCallback(() => {
setBlockTypesFilter([]);
setTagsFilter([]);
setProblemTypesFilter([]);
}, []);
// Initialize a connection to Meilisearch:
@@ -137,6 +143,7 @@ export const SearchContextProvider: React.FC<{
extraFilter,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
sort,
});
@@ -149,6 +156,8 @@ export const SearchContextProvider: React.FC<{
setSearchKeywords,
blockTypesFilter,
setBlockTypesFilter,
problemTypesFilter,
setProblemTypesFilter,
tagsFilter,
setTagsFilter,
extraFilter,

View File

@@ -140,6 +140,7 @@ interface FetchSearchParams {
indexName: string,
searchKeywords: string,
blockTypesFilter?: string[],
problemTypesFilter?: string[],
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
tagsFilter?: string[],
extraFilter?: Filter,
@@ -153,6 +154,7 @@ export async function fetchSearchResults({
indexName,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
extraFilter,
sort,
@@ -162,6 +164,7 @@ export async function fetchSearchResults({
nextOffset: number | undefined,
totalHits: number,
blockTypes: Record<string, number>,
problemTypes: Record<string, number>,
}> {
const queries: MultiSearchQuery[] = [];
@@ -170,10 +173,18 @@ export async function fetchSearchResults({
const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : [];
const problemTypesFilterFormatted = problemTypesFilter?.length ? [problemTypesFilter.map(pt => `content.problem_types = ${pt}`)] : [];
const tagsFilterFormatted = formatTagsFilter(tagsFilter);
const limit = 20; // How many results to retrieve per page.
// To filter normal block types and problem types as 'OR' query
const typeFilters = [[
...blockTypesFilterFormatted,
...problemTypesFilterFormatted,
].flat()];
// First query is always to get the hits, with all the filters applied.
queries.push({
indexUid: indexName,
@@ -181,8 +192,8 @@ export async function fetchSearchResults({
filter: [
// top-level entries in the array are AND conditions and must all match
// Inner arrays are OR conditions, where only one needs to match.
...typeFilters,
...extraFilterFormatted,
...blockTypesFilterFormatted,
...tagsFilterFormatted,
],
attributesToHighlight: ['display_name', 'content'],
@@ -199,7 +210,7 @@ export async function fetchSearchResults({
queries.push({
indexUid: indexName,
q: searchKeywords,
facets: ['block_type'],
facets: ['block_type', 'content.problem_types'],
filter: [
...extraFilterFormatted,
// We exclude the block type filter here so we get all the other available options for it.
@@ -213,6 +224,7 @@ export async function fetchSearchResults({
hits: results[0].hits.map(formatSearchHit),
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length,
blockTypes: results[1].facetDistribution?.block_type ?? {},
problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {},
nextOffset: results[0].hits.length === limit ? offset + limit : undefined,
};
}

View File

@@ -37,6 +37,7 @@ export const useContentSearchResults = ({
extraFilter,
searchKeywords,
blockTypesFilter = [],
problemTypesFilter = [],
tagsFilter = [],
sort = [],
}: {
@@ -50,6 +51,8 @@ export const useContentSearchResults = ({
searchKeywords: string;
/** Only search for these block types (e.g. `["html", "problem"]`) */
blockTypesFilter?: string[];
/** Only search for these problem types (e.g. `["choiceresponse", "multiplechoiceresponse"]`) */
problemTypesFilter?: string[];
/** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */
tagsFilter?: string[];
/** Sort search results using these options */
@@ -66,6 +69,7 @@ export const useContentSearchResults = ({
extraFilter,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
sort,
],
@@ -79,6 +83,7 @@ export const useContentSearchResults = ({
indexName,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
sort,
// For infinite pagination of results, we can retrieve additional pages if requested.
@@ -102,6 +107,7 @@ export const useContentSearchResults = ({
hits,
// The distribution of block type filter options
blockTypes: pages?.[0]?.blockTypes ?? {},
problemTypes: pages?.[0]?.problemTypes ?? {},
status: query.status,
isFetching: query.isFetching,
isError: query.isError,

View File

@@ -105,6 +105,36 @@ const messages = defineMessages({
defaultMessage: 'Video',
description: 'Name of the "Video" component type in Studio',
},
'blockType.choiceresponse': {
id: 'course-authoring.course-search.blockType.choiceresponse',
defaultMessage: 'Multiple Choice',
description: 'Name of the "choiceresponse" component type in Studio',
},
'blockType.multiplechoiceresponse': {
id: 'course-authoring.course-search.blockType.multiplechoiceresponse',
defaultMessage: 'Checkboxes',
description: 'Name of the "multiplechoiceresponse" component type in Studio',
},
'blockType.numericalresponse': {
id: 'course-authoring.course-search.blockType.numericalresponse',
defaultMessage: 'Numerical Input',
description: 'Name of the "numericalresponse" component type in Studio',
},
'blockType.optionresponse': {
id: 'course-authoring.course-search.blockType.optionresponse',
defaultMessage: 'Dropdown',
description: 'Name of the "optionresponse" component type in Studio',
},
'blockType.stringresponse': {
id: 'course-authoring.course-search.blockType.stringresponse',
defaultMessage: 'Text Input',
description: 'Name of the "stringresponse" component type in Studio',
},
'blockType.formularesponse': {
id: 'course-authoring.course-search.blockType.formularesponse',
defaultMessage: 'Math Expression',
description: 'Name of the "formularesponse" component type in Studio',
},
blockTagsFilter: {
id: 'course-authoring.search-manager.blockTagsFilter',
defaultMessage: 'Tags',
@@ -170,6 +200,11 @@ const messages = defineMessages({
defaultMessage: 'Recently Modified',
description: 'Label for the content search sort drop-down which sorts by modified date, descending',
},
openProblemSubmenuAlt: {
id: 'course-authoring.filter.problem-submenu.icon-button.alt',
defaultMessage: 'Open problem types filters',
description: 'Alt of the icon button to open problem types filters',
},
searchSortMostRelevant: {
id: 'course-authoring.course-search.searchSort.mostRelevant',
defaultMessage: 'Most Relevant',

View File

@@ -10,7 +10,6 @@ import {
render,
waitFor,
within,
getByLabelText as getByLabelTextIn,
type RenderResult,
} from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';
@@ -175,8 +174,8 @@ describe('<SearchUI />', () => {
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return requestedFilter?.[0] === 'type = "course_block"'
&& requestedFilter?.[1] === 'context_key = "course-v1:org+test+123"';
return requestedFilter?.[1] === 'type = "course_block"'
&& requestedFilter?.[2] === 'context_key = "course-v1:org+test+123"';
});
// Now we should see the results:
expect(queryByText('Enter a keyword')).toBeNull();
@@ -400,7 +399,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
// the filter is: ['type = "course_block"', 'context_key = "course-v1:org+test+123"']
return (requestedFilter?.length === 2);
return (requestedFilter?.length === 3);
});
// Now we should see the results:
expect(getByText('6 results found')).toBeInTheDocument();
@@ -408,13 +407,12 @@ describe('<SearchUI />', () => {
});
it('can filter results by component/XBlock type', async () => {
const { getByRole } = rendered;
const { getByRole, getByText } = rendered;
// Now open the filters menu:
fireEvent.click(getByRole('button', { name: 'Type' }), {});
// The dropdown menu has role="group"
await waitFor(() => { expect(getByRole('group')).toBeInTheDocument(); });
const popupMenu = getByRole('group');
const problemFilterCheckbox = getByLabelTextIn(popupMenu, /Problem/i);
const problemFilterCheckbox = getByText(/Problem/i);
fireEvent.click(problemFilterCheckbox, {});
await waitFor(() => {
expect(rendered.getByRole('button', { name: /type: problem/i, hidden: true })).toBeInTheDocument();
@@ -427,9 +425,16 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[
'block_type = problem',
'content.problem_types = choiceresponse',
'content.problem_types = multiplechoiceresponse',
'content.problem_types = numericalresponse',
'content.problem_types = optionresponse',
'content.problem_types = stringresponse',
],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
['block_type = problem'], // <-- the newly added filter, sent with the request
]);
});
});
@@ -453,6 +458,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
'tags.taxonomy = "ESDC Skills and Competencies"', // <-- the newly added filter, sent with the request
@@ -487,6 +493,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
'tags.level0 = "ESDC Skills and Competencies > Abilities"',

View File

@@ -355,6 +355,13 @@
"problem": 16,
"vertical": 2,
"video": 1
},
"content.problem_types": {
"choiceresponse": 2,
"multiplechoiceresponse": 6,
"numericalresponse": 3,
"optionresponse": 4,
"stringresponse": 1
}
},
"facetStats": {}