feat: New library sync icon [FC-0114] (#2739)

Updates the library sync icon with new states: error, broken, ready to sync and with overrides
This commit is contained in:
Chris Chávez
2025-12-16 14:54:28 -05:00
committed by GitHub
parent b7dd6706c2
commit 41a326f7b4
15 changed files with 311 additions and 47 deletions

View File

@@ -47,7 +47,9 @@ const TitleButton = ({
onClick={onTitleClick}
title={title}
>
{prefixIcon}
<div className="mr-2">
{prefixIcon}
</div>
<span className={`${namePrefix}-card-title mb-0 truncate-1-line`}>
{title}
</span>

View File

@@ -14,19 +14,23 @@ const TitleLink = ({
namePrefix,
prefixIcon,
}: TitleLinkProps) => (
<Button
as={Link}
variant="tertiary"
data-testid={`${namePrefix}-card-header__title-link`}
className="item-card-header__title-btn align-items-end"
to={titleLink}
title={title}
>
{prefixIcon}
<span className={`${namePrefix}-card-title mb-0 truncate-1-line`}>
{title}
</span>
</Button>
<>
<div className="mr-2">
{prefixIcon}
</div>
<Button
as={Link}
variant="tertiary"
data-testid={`${namePrefix}-card-header__title-link`}
className="item-card-header__title-btn align-items-end"
to={titleLink}
title={title}
>
<span className={`${namePrefix}-card-title mb-0 truncate-1-line text-left`}>
{title}
</span>
</Button>
</>
);
export default TitleLink;

View File

@@ -75,6 +75,7 @@ const section = {
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
upstreamName: 'Upstream',
},
} satisfies Partial<XBlock> as XBlock;

View File

@@ -257,7 +257,13 @@ const SectionCard = ({
isExpanded={isExpanded}
onTitleClick={handleExpandContent}
namePrefix={namePrefix}
prefixIcon={<UpstreamInfoIcon upstreamInfo={upstreamInfo} />}
prefixIcon={(
<UpstreamInfoIcon
upstreamInfo={upstreamInfo}
size="md"
openSyncModal={openSyncModal}
/>
)}
/>
);

View File

@@ -79,6 +79,7 @@ const subsection: XBlock = {
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
upstreamName: 'Upstream',
},
} satisfies Partial<XBlock> as XBlock;

View File

@@ -205,7 +205,13 @@ const SubsectionCard = ({
isExpanded={isExpanded}
onTitleClick={handleExpandContent}
namePrefix={namePrefix}
prefixIcon={<UpstreamInfoIcon upstreamInfo={upstreamInfo} />}
prefixIcon={(
<UpstreamInfoIcon
upstreamInfo={upstreamInfo}
size="sm"
openSyncModal={openSyncModal}
/>
)}
/>
);

View File

@@ -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;
}
}

View File

@@ -69,6 +69,7 @@ const unit = {
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
upstreamName: 'Upstream',
},
} satisfies Partial<XBlock> as XBlock;

View File

@@ -170,7 +170,13 @@ const UnitCard = ({
title={displayName}
titleLink={getTitleLink(id)}
namePrefix={namePrefix}
prefixIcon={<UpstreamInfoIcon upstreamInfo={upstreamInfo} size="sm" />}
prefixIcon={(
<UpstreamInfoIcon
upstreamInfo={upstreamInfo}
size="xs"
openSyncModal={openSyncModal}
/>
)}
/>
);

View File

@@ -57,6 +57,7 @@ export interface UpstreamChildrenInfo {
export interface UpstreamInfo {
readyToSync: boolean,
upstreamRef: string,
upstreamName: string,
versionSynced: number,
versionAvailable: number | null,
versionDeclined: number | null,

View File

@@ -15,3 +15,4 @@
@import "./modal-iframe";
@import "./alert-message";
@import "./inplace-text-editor/InplaceTextEditor";
@import "./upstream-info-icon/UpstreamInfoIcon";

View File

@@ -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;
}
}

View File

@@ -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(
<IntlProvider locale="en">
<UpstreamInfoIcon upstreamInfo={upstreamInfo} />
</IntlProvider>,
<UpstreamInfoIcon upstreamInfo={upstreamInfo} openSyncModal={mockOpenSyncModal} />,
)
);
describe('<UpstreamInfoIcon>', () => {
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();
});
});

View File

@@ -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<UpstreamInfoIconProps> = ({ 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 = (
<Icon
size={size}
title={intl.formatMessage(messages.upstreamLinkError)}
aria-label={intl.formatMessage(messages.upstreamLinkError)}
src={LinkOff}
/>
);
} else if (upstreamInfo.readyToSync) {
tooltipMessage = intl.formatMessage(
messages.upstreamLinkReadyToSyncTooltip,
{
upstreamName: upstreamInfo.upstreamName,
b: BoldText,
},
);
secondIcon = (
<Icon
size={size}
title={intl.formatMessage(messages.upstreamLinkReadyToSyncAriaLabel)}
aria-label={intl.formatMessage(messages.upstreamLinkReadyToSyncAriaLabel)}
src={Sync}
/>
);
} else if ((upstreamInfo.downstreamCustomized.length || 0) > 0) {
tooltipMessage = intl.formatMessage(messages.upstreamLinkOverridesAriaLabel);
secondIcon = (
<Icon
size={size}
title={intl.formatMessage(messages.upstreamLinkOverridesAriaLabel)}
aria-label={intl.formatMessage(messages.upstreamLinkOverridesAriaLabel)}
src={CallSplit}
/>
);
}
return (
<OverlayTrigger
key={`upstream-icon-${upstreamInfo.upstreamRef}`}
placement="top"
overlay={(
<Tooltip id={`upstream-icon-tooltip-${upstreamInfo.upstreamRef}`}>
{tooltipMessage}
</Tooltip>
)}
>
<div
className={
`upstream-info-icon size-${secondIcon ? 'two' : 'one'}-${size} ${upstreamInfo.readyToSync ? 'sync-state' : ''} rounded-sm d-flex justify-content-center`
}
>
<Icon
title={intl.formatMessage(messages.upstreamLinkOk)}
aria-label={intl.formatMessage(messages.upstreamLinkOk)}
src={Newsstand}
size={size}
/>
{secondIcon}
</div>
</OverlayTrigger>
);
};
export const UpstreamInfoIcon: React.FC<UpstreamInfoIconProps & { openSyncModal: () => 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 (
<Button
variant="tertiary"
className="border-0 px-0 py-0"
onClick={handleSyncModal}
>
<UpstreamInfoIconContent upstreamInfo={upstreamInfo} size={size} />
</Button>
);
}
return (
<Icon
{...iconProps}
size={size}
className="mr-1"
/>
<UpstreamInfoIconContent upstreamInfo={upstreamInfo} size={size} />
);
};

View File

@@ -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 <b>{upstreamName}</b> 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 <b>{upstreamName}</b>',
id: 'upstream-icon.ok.tooltip',
description: 'Tooltip text for the upstream icon when the link is valid.',
},
});
export default messages;