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:
|
jobs:
|
||||||
version-check:
|
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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [18, 20]
|
node: [18, 20]
|
||||||
continue-on-error: ${{ matrix.node == 20 }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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,
|
render,
|
||||||
waitFor,
|
waitFor,
|
||||||
screen,
|
screen,
|
||||||
|
within,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import fetchMock from 'fetch-mock-jest';
|
import fetchMock from 'fetch-mock-jest';
|
||||||
import initializeStore from '../store';
|
import initializeStore from '../store';
|
||||||
import { getContentSearchConfigUrl } from '../search-manager/data/api';
|
import { getContentSearchConfigUrl } from '../search-manager/data/api';
|
||||||
import mockResult from '../search-modal/__mocks__/search-result.json';
|
import mockResult from '../search-modal/__mocks__/search-result.json';
|
||||||
import mockEmptyResult from '../search-modal/__mocks__/empty-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 '.';
|
import { LibraryLayout } from '.';
|
||||||
|
|
||||||
let store;
|
let store;
|
||||||
@@ -61,16 +62,17 @@ const returnEmptyResult = (_url, req) => {
|
|||||||
const returnLowNumberResults = (_url, req) => {
|
const returnLowNumberResults = (_url, req) => {
|
||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const query = requestData?.queries[0]?.q ?? '';
|
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,
|
// 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.
|
// 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
|
// Limit number of results to just 2
|
||||||
mockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
|
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
|
||||||
mockResult.results[0].estimatedTotalHits = 2;
|
newMockResult.results[0].estimatedTotalHits = 2;
|
||||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
// 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
|
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||||
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||||
return mockResult;
|
return newMockResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
const libraryData: ContentLibrary = {
|
const libraryData: ContentLibrary = {
|
||||||
@@ -97,6 +99,13 @@ const libraryData: ContentLibrary = {
|
|||||||
updated: '2024-07-20',
|
updated: '2024-07-20',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const xBlockFields = {
|
||||||
|
display_name: 'Test HTML Block',
|
||||||
|
metadata: {
|
||||||
|
display_name: 'Test HTML Block',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const clipboardBroadcastChannelMock = {
|
const clipboardBroadcastChannelMock = {
|
||||||
postMessage: jest.fn(),
|
postMessage: jest.fn(),
|
||||||
close: jest.fn(),
|
close: jest.fn(),
|
||||||
@@ -158,6 +167,19 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
queryClient.clear();
|
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', () => {
|
it('shows the spinner before the query is complete', () => {
|
||||||
mockUseParams.mockReturnValue({ libraryId: '1' });
|
mockUseParams.mockReturnValue({ libraryId: '1' });
|
||||||
// @ts-ignore Use unresolved promise to keep the Loading visible
|
// @ts-ignore Use unresolved promise to keep the Loading visible
|
||||||
@@ -185,12 +207,9 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('show library data', async () => {
|
it('show library data', async () => {
|
||||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
|
||||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getByRole, getAllByText, getByText, queryByText, findByText, findAllByText,
|
getByRole, getAllByText, getByText, queryByText, findByText, findAllByText,
|
||||||
} = render(<RootWrapper />);
|
} = await renderLibraryPage();
|
||||||
|
|
||||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||||
|
|
||||||
@@ -263,10 +282,7 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('show new content button', async () => {
|
it('show new content button', async () => {
|
||||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
await renderLibraryPage();
|
||||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
|
||||||
|
|
||||||
render(<RootWrapper />);
|
|
||||||
|
|
||||||
expect(await screen.findByRole('heading')).toBeInTheDocument();
|
expect(await screen.findByRole('heading')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
|
||||||
@@ -322,10 +338,7 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should open and close new content sidebar', async () => {
|
it('should open and close new content sidebar', async () => {
|
||||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
await renderLibraryPage();
|
||||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
|
||||||
|
|
||||||
render(<RootWrapper />);
|
|
||||||
|
|
||||||
expect(await screen.findByRole('heading')).toBeInTheDocument();
|
expect(await screen.findByRole('heading')).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
|
||||||
@@ -342,10 +355,7 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should open Library Info by default', async () => {
|
it('should open Library Info by default', async () => {
|
||||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
await renderLibraryPage();
|
||||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
|
||||||
|
|
||||||
render(<RootWrapper />);
|
|
||||||
|
|
||||||
expect(await screen.findByText('Content library')).toBeInTheDocument();
|
expect(await screen.findByText('Content library')).toBeInTheDocument();
|
||||||
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
||||||
@@ -361,10 +371,7 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should close and open Library Info', async () => {
|
it('should close and open Library Info', async () => {
|
||||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
await renderLibraryPage();
|
||||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
|
||||||
|
|
||||||
render(<RootWrapper />);
|
|
||||||
|
|
||||||
expect(await screen.findByText('Content library')).toBeInTheDocument();
|
expect(await screen.findByText('Content library')).toBeInTheDocument();
|
||||||
expect((await screen.findAllByText(libraryData.title))[0]).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 () => {
|
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 {
|
const {
|
||||||
getByRole, getByText, queryByText, getAllByText, findAllByText,
|
getByRole, getByText, queryByText, getAllByText, findAllByText,
|
||||||
} = render(<RootWrapper />);
|
} = await renderLibraryPage();
|
||||||
|
|
||||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
|
||||||
|
|
||||||
expect(getByText('Content library')).toBeInTheDocument();
|
expect(getByText('Content library')).toBeInTheDocument();
|
||||||
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
|
||||||
@@ -456,13 +458,9 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sort library components', async () => {
|
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 {
|
const {
|
||||||
findByTitle, getAllByText, getByRole, getByTitle,
|
findByTitle, getAllByText, getByRole, getByTitle,
|
||||||
} = render(<RootWrapper />);
|
} = await renderLibraryPage();
|
||||||
|
|
||||||
expect(await findByTitle('Sort search results')).toBeInTheDocument();
|
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"
|
// Re-selecting the previous sort option resets sort to default "Recently Modified"
|
||||||
await testSortOption('Recently Published', 'modified:desc', true);
|
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
|
// Enter a keyword into the search box
|
||||||
const searchBox = getByRole('searchbox');
|
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 {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Col,
|
|
||||||
Container,
|
Container,
|
||||||
Row,
|
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
Tab,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -152,78 +150,76 @@ const LibraryAuthoringPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="library-authoring-page">
|
<div className="d-flex overflow-auto">
|
||||||
<Row>
|
<div className="flex-grow-1 align-content-center">
|
||||||
<Col>
|
<Header
|
||||||
<Header
|
number={libraryData.slug}
|
||||||
number={libraryData.slug}
|
title={libraryData.title}
|
||||||
title={libraryData.title}
|
org={libraryData.org}
|
||||||
org={libraryData.org}
|
contextId={libraryId}
|
||||||
contextId={libraryId}
|
isLibrary
|
||||||
isLibrary
|
/>
|
||||||
/>
|
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
|
||||||
<SearchContextProvider
|
<SearchContextProvider
|
||||||
extraFilter={`context_key = "${libraryId}"`}
|
extraFilter={`context_key = "${libraryId}"`}
|
||||||
>
|
>
|
||||||
<Container size="xl" className="p-4 mt-3">
|
<SubHeader
|
||||||
<SubHeader
|
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
|
||||||
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
|
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
|
||||||
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" />
|
<Route
|
||||||
<div className="d-flex mt-3 align-items-center">
|
path={TabList.components}
|
||||||
<FilterByTags />
|
element={<LibraryComponents libraryId={libraryId} variant="full" />}
|
||||||
<FilterByBlockType />
|
/>
|
||||||
<ClearFiltersButton />
|
<Route
|
||||||
<div className="flex-grow-1" />
|
path={TabList.collections}
|
||||||
<SearchSortWidget />
|
element={<LibraryCollections />}
|
||||||
</div>
|
/>
|
||||||
<Tabs
|
<Route
|
||||||
variant="tabs"
|
path="*"
|
||||||
activeKey={activeKey}
|
element={<NotFoundAlert />}
|
||||||
onSelect={handleTabChange}
|
/>
|
||||||
className="my-3"
|
</Routes>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</SearchContextProvider>
|
</SearchContextProvider>
|
||||||
<StudioFooter />
|
</Container>
|
||||||
</Col>
|
<StudioFooter />
|
||||||
{ sidebarBodyComponent !== null && (
|
</div>
|
||||||
<Col xs={3} md={3} className="box-shadow-left-1">
|
{ !!sidebarBodyComponent && (
|
||||||
<LibrarySidebar library={libraryData} />
|
<div className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar">
|
||||||
</Col>
|
<LibrarySidebar library={libraryData} />
|
||||||
)}
|
</div>
|
||||||
</Row>
|
)}
|
||||||
</Container>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
export enum SidebarBodyComponentId {
|
export enum SidebarBodyComponentId {
|
||||||
AddContent = 'add-content',
|
AddContent = 'add-content',
|
||||||
Info = 'info',
|
Info = 'info',
|
||||||
|
ComponentInfo = 'component-info',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryContextData {
|
export interface LibraryContextData {
|
||||||
@@ -11,6 +12,8 @@ export interface LibraryContextData {
|
|||||||
closeLibrarySidebar: () => void;
|
closeLibrarySidebar: () => void;
|
||||||
openAddContentSidebar: () => void;
|
openAddContentSidebar: () => void;
|
||||||
openInfoSidebar: () => void;
|
openInfoSidebar: () => void;
|
||||||
|
openComponentInfoSidebar: (usageKey: string) => void;
|
||||||
|
currentComponentUsageKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LibraryContext = React.createContext({
|
export const LibraryContext = React.createContext({
|
||||||
@@ -18,6 +21,7 @@ export const LibraryContext = React.createContext({
|
|||||||
closeLibrarySidebar: () => {},
|
closeLibrarySidebar: () => {},
|
||||||
openAddContentSidebar: () => {},
|
openAddContentSidebar: () => {},
|
||||||
openInfoSidebar: () => {},
|
openInfoSidebar: () => {},
|
||||||
|
openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
} as LibraryContextData);
|
} as LibraryContextData);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,21 +29,42 @@ export const LibraryContext = React.createContext({
|
|||||||
*/
|
*/
|
||||||
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
||||||
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
|
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
|
||||||
|
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
|
||||||
|
|
||||||
const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
|
const closeLibrarySidebar = React.useCallback(() => {
|
||||||
const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []);
|
setSidebarBodyComponent(null);
|
||||||
const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []);
|
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(() => ({
|
const context = React.useMemo(() => ({
|
||||||
sidebarBodyComponent,
|
sidebarBodyComponent,
|
||||||
closeLibrarySidebar,
|
closeLibrarySidebar,
|
||||||
openAddContentSidebar,
|
openAddContentSidebar,
|
||||||
openInfoSidebar,
|
openInfoSidebar,
|
||||||
|
openComponentInfoSidebar,
|
||||||
|
currentComponentUsageKey,
|
||||||
}), [
|
}), [
|
||||||
sidebarBodyComponent,
|
sidebarBodyComponent,
|
||||||
closeLibrarySidebar,
|
closeLibrarySidebar,
|
||||||
openAddContentSidebar,
|
openAddContentSidebar,
|
||||||
openInfoSidebar,
|
openInfoSidebar,
|
||||||
|
openComponentInfoSidebar,
|
||||||
|
currentComponentUsageKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
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 TagCount from '../../generic/tag-count';
|
||||||
import { ToastContext } from '../../generic/toast-context';
|
import { ToastContext } from '../../generic/toast-context';
|
||||||
import { type ContentHit, Highlight } from '../../search-manager';
|
import { type ContentHit, Highlight } from '../../search-manager';
|
||||||
|
import { LibraryContext } from '../common/context';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
|
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ type ComponentCardProps = {
|
|||||||
blockTypeDisplayName: string,
|
blockTypeDisplayName: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
|
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { showToast } = useContext(ToastContext);
|
const { showToast } = useContext(ToastContext);
|
||||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||||
@@ -38,7 +39,7 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown id="component-card-dropdown">
|
<Dropdown id="component-card-dropdown" onClick={(e) => e.stopPropagation()}>
|
||||||
<Dropdown.Toggle
|
<Dropdown.Toggle
|
||||||
id="component-card-menu-toggle"
|
id="component-card-menu-toggle"
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
@@ -64,6 +65,10 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
|
const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
|
||||||
|
const {
|
||||||
|
openComponentInfoSidebar,
|
||||||
|
} = useContext(LibraryContext);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
blockType,
|
blockType,
|
||||||
formatted,
|
formatted,
|
||||||
@@ -84,7 +89,15 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="library-component-card">
|
<Container className="library-component-card">
|
||||||
<Card>
|
<Card
|
||||||
|
isClickable
|
||||||
|
onClick={() => openComponentInfoSidebar(usageKey)}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) => {
|
||||||
|
if (['Enter', ' '].includes(e.key)) {
|
||||||
|
openComponentInfoSidebar(usageKey);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Card.Header
|
<Card.Header
|
||||||
className={`library-component-header ${getComponentStyleColor(blockType)}`}
|
className={`library-component-header ${getComponentStyleColor(blockType)}`}
|
||||||
title={
|
title={
|
||||||
@@ -92,7 +105,7 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
|
|||||||
}
|
}
|
||||||
actions={(
|
actions={(
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
<ComponentCardMenu usageKey={usageKey} />
|
<ComponentMenu usageKey={usageKey} />
|
||||||
</ActionRow>
|
</ActionRow>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
|
|||||||
<CardGrid
|
<CardGrid
|
||||||
columnSizes={{
|
columnSizes={{
|
||||||
sm: 12,
|
sm: 12,
|
||||||
md: 5,
|
md: 6,
|
||||||
lg: 4,
|
lg: 4,
|
||||||
xl: 3,
|
xl: 3,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// eslint-disable-next-line import/prefer-default-export
|
|
||||||
export { default as LibraryComponents } from './LibraryComponents';
|
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.
|
* Get the URL for paste clipboard content into library.
|
||||||
*/
|
*/
|
||||||
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
|
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 {
|
export interface ContentLibrary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,6 +68,12 @@ export interface LibrariesV2Response {
|
|||||||
results: ContentLibrary[],
|
results: ContentLibrary[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface XBlockFields {
|
||||||
|
displayName: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
/* Additional custom parameters for the API request. */
|
/* Additional custom parameters for the API request. */
|
||||||
export interface GetLibrariesV2CustomParams {
|
export interface GetLibrariesV2CustomParams {
|
||||||
/* (optional) Library type, default `complex` */
|
/* (optional) Library type, default `complex` */
|
||||||
@@ -110,6 +120,13 @@ export interface LibraryPasteClipboardRequest {
|
|||||||
blockId: string;
|
blockId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateXBlockFieldsRequest {
|
||||||
|
data?: unknown;
|
||||||
|
metadata?: {
|
||||||
|
display_name?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch block types of a library
|
* Fetch block types of a library
|
||||||
*/
|
*/
|
||||||
@@ -211,3 +228,19 @@ export async function libraryPasteClipboard({
|
|||||||
);
|
);
|
||||||
return data;
|
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 {
|
import {
|
||||||
useQuery, useMutation, useQueryClient, Query,
|
useQuery, useMutation, useQueryClient, type Query,
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type GetLibrariesV2CustomParams,
|
type GetLibrariesV2CustomParams,
|
||||||
|
type ContentLibrary,
|
||||||
|
type XBlockFields,
|
||||||
|
type UpdateXBlockFieldsRequest,
|
||||||
getContentLibrary,
|
getContentLibrary,
|
||||||
getLibraryBlockTypes,
|
getLibraryBlockTypes,
|
||||||
createLibraryBlock,
|
createLibraryBlock,
|
||||||
@@ -11,10 +15,26 @@ import {
|
|||||||
commitLibraryChanges,
|
commitLibraryChanges,
|
||||||
revertLibraryChanges,
|
revertLibraryChanges,
|
||||||
updateLibraryMetadata,
|
updateLibraryMetadata,
|
||||||
ContentLibrary,
|
|
||||||
libraryPasteClipboard,
|
libraryPasteClipboard,
|
||||||
|
getXBlockFields,
|
||||||
|
updateXBlockFields,
|
||||||
} from './api';
|
} 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 = {
|
export const libraryAuthoringQueryKeys = {
|
||||||
all: ['contentLibrary'],
|
all: ['contentLibrary'],
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +52,13 @@ export const libraryAuthoringQueryKeys = {
|
|||||||
'content',
|
'content',
|
||||||
'libraryBlockTypes',
|
'libraryBlockTypes',
|
||||||
],
|
],
|
||||||
|
xblockFields: (contentLibraryId: string, usageKey: string) => [
|
||||||
|
...libraryAuthoringQueryKeys.all,
|
||||||
|
...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId),
|
||||||
|
'content',
|
||||||
|
'xblockFields',
|
||||||
|
usageKey,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,22 +151,7 @@ export const useRevertLibraryChanges = () => {
|
|||||||
mutationFn: revertLibraryChanges,
|
mutationFn: revertLibraryChanges,
|
||||||
onSettled: (_data, _error, libraryId) => {
|
onSettled: (_data, _error, libraryId) => {
|
||||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
|
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||||
// 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}"`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -150,7 +162,50 @@ export const useLibraryPasteClipboard = () => {
|
|||||||
mutationFn: libraryPasteClipboard,
|
mutationFn: libraryPasteClipboard,
|
||||||
onSettled: (_data, _error, variables) => {
|
onSettled: (_data, _error, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) });
|
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 { Edit } from '@openedx/paragon/icons';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { ContentLibrary } from '../data/api';
|
import type { ContentLibrary } from '../data/api';
|
||||||
import { useUpdateLibraryMetadata } from '../data/apiHooks';
|
import { useUpdateLibraryMetadata } from '../data/apiHooks';
|
||||||
import { ToastContext } from '../../generic/toast-context';
|
import { ToastContext } from '../../generic/toast-context';
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import messages from '../messages';
|
|||||||
import { AddContentContainer, AddContentHeader } from '../add-content';
|
import { AddContentContainer, AddContentHeader } from '../add-content';
|
||||||
import { LibraryContext, SidebarBodyComponentId } from '../common/context';
|
import { LibraryContext, SidebarBodyComponentId } from '../common/context';
|
||||||
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
|
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
|
||||||
|
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
|
||||||
import { ContentLibrary } from '../data/api';
|
import { ContentLibrary } from '../data/api';
|
||||||
|
|
||||||
type LibrarySidebarProps = {
|
type LibrarySidebarProps = {
|
||||||
@@ -27,25 +28,35 @@ type LibrarySidebarProps = {
|
|||||||
*/
|
*/
|
||||||
const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
|
const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext);
|
const {
|
||||||
|
sidebarBodyComponent,
|
||||||
|
closeLibrarySidebar,
|
||||||
|
currentComponentUsageKey,
|
||||||
|
} = useContext(LibraryContext);
|
||||||
|
|
||||||
const bodyComponentMap = {
|
const bodyComponentMap = {
|
||||||
[SidebarBodyComponentId.AddContent]: <AddContentContainer />,
|
[SidebarBodyComponentId.AddContent]: <AddContentContainer />,
|
||||||
[SidebarBodyComponentId.Info]: <LibraryInfo library={library} />,
|
[SidebarBodyComponentId.Info]: <LibraryInfo library={library} />,
|
||||||
|
[SidebarBodyComponentId.ComponentInfo]: (
|
||||||
|
currentComponentUsageKey && <ComponentInfo usageKey={currentComponentUsageKey} />
|
||||||
|
),
|
||||||
unknown: null,
|
unknown: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerComponentMap = {
|
const headerComponentMap = {
|
||||||
'add-content': <AddContentHeader />,
|
[SidebarBodyComponentId.AddContent]: <AddContentHeader />,
|
||||||
info: <LibraryInfoHeader library={library} />,
|
[SidebarBodyComponentId.Info]: <LibraryInfoHeader library={library} />,
|
||||||
|
[SidebarBodyComponentId.ComponentInfo]: (
|
||||||
|
currentComponentUsageKey && <ComponentInfoHeader library={library} usageKey={currentComponentUsageKey} />
|
||||||
|
),
|
||||||
unknown: null,
|
unknown: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown'];
|
const buildBody = () : React.ReactNode => bodyComponentMap[sidebarBodyComponent || 'unknown'];
|
||||||
const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown'];
|
const buildHeader = (): React.ReactNode => headerComponentMap[sidebarBodyComponent || 'unknown'];
|
||||||
|
|
||||||
return (
|
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">
|
<Stack direction="horizontal" className="d-flex justify-content-between">
|
||||||
{buildHeader()}
|
{buildHeader()}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -9,3 +9,19 @@
|
|||||||
.clear-filter-button:hover {
|
.clear-filter-button:hover {
|
||||||
color: $info-900 !important;
|
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 React, { useCallback, useEffect } from 'react';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Form,
|
Form,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
ModalPopup,
|
||||||
|
useToggle,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { FilterList } from '@openedx/paragon/icons';
|
import { KeyboardArrowRight, FilterList } from '@openedx/paragon/icons';
|
||||||
import SearchFilterWidget from './SearchFilterWidget';
|
import SearchFilterWidget from './SearchFilterWidget';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import BlockTypeLabel from './BlockTypeLabel';
|
import BlockTypeLabel from './BlockTypeLabel';
|
||||||
import { useSearchContext } from './SearchManager';
|
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)
|
* 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.
|
* e.g. Limit results to "Text" (html) and "Problem" (problem) components.
|
||||||
@@ -21,9 +216,16 @@ const FilterByBlockType: React.FC<Record<never, never>> = () => {
|
|||||||
const {
|
const {
|
||||||
blockTypes,
|
blockTypes,
|
||||||
blockTypesFilter,
|
blockTypesFilter,
|
||||||
|
problemTypesFilter,
|
||||||
setBlockTypesFilter,
|
setBlockTypesFilter,
|
||||||
|
setProblemTypesFilter,
|
||||||
} = useSearchContext();
|
} = useSearchContext();
|
||||||
|
|
||||||
|
const clearFilters = useCallback(/* istanbul ignore next */ () => {
|
||||||
|
setBlockTypesFilter([]);
|
||||||
|
setProblemTypesFilter([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sort blocktypes in order of hierarchy followed by alphabetically for components
|
// Sort blocktypes in order of hierarchy followed by alphabetically for components
|
||||||
const sortedBlockTypeKeys = Object.keys(blockTypes).sort((a, b) => {
|
const sortedBlockTypeKeys = Object.keys(blockTypes).sort((a, b) => {
|
||||||
const order = {
|
const order = {
|
||||||
@@ -57,41 +259,26 @@ const FilterByBlockType: React.FC<Record<never, never>> = () => {
|
|||||||
sortedBlockTypes[key] = blockTypes[key];
|
sortedBlockTypes[key] = blockTypes[key];
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCheckboxChange = React.useCallback((e) => {
|
const appliedFilters = [...blockTypesFilter, ...problemTypesFilter].map(
|
||||||
setBlockTypesFilter(currentFilters => {
|
blockType => ({ label: <BlockTypeLabel type={blockType} /> }),
|
||||||
if (currentFilters.includes(e.target.value)) {
|
);
|
||||||
return currentFilters.filter(x => x !== e.target.value);
|
|
||||||
}
|
|
||||||
return [...currentFilters, e.target.value];
|
|
||||||
});
|
|
||||||
}, [setBlockTypesFilter]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchFilterWidget
|
<SearchFilterWidget
|
||||||
appliedFilters={blockTypesFilter.map(blockType => ({ label: <BlockTypeLabel type={blockType} /> }))}
|
appliedFilters={appliedFilters}
|
||||||
label={<FormattedMessage {...messages.blockTypeFilter} />}
|
label={<FormattedMessage {...messages.blockTypeFilter} />}
|
||||||
clearFilter={() => setBlockTypesFilter([])}
|
clearFilter={clearFilters}
|
||||||
icon={FilterList}
|
icon={FilterList}
|
||||||
>
|
>
|
||||||
<Form.Group className="mb-0">
|
<Form.Group className="mb-0">
|
||||||
<Form.CheckboxSet
|
<Form.CheckboxSet
|
||||||
name="block-type-filter"
|
name="block-type-filter"
|
||||||
defaultValue={blockTypesFilter}
|
value={blockTypesFilter}
|
||||||
>
|
>
|
||||||
<Menu className="block-type-refinement-menu" style={{ boxShadow: 'none' }}>
|
<Menu className="block-type-refinement-menu" style={{ boxShadow: 'none' }}>
|
||||||
{
|
{
|
||||||
Object.entries(sortedBlockTypes).map(([blockType, count]) => (
|
Object.entries(sortedBlockTypes).map(([blockType, count]) => (
|
||||||
<label key={blockType} className="d-inline">
|
<FilterItem blockType={blockType} count={count} />
|
||||||
<MenuItem
|
|
||||||
as={Form.Checkbox}
|
|
||||||
value={blockType}
|
|
||||||
checked={blockTypesFilter.includes(blockType)}
|
|
||||||
onChange={handleCheckboxChange}
|
|
||||||
>
|
|
||||||
<BlockTypeLabel type={blockType} />{' '}
|
|
||||||
<Badge variant="light" pill>{count}</Badge>
|
|
||||||
</MenuItem>
|
|
||||||
</label>
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ export interface SearchContextData {
|
|||||||
setSearchKeywords: React.Dispatch<React.SetStateAction<string>>;
|
setSearchKeywords: React.Dispatch<React.SetStateAction<string>>;
|
||||||
blockTypesFilter: string[];
|
blockTypesFilter: string[];
|
||||||
setBlockTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
|
setBlockTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
problemTypesFilter: string[];
|
||||||
|
setProblemTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
tagsFilter: string[];
|
tagsFilter: string[];
|
||||||
setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>;
|
setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
blockTypes: Record<string, number>;
|
blockTypes: Record<string, number>;
|
||||||
|
problemTypes: Record<string, number>;
|
||||||
extraFilter?: Filter;
|
extraFilter?: Filter;
|
||||||
canClearFilters: boolean;
|
canClearFilters: boolean;
|
||||||
clearFilters: () => void;
|
clearFilters: () => void;
|
||||||
@@ -88,6 +91,7 @@ export const SearchContextProvider: React.FC<{
|
|||||||
}> = ({ overrideSearchSortOrder, ...props }) => {
|
}> = ({ overrideSearchSortOrder, ...props }) => {
|
||||||
const [searchKeywords, setSearchKeywords] = React.useState('');
|
const [searchKeywords, setSearchKeywords] = React.useState('');
|
||||||
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
|
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
|
||||||
|
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
|
||||||
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
|
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
|
||||||
const extraFilter: string[] = forceArray(props.extraFilter);
|
const extraFilter: string[] = forceArray(props.extraFilter);
|
||||||
|
|
||||||
@@ -112,12 +116,14 @@ export const SearchContextProvider: React.FC<{
|
|||||||
|
|
||||||
const canClearFilters = (
|
const canClearFilters = (
|
||||||
blockTypesFilter.length > 0
|
blockTypesFilter.length > 0
|
||||||
|
|| problemTypesFilter.length > 0
|
||||||
|| tagsFilter.length > 0
|
|| tagsFilter.length > 0
|
||||||
);
|
);
|
||||||
const isFiltered = canClearFilters || (searchKeywords !== '');
|
const isFiltered = canClearFilters || (searchKeywords !== '');
|
||||||
const clearFilters = React.useCallback(() => {
|
const clearFilters = React.useCallback(() => {
|
||||||
setBlockTypesFilter([]);
|
setBlockTypesFilter([]);
|
||||||
setTagsFilter([]);
|
setTagsFilter([]);
|
||||||
|
setProblemTypesFilter([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize a connection to Meilisearch:
|
// Initialize a connection to Meilisearch:
|
||||||
@@ -137,6 +143,7 @@ export const SearchContextProvider: React.FC<{
|
|||||||
extraFilter,
|
extraFilter,
|
||||||
searchKeywords,
|
searchKeywords,
|
||||||
blockTypesFilter,
|
blockTypesFilter,
|
||||||
|
problemTypesFilter,
|
||||||
tagsFilter,
|
tagsFilter,
|
||||||
sort,
|
sort,
|
||||||
});
|
});
|
||||||
@@ -149,6 +156,8 @@ export const SearchContextProvider: React.FC<{
|
|||||||
setSearchKeywords,
|
setSearchKeywords,
|
||||||
blockTypesFilter,
|
blockTypesFilter,
|
||||||
setBlockTypesFilter,
|
setBlockTypesFilter,
|
||||||
|
problemTypesFilter,
|
||||||
|
setProblemTypesFilter,
|
||||||
tagsFilter,
|
tagsFilter,
|
||||||
setTagsFilter,
|
setTagsFilter,
|
||||||
extraFilter,
|
extraFilter,
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ interface FetchSearchParams {
|
|||||||
indexName: string,
|
indexName: string,
|
||||||
searchKeywords: string,
|
searchKeywords: string,
|
||||||
blockTypesFilter?: string[],
|
blockTypesFilter?: string[],
|
||||||
|
problemTypesFilter?: string[],
|
||||||
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
|
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
|
||||||
tagsFilter?: string[],
|
tagsFilter?: string[],
|
||||||
extraFilter?: Filter,
|
extraFilter?: Filter,
|
||||||
@@ -153,6 +154,7 @@ export async function fetchSearchResults({
|
|||||||
indexName,
|
indexName,
|
||||||
searchKeywords,
|
searchKeywords,
|
||||||
blockTypesFilter,
|
blockTypesFilter,
|
||||||
|
problemTypesFilter,
|
||||||
tagsFilter,
|
tagsFilter,
|
||||||
extraFilter,
|
extraFilter,
|
||||||
sort,
|
sort,
|
||||||
@@ -162,6 +164,7 @@ export async function fetchSearchResults({
|
|||||||
nextOffset: number | undefined,
|
nextOffset: number | undefined,
|
||||||
totalHits: number,
|
totalHits: number,
|
||||||
blockTypes: Record<string, number>,
|
blockTypes: Record<string, number>,
|
||||||
|
problemTypes: Record<string, number>,
|
||||||
}> {
|
}> {
|
||||||
const queries: MultiSearchQuery[] = [];
|
const queries: MultiSearchQuery[] = [];
|
||||||
|
|
||||||
@@ -170,10 +173,18 @@ export async function fetchSearchResults({
|
|||||||
|
|
||||||
const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : [];
|
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 tagsFilterFormatted = formatTagsFilter(tagsFilter);
|
||||||
|
|
||||||
const limit = 20; // How many results to retrieve per page.
|
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.
|
// First query is always to get the hits, with all the filters applied.
|
||||||
queries.push({
|
queries.push({
|
||||||
indexUid: indexName,
|
indexUid: indexName,
|
||||||
@@ -181,8 +192,8 @@ export async function fetchSearchResults({
|
|||||||
filter: [
|
filter: [
|
||||||
// top-level entries in the array are AND conditions and must all match
|
// top-level entries in the array are AND conditions and must all match
|
||||||
// Inner arrays are OR conditions, where only one needs to match.
|
// Inner arrays are OR conditions, where only one needs to match.
|
||||||
|
...typeFilters,
|
||||||
...extraFilterFormatted,
|
...extraFilterFormatted,
|
||||||
...blockTypesFilterFormatted,
|
|
||||||
...tagsFilterFormatted,
|
...tagsFilterFormatted,
|
||||||
],
|
],
|
||||||
attributesToHighlight: ['display_name', 'content'],
|
attributesToHighlight: ['display_name', 'content'],
|
||||||
@@ -199,7 +210,7 @@ export async function fetchSearchResults({
|
|||||||
queries.push({
|
queries.push({
|
||||||
indexUid: indexName,
|
indexUid: indexName,
|
||||||
q: searchKeywords,
|
q: searchKeywords,
|
||||||
facets: ['block_type'],
|
facets: ['block_type', 'content.problem_types'],
|
||||||
filter: [
|
filter: [
|
||||||
...extraFilterFormatted,
|
...extraFilterFormatted,
|
||||||
// We exclude the block type filter here so we get all the other available options for it.
|
// 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),
|
hits: results[0].hits.map(formatSearchHit),
|
||||||
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length,
|
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length,
|
||||||
blockTypes: results[1].facetDistribution?.block_type ?? {},
|
blockTypes: results[1].facetDistribution?.block_type ?? {},
|
||||||
|
problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {},
|
||||||
nextOffset: results[0].hits.length === limit ? offset + limit : undefined,
|
nextOffset: results[0].hits.length === limit ? offset + limit : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const useContentSearchResults = ({
|
|||||||
extraFilter,
|
extraFilter,
|
||||||
searchKeywords,
|
searchKeywords,
|
||||||
blockTypesFilter = [],
|
blockTypesFilter = [],
|
||||||
|
problemTypesFilter = [],
|
||||||
tagsFilter = [],
|
tagsFilter = [],
|
||||||
sort = [],
|
sort = [],
|
||||||
}: {
|
}: {
|
||||||
@@ -50,6 +51,8 @@ export const useContentSearchResults = ({
|
|||||||
searchKeywords: string;
|
searchKeywords: string;
|
||||||
/** Only search for these block types (e.g. `["html", "problem"]`) */
|
/** Only search for these block types (e.g. `["html", "problem"]`) */
|
||||||
blockTypesFilter?: string[];
|
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"]` */
|
/** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */
|
||||||
tagsFilter?: string[];
|
tagsFilter?: string[];
|
||||||
/** Sort search results using these options */
|
/** Sort search results using these options */
|
||||||
@@ -66,6 +69,7 @@ export const useContentSearchResults = ({
|
|||||||
extraFilter,
|
extraFilter,
|
||||||
searchKeywords,
|
searchKeywords,
|
||||||
blockTypesFilter,
|
blockTypesFilter,
|
||||||
|
problemTypesFilter,
|
||||||
tagsFilter,
|
tagsFilter,
|
||||||
sort,
|
sort,
|
||||||
],
|
],
|
||||||
@@ -79,6 +83,7 @@ export const useContentSearchResults = ({
|
|||||||
indexName,
|
indexName,
|
||||||
searchKeywords,
|
searchKeywords,
|
||||||
blockTypesFilter,
|
blockTypesFilter,
|
||||||
|
problemTypesFilter,
|
||||||
tagsFilter,
|
tagsFilter,
|
||||||
sort,
|
sort,
|
||||||
// For infinite pagination of results, we can retrieve additional pages if requested.
|
// For infinite pagination of results, we can retrieve additional pages if requested.
|
||||||
@@ -102,6 +107,7 @@ export const useContentSearchResults = ({
|
|||||||
hits,
|
hits,
|
||||||
// The distribution of block type filter options
|
// The distribution of block type filter options
|
||||||
blockTypes: pages?.[0]?.blockTypes ?? {},
|
blockTypes: pages?.[0]?.blockTypes ?? {},
|
||||||
|
problemTypes: pages?.[0]?.problemTypes ?? {},
|
||||||
status: query.status,
|
status: query.status,
|
||||||
isFetching: query.isFetching,
|
isFetching: query.isFetching,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
|
|||||||
@@ -105,6 +105,36 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Video',
|
defaultMessage: 'Video',
|
||||||
description: 'Name of the "Video" component type in Studio',
|
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: {
|
blockTagsFilter: {
|
||||||
id: 'course-authoring.search-manager.blockTagsFilter',
|
id: 'course-authoring.search-manager.blockTagsFilter',
|
||||||
defaultMessage: 'Tags',
|
defaultMessage: 'Tags',
|
||||||
@@ -170,6 +200,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Recently Modified',
|
defaultMessage: 'Recently Modified',
|
||||||
description: 'Label for the content search sort drop-down which sorts by modified date, descending',
|
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: {
|
searchSortMostRelevant: {
|
||||||
id: 'course-authoring.course-search.searchSort.mostRelevant',
|
id: 'course-authoring.course-search.searchSort.mostRelevant',
|
||||||
defaultMessage: 'Most Relevant',
|
defaultMessage: 'Most Relevant',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
render,
|
render,
|
||||||
waitFor,
|
waitFor,
|
||||||
within,
|
within,
|
||||||
getByLabelText as getByLabelTextIn,
|
|
||||||
type RenderResult,
|
type RenderResult,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import fetchMock from 'fetch-mock-jest';
|
import fetchMock from 'fetch-mock-jest';
|
||||||
@@ -175,8 +174,8 @@ describe('<SearchUI />', () => {
|
|||||||
expect(fetchMock).toHaveLastFetched((_url, req) => {
|
expect(fetchMock).toHaveLastFetched((_url, req) => {
|
||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries[0].filter;
|
const requestedFilter = requestData?.queries[0].filter;
|
||||||
return requestedFilter?.[0] === 'type = "course_block"'
|
return requestedFilter?.[1] === 'type = "course_block"'
|
||||||
&& requestedFilter?.[1] === 'context_key = "course-v1:org+test+123"';
|
&& requestedFilter?.[2] === 'context_key = "course-v1:org+test+123"';
|
||||||
});
|
});
|
||||||
// Now we should see the results:
|
// Now we should see the results:
|
||||||
expect(queryByText('Enter a keyword')).toBeNull();
|
expect(queryByText('Enter a keyword')).toBeNull();
|
||||||
@@ -400,7 +399,7 @@ describe('<SearchUI />', () => {
|
|||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries[0].filter;
|
const requestedFilter = requestData?.queries[0].filter;
|
||||||
// the filter is: ['type = "course_block"', 'context_key = "course-v1:org+test+123"']
|
// 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:
|
// Now we should see the results:
|
||||||
expect(getByText('6 results found')).toBeInTheDocument();
|
expect(getByText('6 results found')).toBeInTheDocument();
|
||||||
@@ -408,13 +407,12 @@ describe('<SearchUI />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can filter results by component/XBlock type', async () => {
|
it('can filter results by component/XBlock type', async () => {
|
||||||
const { getByRole } = rendered;
|
const { getByRole, getByText } = rendered;
|
||||||
// Now open the filters menu:
|
// Now open the filters menu:
|
||||||
fireEvent.click(getByRole('button', { name: 'Type' }), {});
|
fireEvent.click(getByRole('button', { name: 'Type' }), {});
|
||||||
// The dropdown menu has role="group"
|
// The dropdown menu has role="group"
|
||||||
await waitFor(() => { expect(getByRole('group')).toBeInTheDocument(); });
|
await waitFor(() => { expect(getByRole('group')).toBeInTheDocument(); });
|
||||||
const popupMenu = getByRole('group');
|
const problemFilterCheckbox = getByText(/Problem/i);
|
||||||
const problemFilterCheckbox = getByLabelTextIn(popupMenu, /Problem/i);
|
|
||||||
fireEvent.click(problemFilterCheckbox, {});
|
fireEvent.click(problemFilterCheckbox, {});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(rendered.getByRole('button', { name: /type: problem/i, hidden: true })).toBeInTheDocument();
|
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 requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries[0].filter;
|
const requestedFilter = requestData?.queries[0].filter;
|
||||||
return JSON.stringify(requestedFilter) === JSON.stringify([
|
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"',
|
'type = "course_block"',
|
||||||
'context_key = "course-v1:org+test+123"',
|
'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 requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries?.[0]?.filter;
|
const requestedFilter = requestData?.queries?.[0]?.filter;
|
||||||
return JSON.stringify(requestedFilter) === JSON.stringify([
|
return JSON.stringify(requestedFilter) === JSON.stringify([
|
||||||
|
[],
|
||||||
'type = "course_block"',
|
'type = "course_block"',
|
||||||
'context_key = "course-v1:org+test+123"',
|
'context_key = "course-v1:org+test+123"',
|
||||||
'tags.taxonomy = "ESDC Skills and Competencies"', // <-- the newly added filter, sent with the request
|
'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 requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries?.[0]?.filter;
|
const requestedFilter = requestData?.queries?.[0]?.filter;
|
||||||
return JSON.stringify(requestedFilter) === JSON.stringify([
|
return JSON.stringify(requestedFilter) === JSON.stringify([
|
||||||
|
[],
|
||||||
'type = "course_block"',
|
'type = "course_block"',
|
||||||
'context_key = "course-v1:org+test+123"',
|
'context_key = "course-v1:org+test+123"',
|
||||||
'tags.level0 = "ESDC Skills and Competencies > Abilities"',
|
'tags.level0 = "ESDC Skills and Competencies > Abilities"',
|
||||||
|
|||||||
@@ -355,6 +355,13 @@
|
|||||||
"problem": 16,
|
"problem": 16,
|
||||||
"vertical": 2,
|
"vertical": 2,
|
||||||
"video": 1
|
"video": 1
|
||||||
|
},
|
||||||
|
"content.problem_types": {
|
||||||
|
"choiceresponse": 2,
|
||||||
|
"multiplechoiceresponse": 6,
|
||||||
|
"numericalresponse": 3,
|
||||||
|
"optionresponse": 4,
|
||||||
|
"stringresponse": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"facetStats": {}
|
"facetStats": {}
|
||||||
|
|||||||
Reference in New Issue
Block a user