Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add component sidebar manage tab [FC-0062] #1275

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/library-authoring/component-info/ComponentInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Link } from 'react-router-dom';
import { getEditUrl } from '../components/utils';
import { ComponentMenu } from '../components';
import { ComponentDeveloperInfo } from './ComponentDeveloperInfo';
import ComponentManagement from './ComponentManagement';
import ComponentPreview from './ComponentPreview';
import messages from './messages';

Expand Down Expand Up @@ -46,7 +47,7 @@ const ComponentInfo = ({ usageKey }: ComponentInfoProps) => {
<ComponentPreview usageKey={usageKey} />
</Tab>
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
Manage tab placeholder
<ComponentManagement usageKey={usageKey} />
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
Details tab placeholder
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { setConfig, getConfig } from '@edx/frontend-platform';

import {
initializeMocks,
render,
screen,
} from '../../testUtils';
import { mockLibraryBlockMetadata } from '../data/api.mocks';
import ComponentManagement from './ComponentManagement';

/*
* FIXME: Summarize the reason here
* https://stackoverflow.com/questions/47902335/innertext-is-undefined-in-jest-test
*/
const getInnerText = (element: Element) => element?.textContent
?.split('\n')
.filter((text) => text && !text.match(/^\s+$/))
.map((text) => text.trim())
.join(' ');

const matchInnerText = (nodeName: string, textToMatch: string) => (_: string, element: Element) => (
element.nodeName === nodeName && getInnerText(element) === textToMatch
);

describe('<ComponentManagement />', () => {
it('should render draft status', async () => {
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
expect(await screen.findByText('Draft')).toBeInTheDocument();
expect(await screen.findByText('(Never Published)')).toBeInTheDocument();
expect(screen.getByText(matchInnerText('SPAN', 'Draft saved on June 20, 2024 at 13:54 UTC.'))).toBeInTheDocument();
});

it('should render published status', async () => {
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyPublished} />);
expect(await screen.findByText('Published')).toBeInTheDocument();
expect(screen.getByText('Published')).toBeInTheDocument();
expect(
screen.getByText(matchInnerText('SPAN', 'Last published on June 21, 2024 at 24:00 UTC by Luke.')),
).toBeInTheDocument();
});

it('should render the tagging info', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
expect(await screen.findByText('Tags')).toBeInTheDocument();
// TODO: replace with actual data when implement tag list
expect(screen.queryByText('Tags placeholder')).toBeInTheDocument();
});

it('should not render draft status', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
});
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
expect(await screen.findByText('Draft')).toBeInTheDocument();
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test for the component published state too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: 98ebc92

});
57 changes: 57 additions & 0 deletions src/library-authoring/component-info/ComponentManagement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon, Stack } from '@openedx/paragon';
import { Tag } from '@openedx/paragon/icons';

import { useLibraryBlockMetadata } from '../data/apiHooks';
import StatusWidget from '../generic/status-widget';
import messages from './messages';

interface ComponentManagementProps {
usageKey: string;
}
const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
const intl = useIntl();
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);

if (!componentMetadata) {
return null;
}

return (
<Stack gap={3}>
<StatusWidget
{...componentMetadata}
/>
{[true, 'true'].includes(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES)
&& (
<Collapsible
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test that enables tagging to verify this code path? It's enough to just check for the presence + absence of "Tags" title, until we get that feature implemented.

Would be great if you could check for the Collections title too

defaultOpen
title={(
<Stack gap={1} direction="horizontal">
<Icon src={Tag} />
{intl.formatMessage(messages.manageTabTagsTitle)}
</Stack>
)}
className="border-0"
>
Tags placeholder
</Collapsible>
)}
<Collapsible
defaultOpen
title={(
<Stack gap={1} direction="horizontal">
<Icon src={Tag} />
{intl.formatMessage(messages.manageTabCollectionsTitle)}
</Stack>
)}
className="border-0"
>
Collections placeholder
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is showing the component's Collections out of scope here? Thanks to your suggestions, we should have data available once edx-platform#35469 merges.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will create a follow up tasks to handle the collections.

</Collapsible>
</Stack>
);
};

export default ComponentManagement;
10 changes: 10 additions & 0 deletions src/library-authoring/component-info/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ const messages = defineMessages({
defaultMessage: 'Manage',
description: 'Title for manage tab',
},
manageTabTagsTitle: {
id: 'course-authoring.library-authoring.component.manage-tab.tags-title',
defaultMessage: 'Tags',
description: 'Title for the Tags container in the management tab',
},
manageTabCollectionsTitle: {
id: 'course-authoring.library-authoring.component.manage-tab.collections-title',
defaultMessage: 'Collections',
description: 'Title for the Collections container in the management tab',
},
detailsTabTitle: {
id: 'course-authoring.library-authoring.component.details-tab.title',
defaultMessage: 'Details',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
Expand Down
60 changes: 58 additions & 2 deletions src/library-authoring/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,26 @@ mockCreateLibraryBlock.newHtmlData = {
blockType: 'html',
displayName: 'New Text Component',
hasUnpublishedChanges: true,
lastPublished: null, // or e.g. '2024-08-30T16:37:42Z',
publishedBy: null, // or e.g. 'test_author',
lastDraftCreated: '2024-07-22T21:37:49Z',
lastDraftCreatedBy: null,
created: '2024-07-22T21:37:49Z',
tagsCount: 0,
} satisfies api.CreateBlockDataResponse;
} satisfies api.LibraryBlockMetadata;
mockCreateLibraryBlock.newProblemData = {
id: 'lb:Axim:TEST:problem:prob1',
defKey: 'prob1',
blockType: 'problem',
displayName: 'New Problem',
hasUnpublishedChanges: true,
lastPublished: null, // or e.g. '2024-08-30T16:37:42Z',
publishedBy: null, // or e.g. 'test_author',
lastDraftCreated: '2024-07-22T21:37:49Z',
lastDraftCreatedBy: null,
created: '2024-07-22T21:37:49Z',
tagsCount: 0,
} satisfies api.CreateBlockDataResponse;
} satisfies api.LibraryBlockMetadata;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockCreateLibraryBlock.applyMock = () => (
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
Expand Down Expand Up @@ -172,3 +182,49 @@ mockXBlockFields.dataNewHtml = {
} satisfies api.XBlockFields;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields);

/**
* Mock for `getLibraryBlockMetadata()`
*
* This mock returns different data/responses depending on the ID of the block
* that you request. Use `mockLibraryBlockMetadata.applyMock()` to apply it to the whole
* test suite.
*/
export async function mockLibraryBlockMetadata(usageKey: string): Promise<api.LibraryBlockMetadata> {
const thisMock = mockLibraryBlockMetadata;
switch (usageKey) {
case thisMock.usageKeyNeverPublished: return thisMock.dataNeverPublished;
case thisMock.usageKeyPublished: return thisMock.dataPublished;
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
}
}
mockLibraryBlockMetadata.usageKeyNeverPublished = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
mockLibraryBlockMetadata.dataNeverPublished = {
id: 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1',
defKey: null,
blockType: 'html',
displayName: 'Introduction to Testing 1',
lastPublished: null,
publishedBy: null,
lastDraftCreated: null,
lastDraftCreatedBy: null,
hasUnpublishedChanges: false,
created: '2024-06-20T13:54:21Z',
tagsCount: 0,
} satisfies api.LibraryBlockMetadata;
mockLibraryBlockMetadata.usageKeyPublished = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
mockLibraryBlockMetadata.dataPublished = {
id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2',
defKey: null,
blockType: 'html',
displayName: 'Introduction to Testing 2',
lastPublished: '2024-06-21T00:00:00',
publishedBy: 'Luke',
lastDraftCreated: null,
lastDraftCreatedBy: '2024-06-20T20:00:00Z',
hasUnpublishedChanges: false,
created: '2024-06-20T13:54:21Z',
tagsCount: 0,
} satisfies api.LibraryBlockMetadata;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);
27 changes: 24 additions & 3 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
*/
export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;

/**
* Get the URL for library block metadata.
*/
export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/`;

/**
* Get the URL for content library list API.
*/
export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`;

/**
Expand Down Expand Up @@ -110,12 +118,17 @@
definitionId: string;
}

export interface CreateBlockDataResponse {
export interface LibraryBlockMetadata {
id: string;
blockType: string;
defKey: string | null;
displayName: string;
lastPublished: string | null;
publishedBy: string | null,
lastDraftCreated: string | null,
lastDraftCreatedBy: string | null,
hasUnpublishedChanges: boolean;
created: string | null,
tagsCount: number;
}

Expand Down Expand Up @@ -166,7 +179,7 @@
libraryId,
blockType,
definitionId,
}: CreateBlockDataRequest): Promise<CreateBlockDataResponse> {
}: CreateBlockDataRequest): Promise<LibraryBlockMetadata> {
const client = getAuthenticatedHttpClient();
const { data } = await client.post(
getCreateLibraryBlockUrl(libraryId),
Expand Down Expand Up @@ -229,7 +242,7 @@
export async function libraryPasteClipboard({
libraryId,
blockId,
}: LibraryPasteClipboardRequest): Promise<CreateBlockDataResponse> {
}: LibraryPasteClipboardRequest): Promise<LibraryBlockMetadata> {
const client = getAuthenticatedHttpClient();
const { data } = await client.post(
getLibraryPasteClipboardUrl(libraryId),
Expand All @@ -240,6 +253,14 @@
return data;
}

/**
* Fetch library block metadata.
*/
export async function getLibraryBlockMetadata(usageKey: string): Promise<LibraryBlockMetadata> {
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockMetadataUrl(usageKey));
return camelCaseObject(data);

Check warning on line 261 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L261

Added line #L261 was not covered by tests
}

/**
* Fetch xblock fields.
*/
Expand Down
9 changes: 9 additions & 0 deletions src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
revertLibraryChanges,
updateLibraryMetadata,
libraryPasteClipboard,
getLibraryBlockMetadata,
getXBlockFields,
updateXBlockFields,
createCollection,
Expand Down Expand Up @@ -72,6 +73,7 @@ export const xblockQueryKeys = {
xblockFields: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'fields'],
/** OLX (XML representation of the fields/content) */
xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'],
componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'],
};

/**
Expand Down Expand Up @@ -197,6 +199,13 @@ export const useLibraryPasteClipboard = () => {
});
};

export const useLibraryBlockMetadata = (usageId: string) => (
useQuery({
queryKey: xblockQueryKeys.componentMetadata(usageId),
queryFn: () => getLibraryBlockMetadata(usageId),
})
);

export const useXBlockFields = (usageKey: string) => (
useQuery({
queryKey: xblockQueryKeys.xblockFields(usageKey),
Expand Down
1 change: 1 addition & 0 deletions src/library-authoring/generic/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "./status-widget/StatusWidget";
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.library-publish-status {
.status-widget {
&.draft-status {
background-color: #FDF3E9;
border-top: 4px solid #F4B57B;
Expand All @@ -9,3 +9,4 @@
border-top: 4px solid $info-400;
}
}

Loading