[FC-0036] Tags Sidebar (#852)

* refactor: Unit sidebar to create the TagsSidebar

* feat: Structure of TagsSidebar and TagsTree

* feat: Adding styles to the TagsTree

* feat: TagsSidebarHeader created

* feat: Add count on TagsSidebarHeader

* test: Tests for new components added

* style: Update tags count with opacity when the count is zero

* refactor: Extract tag count component as generic

* refactor: Transform Sidebar to a wrapper component

---------

Co-authored-by: Rômulo Penido <romulo@opencraft.com>
This commit is contained in:
Chris Chávez
2024-03-15 11:29:28 -05:00
committed by GitHub
parent 6ae9cdac00
commit d57ecc6779
29 changed files with 667 additions and 101 deletions

2
package-lock.json generated
View File

@@ -54,7 +54,7 @@
"react-responsive": "9.0.2",
"react-router": "6.16.0",
"react-router-dom": "6.16.0",
"react-select": "^5.8.0",
"react-select": "5.8.0",
"react-textarea-autosize": "^8.4.1",
"react-transition-group": "4.4.5",
"redux": "4.0.5",

View File

@@ -31,15 +31,18 @@ import Loading from '../generic/Loading';
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
* Functions to close the drawer are handled internally.
* TODO: We can delete this method when is no longer used on edx-platform.
* - If you want to use it as react component, you need to pass the content id and the close functions
* through the component parameters.
*/
const ContentTagsDrawer = ({ id, onClose }) => {
const intl = useIntl();
// TODO: We can delete this when the iframe is no longer used on edx-platform
const params = useParams();
let contentId = id;
if (contentId === undefined) {
// TODO: We can delete this when the iframe is no longer used on edx-platform
contentId = params.contentId;
}

View File

@@ -0,0 +1,3 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20,
};

View File

@@ -0,0 +1,35 @@
module.exports = {
'hierarchical taxonomy tag 1': {
children: {
'hierarchical taxonomy tag 1.7': {
children: {
'hierarchical taxonomy tag 1.7.59': {
children: {},
},
},
},
},
},
'hierarchical taxonomy tag 2': {
children: {
'hierarchical taxonomy tag 2.13': {
children: {
'hierarchical taxonomy tag 2.13.46': {
children: {},
},
},
},
},
},
'hierarchical taxonomy tag 3': {
children: {
'hierarchical taxonomy tag 3.4': {
children: {
'hierarchical taxonomy tag 3.4.50': {
children: {},
},
},
},
},
},
};

View File

@@ -2,3 +2,5 @@ export { default as taxonomyTagsMock } from './taxonomyTagsMock';
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
export { default as contentDataMock } from './contentDataMock';
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';
export { default as contentTaxonomyTagsCountMock } from './contentTaxonomyTagsCountMock';
export { default as contentTaxonomyTagsTreeMock } from './contentTaxonomyTagsTreeMock';

View File

@@ -31,6 +31,7 @@ export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href;
/**
* Get all tags that belong to taxonomy.
@@ -54,6 +55,19 @@ export async function getContentTaxonomyTagsData(contentId) {
return camelCaseObject(data[contentId]);
}
/**
* Get the count of tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the count of the applied tags for
* @returns {Promise<number>}
*/
export async function getContentTaxonomyTagsCount(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsCountApiUrl(contentId));
if (contentId in data) {
return camelCaseObject(data[contentId]);
}
return 0;
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
* @param {string} contentId The id of the content object (unit/component)

View File

@@ -6,6 +6,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
taxonomyTagsMock,
contentTaxonomyTagsMock,
contentTaxonomyTagsCountMock,
contentDataMock,
updateContentTaxonomyTagsMock,
} from '../__mocks__';
@@ -19,6 +20,8 @@ import {
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
getContentTaxonomyTagsCountApiUrl,
getContentTaxonomyTagsCount,
} from './api';
let axiosMock;
@@ -88,6 +91,24 @@ describe('content tags drawer api calls', () => {
expect(result).toEqual(contentTaxonomyTagsMock[contentId]);
});
it('should get content taxonomy tags count', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, contentTaxonomyTagsCountMock);
const result = await getContentTaxonomyTagsCount(contentId);
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId));
expect(result).toEqual(contentTaxonomyTagsCountMock[contentId]);
});
it('should get content taxonomy tags count as zero', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, {});
const result = await getContentTaxonomyTagsCount(contentId);
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId));
expect(result).toEqual(0);
});
it('should get content data for course component', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock);

View File

@@ -11,6 +11,7 @@ import {
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
@@ -105,6 +106,17 @@ export const useContentTaxonomyTagsData = (contentId) => (
})
);
/**
* Build the query to get the count og taxonomy tags applied to the content object
* @param {string} contentId The ID of the content object to fetch the count of the applied tags for
*/
export const useContentTaxonomyTagsCount = (contentId) => (
useQuery({
queryKey: ['contentTaxonomyTagsCount', contentId],
queryFn: () => getContentTaxonomyTagsCount(contentId),
})
);
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
@@ -139,6 +151,7 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
/// Invalidate query with pattern on course outline
queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] });
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTagsCount', contentId] });
},
});
};

View File

@@ -6,6 +6,7 @@ import {
useContentTaxonomyTagsData,
useContentData,
useContentTaxonomyTagsUpdater,
useContentTaxonomyTagsCount,
} from './apiHooks';
import { updateContentTaxonomyTags } from './api';
@@ -134,6 +135,24 @@ describe('useContentTaxonomyTagsData', () => {
});
});
describe('useContentTaxonomyTagsCount', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
const result = useContentTaxonomyTagsCount(contentId);
expect(result).toEqual({ isSuccess: true, data: 'data' });
});
it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
const result = useContentTaxonomyTagsCount(contentId);
expect(result).toEqual({ isSuccess: false });
});
});
describe('useContentData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });

View File

@@ -0,0 +1,2 @@
@import "content-tags-drawer/TagBubble";
@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls";

View File

@@ -33,6 +33,16 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
defaultMessage: 'taxonomy tags selection',
},
manageTagsButton: {
id: 'course-authoring.content-tags-drawer.button.manage',
defaultMessage: 'Manage Tags',
description: 'Label in the button that opens the drawer to edit content tags',
},
tagsSidebarTitle: {
id: 'course-authoring.course-unit.sidebar.tags.title',
defaultMessage: 'Unit Tags',
description: 'Title of the tags sidebar',
},
collapsibleAddTagsPlaceholderText: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.placeholder-text',
defaultMessage: 'Add a tag',

View File

@@ -0,0 +1,112 @@
// @ts-check
import React, { useState, useMemo } from 'react';
import {
Card, Stack, Button, Sheet, Collapsible, Icon,
} from '@openedx/paragon';
import { ArrowDropDown, ArrowDropUp } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import { ContentTagsDrawer } from '..';
import messages from '../messages';
import { useContentTaxonomyTagsData } from '../data/apiHooks';
import { LoadingSpinner } from '../../generic/Loading';
import TagsTree from './TagsTree';
const TagsSidebarBody = () => {
const intl = useIntl();
const [showManageTags, setShowManageTags] = useState(false);
const contentId = useParams().blockId;
const onClose = () => setShowManageTags(false);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId || '');
const buildTagsTree = (contentTags) => {
const resultTree = {};
contentTags.forEach(item => {
let currentLevel = resultTree;
item.lineage.forEach((key) => {
if (!currentLevel[key]) {
currentLevel[key] = {
children: {},
canChangeObjecttag: item.canChangeObjecttag,
canDeleteObjecttag: item.canDeleteObjecttag,
};
}
currentLevel = currentLevel[key].children;
});
});
return resultTree;
};
const tree = useMemo(() => {
const result = [];
if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) {
contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => {
result.push({
...taxonomy,
tags: buildTagsTree(taxonomy.tags),
});
});
}
return result;
}, [isContentTaxonomyTagsLoaded, contentTaxonomyTagsData]);
return (
<>
<Card.Body
className="course-unit-sidebar-date tags-sidebar-body pl-2.5"
>
<Stack>
{ isContentTaxonomyTagsLoaded
? (
<Stack>
{tree.map((taxonomy) => (
<div key={taxonomy.name}>
<Collapsible
className="tags-sidebar-taxonomy border-0 .font-weight-bold"
styling="card"
title={taxonomy.name}
iconWhenClosed={<Icon src={ArrowDropDown} />}
iconWhenOpen={<Icon src={ArrowDropUp} />}
>
<TagsTree tags={taxonomy.tags} parentKey={taxonomy.name} />
</Collapsible>
</div>
))}
</Stack>
)
: (
<div className="d-flex justify-content-center">
<LoadingSpinner />
</div>
)}
<Button className="mt-3 ml-2" variant="outline-primary" size="sm" onClick={() => setShowManageTags(true)}>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
</Stack>
</Card.Body>
<Sheet
position="right"
show={showManageTags}
onClose={onClose}
>
<ContentTagsDrawer
id={contentId}
onClose={onClose}
/>
</Sheet>
</>
);
};
TagsSidebarBody.propTypes = {};
export default TagsSidebarBody;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import TagsSidebarBody from './TagsSidebarBody';
import { useContentTaxonomyTagsData } from '../data/apiHooks';
import { contentTaxonomyTagsMock } from '../__mocks__';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
jest.mock('../data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
}));
jest.mock('../ContentTagsDrawer', () => jest.fn(() => <div>Mocked ContentTagsDrawer</div>));
const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<TagsSidebarBody />
</IntlProvider>
);
describe('<TagSidebarBody>', () => {
it('shows spinner before the content data query is complete', () => {
render(<RootWrapper />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('should render data after wuery is complete', () => {
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: contentTaxonomyTagsMock[contentId],
});
render(<RootWrapper />);
const taxonomyButton = screen.getByRole('button', { name: /hierarchicaltaxonomy/i });
expect(taxonomyButton).toBeInTheDocument();
/// ContentTagsDrawer must be closed
expect(screen.queryByText('Mocked ContentTagsDrawer')).not.toBeInTheDocument();
});
it('should open ContentTagsDrawer', () => {
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: contentTaxonomyTagsMock[contentId],
});
render(<RootWrapper />);
const manageButton = screen.getByRole('button', { name: /manage tags/i });
fireEvent.click(manageButton);
expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,23 @@
.tags-sidebar {
.tags-sidebar-body {
.tags-sidebar-taxonomy {
.collapsible-trigger {
font-weight: bold;
border: none;
justify-content: start;
padding-left: 0;
padding-bottom: 0;
.collapsible-icon {
order: -1;
margin-left: 0;
}
}
.collapsible-body {
padding-top: 0;
padding-bottom: 0;
}
}
}
}

View File

@@ -0,0 +1,36 @@
// @ts-check
import React from 'react';
import { Stack } from '@openedx/paragon';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import { useContentTaxonomyTagsCount } from '../data/apiHooks';
import TagCount from '../../generic/tag-count';
const TagsSidebarHeader = () => {
const intl = useIntl();
const contentId = useParams().blockId;
const {
data: contentTaxonomyTagsCount,
isSuccess: isContentTaxonomyTagsCountLoaded,
} = useContentTaxonomyTagsCount(contentId || '');
return (
<Stack
className="course-unit-sidebar-header justify-content-between pb-1"
direction="horizontal"
>
<h3 className="course-unit-sidebar-header-title m-0">
{intl.formatMessage(messages.tagsSidebarTitle)}
</h3>
{ isContentTaxonomyTagsCountLoaded
&& <TagCount count={contentTaxonomyTagsCount} />}
</Stack>
);
};
TagsSidebarHeader.propTypes = {};
export default TagsSidebarHeader;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import TagsSidebarHeader from './TagsSidebarHeader';
import { useContentTaxonomyTagsCount } from '../data/apiHooks';
jest.mock('../data/apiHooks', () => ({
useContentTaxonomyTagsCount: jest.fn(() => ({
isSuccess: false,
data: 17,
})),
}));
const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<TagsSidebarHeader />
</IntlProvider>
);
describe('<TagsSidebarHeader>', () => {
it('should not render count on loading', () => {
render(<RootWrapper />);
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
expect(screen.queryByText('17')).not.toBeInTheDocument();
});
it('should render count after query is complete', () => {
useContentTaxonomyTagsCount.mockReturnValue({
isSuccess: true,
data: 17,
});
render(<RootWrapper />);
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
expect(screen.getByText('17')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,50 @@
// @ts-check
import React from 'react';
import PropTypes from 'prop-types';
import { Icon } from '@openedx/paragon';
import { Tag } from '@openedx/paragon/icons';
const TagsTree = ({ tags, rootDepth, parentKey }) => {
if (Object.keys(tags).length === 0) {
return null;
}
// Used to Generate tabs for the parents of this tree
const tabsNumberArray = Array.from({ length: rootDepth }, (_, index) => index + 1);
return (
<div className="tags-tree" key={parentKey}>
{Object.keys(tags).map((key) => (
<div className="mt-1.5 mb-1.5" key={key}>
<div className="d-flex pl-2.5" key={key}>
{
tabsNumberArray.map((index) => <span className="d-inline-block ml-4" key={`${key}-${index}`} />)
}
<Icon src={Tag} className="mr-1 pb-1.5 text-info-500" />{key}
</div>
{ tags[key].children
&& (
<TagsTree
tags={tags[key].children}
rootDepth={rootDepth + 1}
parentKey={key}
/>
)}
</div>
))}
</div>
);
};
TagsTree.propTypes = {
tags: PropTypes.shape({}).isRequired,
parentKey: PropTypes.string,
rootDepth: PropTypes.number,
};
TagsTree.defaultProps = {
rootDepth: 0,
parentKey: undefined,
};
export default TagsTree;

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import TagsTree from './TagsTree';
import { contentTaxonomyTagsTreeMock } from '../__mocks__';
describe('<TagsTree>', () => {
it('should render component and tags correctly', () => {
render(<TagsTree tags={contentTaxonomyTagsTreeMock} />);
expect(screen.getByText('hierarchical taxonomy tag 1')).toBeInTheDocument();
expect(screen.getByText('hierarchical taxonomy tag 2.13')).toBeInTheDocument();
expect(screen.getByText('hierarchical taxonomy tag 3.4.50')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,13 @@
import TagsSidebarHeader from './TagsSidebarHeader';
import TagsSidebarBody from './TagsSidebarBody';
const TagsSidebarControls = () => (
<>
<TagsSidebarHeader />
<TagsSidebarBody />
</>
);
TagsSidebarControls.propTypes = {};
export default TagsSidebarControls;

View File

@@ -24,6 +24,9 @@ import Sequence from './course-sequence';
import Sidebar from './sidebar';
import { useCourseUnit } from './hooks';
import messages from './messages';
import PublishControls from './sidebar/PublishControls';
import LocationInfo from './sidebar/LocationInfo';
import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
@@ -133,8 +136,15 @@ const CourseUnit = ({ courseId }) => {
</Layout.Element>
<Layout.Element>
<Stack gap={3}>
<Sidebar blockId={blockId} data-testid="course-unit-sidebar" />
<Sidebar displayUnitLocation data-testid="course-unit-location-sidebar" />
<Sidebar data-testid="course-unit-sidebar">
<PublishControls blockId={blockId} />
</Sidebar>
<Sidebar className="tags-sidebar">
<TagsSidebarControls />
</Sidebar>
<Sidebar data-testid="course-unit-location-sidebar">
<LocationInfo />
</Sidebar>
</Stack>
</Layout.Element>
</Layout>

View File

@@ -44,6 +44,7 @@ import deleteModalMessages from '../generic/delete-modal/messages';
import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
let axiosMock;
let store;
@@ -59,6 +60,31 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedUsedNavigate,
}));
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(({ queryKey }) => {
if (queryKey[0] === 'contentTaxonomyTags') {
return {
data: {
taxonomies: [],
},
isSuccess: true,
};
} if (queryKey[0] === 'contentTaxonomyTagsCount') {
return {
data: 17,
isSuccess: true,
};
}
return {
data: {},
isSuccess: true,
};
}),
useQueryClient: jest.fn(() => ({
setQueryData: jest.fn(),
})),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
@@ -92,6 +118,12 @@ describe('<CourseUnit />', () => {
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, courseVerticalChildrenMock);
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
axiosMock
.onGet(getContentTaxonomyTagsApiUrl(blockId))
.reply(200, {});
axiosMock
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
.reply(200, 17);
});
it('render CourseUnit component correctly', async () => {

View File

@@ -1,9 +1,9 @@
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import { Button } from '@openedx/paragon';
import { Plus as PlusIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNavigate } from 'react-router-dom';
import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice';
import { getCourseId, getSequenceId } from '../../data/selectors';

View File

@@ -0,0 +1,38 @@
import { useSelector } from 'react-redux';
import useCourseUnitData from './hooks';
import { getCourseUnitData } from '../data/selectors';
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
const LocationInfo = () => {
const {
title,
locationId,
releaseLabel,
visibilityState,
visibleToStaffOnly,
} = useCourseUnitData(useSelector(getCourseUnitData));
return (
<>
<SidebarHeader
title={title}
visibilityState={visibilityState}
displayUnitLocation
/>
<SidebarBody
locationId={locationId}
releaseLabel={releaseLabel}
displayUnitLocation
/>
<SidebarFooter
locationId={locationId}
visibleToStaffOnly={visibleToStaffOnly}
displayUnitLocation
/>
</>
);
};
LocationInfo.propTypes = {};
export default LocationInfo;

View File

@@ -0,0 +1,92 @@
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useToggle } from '@openedx/paragon';
import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import useCourseUnitData from './hooks';
import { editCourseUnitVisibilityAndData } from '../data/thunk';
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
import { PUBLISH_TYPES } from '../constants';
import { getCourseUnitData } from '../data/selectors';
import messages from './messages';
import ModalNotification from '../../generic/modal-notification';
const PublishControls = ({ blockId }) => {
const {
title,
locationId,
releaseLabel,
visibilityState,
visibleToStaffOnly,
} = useCourseUnitData(useSelector(getCourseUnitData));
const intl = useIntl();
const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false);
const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false);
const dispatch = useDispatch();
const handleCourseUnitVisibility = () => {
closeVisibleModal();
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null));
};
const handleCourseUnitDiscardChanges = () => {
closeDiscardModal();
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges));
};
const handleCourseUnitPublish = () => {
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic));
};
return (
<>
<SidebarHeader
title={title}
visibilityState={visibilityState}
/>
<SidebarBody
releaseLabel={releaseLabel}
visibleToStaffOnly={visibleToStaffOnly}
/>
<SidebarFooter
locationId={locationId}
openDiscardModal={openDiscardModal}
openVisibleModal={openVisibleModal}
handlePublishing={handleCourseUnitPublish}
visibleToStaffOnly={visibleToStaffOnly}
/>
<ModalNotification
title={intl.formatMessage(messages.modalDiscardUnitChangesTitle)}
isOpen={isDiscardModalOpen}
actionButtonText={intl.formatMessage(messages.modalDiscardUnitChangesActionButtonText)}
cancelButtonText={intl.formatMessage(messages.modalDiscardUnitChangesCancelButtonText)}
handleAction={handleCourseUnitDiscardChanges}
handleCancel={closeDiscardModal}
message={intl.formatMessage(messages.modalDiscardUnitChangesDescription)}
icon={InfoOutlineIcon}
/>
<ModalNotification
title={intl.formatMessage(messages.modalMakeVisibilityTitle)}
isOpen={isVisibleModalOpen}
actionButtonText={intl.formatMessage(messages.modalMakeVisibilityActionButtonText)}
cancelButtonText={intl.formatMessage(messages.modalMakeVisibilityCancelButtonText)}
handleAction={handleCourseUnitVisibility}
handleCancel={closeVisibleModal}
message={intl.formatMessage(messages.modalMakeVisibilityDescription)}
icon={InfoOutlineIcon}
/>
</>
);
};
PublishControls.propTypes = {
blockId: PropTypes.string,
};
PublishControls.defaultProps = {
blockId: null,
};
export default PublishControls;

View File

@@ -68,9 +68,9 @@
@extend %base-font-params;
}
}
&.is-stuff-only .course-unit-sidebar-date-and-with {
text-decoration: line-through;
&.is-stuff-only .course-unit-sidebar-date-and-with {
text-decoration: line-through;
}
}
}

View File

@@ -3,12 +3,18 @@ import { useSelector } from 'react-redux';
import { Card, Stack } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import { getCourseUnitData } from '../../data/selectors';
import { getPublishInfo } from '../utils';
import messages from '../messages';
import ReleaseInfoComponent from './ReleaseInfoComponent';
const SidebarBody = ({ releaseLabel, displayUnitLocation, locationId }) => {
const SidebarBody = ({
releaseLabel,
displayUnitLocation,
locationId,
visibleToStaffOnly,
}) => {
const intl = useIntl();
const {
editedOn,
@@ -19,7 +25,10 @@ const SidebarBody = ({ releaseLabel, displayUnitLocation, locationId }) => {
} = useSelector(getCourseUnitData);
return (
<Card.Body className="course-unit-sidebar-date">
<Card.Body className={classNames('course-unit-sidebar-date', {
'is-stuff-only': visibleToStaffOnly,
})}
>
<Stack>
{displayUnitLocation ? (
<span>
@@ -55,11 +64,13 @@ SidebarBody.propTypes = {
releaseLabel: PropTypes.string.isRequired,
displayUnitLocation: PropTypes.bool,
locationId: PropTypes.string,
visibleToStaffOnly: PropTypes.bool,
};
SidebarBody.defaultProps = {
displayUnitLocation: false,
locationId: null,
visibleToStaffOnly: false,
};
export default SidebarBody;

View File

@@ -1,102 +1,24 @@
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import classNames from 'classnames';
import { Card, useToggle } from '@openedx/paragon';
import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Card } from '@openedx/paragon';
import ModalNotification from '../../generic/modal-notification';
import { editCourseUnitVisibilityAndData } from '../data/thunk';
import { getCourseUnitData } from '../data/selectors';
import { PUBLISH_TYPES } from '../constants';
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
import useCourseUnitData from './hooks';
import messages from './messages';
const Sidebar = ({ blockId, displayUnitLocation, ...props }) => {
const {
title,
locationId,
releaseLabel,
visibilityState,
visibleToStaffOnly,
} = useCourseUnitData(useSelector(getCourseUnitData));
const intl = useIntl();
const dispatch = useDispatch();
const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false);
const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false);
const handleCourseUnitVisibility = () => {
closeVisibleModal();
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null));
};
const handleCourseUnitDiscardChanges = () => {
closeDiscardModal();
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges));
};
const handleCourseUnitPublish = () => {
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic));
};
return (
<Card
className={classNames('course-unit-sidebar', {
'is-stuff-only': visibleToStaffOnly,
})}
{...props}
>
<SidebarHeader
title={title}
visibilityState={visibilityState}
displayUnitLocation={displayUnitLocation}
/>
<SidebarBody
locationId={locationId}
releaseLabel={releaseLabel}
displayUnitLocation={displayUnitLocation}
/>
<SidebarFooter
locationId={locationId}
openDiscardModal={openDiscardModal}
openVisibleModal={openVisibleModal}
displayUnitLocation={displayUnitLocation}
handlePublishing={handleCourseUnitPublish}
visibleToStaffOnly={visibleToStaffOnly}
/>
<ModalNotification
title={intl.formatMessage(messages.modalDiscardUnitChangesTitle)}
isOpen={isDiscardModalOpen}
actionButtonText={intl.formatMessage(messages.modalDiscardUnitChangesActionButtonText)}
cancelButtonText={intl.formatMessage(messages.modalDiscardUnitChangesCancelButtonText)}
handleAction={handleCourseUnitDiscardChanges}
handleCancel={closeDiscardModal}
message={intl.formatMessage(messages.modalDiscardUnitChangesDescription)}
icon={InfoOutlineIcon}
/>
<ModalNotification
title={intl.formatMessage(messages.modalMakeVisibilityTitle)}
isOpen={isVisibleModalOpen}
actionButtonText={intl.formatMessage(messages.modalMakeVisibilityActionButtonText)}
cancelButtonText={intl.formatMessage(messages.modalMakeVisibilityCancelButtonText)}
handleAction={handleCourseUnitVisibility}
handleCancel={closeVisibleModal}
message={intl.formatMessage(messages.modalMakeVisibilityDescription)}
icon={InfoOutlineIcon}
/>
</Card>
);
};
const Sidebar = ({ className, children, ...props }) => (
<Card
className={classNames('course-unit-sidebar', className)}
{...props}
>
{children}
</Card>
);
Sidebar.propTypes = {
blockId: PropTypes.string,
displayUnitLocation: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
};
Sidebar.defaultProps = {
blockId: null,
displayUnitLocation: false,
className: null,
children: null,
};
export default Sidebar;

View File

@@ -6,3 +6,4 @@
@import "./create-or-rerun-course/CreateOrRerunCourseForm";
@import "./WysiwygEditor";
@import "./course-stepper/CouseStepper";
@import "./tag-count/TagCount";

View File

@@ -19,7 +19,7 @@
@import "import-page/CourseImportPage";
@import "taxonomy";
@import "files-and-videos";
@import "content-tags-drawer/TagBubble";
@import "content-tags-drawer";
@import "course-outline/CourseOutline";
@import "course-unit/CourseUnit";
@import "course-checklist/CourseChecklist";