diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx
index 2e4bfac07..227219c45 100644
--- a/src/course-outline/section-card/SectionCard.tsx
+++ b/src/course-outline/section-card/SectionCard.tsx
@@ -4,9 +4,8 @@ import {
import { useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
- Bubble, Button, Icon, StandardModal, useToggle,
+ Bubble, Button, StandardModal, useToggle,
} from '@openedx/paragon';
-import { Newsstand } from '@openedx/paragon/icons';
import { useSearchParams } from 'react-router-dom';
import classNames from 'classnames';
@@ -23,7 +22,8 @@ import { ContainerType } from '@src/generic/key-utils';
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
import { ContentType } from '@src/library-authoring/routes';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
-import { XBlock } from '@src/data/types';
+import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
+import type { XBlock } from '@src/data/types';
import messages from './messages';
interface SectionCardProps {
@@ -123,6 +123,7 @@ const SectionCard = ({
highlights,
actions: sectionActions,
isHeaderVisible = true,
+ upstreamInfo,
} = section;
useEffect(() => {
@@ -225,9 +226,7 @@ const SectionCard = ({
isExpanded={isExpanded}
onTitleClick={handleExpandContent}
namePrefix={namePrefix}
- prefixIcon={!!section.upstreamInfo?.upstreamRef && (
-
- )}
+ prefixIcon={}
/>
);
diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx
index 2ede79e35..c70952f63 100644
--- a/src/course-outline/subsection-card/SubsectionCard.tsx
+++ b/src/course-outline/subsection-card/SubsectionCard.tsx
@@ -4,8 +4,7 @@ import React, {
import { useDispatch } from 'react-redux';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { Icon, StandardModal, useToggle } from '@openedx/paragon';
-import { Newsstand } from '@openedx/paragon/icons';
+import { StandardModal, useToggle } from '@openedx/paragon';
import classNames from 'classnames';
import { isEmpty } from 'lodash';
@@ -22,9 +21,10 @@ import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { ContainerType } from '@src/generic/key-utils';
+import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
import { ContentType } from '@src/library-authoring/routes';
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
-import { XBlock } from '@src/data/types';
+import type { XBlock } from '@src/data/types';
import messages from './messages';
interface SubsectionCardProps {
@@ -105,6 +105,7 @@ const SubsectionCard = ({
isHeaderVisible = true,
enableCopyPasteUnits = false,
proctoringExamConfigurationLink,
+ upstreamInfo,
} = subsection;
// re-create actions object for customizations
@@ -173,9 +174,7 @@ const SubsectionCard = ({
isExpanded={isExpanded}
onTitleClick={handleExpandContent}
namePrefix={namePrefix}
- prefixIcon={!!subsection.upstreamInfo?.upstreamRef && (
-
- )}
+ prefixIcon={}
/>
);
diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx
index 9e08cb1c9..975844fdb 100644
--- a/src/course-outline/unit-card/UnitCard.tsx
+++ b/src/course-outline/unit-card/UnitCard.tsx
@@ -5,8 +5,7 @@ import {
useRef,
} from 'react';
import { useDispatch } from 'react-redux';
-import { Icon, useToggle } from '@openedx/paragon';
-import { Newsstand } from '@openedx/paragon/icons';
+import { useToggle } from '@openedx/paragon';
import { isEmpty } from 'lodash';
import { useSearchParams } from 'react-router-dom';
@@ -21,8 +20,9 @@ import TitleLink from '@src/course-outline/card-header/TitleLink';
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
import { useClipboard } from '@src/generic/clipboard';
+import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
-import { XBlock } from '@src/data/types';
+import type { XBlock } from '@src/data/types';
interface UnitCardProps {
unit: XBlock;
@@ -162,9 +162,7 @@ const UnitCard = ({
title={displayName}
titleLink={getTitleLink(id)}
namePrefix={namePrefix}
- prefixIcon={!!unit.upstreamInfo?.upstreamRef && (
-
- )}
+ prefixIcon={}
/>
);
diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.ts
similarity index 55%
rename from src/course-unit/data/utils.js
rename to src/course-unit/data/utils.ts
index 891021deb..94c457455 100644
--- a/src/course-unit/data/utils.js
+++ b/src/course-unit/data/utils.ts
@@ -1,5 +1,7 @@
import { camelCaseObject } from '@edx/frontend-platform';
+import type { XBlock } from '@src/data/types';
+
import { NOTIFICATION_MESSAGES } from '../../constants';
import { PUBLISH_TYPES } from '../constants';
@@ -27,35 +29,42 @@ export function normalizeCourseSectionVerticalData(metadata) {
/**
* Get the notification message based on the publishing type and visibility.
- * @param {string} type - The publishing type.
- * @param {boolean} isVisible - The visibility status.
- * @param {boolean} isModalView - The modal view status.
- * @returns {string} The corresponding notification message.
+ * @param type - The publishing type.
+ * @param isVisible - The visibility status.
+ * @param isModalView - The modal view status.
+ * @returns The corresponding notification message.
*/
-export const getNotificationMessage = (type, isVisible, isModalView) => {
- let notificationMessage;
-
+export const getNotificationMessage = (type: string, isVisible: boolean, isModalView: boolean): string => {
if (type === PUBLISH_TYPES.discardChanges) {
- notificationMessage = NOTIFICATION_MESSAGES.discardChanges;
- } else if (type === PUBLISH_TYPES.makePublic) {
- notificationMessage = NOTIFICATION_MESSAGES.publishing;
- } else if (type === PUBLISH_TYPES.republish && isModalView) {
- notificationMessage = NOTIFICATION_MESSAGES.saving;
- } else if (type === PUBLISH_TYPES.republish && !isVisible) {
- notificationMessage = NOTIFICATION_MESSAGES.makingVisibleToStudents;
- } else if (type === PUBLISH_TYPES.republish && isVisible) {
- notificationMessage = NOTIFICATION_MESSAGES.hidingFromStudents;
+ return NOTIFICATION_MESSAGES.discardChanges;
+ }
+ if (type === PUBLISH_TYPES.makePublic) {
+ return NOTIFICATION_MESSAGES.publishing;
+ }
+ if (type === PUBLISH_TYPES.republish && isModalView) {
+ return NOTIFICATION_MESSAGES.saving;
+ }
+ // istanbul ignore next: this is not used in the app
+ if (type === PUBLISH_TYPES.republish && !isVisible) {
+ return NOTIFICATION_MESSAGES.makingVisibleToStudents;
}
- return notificationMessage;
+ // istanbul ignore next: this is not used in the app
+ if (type === PUBLISH_TYPES.republish && isVisible) {
+ return NOTIFICATION_MESSAGES.hidingFromStudents;
+ }
+
+ // istanbul ignore next: should never hit this case
+ return NOTIFICATION_MESSAGES.empty;
};
/**
* Updates the 'id' property of objects in the data structure using the 'blockId' value where present.
- * @param {Object} data - The original data structure to be updated.
- * @returns {Object} - The updated data structure with updated 'id' values.
+ * @param data - The original data structure to be updated.
+ * @returns The updated data structure with updated 'id' values.
*/
-export const updateXBlockBlockIdToId = (data) => {
+export const updateXBlockBlockIdToId = (data: object): object => {
+ // istanbul ignore if: should never hit this case
if (typeof data !== 'object' || data === null) {
return data;
}
@@ -64,7 +73,7 @@ export const updateXBlockBlockIdToId = (data) => {
return data.map(updateXBlockBlockIdToId);
}
- const updatedData = {};
+ const updatedData: Record = {};
Object.keys(data).forEach(key => {
const value = data[key];
@@ -90,9 +99,11 @@ export const updateXBlockBlockIdToId = (data) => {
*
* Units sourced from libraries are read-only (temporary, for Teak).
*
- * @param {object} unit - uses the 'upstreamInfo' object if found.
- * @returns {boolean} True if readOnly, False if editable.
+ * @param unit - uses the 'upstreamInfo' object if found.
+ * @returns True if readOnly, False if editable.
*/
-export const isUnitReadOnly = ({ upstreamInfo }) => (
- upstreamInfo && upstreamInfo.upstreamRef && upstreamInfo.upstreamRef.startsWith('lct:')
+export const isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
+ !!upstreamInfo
+ && !!upstreamInfo.upstreamRef
+ && upstreamInfo.upstreamRef.startsWith('lct:')
);
diff --git a/src/data/types.ts b/src/data/types.ts
index f7fedfd82..c2ac2a469 100644
--- a/src/data/types.ts
+++ b/src/data/types.ts
@@ -47,10 +47,11 @@ export interface XBlockPrereqs {
blockDisplayName: string;
}
-export interface UpstreeamInfo {
+export interface UpstreamInfo {
readyToSync: boolean,
upstreamRef: string,
versionSynced: number,
+ errorMessage: string | null,
}
export interface XBlock {
@@ -106,5 +107,5 @@ export interface XBlock {
prereqMinScore?: number;
prereqMinCompletion?: number;
discussionEnabled?: boolean;
- upstreamInfo?: UpstreeamInfo;
+ upstreamInfo?: UpstreamInfo;
}
diff --git a/src/generic/upstream-info-icon/UpstreamInfoIcon.test.tsx b/src/generic/upstream-info-icon/UpstreamInfoIcon.test.tsx
new file mode 100644
index 000000000..6dc3dc6c9
--- /dev/null
+++ b/src/generic/upstream-info-icon/UpstreamInfoIcon.test.tsx
@@ -0,0 +1,35 @@
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { render, screen } from '@testing-library/react';
+import { UpstreamInfoIcon, UpstreamInfoIconProps } from '.';
+
+type UpstreamInfo = UpstreamInfoIconProps['upstreamInfo'];
+
+const renderComponent = (upstreamInfo?: UpstreamInfo) => (
+ render(
+
+
+ ,
+ )
+);
+
+describe('', () => {
+ it('should render with link', () => {
+ renderComponent({ upstreamRef: 'some-ref', errorMessage: null });
+ expect(screen.getByTitle('This item is linked to a library item.')).toBeInTheDocument();
+ });
+
+ it('should render with broken link', () => {
+ renderComponent({ upstreamRef: 'some-ref', errorMessage: 'upstream error' });
+ expect(screen.getByTitle('The link to the library item is broken.')).toBeInTheDocument();
+ });
+
+ it('should render null without upstream', () => {
+ const { container } = renderComponent(undefined);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should render null without upstreamRf', () => {
+ const { container } = renderComponent({ upstreamRef: null, errorMessage: null });
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/generic/upstream-info-icon/index.tsx b/src/generic/upstream-info-icon/index.tsx
new file mode 100644
index 000000000..cf12c1eb4
--- /dev/null
+++ b/src/generic/upstream-info-icon/index.tsx
@@ -0,0 +1,41 @@
+/* eslint-disable react/prop-types */
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Icon } from '@openedx/paragon';
+import { LinkOff, Newsstand } from '@openedx/paragon/icons';
+
+import messages from './messages';
+
+export interface UpstreamInfoIconProps {
+ upstreamInfo?: {
+ errorMessage?: string | null;
+ upstreamRef?: string | null;
+ };
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'inline';
+}
+
+export const UpstreamInfoIcon: React.FC = ({ upstreamInfo, size }) => {
+ const intl = useIntl();
+ if (!upstreamInfo?.upstreamRef) {
+ return null;
+ }
+
+ const iconProps = !upstreamInfo?.errorMessage
+ ? {
+ title: intl.formatMessage(messages.upstreamLinkOk),
+ ariaLabel: intl.formatMessage(messages.upstreamLinkOk),
+ src: Newsstand,
+ }
+ : {
+ title: intl.formatMessage(messages.upstreamLinkError),
+ ariaLabel: intl.formatMessage(messages.upstreamLinkError),
+ src: LinkOff,
+ };
+
+ return (
+
+ );
+};
diff --git a/src/generic/upstream-info-icon/messages.ts b/src/generic/upstream-info-icon/messages.ts
new file mode 100644
index 000000000..7f3f6c69d
--- /dev/null
+++ b/src/generic/upstream-info-icon/messages.ts
@@ -0,0 +1,16 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ upstreamLinkOk: {
+ defaultMessage: 'This item is linked to a library item.',
+ id: 'upstream-icon.ok',
+ description: 'Hint and aria-label for the upstream icon when the link is valid.',
+ },
+ upstreamLinkError: {
+ defaultMessage: 'The link to the library item is broken.',
+ id: 'upstream-icon.error',
+ description: 'Hint and aria-label for the upstream icon when the link is broken.',
+ },
+});
+
+export default messages;