From 2c9f90ba5afce7c1d50e4599adae244d160c892b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 18 Aug 2025 19:21:47 -0300 Subject: [PATCH] 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. --- .../section-card/SectionCard.tsx | 11 ++-- .../subsection-card/SubsectionCard.tsx | 11 ++-- src/course-outline/unit-card/UnitCard.tsx | 10 ++- src/course-unit/data/{utils.js => utils.ts} | 61 +++++++++++-------- src/data/types.ts | 5 +- .../UpstreamInfoIcon.test.tsx | 35 +++++++++++ src/generic/upstream-info-icon/index.tsx | 41 +++++++++++++ src/generic/upstream-info-icon/messages.ts | 16 +++++ 8 files changed, 145 insertions(+), 45 deletions(-) rename src/course-unit/data/{utils.js => utils.ts} (55%) create mode 100644 src/generic/upstream-info-icon/UpstreamInfoIcon.test.tsx create mode 100644 src/generic/upstream-info-icon/index.tsx create mode 100644 src/generic/upstream-info-icon/messages.ts 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;