Skip to content

Commit dd7e4d4

Browse files
authored
feat: add component sidebar manage tab [FC-0062] (#1275)
1 parent 902853d commit dd7e4d4

File tree

17 files changed

+512
-196
lines changed

17 files changed

+512
-196
lines changed

src/library-authoring/component-info/ComponentInfo.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Link } from 'react-router-dom';
1111
import { getEditUrl } from '../components/utils';
1212
import { ComponentMenu } from '../components';
1313
import { ComponentDeveloperInfo } from './ComponentDeveloperInfo';
14+
import ComponentManagement from './ComponentManagement';
1415
import ComponentPreview from './ComponentPreview';
1516
import messages from './messages';
1617

@@ -46,7 +47,7 @@ const ComponentInfo = ({ usageKey }: ComponentInfoProps) => {
4647
<ComponentPreview usageKey={usageKey} />
4748
</Tab>
4849
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
49-
Manage tab placeholder
50+
<ComponentManagement usageKey={usageKey} />
5051
</Tab>
5152
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
5253
Details tab placeholder

src/library-authoring/component-info/ComponentInfoHeader.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import React from 'react';
21
import MockAdapter from 'axios-mock-adapter';
32
import { IntlProvider } from '@edx/frontend-platform/i18n';
43
import { AppProvider } from '@edx/frontend-platform/react';
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { setConfig, getConfig } from '@edx/frontend-platform';
2+
3+
import {
4+
initializeMocks,
5+
render,
6+
screen,
7+
} from '../../testUtils';
8+
import { mockLibraryBlockMetadata } from '../data/api.mocks';
9+
import ComponentManagement from './ComponentManagement';
10+
11+
/*
12+
* FIXME: Summarize the reason here
13+
* https://stackoverflow.com/questions/47902335/innertext-is-undefined-in-jest-test
14+
*/
15+
const getInnerText = (element: Element) => element?.textContent
16+
?.split('\n')
17+
.filter((text) => text && !text.match(/^\s+$/))
18+
.map((text) => text.trim())
19+
.join(' ');
20+
21+
const matchInnerText = (nodeName: string, textToMatch: string) => (_: string, element: Element) => (
22+
element.nodeName === nodeName && getInnerText(element) === textToMatch
23+
);
24+
25+
describe('<ComponentManagement />', () => {
26+
it('should render draft status', async () => {
27+
initializeMocks();
28+
mockLibraryBlockMetadata.applyMock();
29+
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
30+
expect(await screen.findByText('Draft')).toBeInTheDocument();
31+
expect(await screen.findByText('(Never Published)')).toBeInTheDocument();
32+
expect(screen.getByText(matchInnerText('SPAN', 'Draft saved on June 20, 2024 at 13:54 UTC.'))).toBeInTheDocument();
33+
});
34+
35+
it('should render published status', async () => {
36+
initializeMocks();
37+
mockLibraryBlockMetadata.applyMock();
38+
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyPublished} />);
39+
expect(await screen.findByText('Published')).toBeInTheDocument();
40+
expect(screen.getByText('Published')).toBeInTheDocument();
41+
expect(
42+
screen.getByText(matchInnerText('SPAN', 'Last published on June 21, 2024 at 24:00 UTC by Luke.')),
43+
).toBeInTheDocument();
44+
});
45+
46+
it('should render the tagging info', async () => {
47+
setConfig({
48+
...getConfig(),
49+
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
50+
});
51+
initializeMocks();
52+
mockLibraryBlockMetadata.applyMock();
53+
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
54+
expect(await screen.findByText('Tags')).toBeInTheDocument();
55+
// TODO: replace with actual data when implement tag list
56+
expect(screen.queryByText('Tags placeholder')).toBeInTheDocument();
57+
});
58+
59+
it('should not render draft status', async () => {
60+
setConfig({
61+
...getConfig(),
62+
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
63+
});
64+
initializeMocks();
65+
mockLibraryBlockMetadata.applyMock();
66+
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
67+
expect(await screen.findByText('Draft')).toBeInTheDocument();
68+
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
69+
});
70+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { getConfig } from '@edx/frontend-platform';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { Collapsible, Icon, Stack } from '@openedx/paragon';
4+
import { Tag } from '@openedx/paragon/icons';
5+
6+
import { useLibraryBlockMetadata } from '../data/apiHooks';
7+
import StatusWidget from '../generic/status-widget';
8+
import messages from './messages';
9+
10+
interface ComponentManagementProps {
11+
usageKey: string;
12+
}
13+
const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
14+
const intl = useIntl();
15+
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
16+
17+
if (!componentMetadata) {
18+
return null;
19+
}
20+
21+
return (
22+
<Stack gap={3}>
23+
<StatusWidget
24+
{...componentMetadata}
25+
/>
26+
{[true, 'true'].includes(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES)
27+
&& (
28+
<Collapsible
29+
defaultOpen
30+
title={(
31+
<Stack gap={1} direction="horizontal">
32+
<Icon src={Tag} />
33+
{intl.formatMessage(messages.manageTabTagsTitle)}
34+
</Stack>
35+
)}
36+
className="border-0"
37+
>
38+
Tags placeholder
39+
</Collapsible>
40+
)}
41+
<Collapsible
42+
defaultOpen
43+
title={(
44+
<Stack gap={1} direction="horizontal">
45+
<Icon src={Tag} />
46+
{intl.formatMessage(messages.manageTabCollectionsTitle)}
47+
</Stack>
48+
)}
49+
className="border-0"
50+
>
51+
Collections placeholder
52+
</Collapsible>
53+
</Stack>
54+
);
55+
};
56+
57+
export default ComponentManagement;

src/library-authoring/component-info/messages.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ const messages = defineMessages({
3636
defaultMessage: 'Manage',
3737
description: 'Title for manage tab',
3838
},
39+
manageTabTagsTitle: {
40+
id: 'course-authoring.library-authoring.component.manage-tab.tags-title',
41+
defaultMessage: 'Tags',
42+
description: 'Title for the Tags container in the management tab',
43+
},
44+
manageTabCollectionsTitle: {
45+
id: 'course-authoring.library-authoring.component.manage-tab.collections-title',
46+
defaultMessage: 'Collections',
47+
description: 'Title for the Collections container in the management tab',
48+
},
3949
detailsTabTitle: {
4050
id: 'course-authoring.library-authoring.component.details-tab.title',
4151
defaultMessage: 'Details',

src/library-authoring/components/LibraryComponents.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import React from 'react';
21
import { AppProvider } from '@edx/frontend-platform/react';
32
import { initializeMockApp } from '@edx/frontend-platform';
43
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

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

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,26 @@ mockCreateLibraryBlock.newHtmlData = {
126126
blockType: 'html',
127127
displayName: 'New Text Component',
128128
hasUnpublishedChanges: true,
129+
lastPublished: null, // or e.g. '2024-08-30T16:37:42Z',
130+
publishedBy: null, // or e.g. 'test_author',
131+
lastDraftCreated: '2024-07-22T21:37:49Z',
132+
lastDraftCreatedBy: null,
133+
created: '2024-07-22T21:37:49Z',
129134
tagsCount: 0,
130-
} satisfies api.CreateBlockDataResponse;
135+
} satisfies api.LibraryBlockMetadata;
131136
mockCreateLibraryBlock.newProblemData = {
132137
id: 'lb:Axim:TEST:problem:prob1',
133138
defKey: 'prob1',
134139
blockType: 'problem',
135140
displayName: 'New Problem',
136141
hasUnpublishedChanges: true,
142+
lastPublished: null, // or e.g. '2024-08-30T16:37:42Z',
143+
publishedBy: null, // or e.g. 'test_author',
144+
lastDraftCreated: '2024-07-22T21:37:49Z',
145+
lastDraftCreatedBy: null,
146+
created: '2024-07-22T21:37:49Z',
137147
tagsCount: 0,
138-
} satisfies api.CreateBlockDataResponse;
148+
} satisfies api.LibraryBlockMetadata;
139149
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
140150
mockCreateLibraryBlock.applyMock = () => (
141151
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
@@ -172,3 +182,49 @@ mockXBlockFields.dataNewHtml = {
172182
} satisfies api.XBlockFields;
173183
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
174184
mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields);
185+
186+
/**
187+
* Mock for `getLibraryBlockMetadata()`
188+
*
189+
* This mock returns different data/responses depending on the ID of the block
190+
* that you request. Use `mockLibraryBlockMetadata.applyMock()` to apply it to the whole
191+
* test suite.
192+
*/
193+
export async function mockLibraryBlockMetadata(usageKey: string): Promise<api.LibraryBlockMetadata> {
194+
const thisMock = mockLibraryBlockMetadata;
195+
switch (usageKey) {
196+
case thisMock.usageKeyNeverPublished: return thisMock.dataNeverPublished;
197+
case thisMock.usageKeyPublished: return thisMock.dataPublished;
198+
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
199+
}
200+
}
201+
mockLibraryBlockMetadata.usageKeyNeverPublished = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
202+
mockLibraryBlockMetadata.dataNeverPublished = {
203+
id: 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1',
204+
defKey: null,
205+
blockType: 'html',
206+
displayName: 'Introduction to Testing 1',
207+
lastPublished: null,
208+
publishedBy: null,
209+
lastDraftCreated: null,
210+
lastDraftCreatedBy: null,
211+
hasUnpublishedChanges: false,
212+
created: '2024-06-20T13:54:21Z',
213+
tagsCount: 0,
214+
} satisfies api.LibraryBlockMetadata;
215+
mockLibraryBlockMetadata.usageKeyPublished = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
216+
mockLibraryBlockMetadata.dataPublished = {
217+
id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2',
218+
defKey: null,
219+
blockType: 'html',
220+
displayName: 'Introduction to Testing 2',
221+
lastPublished: '2024-06-21T00:00:00',
222+
publishedBy: 'Luke',
223+
lastDraftCreated: null,
224+
lastDraftCreatedBy: '2024-06-20T20:00:00Z',
225+
hasUnpublishedChanges: false,
226+
created: '2024-06-20T13:54:21Z',
227+
tagsCount: 0,
228+
} satisfies api.LibraryBlockMetadata;
229+
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
230+
mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);

src/library-authoring/data/api.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl()
1818
*/
1919
export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;
2020

21+
/**
22+
* Get the URL for library block metadata.
23+
*/
24+
export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/`;
25+
26+
/**
27+
* Get the URL for content library list API.
28+
*/
2129
export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`;
2230

2331
/**
@@ -110,12 +118,17 @@ export interface CreateBlockDataRequest {
110118
definitionId: string;
111119
}
112120

113-
export interface CreateBlockDataResponse {
121+
export interface LibraryBlockMetadata {
114122
id: string;
115123
blockType: string;
116124
defKey: string | null;
117125
displayName: string;
126+
lastPublished: string | null;
127+
publishedBy: string | null,
128+
lastDraftCreated: string | null,
129+
lastDraftCreatedBy: string | null,
118130
hasUnpublishedChanges: boolean;
131+
created: string | null,
119132
tagsCount: number;
120133
}
121134

@@ -166,7 +179,7 @@ export async function createLibraryBlock({
166179
libraryId,
167180
blockType,
168181
definitionId,
169-
}: CreateBlockDataRequest): Promise<CreateBlockDataResponse> {
182+
}: CreateBlockDataRequest): Promise<LibraryBlockMetadata> {
170183
const client = getAuthenticatedHttpClient();
171184
const { data } = await client.post(
172185
getCreateLibraryBlockUrl(libraryId),
@@ -229,7 +242,7 @@ export async function revertLibraryChanges(libraryId: string) {
229242
export async function libraryPasteClipboard({
230243
libraryId,
231244
blockId,
232-
}: LibraryPasteClipboardRequest): Promise<CreateBlockDataResponse> {
245+
}: LibraryPasteClipboardRequest): Promise<LibraryBlockMetadata> {
233246
const client = getAuthenticatedHttpClient();
234247
const { data } = await client.post(
235248
getLibraryPasteClipboardUrl(libraryId),
@@ -240,6 +253,14 @@ export async function libraryPasteClipboard({
240253
return data;
241254
}
242255

256+
/**
257+
* Fetch library block metadata.
258+
*/
259+
export async function getLibraryBlockMetadata(usageKey: string): Promise<LibraryBlockMetadata> {
260+
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockMetadataUrl(usageKey));
261+
return camelCaseObject(data);
262+
}
263+
243264
/**
244265
* Fetch xblock fields.
245266
*/

src/library-authoring/data/apiHooks.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
revertLibraryChanges,
2222
updateLibraryMetadata,
2323
libraryPasteClipboard,
24+
getLibraryBlockMetadata,
2425
getXBlockFields,
2526
updateXBlockFields,
2627
createCollection,
@@ -72,6 +73,7 @@ export const xblockQueryKeys = {
7273
xblockFields: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'fields'],
7374
/** OLX (XML representation of the fields/content) */
7475
xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'],
76+
componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'],
7577
};
7678

7779
/**
@@ -197,6 +199,13 @@ export const useLibraryPasteClipboard = () => {
197199
});
198200
};
199201

202+
export const useLibraryBlockMetadata = (usageId: string) => (
203+
useQuery({
204+
queryKey: xblockQueryKeys.componentMetadata(usageId),
205+
queryFn: () => getLibraryBlockMetadata(usageId),
206+
})
207+
);
208+
200209
export const useXBlockFields = (usageKey: string) => (
201210
useQuery({
202211
queryKey: xblockQueryKeys.xblockFields(usageKey),
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@import "./status-widget/StatusWidget";

0 commit comments

Comments
 (0)