Skip to content

Commit 2c9f90b

Browse files
authored
fix: change container sync status icon [FC-0097] (#2360)
Changes the sync icon for Sections, Subsections, and Units in case the Upstream source is deleted.
1 parent ada52c3 commit 2c9f90b

File tree

8 files changed

+145
-45
lines changed

8 files changed

+145
-45
lines changed

src/course-outline/section-card/SectionCard.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import {
44
import { useDispatch } from 'react-redux';
55
import { useIntl } from '@edx/frontend-platform/i18n';
66
import {
7-
Bubble, Button, Icon, StandardModal, useToggle,
7+
Bubble, Button, StandardModal, useToggle,
88
} from '@openedx/paragon';
9-
import { Newsstand } from '@openedx/paragon/icons';
109
import { useSearchParams } from 'react-router-dom';
1110
import classNames from 'classnames';
1211

@@ -23,7 +22,8 @@ import { ContainerType } from '@src/generic/key-utils';
2322
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
2423
import { ContentType } from '@src/library-authoring/routes';
2524
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
26-
import { XBlock } from '@src/data/types';
25+
import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
26+
import type { XBlock } from '@src/data/types';
2727
import messages from './messages';
2828

2929
interface SectionCardProps {
@@ -123,6 +123,7 @@ const SectionCard = ({
123123
highlights,
124124
actions: sectionActions,
125125
isHeaderVisible = true,
126+
upstreamInfo,
126127
} = section;
127128

128129
useEffect(() => {
@@ -225,9 +226,7 @@ const SectionCard = ({
225226
isExpanded={isExpanded}
226227
onTitleClick={handleExpandContent}
227228
namePrefix={namePrefix}
228-
prefixIcon={!!section.upstreamInfo?.upstreamRef && (
229-
<Icon src={Newsstand} className="mr-1" />
230-
)}
229+
prefixIcon={<UpstreamInfoIcon upstreamInfo={upstreamInfo} />}
231230
/>
232231
);
233232

src/course-outline/subsection-card/SubsectionCard.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import React, {
44
import { useDispatch } from 'react-redux';
55
import { useSearchParams } from 'react-router-dom';
66
import { useIntl } from '@edx/frontend-platform/i18n';
7-
import { Icon, StandardModal, useToggle } from '@openedx/paragon';
8-
import { Newsstand } from '@openedx/paragon/icons';
7+
import { StandardModal, useToggle } from '@openedx/paragon';
98
import classNames from 'classnames';
109
import { isEmpty } from 'lodash';
1110

@@ -22,9 +21,10 @@ import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course
2221
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
2322
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
2423
import { ContainerType } from '@src/generic/key-utils';
24+
import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
2525
import { ContentType } from '@src/library-authoring/routes';
2626
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
27-
import { XBlock } from '@src/data/types';
27+
import type { XBlock } from '@src/data/types';
2828
import messages from './messages';
2929

3030
interface SubsectionCardProps {
@@ -105,6 +105,7 @@ const SubsectionCard = ({
105105
isHeaderVisible = true,
106106
enableCopyPasteUnits = false,
107107
proctoringExamConfigurationLink,
108+
upstreamInfo,
108109
} = subsection;
109110

110111
// re-create actions object for customizations
@@ -173,9 +174,7 @@ const SubsectionCard = ({
173174
isExpanded={isExpanded}
174175
onTitleClick={handleExpandContent}
175176
namePrefix={namePrefix}
176-
prefixIcon={!!subsection.upstreamInfo?.upstreamRef && (
177-
<Icon src={Newsstand} className="mr-1" />
178-
)}
177+
prefixIcon={<UpstreamInfoIcon upstreamInfo={upstreamInfo} />}
179178
/>
180179
);
181180

src/course-outline/unit-card/UnitCard.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import {
55
useRef,
66
} from 'react';
77
import { useDispatch } from 'react-redux';
8-
import { Icon, useToggle } from '@openedx/paragon';
9-
import { Newsstand } from '@openedx/paragon/icons';
8+
import { useToggle } from '@openedx/paragon';
109
import { isEmpty } from 'lodash';
1110
import { useSearchParams } from 'react-router-dom';
1211

@@ -21,8 +20,9 @@ import TitleLink from '@src/course-outline/card-header/TitleLink';
2120
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
2221
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
2322
import { useClipboard } from '@src/generic/clipboard';
23+
import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
2424
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
25-
import { XBlock } from '@src/data/types';
25+
import type { XBlock } from '@src/data/types';
2626

2727
interface UnitCardProps {
2828
unit: XBlock;
@@ -162,9 +162,7 @@ const UnitCard = ({
162162
title={displayName}
163163
titleLink={getTitleLink(id)}
164164
namePrefix={namePrefix}
165-
prefixIcon={!!unit.upstreamInfo?.upstreamRef && (
166-
<Icon src={Newsstand} size="sm" className="mr-1" />
167-
)}
165+
prefixIcon={<UpstreamInfoIcon upstreamInfo={upstreamInfo} size="sm" />}
168166
/>
169167
);
170168

src/course-unit/data/utils.js renamed to src/course-unit/data/utils.ts

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { camelCaseObject } from '@edx/frontend-platform';
22

3+
import type { XBlock } from '@src/data/types';
4+
35
import { NOTIFICATION_MESSAGES } from '../../constants';
46
import { PUBLISH_TYPES } from '../constants';
57

@@ -27,35 +29,42 @@ export function normalizeCourseSectionVerticalData(metadata) {
2729

2830
/**
2931
* Get the notification message based on the publishing type and visibility.
30-
* @param {string} type - The publishing type.
31-
* @param {boolean} isVisible - The visibility status.
32-
* @param {boolean} isModalView - The modal view status.
33-
* @returns {string} The corresponding notification message.
32+
* @param type - The publishing type.
33+
* @param isVisible - The visibility status.
34+
* @param isModalView - The modal view status.
35+
* @returns The corresponding notification message.
3436
*/
35-
export const getNotificationMessage = (type, isVisible, isModalView) => {
36-
let notificationMessage;
37-
37+
export const getNotificationMessage = (type: string, isVisible: boolean, isModalView: boolean): string => {
3838
if (type === PUBLISH_TYPES.discardChanges) {
39-
notificationMessage = NOTIFICATION_MESSAGES.discardChanges;
40-
} else if (type === PUBLISH_TYPES.makePublic) {
41-
notificationMessage = NOTIFICATION_MESSAGES.publishing;
42-
} else if (type === PUBLISH_TYPES.republish && isModalView) {
43-
notificationMessage = NOTIFICATION_MESSAGES.saving;
44-
} else if (type === PUBLISH_TYPES.republish && !isVisible) {
45-
notificationMessage = NOTIFICATION_MESSAGES.makingVisibleToStudents;
46-
} else if (type === PUBLISH_TYPES.republish && isVisible) {
47-
notificationMessage = NOTIFICATION_MESSAGES.hidingFromStudents;
39+
return NOTIFICATION_MESSAGES.discardChanges;
40+
}
41+
if (type === PUBLISH_TYPES.makePublic) {
42+
return NOTIFICATION_MESSAGES.publishing;
43+
}
44+
if (type === PUBLISH_TYPES.republish && isModalView) {
45+
return NOTIFICATION_MESSAGES.saving;
46+
}
47+
// istanbul ignore next: this is not used in the app
48+
if (type === PUBLISH_TYPES.republish && !isVisible) {
49+
return NOTIFICATION_MESSAGES.makingVisibleToStudents;
50+
}
51+
52+
// istanbul ignore next: this is not used in the app
53+
if (type === PUBLISH_TYPES.republish && isVisible) {
54+
return NOTIFICATION_MESSAGES.hidingFromStudents;
4855
}
4956

50-
return notificationMessage;
57+
// istanbul ignore next: should never hit this case
58+
return NOTIFICATION_MESSAGES.empty;
5159
};
5260

5361
/**
5462
* Updates the 'id' property of objects in the data structure using the 'blockId' value where present.
55-
* @param {Object} data - The original data structure to be updated.
56-
* @returns {Object} - The updated data structure with updated 'id' values.
63+
* @param data - The original data structure to be updated.
64+
* @returns The updated data structure with updated 'id' values.
5765
*/
58-
export const updateXBlockBlockIdToId = (data) => {
66+
export const updateXBlockBlockIdToId = (data: object): object => {
67+
// istanbul ignore if: should never hit this case
5968
if (typeof data !== 'object' || data === null) {
6069
return data;
6170
}
@@ -64,7 +73,7 @@ export const updateXBlockBlockIdToId = (data) => {
6473
return data.map(updateXBlockBlockIdToId);
6574
}
6675

67-
const updatedData = {};
76+
const updatedData: Record<string, any> = {};
6877

6978
Object.keys(data).forEach(key => {
7079
const value = data[key];
@@ -90,9 +99,11 @@ export const updateXBlockBlockIdToId = (data) => {
9099
*
91100
* Units sourced from libraries are read-only (temporary, for Teak).
92101
*
93-
* @param {object} unit - uses the 'upstreamInfo' object if found.
94-
* @returns {boolean} True if readOnly, False if editable.
102+
* @param unit - uses the 'upstreamInfo' object if found.
103+
* @returns True if readOnly, False if editable.
95104
*/
96-
export const isUnitReadOnly = ({ upstreamInfo }) => (
97-
upstreamInfo && upstreamInfo.upstreamRef && upstreamInfo.upstreamRef.startsWith('lct:')
105+
export const isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
106+
!!upstreamInfo
107+
&& !!upstreamInfo.upstreamRef
108+
&& upstreamInfo.upstreamRef.startsWith('lct:')
98109
);

src/data/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ export interface XBlockPrereqs {
4747
blockDisplayName: string;
4848
}
4949

50-
export interface UpstreeamInfo {
50+
export interface UpstreamInfo {
5151
readyToSync: boolean,
5252
upstreamRef: string,
5353
versionSynced: number,
54+
errorMessage: string | null,
5455
}
5556

5657
export interface XBlock {
@@ -106,5 +107,5 @@ export interface XBlock {
106107
prereqMinScore?: number;
107108
prereqMinCompletion?: number;
108109
discussionEnabled?: boolean;
109-
upstreamInfo?: UpstreeamInfo;
110+
upstreamInfo?: UpstreamInfo;
110111
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { IntlProvider } from '@edx/frontend-platform/i18n';
2+
import { render, screen } from '@testing-library/react';
3+
import { UpstreamInfoIcon, UpstreamInfoIconProps } from '.';
4+
5+
type UpstreamInfo = UpstreamInfoIconProps['upstreamInfo'];
6+
7+
const renderComponent = (upstreamInfo?: UpstreamInfo) => (
8+
render(
9+
<IntlProvider locale="en">
10+
<UpstreamInfoIcon upstreamInfo={upstreamInfo} />
11+
</IntlProvider>,
12+
)
13+
);
14+
15+
describe('<UpstreamInfoIcon>', () => {
16+
it('should render with link', () => {
17+
renderComponent({ upstreamRef: 'some-ref', errorMessage: null });
18+
expect(screen.getByTitle('This item is linked to a library item.')).toBeInTheDocument();
19+
});
20+
21+
it('should render with broken link', () => {
22+
renderComponent({ upstreamRef: 'some-ref', errorMessage: 'upstream error' });
23+
expect(screen.getByTitle('The link to the library item is broken.')).toBeInTheDocument();
24+
});
25+
26+
it('should render null without upstream', () => {
27+
const { container } = renderComponent(undefined);
28+
expect(container).toBeEmptyDOMElement();
29+
});
30+
31+
it('should render null without upstreamRf', () => {
32+
const { container } = renderComponent({ upstreamRef: null, errorMessage: null });
33+
expect(container).toBeEmptyDOMElement();
34+
});
35+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/* eslint-disable react/prop-types */
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { Icon } from '@openedx/paragon';
4+
import { LinkOff, Newsstand } from '@openedx/paragon/icons';
5+
6+
import messages from './messages';
7+
8+
export interface UpstreamInfoIconProps {
9+
upstreamInfo?: {
10+
errorMessage?: string | null;
11+
upstreamRef?: string | null;
12+
};
13+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'inline';
14+
}
15+
16+
export const UpstreamInfoIcon: React.FC<UpstreamInfoIconProps> = ({ upstreamInfo, size }) => {
17+
const intl = useIntl();
18+
if (!upstreamInfo?.upstreamRef) {
19+
return null;
20+
}
21+
22+
const iconProps = !upstreamInfo?.errorMessage
23+
? {
24+
title: intl.formatMessage(messages.upstreamLinkOk),
25+
ariaLabel: intl.formatMessage(messages.upstreamLinkOk),
26+
src: Newsstand,
27+
}
28+
: {
29+
title: intl.formatMessage(messages.upstreamLinkError),
30+
ariaLabel: intl.formatMessage(messages.upstreamLinkError),
31+
src: LinkOff,
32+
};
33+
34+
return (
35+
<Icon
36+
{...iconProps}
37+
size={size}
38+
className="mr-1"
39+
/>
40+
);
41+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
upstreamLinkOk: {
5+
defaultMessage: 'This item is linked to a library item.',
6+
id: 'upstream-icon.ok',
7+
description: 'Hint and aria-label for the upstream icon when the link is valid.',
8+
},
9+
upstreamLinkError: {
10+
defaultMessage: 'The link to the library item is broken.',
11+
id: 'upstream-icon.error',
12+
description: 'Hint and aria-label for the upstream icon when the link is broken.',
13+
},
14+
});
15+
16+
export default messages;

0 commit comments

Comments
 (0)