Skip to content

Commit 4f5346e

Browse files
authored
feat: Library info sidebar - allows lib rename+publish (#1138)
1 parent 8285f8e commit 4f5346e

22 files changed

+1124
-65
lines changed

src/index.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
@import "export-page/CourseExportPage";
1919
@import "import-page/CourseImportPage";
2020
@import "taxonomy";
21+
@import "library-authoring";
2122
@import "files-and-videos";
2223
@import "content-tags-drawer";
2324
@import "course-outline/CourseOutline";
@@ -30,7 +31,6 @@
3031
@import "search-manager";
3132
@import "certificates/scss/Certificates";
3233
@import "group-configurations/GroupConfigurations";
33-
@import "library-authoring";
3434

3535
// To apply the glow effect to the selected Section/Subsection, in the Course Outline
3636
div.row:has(> div > div.highlight) {

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,18 @@ const libraryData: ContentLibrary = {
8383
numBlocks: 2,
8484
version: 0,
8585
lastPublished: null,
86+
lastDraftCreated: '2024-07-22',
87+
publishedBy: 'staff',
88+
lastDraftCreatedBy: 'staff',
8689
allowLti: false,
8790
allowPublicLearning: false,
8891
allowPublicRead: false,
8992
hasUnpublishedChanges: true,
9093
hasUnpublishedDeletes: false,
9194
canEditLibrary: true,
9295
license: '',
96+
created: '2024-06-26',
97+
updated: '2024-07-20',
9398
};
9499

95100
const RootWrapper = () => (
@@ -177,23 +182,23 @@ describe('<LibraryAuthoringPage />', () => {
177182
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
178183

179184
const {
180-
getByRole, getByText, getAllByText, queryByText,
185+
getByRole, getByText, queryByText, findByText, findAllByText,
181186
} = render(<RootWrapper />);
182187

183188
// Ensure the search endpoint is called:
184189
// Call 1: To fetch searchable/filterable/sortable library data
185190
// Call 2: To fetch the recently modified components only
186191
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
187192

188-
expect(getByText('Content library')).toBeInTheDocument();
189-
expect(getByText(libraryData.title)).toBeInTheDocument();
193+
expect(await findByText('Content library')).toBeInTheDocument();
194+
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
190195

191196
expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
192197

193198
expect(getByText('Recently Modified')).toBeInTheDocument();
194199
expect(getByText('Collections (0)')).toBeInTheDocument();
195200
expect(getByText('Components (6)')).toBeInTheDocument();
196-
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
201+
expect((await findAllByText('Test HTML Block'))[0]).toBeInTheDocument();
197202

198203
// Navigate to the components tab
199204
fireEvent.click(getByRole('tab', { name: 'Components' }));
@@ -222,10 +227,10 @@ describe('<LibraryAuthoringPage />', () => {
222227
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
223228
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
224229

225-
const { findByText, getByText } = render(<RootWrapper />);
230+
const { findByText, getByText, findAllByText } = render(<RootWrapper />);
226231

227232
expect(await findByText('Content library')).toBeInTheDocument();
228-
expect(await findByText(libraryData.title)).toBeInTheDocument();
233+
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
229234

230235
// Ensure the search endpoint is called:
231236
// Call 1: To fetch searchable/filterable/sortable library data
@@ -282,10 +287,15 @@ describe('<LibraryAuthoringPage />', () => {
282287
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
283288
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
284289

285-
const { findByText, getByRole, getByText } = render(<RootWrapper />);
290+
const {
291+
findByText,
292+
getByRole,
293+
getByText,
294+
findAllByText,
295+
} = render(<RootWrapper />);
286296

287297
expect(await findByText('Content library')).toBeInTheDocument();
288-
expect(await findByText(libraryData.title)).toBeInTheDocument();
298+
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
289299

290300
// Ensure the search endpoint is called:
291301
// Call 1: To fetch searchable/filterable/sortable library data
@@ -329,12 +339,54 @@ describe('<LibraryAuthoringPage />', () => {
329339
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
330340
});
331341

342+
it('should open Library Info by default', async () => {
343+
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
344+
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
345+
346+
render(<RootWrapper />);
347+
348+
expect(await screen.findByText('Content library')).toBeInTheDocument();
349+
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
350+
expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument();
351+
352+
expect(screen.getByText('Draft')).toBeInTheDocument();
353+
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
354+
expect(screen.getByText('July 22, 2024')).toBeInTheDocument();
355+
expect(screen.getByText('staff')).toBeInTheDocument();
356+
expect(screen.getByText(libraryData.org)).toBeInTheDocument();
357+
expect(screen.getByText('July 20, 2024')).toBeInTheDocument();
358+
expect(screen.getByText('June 26, 2024')).toBeInTheDocument();
359+
});
360+
361+
it('should close and open Library Info', async () => {
362+
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
363+
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
364+
365+
render(<RootWrapper />);
366+
367+
expect(await screen.findByText('Content library')).toBeInTheDocument();
368+
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
369+
expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument();
370+
371+
const closeButton = screen.getByRole('button', { name: /close/i });
372+
fireEvent.click(closeButton);
373+
374+
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
375+
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
376+
377+
const libraryInfoButton = screen.getByRole('button', { name: /library info/i });
378+
fireEvent.click(libraryInfoButton);
379+
380+
expect(screen.getByText('Draft')).toBeInTheDocument();
381+
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
382+
});
383+
332384
it('show the "View All" button when viewing library with many components', async () => {
333385
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
334386
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
335387

336388
const {
337-
getByRole, getByText, queryByText, getAllByText,
389+
getByRole, getByText, queryByText, getAllByText, findAllByText,
338390
} = render(<RootWrapper />);
339391

340392
// Ensure the search endpoint is called:
@@ -343,7 +395,7 @@ describe('<LibraryAuthoringPage />', () => {
343395
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
344396

345397
expect(getByText('Content library')).toBeInTheDocument();
346-
expect(getByText(libraryData.title)).toBeInTheDocument();
398+
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
347399

348400
await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); });
349401
expect(getByText('Collections (0)')).toBeInTheDocument();
@@ -376,7 +428,7 @@ describe('<LibraryAuthoringPage />', () => {
376428
fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true });
377429

378430
const {
379-
getByText, queryByText, getAllByText,
431+
getByText, queryByText, getAllByText, findAllByText,
380432
} = render(<RootWrapper />);
381433

382434
// Ensure the search endpoint is called:
@@ -385,7 +437,7 @@ describe('<LibraryAuthoringPage />', () => {
385437
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
386438

387439
expect(getByText('Content library')).toBeInTheDocument();
388-
expect(getByText(libraryData.title)).toBeInTheDocument();
440+
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
389441

390442
await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); });
391443
expect(getByText('Collections (0)')).toBeInTheDocument();

src/library-authoring/LibraryAuthoringPage.tsx

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
import React, { useContext } from 'react';
1+
import React, { useContext, useEffect } from 'react';
22
import { StudioFooter } from '@edx/frontend-component-footer';
33
import { useIntl } from '@edx/frontend-platform/i18n';
44
import {
55
Badge,
66
Button,
77
Col,
88
Container,
9-
Icon,
10-
IconButton,
119
Row,
1210
Stack,
1311
Tab,
@@ -52,37 +50,40 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
5250
const intl = useIntl();
5351
const {
5452
openAddContentSidebar,
53+
openInfoSidebar,
5554
} = useContext(LibraryContext);
5655

5756
if (!canEditLibrary) {
5857
return null;
5958
}
6059

6160
return (
62-
<Button
63-
iconBefore={Add}
64-
variant="primary rounded-0"
65-
onClick={() => openAddContentSidebar()}
66-
disabled={!canEditLibrary}
67-
>
68-
{intl.formatMessage(messages.newContentButton)}
69-
</Button>
61+
<>
62+
<Button
63+
iconBefore={InfoOutline}
64+
variant="outline-primary rounded-0"
65+
onClick={openInfoSidebar}
66+
>
67+
{intl.formatMessage(messages.libraryInfoButton)}
68+
</Button>
69+
<Button
70+
iconBefore={Add}
71+
variant="primary rounded-0"
72+
onClick={openAddContentSidebar}
73+
disabled={!canEditLibrary}
74+
>
75+
{intl.formatMessage(messages.newContentButton)}
76+
</Button>
77+
</>
7078
);
7179
};
7280

7381
const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibrary: boolean }) => {
7482
const intl = useIntl();
83+
7584
return (
7685
<Stack direction="vertical">
77-
<Stack direction="horizontal">
78-
{title}
79-
<IconButton
80-
src={InfoOutline}
81-
iconAs={Icon}
82-
alt={intl.formatMessage(messages.headingInfoAlt)}
83-
className="mr-2"
84-
/>
85-
</Stack>
86+
{title}
8687
{ !canEditLibrary && (
8788
<div>
8889
<Badge variant="primary" style={{ fontSize: '50%' }}>
@@ -104,7 +105,14 @@ const LibraryAuthoringPage = () => {
104105

105106
const currentPath = location.pathname.split('/').pop();
106107
const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home;
107-
const { sidebarBodyComponent } = useContext(LibraryContext);
108+
const {
109+
sidebarBodyComponent,
110+
openInfoSidebar,
111+
} = useContext(LibraryContext);
112+
113+
useEffect(() => {
114+
openInfoSidebar();
115+
}, []);
108116

109117
const [searchParams] = useSearchParams();
110118

@@ -190,8 +198,8 @@ const LibraryAuthoringPage = () => {
190198
<StudioFooter />
191199
</Col>
192200
{ sidebarBodyComponent !== null && (
193-
<Col xs={6} md={4} className="box-shadow-left-1">
194-
<LibrarySidebar />
201+
<Col xs={3} md={3} className="box-shadow-left-1">
202+
<LibrarySidebar library={libraryData} />
195203
</Col>
196204
)}
197205
</Row>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import React from 'react';
2+
import { FormattedMessage } from '@edx/frontend-platform/i18n';
3+
import messages from './messages';
4+
5+
const AddContentHeader = () => (
6+
<span className="font-weight-bold m-1.5">
7+
<FormattedMessage {...messages.addContentTitle} />
8+
</span>
9+
);
10+
11+
export default AddContentHeader;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
// eslint-disable-next-line import/prefer-default-export
21
export { default as AddContentContainer } from './AddContentContainer';
2+
export { default as AddContentHeader } from './AddContentHeader';

src/library-authoring/add-content/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ const messages = defineMessages({
5050
defaultMessage: 'There was an error creating the content.',
5151
description: 'Message when creation of content in library is on error',
5252
},
53+
addContentTitle: {
54+
id: 'course-authoring.library-authoring.sidebar.title.add-content',
55+
defaultMessage: 'Add Content',
56+
description: 'Title of add content in library container.',
57+
},
5358
});
5459

5560
export default messages;

src/library-authoring/common/context.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
/* eslint-disable react/require-default-props */
22
import React from 'react';
33

4-
enum SidebarBodyComponentId {
4+
export enum SidebarBodyComponentId {
55
AddContent = 'add-content',
6+
Info = 'info',
67
}
78

89
export interface LibraryContextData {
910
sidebarBodyComponent: SidebarBodyComponentId | null;
1011
closeLibrarySidebar: () => void;
1112
openAddContentSidebar: () => void;
13+
openInfoSidebar: () => void;
1214
}
1315

1416
export const LibraryContext = React.createContext({
1517
sidebarBodyComponent: null,
1618
closeLibrarySidebar: () => {},
1719
openAddContentSidebar: () => {},
20+
openInfoSidebar: () => {},
1821
} as LibraryContextData);
1922

2023
/**
@@ -25,12 +28,19 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
2528

2629
const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
2730
const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []);
31+
const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []);
2832

2933
const context = React.useMemo(() => ({
3034
sidebarBodyComponent,
3135
closeLibrarySidebar,
3236
openAddContentSidebar,
33-
}), [sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar]);
37+
openInfoSidebar,
38+
}), [
39+
sidebarBodyComponent,
40+
closeLibrarySidebar,
41+
openAddContentSidebar,
42+
openInfoSidebar,
43+
]);
3444

3545
return (
3646
<LibraryContext.Provider value={context}>

src/library-authoring/data/api.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import MockAdapter from 'axios-mock-adapter';
22
import { initializeMockApp } from '@edx/frontend-platform';
33
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
4-
import { createLibraryBlock, getCreateLibraryBlockUrl } from './api';
4+
import {
5+
commitLibraryChanges,
6+
createLibraryBlock,
7+
getCommitLibraryChangesUrl,
8+
getCreateLibraryBlockUrl,
9+
revertLibraryChanges,
10+
} from './api';
511

612
let axiosMock;
713

@@ -21,6 +27,7 @@ describe('library api calls', () => {
2127

2228
afterEach(() => {
2329
jest.clearAllMocks();
30+
axiosMock.restore();
2431
});
2532

2633
it('should create library block', async () => {
@@ -35,4 +42,24 @@ describe('library api calls', () => {
3542

3643
expect(axiosMock.history.post[0].url).toEqual(url);
3744
});
45+
46+
it('should commit library changes', async () => {
47+
const libraryId = 'lib:org:1';
48+
const url = getCommitLibraryChangesUrl(libraryId);
49+
axiosMock.onPost(url).reply(200);
50+
51+
await commitLibraryChanges(libraryId);
52+
53+
expect(axiosMock.history.post[0].url).toEqual(url);
54+
});
55+
56+
it('should revert library changes', async () => {
57+
const libraryId = 'lib:org:1';
58+
const url = getCommitLibraryChangesUrl(libraryId);
59+
axiosMock.onDelete(url).reply(200);
60+
61+
await revertLibraryChanges(libraryId);
62+
63+
expect(axiosMock.history.delete[0].url).toEqual(url);
64+
});
3865
});

0 commit comments

Comments
 (0)