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}
-
- {title}
-
-
+ <>
+
+ {prefixIcon}
+
+
+
+ {title}
+
+
+ >
);
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;