chore: update with master
This commit is contained in:
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/validate.yml
vendored
1
.github/workflows/validate.yml
vendored
@@ -12,7 +12,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [18, 20]
|
||||
continue-on-error: ${{ matrix.node == 20 }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
8003
package-lock.json
generated
8003
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,3 +9,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.library-authoring-sidebar {
|
||||
min-width: 300px;
|
||||
max-width: map-get($grid-breakpoints, "sm");
|
||||
z-index: 1001; // to appear over header
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
50
src/library-authoring/component-info/ComponentInfo.tsx
Normal file
50
src/library-authoring/component-info/ComponentInfo.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
97
src/library-authoring/component-info/ComponentInfoHeader.tsx
Normal file
97
src/library-authoring/component-info/ComponentInfoHeader.tsx
Normal 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;
|
||||
2
src/library-authoring/component-info/index.tsx
Normal file
2
src/library-authoring/component-info/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ComponentInfo } from './ComponentInfo';
|
||||
export { default as ComponentInfoHeader } from './ComponentInfoHeader';
|
||||
50
src/library-authoring/component-info/messages.ts
Normal file
50
src/library-authoring/component-info/messages.ts
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -72,7 +72,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
|
||||
<CardGrid
|
||||
columnSizes={{
|
||||
sm: 12,
|
||||
md: 5,
|
||||
md: 6,
|
||||
lg: 4,
|
||||
xl: 3,
|
||||
}}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as LibraryComponents } from './LibraryComponents';
|
||||
export { ComponentMenu } from './ComponentCard';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
))
|
||||
}
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -355,6 +355,13 @@
|
||||
"problem": 16,
|
||||
"vertical": 2,
|
||||
"video": 1
|
||||
},
|
||||
"content.problem_types": {
|
||||
"choiceresponse": 2,
|
||||
"multiplechoiceresponse": 6,
|
||||
"numericalresponse": 3,
|
||||
"optionresponse": 4,
|
||||
"stringresponse": 1
|
||||
}
|
||||
},
|
||||
"facetStats": {}
|
||||
|
||||
Reference in New Issue
Block a user