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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -75,6 +75,7 @@ const section = {
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
upstreamName: 'Upstream',
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
|
||||
@@ -257,7 +257,13 @@ const SectionCard = ({
|
||||
isExpanded={isExpanded}
|
||||
onTitleClick={handleExpandContent}
|
||||
namePrefix={namePrefix}
|
||||
prefixIcon={<UpstreamInfoIcon upstreamInfo={upstreamInfo} />}
|
||||
prefixIcon={(
|
||||
<UpstreamInfoIcon
|
||||
upstreamInfo={upstreamInfo}
|
||||
size="md"
|
||||
openSyncModal={openSyncModal}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ const subsection: XBlock = {
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
upstreamName: 'Upstream',
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
|
||||
@@ -205,7 +205,13 @@ const SubsectionCard = ({
|
||||
isExpanded={isExpanded}
|
||||
onTitleClick={handleExpandContent}
|
||||
namePrefix={namePrefix}
|
||||
prefixIcon={<UpstreamInfoIcon upstreamInfo={upstreamInfo} />}
|
||||
prefixIcon={(
|
||||
<UpstreamInfoIcon
|
||||
upstreamInfo={upstreamInfo}
|
||||
size="sm"
|
||||
openSyncModal={openSyncModal}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const unit = {
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
upstreamName: 'Upstream',
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface UpstreamChildrenInfo {
|
||||
export interface UpstreamInfo {
|
||||
readyToSync: boolean,
|
||||
upstreamRef: string,
|
||||
upstreamName: string,
|
||||
versionSynced: number,
|
||||
versionAvailable: number | null,
|
||||
versionDeclined: number | null,
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
@import "./modal-iframe";
|
||||
@import "./alert-message";
|
||||
@import "./inplace-text-editor/InplaceTextEditor";
|
||||
@import "./upstream-info-icon/UpstreamInfoIcon";
|
||||
|
||||
47
src/generic/upstream-info-icon/UpstreamInfoIcon.scss
Normal file
47
src/generic/upstream-info-icon/UpstreamInfoIcon.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user