diff --git a/src/course-outline/card-header/TitleButton.tsx b/src/course-outline/card-header/TitleButton.tsx index c1dd59082e..bd16d6c98e 100644 --- a/src/course-outline/card-header/TitleButton.tsx +++ b/src/course-outline/card-header/TitleButton.tsx @@ -47,7 +47,9 @@ const TitleButton = ({ onClick={onTitleClick} title={title} > - {prefixIcon} +
+ {prefixIcon} +
{title} diff --git a/src/course-outline/card-header/TitleLink.tsx b/src/course-outline/card-header/TitleLink.tsx index 622638fd99..0c0f402b26 100644 --- a/src/course-outline/card-header/TitleLink.tsx +++ b/src/course-outline/card-header/TitleLink.tsx @@ -14,19 +14,23 @@ const TitleLink = ({ namePrefix, prefixIcon, }: TitleLinkProps) => ( - + <> +
+ {prefixIcon} +
+ + ); export default TitleLink; diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index f2c37de59d..aca9cdd15d 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -75,6 +75,7 @@ const section = { versionDeclined: null, errorMessage: null, downstreamCustomized: [] as string[], + upstreamName: 'Upstream', }, } satisfies Partial as XBlock; diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 02c1d5c644..5547fc46bb 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -257,7 +257,13 @@ const SectionCard = ({ isExpanded={isExpanded} onTitleClick={handleExpandContent} namePrefix={namePrefix} - prefixIcon={} + prefixIcon={( + + )} /> ); diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index 159f8bd5aa..ce6ad09360 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -79,6 +79,7 @@ const subsection: XBlock = { versionDeclined: null, errorMessage: null, downstreamCustomized: [] as string[], + upstreamName: 'Upstream', }, } satisfies Partial as XBlock; diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 389338519c..0e95a273ff 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -205,7 +205,13 @@ const SubsectionCard = ({ isExpanded={isExpanded} onTitleClick={handleExpandContent} namePrefix={namePrefix} - prefixIcon={} + prefixIcon={( + + )} /> ); diff --git a/src/course-outline/unit-card/UnitCard.scss b/src/course-outline/unit-card/UnitCard.scss index b17f39b5fc..707f6dd1b9 100644 --- a/src/course-outline/unit-card/UnitCard.scss +++ b/src/course-outline/unit-card/UnitCard.scss @@ -10,5 +10,6 @@ font-weight: var(--pgn-typography-headings-font-weight); line-height: var(--pgn-typography-headings-line-height); color: var(--pgn-color-headings-base); + align-self: center; } } diff --git a/src/course-outline/unit-card/UnitCard.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index c8282a2735..9d8ef2b0d7 100644 --- a/src/course-outline/unit-card/UnitCard.test.tsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -69,6 +69,7 @@ const unit = { versionDeclined: null, errorMessage: null, downstreamCustomized: [] as string[], + upstreamName: 'Upstream', }, } satisfies Partial as XBlock; diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 8029f603cc..9c153b4fdf 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -170,7 +170,13 @@ const UnitCard = ({ title={displayName} titleLink={getTitleLink(id)} namePrefix={namePrefix} - prefixIcon={} + prefixIcon={( + + )} /> ); diff --git a/src/data/types.ts b/src/data/types.ts index 4906c15c72..304dc61e73 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -57,6 +57,7 @@ export interface UpstreamChildrenInfo { export interface UpstreamInfo { readyToSync: boolean, upstreamRef: string, + upstreamName: string, versionSynced: number, versionAvailable: number | null, versionDeclined: number | null, diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 41475055ce..49e1cf64b4 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -15,3 +15,4 @@ @import "./modal-iframe"; @import "./alert-message"; @import "./inplace-text-editor/InplaceTextEditor"; +@import "./upstream-info-icon/UpstreamInfoIcon"; diff --git a/src/generic/upstream-info-icon/UpstreamInfoIcon.scss b/src/generic/upstream-info-icon/UpstreamInfoIcon.scss new file mode 100644 index 0000000000..f151949845 --- /dev/null +++ b/src/generic/upstream-info-icon/UpstreamInfoIcon.scss @@ -0,0 +1,47 @@ +.upstream-info-icon { + border: 1px solid var(--pgn-color-light-800); + color: var(--pgn-color-primary-500); + + &.sync-state { + &:hover { + border-color: var(--pgn-color-primary-500); + background-color: var(--pgn-color-primary-500); + color: white; + } + } + + // Sizes with one icon: + + &.size-one-md { + width: 32px; + height: 26px; + } + + &.size-one-sm { + width: 28px; + height: 22px; + } + + &.size-one-xs { + width: 24px; + height: 18px; + } + + + // Sizes with two icons: + + &.size-two-md { + width: 60px; + height: 26px; + } + + &.size-two-sm { + width: 46px; + height: 22px; + } + + &.size-two-xs { + width: 36px; + height: 18px; + } +} diff --git a/src/generic/upstream-info-icon/UpstreamInfoIcon.test.tsx b/src/generic/upstream-info-icon/UpstreamInfoIcon.test.tsx index 6dc3dc6c95..ba5efad0a7 100644 --- a/src/generic/upstream-info-icon/UpstreamInfoIcon.test.tsx +++ b/src/generic/upstream-info-icon/UpstreamInfoIcon.test.tsx @@ -1,35 +1,105 @@ -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { render, screen } from '@testing-library/react'; +import { + render, screen, fireEvent, waitFor, initializeMocks, +} from '@src/testUtils'; import { UpstreamInfoIcon, UpstreamInfoIconProps } from '.'; type UpstreamInfo = UpstreamInfoIconProps['upstreamInfo']; +const mockOpenSyncModal = jest.fn(); const renderComponent = (upstreamInfo?: UpstreamInfo) => ( render( - - - , + , ) ); describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + it('should render with link', () => { - renderComponent({ upstreamRef: 'some-ref', errorMessage: null }); + renderComponent({ + upstreamRef: 'some-ref', + errorMessage: null, + readyToSync: false, + downstreamCustomized: [], + upstreamName: 'Upstream', + }); expect(screen.getByTitle('This item is linked to a library item.')).toBeInTheDocument(); + expect(screen.queryByTitle('The linked library object has updates available.')).not.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(); + renderComponent({ + upstreamRef: 'some-ref', + errorMessage: 'upstream error', + readyToSync: false, + downstreamCustomized: [], + upstreamName: 'Upstream', + }); + expect(screen.getByTitle('This item is linked to a library item.')).toBeInTheDocument(); + expect(screen.getByTitle('The referenced library or library object is not available.')).toBeInTheDocument(); + }); + + it('should render with ready to sync link and opens the sync modal', async () => { + renderComponent({ + upstreamRef: 'some-ref', + errorMessage: null, + readyToSync: true, + downstreamCustomized: [], + upstreamName: 'Upstream', + }); + + const icon = screen.getByTitle('This item is linked to a library item.'); + expect(icon).toBeInTheDocument(); + expect(screen.getByTitle('The linked library object has updates available.')).toBeInTheDocument(); + + fireEvent.click(icon); + await waitFor(() => expect(mockOpenSyncModal).toHaveBeenCalled()); + }); + + it('should render with course overrides', () => { + renderComponent({ + upstreamRef: 'some-ref', + errorMessage: null, + readyToSync: false, + downstreamCustomized: ['data'], + upstreamName: 'Upstream', + }); + + expect(screen.getByTitle('This item is linked to a library item.')).toBeInTheDocument(); + expect(screen.getByTitle('This library reference has course overrides applied.')).toBeInTheDocument(); + }); + + it('should render with ready to sync and course overrides', () => { + renderComponent({ + upstreamRef: 'some-ref', + errorMessage: null, + readyToSync: true, + downstreamCustomized: ['data'], + upstreamName: 'Upstream', + }); + + expect(screen.getByTitle('This item is linked to a library item.')).toBeInTheDocument(); + expect(screen.queryByTitle('This library reference has course overrides applied.')).not.toBeInTheDocument(); + expect(screen.getByTitle('The linked library object has updates available.')).toBeInTheDocument(); }); it('should render null without upstream', () => { - const { container } = renderComponent(undefined); + renderComponent(undefined); + const container = screen.getByTestId('redux-provider'); expect(container).toBeEmptyDOMElement(); }); it('should render null without upstreamRf', () => { - const { container } = renderComponent({ upstreamRef: null, errorMessage: null }); + renderComponent({ + upstreamRef: null, + errorMessage: null, + readyToSync: false, + downstreamCustomized: [], + upstreamName: 'Upstream', + }); + const container = screen.getByTestId('redux-provider'); expect(container).toBeEmptyDOMElement(); }); }); diff --git a/src/generic/upstream-info-icon/index.tsx b/src/generic/upstream-info-icon/index.tsx index cf12c1eb43..ec93a5bd69 100644 --- a/src/generic/upstream-info-icon/index.tsx +++ b/src/generic/upstream-info-icon/index.tsx @@ -1,41 +1,138 @@ /* 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 { + Button, Icon, OverlayTrigger, Tooltip, +} from '@openedx/paragon'; +import { + CallSplit, LinkOff, Newsstand, Sync, +} from '@openedx/paragon/icons'; +import { BoldText } from '@src/utils'; +import { ReactNode } from 'react'; import messages from './messages'; export interface UpstreamInfoIconProps { upstreamInfo?: { errorMessage?: string | null; upstreamRef?: string | null; + upstreamName: string; + readyToSync: boolean; + downstreamCustomized: string[]; }; size?: 'xs' | 'sm' | 'md' | 'lg' | 'inline'; } -export const UpstreamInfoIcon: React.FC = ({ upstreamInfo, size }) => { +const UpstreamInfoIconContent = ({ + upstreamInfo, + size, +}: UpstreamInfoIconProps) => { const intl = useIntl(); + + if (!upstreamInfo) { + return null; + } + + let secondIcon: JSX.Element | undefined; + let tooltipMessage: string | ReactNode = intl.formatMessage( + messages.upstreamLinkTooltip, + { + upstreamName: upstreamInfo.upstreamName, + b: BoldText, + }, + ); + + if (upstreamInfo.errorMessage) { + tooltipMessage = intl.formatMessage(messages.upstreamLinkError); + secondIcon = ( + + ); + } else if (upstreamInfo.readyToSync) { + tooltipMessage = intl.formatMessage( + messages.upstreamLinkReadyToSyncTooltip, + { + upstreamName: upstreamInfo.upstreamName, + b: BoldText, + }, + ); + secondIcon = ( + + ); + } else if ((upstreamInfo.downstreamCustomized.length || 0) > 0) { + tooltipMessage = intl.formatMessage(messages.upstreamLinkOverridesAriaLabel); + secondIcon = ( + + ); + } + + return ( + + {tooltipMessage} + + )} + > +
+ + {secondIcon} +
+
+ ); +}; + +export const UpstreamInfoIcon: React.FC void }> = ({ + upstreamInfo, + size, + openSyncModal, +}) => { 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, - }; + const handleSyncModal = (e) => { + e.stopPropagation(); + openSyncModal(); + }; + + if (upstreamInfo?.readyToSync) { + return ( + + ); + } return ( - + ); }; diff --git a/src/generic/upstream-info-icon/messages.ts b/src/generic/upstream-info-icon/messages.ts index 7f3f6c69d3..ac844a64e8 100644 --- a/src/generic/upstream-info-icon/messages.ts +++ b/src/generic/upstream-info-icon/messages.ts @@ -7,10 +7,30 @@ const messages = defineMessages({ description: 'Hint and aria-label for the upstream icon when the link is valid.', }, upstreamLinkError: { - defaultMessage: 'The link to the library item is broken.', + defaultMessage: 'The referenced library or library object is not available.', id: 'upstream-icon.error', description: 'Hint and aria-label for the upstream icon when the link is broken.', }, + upstreamLinkReadyToSyncAriaLabel: { + defaultMessage: 'The linked library object has updates available.', + id: 'upstream-icon.ready-to-sync.aria-label', + description: 'Hint and aria-label for the upstream icon when the link is ready to sync.', + }, + upstreamLinkReadyToSyncTooltip: { + defaultMessage: 'The linked {upstreamName} has updates available.', + id: 'upstream-icon.ready-to-sync.tooltip', + description: 'Tooltip text for the upstream icon when the link is ready to sync.', + }, + upstreamLinkOverridesAriaLabel: { + defaultMessage: 'This library reference has course overrides applied.', + id: 'upstream-icon.course-overrides.aria-label', + description: 'Hint and aria-label for the upstream icon when the link has course overrides.', + }, + upstreamLinkTooltip: { + defaultMessage: 'This is referenced via {upstreamName}', + id: 'upstream-icon.ok.tooltip', + description: 'Tooltip text for the upstream icon when the link is valid.', + }, }); export default messages;