diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md
index 7190c1b16..565a11c7c 100644
--- a/doc/release/RELEASE-NOTES.md
+++ b/doc/release/RELEASE-NOTES.md
@@ -20,6 +20,10 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* Updated the GET `api/facilities/` and `api/facilities/{os_id}/` endpoints to include `contributor_type` (the raw type from the database) for both public and anonymous sources. Each contributor entry now also includes a `count` field (1 for public contributors and an aggregated count for anonymous entries of the same type), allowing the front end to display and sum counts by type (e.g., “18 Brands”, “9 Suppliers”).
* Additionally, updated GET `api/facilities/{os_id}/` to return the claim request creation date. All of this information is required for the redesigned Production Location page - specifically for the claim banner - as well as for the supply chain network.
* [OSDEV-2369](https://opensupplyhub.atlassian.net/browse/OSDEV-2369) - Moved single-facility data loading and redirect logic into the Production Location details container so the sidebar (including the "Contribute to this profile" section) and main content render with consistent facility data.
+* [OSDEV-2370](https://opensupplyhub.atlassian.net/browse/OSDEV-2370) - Created reusable data point and drawer components for the Production Location page redesign:
+ * Introduced shared `IconComponent` (interactive tooltip with icon) and `LearnMoreLink`; refactored OS ID badge, Data Sources, and Claim status to use them.
+ * Added `DataPoint` component (label, value, status, contributor link, date, and optional "data sources" drawer trigger) and `ContributionsDrawer` with promoted source and list of contribution cards linking to contributor profiles.
+ * Claim form profile step and related tooltips now use `IconComponent`.
### Architecture/Environment changes
* Increased the CPU and memory allocation for the DedupeHub container to `8 CPU` and `40 GB` in the Terraform deployment configuration to address memory overload issues during production location reindexing for the `Test` environment.
diff --git a/src/react/src/__tests__/components/ContributionsDrawer.test.jsx b/src/react/src/__tests__/components/ContributionsDrawer.test.jsx
new file mode 100644
index 000000000..ab140dd47
--- /dev/null
+++ b/src/react/src/__tests__/components/ContributionsDrawer.test.jsx
@@ -0,0 +1,135 @@
+import React, { useState } from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+
+import ContributionsDrawer from '../../components/ProductionLocation/ContributionsDrawer/ContributionsDrawer';
+
+const theme = createMuiTheme();
+
+const renderContributionsDrawer = (props = {}) =>
+ render(
+
+
+
+
+ ,
+ );
+
+function DrawerWithTrigger() {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+
+ );
+}
+
+describe('ContributionsDrawer', () => {
+ test('renders without crashing when open with required props', () => {
+ renderContributionsDrawer({ open: true, onClose: () => {} });
+
+ expect(screen.getByTestId('contributions-drawer')).toBeInTheDocument();
+ });
+
+ test('drawer content is visible when open is true', () => {
+ renderContributionsDrawer({ open: true, onClose: () => {} });
+
+ expect(screen.getByTestId('contributions-drawer')).toBeInTheDocument();
+ expect(screen.getByTestId('contributions-drawer-title')).toBeInTheDocument();
+ });
+
+ test('calls onClose when close button is clicked', () => {
+ const onClose = jest.fn();
+ renderContributionsDrawer({ open: true, onClose });
+
+ fireEvent.click(screen.getByTestId('contributions-drawer-close'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ test('clicking trigger opens drawer and content is visible', () => {
+ render();
+
+ expect(screen.queryByTestId('contributions-drawer')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId('open-drawer-trigger'));
+
+ expect(screen.getByTestId('contributions-drawer')).toBeInTheDocument();
+ expect(screen.getByTestId('contributions-drawer-title')).toBeInTheDocument();
+ });
+
+ test('does not render contribution list when contributions is empty', () => {
+ renderContributionsDrawer({
+ open: true,
+ onClose: () => {},
+ contributions: [],
+ });
+
+ expect(
+ screen.queryByTestId('contributions-drawer-list'),
+ ).not.toBeInTheDocument();
+ });
+
+ test('does not render promoted card when promotedContribution is null', () => {
+ renderContributionsDrawer({
+ open: true,
+ onClose: () => {},
+ promotedContribution: null,
+ });
+
+ expect(
+ screen.queryByTestId('contribution-card-promoted'),
+ ).not.toBeInTheDocument();
+ });
+
+ test('renders contribution list and cards when contributions provided', () => {
+ renderContributionsDrawer({
+ open: true,
+ onClose: () => {},
+ contributions: [
+ {
+ value: 'Value A',
+ sourceName: 'Source A',
+ date: '2022-01-01',
+ userId: 1,
+ },
+ ],
+ });
+
+ expect(screen.getByTestId('contributions-drawer-list')).toBeInTheDocument();
+ expect(screen.getByTestId('contribution-card')).toBeInTheDocument();
+ });
+
+ test('renders promoted card when promotedContribution provided', () => {
+ renderContributionsDrawer({
+ open: true,
+ onClose: () => {},
+ promotedContribution: {
+ value: 'Promoted Value',
+ sourceName: 'Promoted Source',
+ date: '2023-01-01',
+ userId: 10,
+ },
+ });
+
+ expect(
+ screen.getByTestId('contribution-card-promoted'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/react/src/__tests__/components/DataPoint.test.jsx b/src/react/src/__tests__/components/DataPoint.test.jsx
new file mode 100644
index 000000000..944185ab3
--- /dev/null
+++ b/src/react/src/__tests__/components/DataPoint.test.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+
+import DataPoint from '../../components/ProductionLocation/DataPoint/DataPoint';
+import { STATUS_CLAIMED } from '../../components/ProductionLocation/DataPoint/constants';
+
+const theme = createMuiTheme();
+
+const renderDataPoint = (props = {}) =>
+ render(
+
+
+
+
+ ,
+ );
+
+describe('DataPoint', () => {
+ test('renders without crashing with required props', () => {
+ renderDataPoint({ label: 'Name', value: 'Facility One' });
+
+ expect(screen.getByTestId('data-point')).toBeInTheDocument();
+ });
+
+ test('renders label and value via data-testid', () => {
+ renderDataPoint({ label: 'Name', value: 'Facility One' });
+
+ const label = screen.getByTestId('data-point-label');
+ const value = screen.getByTestId('data-point-value');
+ expect(label).toBeInTheDocument();
+ expect(label).toHaveTextContent('Name');
+ expect(value).toBeInTheDocument();
+ expect(value).toHaveTextContent('Facility One');
+ });
+
+ test('renders status chip when statusLabel is provided', () => {
+ renderDataPoint({
+ label: 'Name',
+ value: 'Value',
+ statusLabel: STATUS_CLAIMED,
+ });
+
+ const chip = screen.getByTestId('data-point-status-chip');
+ expect(chip).toBeInTheDocument();
+ });
+
+ test('renders contributor as link with correct href when userId is provided', () => {
+ renderDataPoint({
+ label: 'Name',
+ value: 'Value',
+ contributorName: 'Acme Corp',
+ userId: 42,
+ });
+
+ const contributor = screen.getByTestId('data-point-contributor');
+ expect(contributor).toBeInTheDocument();
+ const link = contributor.querySelector('a[href="/profile/42"]');
+ expect(link).toBeInTheDocument();
+ });
+
+ test('does not render status chip when statusLabel is null', () => {
+ renderDataPoint({ label: 'Name', value: 'Value' });
+
+ expect(screen.queryByTestId('data-point-status-chip')).not.toBeInTheDocument();
+ });
+
+ test('does not render contributor section when contributorName is null', () => {
+ renderDataPoint({
+ label: 'Name',
+ value: 'Value',
+ userId: 1,
+ });
+
+ expect(screen.queryByTestId('data-point-contributor')).not.toBeInTheDocument();
+ });
+
+ test('does not render sources button when drawerData has no contributions', () => {
+ renderDataPoint({
+ label: 'Name',
+ value: 'Value',
+ drawerData: { contributions: [], title: 'Title', subtitle: null },
+ onOpenDrawer: jest.fn(),
+ renderDrawer: () => null,
+ });
+
+ expect(screen.queryByTestId('data-point-sources-button')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/react/src/__tests__/components/DataSourcesInfo.test.jsx b/src/react/src/__tests__/components/DataSourcesInfo.test.jsx
index e7d655934..4a276bafe 100644
--- a/src/react/src/__tests__/components/DataSourcesInfo.test.jsx
+++ b/src/react/src/__tests__/components/DataSourcesInfo.test.jsx
@@ -84,9 +84,7 @@ describe('ProductionLocation DataSourcesInfo', () => {
renderDataSourcesInfo();
expect(
- screen.getByRole('button', {
- name: /more information about data sources/i,
- }),
+ screen.getByTestId('data-sources-info-tooltip'),
).toBeInTheDocument();
});
diff --git a/src/react/src/__tests__/components/OsIdBadge.test.jsx b/src/react/src/__tests__/components/OsIdBadge.test.jsx
index c5644d407..046f4e522 100644
--- a/src/react/src/__tests__/components/OsIdBadge.test.jsx
+++ b/src/react/src/__tests__/components/OsIdBadge.test.jsx
@@ -1,7 +1,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
-import ProductionLocationDetailsOsIdBadge from '../../components/ProductionLocation/Heading/osIdBadge/OsIdBadge';
+import ProductionLocationDetailsOsIdBadge from '../../components/ProductionLocation/Heading/OsIdBadge/OsIdBadge';
jest.mock('react-toastify', () => ({
toast: jest.fn(),
@@ -51,11 +51,7 @@ describe('ProductionLocationDetailsOsIdBadge', () => {
test('renders info button for OS ID tooltip', () => {
renderOsIdBadge({ osId: 'CN2021250D1DTN7' });
- expect(
- screen.getByRole('button', {
- name: /more information about os id/i,
- }),
- ).toBeInTheDocument();
+ expect(screen.getByTestId('os-id-badge-info')).toBeInTheDocument();
});
test('shows Copy Link and Copy OS ID buttons when osId is present', () => {
diff --git a/src/react/src/components/Contribute/DialogTooltip.jsx b/src/react/src/components/Contribute/DialogTooltip.jsx
index 860aaa237..a458d10d8 100644
--- a/src/react/src/components/Contribute/DialogTooltip.jsx
+++ b/src/react/src/components/Contribute/DialogTooltip.jsx
@@ -1,101 +1,21 @@
-import React, { useState, useRef, useCallback, useEffect } from 'react';
-import { shape, string, node, bool } from 'prop-types';
-import { v4 as uuidv4 } from 'uuid';
+import React, { useState } from 'react';
+import { shape, string, node } from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import Tooltip from '@material-ui/core/Tooltip';
-import InteractiveTrigger from './InteractiveTrigger';
import { makeDialogTooltipStyles } from '../../util/styles';
-const LEAVE_TRIGGER_DELAY_MS = 150;
-
-const DialogTooltip = ({
- text,
- childComponent,
- classes,
- interactive = false,
- textHref,
-}) => {
+const DialogTooltip = ({ text, childComponent, classes }) => {
const [arrowRef, setArrowRef] = useState(null);
- const [open, setOpen] = useState(false);
- const closeTimerRef = useRef(null);
- const [tooltipId] = useState(() => `dialog-tooltip-${uuidv4()}`);
-
- const clearCloseTimer = useCallback(() => {
- if (closeTimerRef.current) {
- clearTimeout(closeTimerRef.current);
- closeTimerRef.current = null;
- }
- }, []);
-
- const handleTriggerEnter = useCallback(() => {
- clearCloseTimer();
- setOpen(true);
- }, [clearCloseTimer]);
-
- const handleTriggerLeave = useCallback(() => {
- if (!interactive) return;
- clearCloseTimer();
- closeTimerRef.current = setTimeout(
- () => setOpen(false),
- LEAVE_TRIGGER_DELAY_MS,
- );
- }, [interactive, clearCloseTimer]);
-
- useEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
-
- const handlePopperEnter = useCallback(() => {
- if (!interactive) return;
- clearCloseTimer();
- setOpen(true);
- }, [interactive, clearCloseTimer]);
-
- const handlePopperLeave = useCallback(() => {
- if (!interactive) return;
- setOpen(false);
- }, [interactive]);
-
- const titleContent = (
-
- {text}
- {textHref}
-
-
- );
-
- const arrowModifier = {
- enabled: Boolean(arrowRef),
- element: arrowRef,
- };
- const popperProps = {
- popperOptions: {
- modifiers: { arrow: arrowModifier },
- },
- };
- if (interactive) {
- popperProps.onMouseEnter = handlePopperEnter;
- popperProps.onMouseLeave = handlePopperLeave;
- popperProps.onFocus = handlePopperEnter;
- popperProps.onBlur = handlePopperLeave;
- }
-
- const triggerWrapper = interactive ? (
-
- ) : (
- childComponent
- );
-
return (
setOpen(false) : undefined}
- title={titleContent}
+ enterDelay={200}
+ leaveDelay={200}
+ title={
+ <>
+ {text}
+
+ >
+ }
classes={{
tooltip: classes.tooltipStyles,
popper: classes.popperStyles,
@@ -104,9 +24,18 @@ const DialogTooltip = ({
tooltipPlacementTop: classes.placementTop,
tooltipPlacementBottom: classes.placementBottom,
}}
- PopperProps={popperProps}
+ PopperProps={{
+ popperOptions: {
+ modifiers: {
+ arrow: {
+ enabled: Boolean(arrowRef),
+ element: arrowRef,
+ },
+ },
+ },
+ }}
>
- {triggerWrapper}
+ {childComponent}
);
};
@@ -114,8 +43,6 @@ const DialogTooltip = ({
DialogTooltip.propTypes = {
text: string.isRequired,
childComponent: node.isRequired,
- interactive: bool,
- textHref: node,
classes: shape({
arrow: string.isRequired,
tooltipStyles: string.isRequired,
@@ -127,9 +54,4 @@ DialogTooltip.propTypes = {
}).isRequired,
};
-DialogTooltip.defaultProps = {
- interactive: false,
- textHref: null,
-};
-
export default withStyles(makeDialogTooltipStyles)(DialogTooltip);
diff --git a/src/react/src/components/Contribute/InteractiveTrigger.jsx b/src/react/src/components/Contribute/InteractiveTrigger.jsx
deleted file mode 100644
index 68717e890..000000000
--- a/src/react/src/components/Contribute/InteractiveTrigger.jsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import React from 'react';
-import { node, string, func } from 'prop-types';
-
-const RESET_BUTTON_STYLES = {
- display: 'inline',
- margin: 0,
- padding: 0,
- border: 'none',
- background: 'none',
- font: 'inherit',
-};
-
-const InteractiveTrigger = ({
- childComponent,
- tooltipId,
- onEnter,
- onLeave,
-}) => {
- const handleKeyDownForClone = e => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- onEnter();
- }
- childComponent.props?.onKeyDown?.(e);
- };
-
- if (!React.isValidElement(childComponent)) {
- return (
-
-
-
- );
- }
-
- const cloned = React.cloneElement(childComponent, {
- onFocus: e => {
- onEnter();
- childComponent.props.onFocus?.(e);
- },
- onBlur: e => {
- onLeave();
- childComponent.props.onBlur?.(e);
- },
- onKeyDown: handleKeyDownForClone,
- 'aria-describedby': tooltipId,
- });
- return (
-
- {cloned}
-
- );
-};
-
-InteractiveTrigger.propTypes = {
- childComponent: node.isRequired,
- tooltipId: string.isRequired,
- onEnter: func.isRequired,
- onLeave: func.isRequired,
-};
-
-export default InteractiveTrigger;
diff --git a/src/react/src/components/InitialClaimFlow/ClaimForm/Steps/ProfileStep/ProfileStep.jsx b/src/react/src/components/InitialClaimFlow/ClaimForm/Steps/ProfileStep/ProfileStep.jsx
index eb786a5c8..848841b40 100644
--- a/src/react/src/components/InitialClaimFlow/ClaimForm/Steps/ProfileStep/ProfileStep.jsx
+++ b/src/react/src/components/InitialClaimFlow/ClaimForm/Steps/ProfileStep/ProfileStep.jsx
@@ -8,12 +8,10 @@ import Build from '@material-ui/icons/Build';
import VerifiedUser from '@material-ui/icons/VerifiedUser';
import Spa from '@material-ui/icons/Spa';
-import Tooltip from '@material-ui/core/Tooltip';
-import HelpOutline from '@material-ui/icons/HelpOutline';
-import IconButton from '@material-ui/core/IconButton';
import Switch from '@material-ui/core/Switch';
import FormFieldTitle from '../../../Shared/FormFieldTitle/FormFieldTitle';
import FormFieldHint from '../../../Shared/FormFieldHint/FormFieldHint';
+import IconComponent from '../../../../Shared/IconComponent/IconComponent';
import DialogTooltip from '../../../../Contribute/DialogTooltip';
import StyledSelect from '../../../../Filters/StyledSelect';
import InputErrorText from '../../../../Contribute/InputErrorText';
@@ -136,21 +134,10 @@ const ProfileStep = ({
label={
<>
Production Location Name in Native Language
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -192,25 +179,10 @@ const ProfileStep = ({
label={
<>
Company Phone
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -264,27 +236,10 @@ const ProfileStep = ({
label={
<>
Company Website
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -342,21 +297,10 @@ const ProfileStep = ({
label={
<>
Production Location Description
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -481,25 +425,10 @@ const ProfileStep = ({
label={
<>
Office Name
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -542,25 +471,10 @@ const ProfileStep = ({
label={
<>
Office Address
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -702,25 +616,10 @@ const ProfileStep = ({
label={
<>
Location Type(s)
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -776,25 +675,10 @@ const ProfileStep = ({
label={
<>
Processing Type(s)
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -858,25 +742,10 @@ const ProfileStep = ({
label={
<>
Product Types
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -909,25 +778,10 @@ const ProfileStep = ({
label={
<>
Number of Workers
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -969,25 +823,10 @@ const ProfileStep = ({
label={
<>
Percentage of Female Workers
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -1037,25 +876,10 @@ const ProfileStep = ({
label={
<>
Minimum Order Quantity
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -1112,25 +936,10 @@ const ProfileStep = ({
label={
<>
Average Lead Time
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
@@ -1221,21 +1030,10 @@ const ProfileStep = ({
label={
<>
Affiliations
-
-
-
-
-
+ />
>
}
classes={{ title: classes.formLabel }}
diff --git a/src/react/src/components/InitialClaimFlow/ClaimForm/Steps/ProfileStep/styles.js b/src/react/src/components/InitialClaimFlow/ClaimForm/Steps/ProfileStep/styles.js
index 0ff36fc6e..46caebd9c 100644
--- a/src/react/src/components/InitialClaimFlow/ClaimForm/Steps/ProfileStep/styles.js
+++ b/src/react/src/components/InitialClaimFlow/ClaimForm/Steps/ProfileStep/styles.js
@@ -44,6 +44,9 @@ export const profileStepStyles = theme =>
fontSize: '21px',
fontWeight: 600,
}),
+ helpTooltip: Object.freeze({
+ marginLeft: theme.spacing.unit / 2,
+ }),
inputStyles: Object.freeze({
backgroundColor: COLOURS.WHITE,
fontSize: '18px',
@@ -126,25 +129,6 @@ export const profileStepStyles = theme =>
greenBg: Object.freeze({
backgroundColor: COLOURS.LIGHT_GREEN,
}),
- helpIcon: Object.freeze({
- fontSize: '1rem',
- color: COLOURS.DARK_GREY,
- marginLeft: theme.spacing.unit / 2,
- }),
- helpIconButton: Object.freeze({
- padding: 0,
- '&:hover': {
- backgroundColor: 'transparent',
- },
- }),
- tooltip: Object.freeze({
- fontSize: '14px',
- backgroundColor: COLOURS.WHITE,
- color: 'rgba(0, 0, 0, 0.87)',
- border: `1px solid ${COLOURS.GREY}`,
- padding: theme.spacing.unit * 1.5,
- maxWidth: 300,
- }),
});
export default profileStepStyles;
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionCard/ContributionCard.jsx b/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionCard/ContributionCard.jsx
new file mode 100644
index 000000000..b025ee546
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionCard/ContributionCard.jsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import {
+ object,
+ string,
+ oneOfType,
+ instanceOf,
+ bool,
+ number,
+} from 'prop-types';
+import { Link } from 'react-router-dom';
+import Typography from '@material-ui/core/Typography';
+import { withStyles } from '@material-ui/core/styles';
+import ScheduleIcon from '@material-ui/icons/Schedule';
+import OpenInNewIcon from '@material-ui/icons/OpenInNew';
+
+import { makeProfileRouteLink, formatDate } from '../../../../util/util';
+import { DATE_FORMATS } from '../../../../util/constants';
+import contributionCardStyles from './styles';
+
+const ContributionCard = ({
+ classes,
+ value,
+ sourceName,
+ date,
+ promoted,
+ userId,
+ 'data-testid': dataTestId,
+}) => (
+
+
+ {value}
+
+
+
+ {sourceName &&
+ (userId != null ? (
+
+ {sourceName}
+
+
+ ) : (
+
+ {sourceName}
+
+ ))}
+
+
+ {date ? (
+
+
+ {formatDate(date, DATE_FORMATS.LONG)}
+
+ ) : null}
+
+
+
+);
+
+ContributionCard.propTypes = {
+ classes: object.isRequired,
+ value: string.isRequired,
+ sourceName: string,
+ date: oneOfType([string, instanceOf(Date)]),
+ promoted: bool,
+ userId: oneOfType([string, number]),
+ 'data-testid': string,
+};
+
+ContributionCard.defaultProps = {
+ sourceName: null,
+ date: null,
+ promoted: false,
+ userId: null,
+ 'data-testid': undefined,
+};
+
+export default withStyles(contributionCardStyles)(ContributionCard);
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionCard/styles.js b/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionCard/styles.js
new file mode 100644
index 000000000..4df28de9f
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionCard/styles.js
@@ -0,0 +1,94 @@
+import COLOURS from '../../../../util/COLOURS';
+
+export default () =>
+ Object.freeze({
+ contributionCard: Object.freeze({
+ backgroundColor: COLOURS.WHITE,
+ border: `1px solid ${COLOURS.LIGHT_BORDER_GREY}`,
+ padding: '16px',
+ marginBottom: '8px',
+ }),
+ contributionCardPromoted: Object.freeze({
+ backgroundColor: COLOURS.LIGHT_LAVENDER,
+ }),
+ contributionValueContainer: Object.freeze({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ }),
+ contributionValue: Object.freeze({
+ fontWeight: 600,
+ fontSize: '1.125rem',
+ lineHeight: 1.4,
+ color: COLOURS.NEAR_BLACK,
+ marginBottom: '8px',
+ }),
+ contributionValuePromoted: Object.freeze({
+ fontSize: '1.25rem',
+ }),
+ contributionSourceContainer: Object.freeze({
+ maxWidth: '45%',
+ }),
+ contributionSource: Object.freeze({
+ fontSize: '1rem',
+ lineHeight: 1.4,
+ color: COLOURS.PURPLE,
+ }),
+ contributionSourcePromoted: Object.freeze({
+ fontSize: '1.1rem',
+ }),
+ contributionSourceLink: Object.freeze({
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '4px',
+ fontSize: '1rem',
+ lineHeight: 1.4,
+ color: COLOURS.PURPLE,
+ textDecoration: 'none',
+ cursor: 'pointer',
+ '&:hover': Object.freeze({
+ textDecoration: 'underline',
+ }),
+ }),
+ contributionSourceLinkPromoted: Object.freeze({
+ fontSize: '1.1rem',
+ }),
+ contributionSourceIcon: Object.freeze({
+ fontSize: '0.875rem',
+ color: COLOURS.PURPLE,
+ marginLeft: '4px',
+ }),
+ contributionSourceIconPromoted: Object.freeze({
+ fontSize: '1rem',
+ }),
+ contributionMetaContainer: Object.freeze({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ fontSize: '0.875rem',
+ color: COLOURS.DARK_GREY,
+ gap: '8px',
+ maxWidth: '45%',
+ }),
+ contributionLink: Object.freeze({
+ padding: '4px',
+ color: COLOURS.DARK_GREY,
+ }),
+ dateWithIcon: Object.freeze({
+ display: 'inline-flex',
+ alignItems: 'center',
+ fontSize: '1rem',
+ }),
+ dateWithIconPromoted: Object.freeze({
+ fontSize: '1.1rem',
+ }),
+ dateIcon: Object.freeze({
+ marginRight: '4px',
+ marginTop: '2px',
+ fontSize: '0.8125rem',
+ color: COLOURS.DARK_GREY,
+ }),
+ dateIconPromoted: Object.freeze({
+ fontSize: '0.9rem',
+ }),
+ });
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionsDrawer.jsx b/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionsDrawer.jsx
new file mode 100644
index 000000000..7c8be5d05
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/ContributionsDrawer.jsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import {
+ object,
+ bool,
+ func,
+ string,
+ shape,
+ oneOfType,
+ instanceOf,
+ arrayOf,
+ number,
+} from 'prop-types';
+import Drawer from '@material-ui/core/Drawer';
+import Typography from '@material-ui/core/Typography';
+import IconButton from '@material-ui/core/IconButton';
+import { withStyles } from '@material-ui/core/styles';
+import Divider from '@material-ui/core/Divider';
+import CloseIcon from '@material-ui/icons/Close';
+import PeopleOutlineIcon from '@material-ui/icons/PeopleOutline';
+
+import ContributionCard from './ContributionCard/ContributionCard';
+import InfoBox from './InfoBox/InfoBox';
+import {
+ DEFAULT_TITLE,
+ PROMOTED_SECTION_LABEL,
+ INFO_PROMOTED_TITLE,
+ INFO_CONTRIBUTIONS_TEXT,
+ LEARN_MORE_LABEL,
+ LEARN_MORE_OPEN_DATA_MODEL_URL,
+} from './constants';
+import InfoPromotedText from './InfoPromotedText/InfoPromotedText';
+import DrawerSubtitle from './DrawerSubtitle/DrawerSubtitle';
+import {
+ getContributorCount,
+ getContributionsCount,
+ getContributionsSectionLabel,
+} from './utils';
+import contributionsDrawerStyles from './styles';
+
+const ContributionsDrawer = ({
+ classes,
+ open,
+ onClose,
+ fieldName,
+ promotedContribution,
+ contributions,
+}) => {
+ const contributionsCount = getContributionsCount(contributions);
+ const contributorCount = getContributorCount([
+ ...contributions,
+ promotedContribution,
+ ]);
+ const sectionLabel = getContributionsSectionLabel(contributions);
+
+ return (
+
+
+
+
+
+
+ {DEFAULT_TITLE}
+
+
+
+
+
+
+
+
+ {promotedContribution ? (
+ <>
+
+ {PROMOTED_SECTION_LABEL}
+
+
+
+
+
+ >
+ ) : null}
+
+
+ {sectionLabel}
+
+
+ {INFO_CONTRIBUTIONS_TEXT}
+
+ {contributionsCount > 0 ? (
+
+ {contributions.map((item, index) => (
+
+ ))}
+
+ ) : null}
+
+
+ );
+};
+
+ContributionsDrawer.propTypes = {
+ classes: object.isRequired,
+ open: bool.isRequired,
+ onClose: func.isRequired,
+ fieldName: string,
+ promotedContribution: shape({
+ value: string,
+ sourceName: string,
+ date: oneOfType([string, instanceOf(Date)]),
+ linkUrl: string,
+ userId: oneOfType([string, number]),
+ }),
+ contributions: arrayOf(
+ shape({
+ id: oneOfType([string, number]),
+ value: string,
+ sourceName: string,
+ date: oneOfType([string, instanceOf(Date)]),
+ linkUrl: string,
+ userId: oneOfType([string, number]),
+ }),
+ ),
+};
+
+ContributionsDrawer.defaultProps = {
+ fieldName: null,
+ promotedContribution: null,
+ contributions: [],
+};
+
+export default withStyles(contributionsDrawerStyles)(ContributionsDrawer);
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/DrawerSubtitle/DrawerSubtitle.jsx b/src/react/src/components/ProductionLocation/ContributionsDrawer/DrawerSubtitle/DrawerSubtitle.jsx
new file mode 100644
index 000000000..86d7fedba
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/DrawerSubtitle/DrawerSubtitle.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { object, string, number } from 'prop-types';
+import Typography from '@material-ui/core/Typography';
+import { withStyles } from '@material-ui/core/styles';
+
+import drawerSubtitleStyles from './styles';
+
+const DrawerSubtitle = ({ classes, fieldName, contributorCount }) => {
+ if (fieldName == null || fieldName === '') {
+ return null;
+ }
+ const count = contributorCount;
+ const orgText =
+ count === 1 ? '1 organization has' : `${count} organizations have`;
+ return (
+
+ {orgText} contributed data for{' '}
+ {fieldName}
+
+ );
+};
+
+DrawerSubtitle.propTypes = {
+ classes: object.isRequired,
+ fieldName: string,
+ contributorCount: number.isRequired,
+};
+
+DrawerSubtitle.defaultProps = {
+ fieldName: null,
+};
+
+export default withStyles(drawerSubtitleStyles)(DrawerSubtitle);
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/DrawerSubtitle/styles.js b/src/react/src/components/ProductionLocation/ContributionsDrawer/DrawerSubtitle/styles.js
new file mode 100644
index 000000000..02acfbeae
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/DrawerSubtitle/styles.js
@@ -0,0 +1,17 @@
+import COLOURS from '../../../../util/COLOURS';
+
+export default () =>
+ Object.freeze({
+ subtitle: Object.freeze({
+ marginTop: '8px',
+ marginBottom: '0px',
+ fontSize: '1rem',
+ lineHeight: 1.4,
+ color: COLOURS.DARK_GREY,
+ paddingBottom: '16px',
+ }),
+ fieldName: Object.freeze({
+ fontWeight: 600,
+ color: COLOURS.NEAR_BLACK,
+ }),
+ });
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoBox/InfoBox.jsx b/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoBox/InfoBox.jsx
new file mode 100644
index 000000000..048a4dae6
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoBox/InfoBox.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { object, string, node, oneOf } from 'prop-types';
+import Typography from '@material-ui/core/Typography';
+import InfoIcon from '@material-ui/icons/InfoOutlined';
+import { withStyles } from '@material-ui/core/styles';
+
+import infoBoxStyles from './styles';
+
+const InfoBox = ({
+ classes,
+ title,
+ children,
+ variant,
+ learnMoreUrl,
+ learnMoreLabel,
+}) => {
+ const isPromoted = variant === 'promoted';
+ const boxClass = isPromoted
+ ? classes.infoBoxPromoted
+ : classes.infoBoxContributions;
+ const titleClass = isPromoted
+ ? classes.infoTitle
+ : `${classes.infoTitle} ${classes.infoTitleBlue}`;
+ const infoTextClass = isPromoted
+ ? classes.infoTextPromoted
+ : classes.infoTextContributions;
+ const learnMoreLinkClass = isPromoted
+ ? classes.learnMoreLinkPromoted
+ : classes.learnMoreLinkContributions;
+ const showInfoIcon = !isPromoted;
+
+ const content = (
+ <>
+ {title ? (
+
+ {title}
+
+ ) : null}
+
+ {children}
+
+ {learnMoreUrl && learnMoreLabel ? (
+
+ {learnMoreLabel} →
+
+ ) : null}
+ >
+ );
+
+ return (
+
+ {showInfoIcon ? (
+
+ ) : (
+ content
+ )}
+
+ );
+};
+
+InfoBox.propTypes = {
+ classes: object.isRequired,
+ title: string,
+ children: node.isRequired,
+ variant: oneOf(['promoted', 'contributions']),
+ learnMoreUrl: string,
+ learnMoreLabel: string,
+};
+
+InfoBox.defaultProps = {
+ title: null,
+ variant: 'contributions',
+ learnMoreUrl: null,
+ learnMoreLabel: null,
+};
+
+export default withStyles(infoBoxStyles)(InfoBox);
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoBox/styles.js b/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoBox/styles.js
new file mode 100644
index 000000000..06d202b4a
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoBox/styles.js
@@ -0,0 +1,70 @@
+import COLOURS from '../../../../util/COLOURS';
+
+export default () =>
+ Object.freeze({
+ infoBox: Object.freeze({
+ padding: '16px',
+ marginBottom: '16px',
+ }),
+ infoBoxPromoted: Object.freeze({
+ backgroundColor: COLOURS.LIGHT_LAVENDER,
+ border: `1px solid ${COLOURS.LIGHT_LAVENDER_BORDER}`,
+ }),
+ infoBoxContributions: Object.freeze({
+ backgroundColor: COLOURS.EXTRA_LIGHT_BLUE,
+ border: `1px solid ${COLOURS.LIGHT_SKY_BLUE}`,
+ }),
+ infoTitle: Object.freeze({
+ fontSize: '0.875rem',
+ lineHeight: 1.4,
+ color: COLOURS.PURPLE,
+ marginBottom: '8px',
+ }),
+ infoTitleBlue: Object.freeze({
+ color: COLOURS.MATERIAL_BLUE,
+ }),
+ infoBoxWithIcon: Object.freeze({
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: '8px',
+ }),
+ infoIcon: Object.freeze({
+ fontSize: '1.25rem',
+ color: COLOURS.MATERIAL_BLUE,
+ flexShrink: 0,
+ marginTop: '2px',
+ marginRight: '8px',
+ }),
+ infoBoxContent: Object.freeze({
+ flex: 1,
+ }),
+ infoTextPromoted: Object.freeze({
+ fontSize: '0.875rem',
+ lineHeight: 1.5,
+ color: COLOURS.DARK_GREY,
+ }),
+ infoTextContributions: Object.freeze({
+ fontSize: '1rem',
+ lineHeight: 1.5,
+ color: COLOURS.DARK_GREY,
+ }),
+ learnMoreLinkContributions: Object.freeze({
+ marginTop: '8px',
+ display: 'inline-flex',
+ alignItems: 'center',
+ color: COLOURS.MATERIAL_BLUE,
+ fontSize: '1rem',
+ textDecoration: 'none',
+ '&:hover': Object.freeze({
+ textDecoration: 'underline',
+ }),
+ }),
+ learnMoreLinkPromoted: Object.freeze({
+ marginTop: '8px',
+ display: 'inline-flex',
+ alignItems: 'center',
+ color: COLOURS.MATERIAL_BLUE,
+ fontSize: '0.875rem',
+ textDecoration: 'none',
+ }),
+ });
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoPromotedText/InfoPromotedText.jsx b/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoPromotedText/InfoPromotedText.jsx
new file mode 100644
index 000000000..5d9f55745
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoPromotedText/InfoPromotedText.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { object } from 'prop-types';
+import { withStyles } from '@material-ui/core/styles';
+
+import infoPromotedTextStyles from './styles';
+
+const InfoPromotedText = ({ classes }) => (
+ <>
+ OS Hub automatically prioritizes data in this order: (1) claimed
+ locations where owners/managers submitted data, (2) most frequently
+ submitted values. The OS Hub team also actively moderates to promote
+ quality data. To request reordering, email{' '}
+
+ Support
+ {' '}
+ with the OS ID, preferred data entry, and justification.
+ >
+);
+
+InfoPromotedText.propTypes = {
+ classes: object.isRequired,
+};
+
+export default withStyles(infoPromotedTextStyles)(InfoPromotedText);
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoPromotedText/styles.js b/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoPromotedText/styles.js
new file mode 100644
index 000000000..033358261
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/InfoPromotedText/styles.js
@@ -0,0 +1,12 @@
+import COLOURS from '../../../../util/COLOURS';
+
+export default () =>
+ Object.freeze({
+ supportLink: Object.freeze({
+ color: COLOURS.PURPLE,
+ textDecoration: 'none',
+ '&:hover': Object.freeze({
+ textDecoration: 'underline',
+ }),
+ }),
+ });
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/constants.js b/src/react/src/components/ProductionLocation/ContributionsDrawer/constants.js
new file mode 100644
index 000000000..c0b2ae76f
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/constants.js
@@ -0,0 +1,9 @@
+export const DEFAULT_TITLE = 'All Data Sources';
+export const PROMOTED_SECTION_LABEL = 'Highlighted Data Source';
+export const CONTRIBUTIONS_SECTION_LABEL = 'Other Data Sources';
+export const INFO_PROMOTED_TITLE = 'Why is this data source displayed first?';
+export const INFO_CONTRIBUTIONS_TEXT =
+ 'Multiple organizations may have shared information for this data point. You can see the list of historical data sources below. Click on the organization name to learn more about them and the data they have shared';
+export const LEARN_MORE_LABEL = 'Learn more about our open data model';
+export const LEARN_MORE_OPEN_DATA_MODEL_URL =
+ 'https://info.opensupplyhub.org/resources/an-open-data-model';
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/styles.js b/src/react/src/components/ProductionLocation/ContributionsDrawer/styles.js
new file mode 100644
index 000000000..ab766e8fb
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/styles.js
@@ -0,0 +1,54 @@
+import COLOURS from '../../../util/COLOURS';
+
+export default () =>
+ Object.freeze({
+ drawerPaper: Object.freeze({
+ width: '390px',
+ boxShadow: '-4px 0 24px rgba(0,0,0,0.12)',
+ }),
+ drawerContent: Object.freeze({
+ padding: '24px',
+ overflowY: 'auto',
+ height: '100%',
+ }),
+ header: Object.freeze({
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ marginBottom: '12px',
+ }),
+ headerLeft: Object.freeze({
+ display: 'flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: '8px',
+ }),
+ titleIcon: Object.freeze({
+ fontSize: '1.5rem',
+ color: COLOURS.PURPLE,
+ }),
+ title: Object.freeze({
+ fontWeight: 700,
+ fontSize: '1.4rem',
+ lineHeight: 1.3,
+ color: COLOURS.NEAR_BLACK,
+ marginLeft: '10px',
+ }),
+ closeButton: Object.freeze({
+ margin: '-8px',
+ color: COLOURS.DARK_GREY,
+ '&:hover': Object.freeze({
+ backgroundColor: COLOURS.HOVER_GREY,
+ color: COLOURS.PURPLE,
+ }),
+ }),
+ sectionLabel: Object.freeze({
+ fontSize: '0.9rem',
+ color: COLOURS.DARK_GREY,
+ textTransform: 'uppercase',
+ letterSpacing: '0.5px',
+ marginTop: '16px',
+ marginBottom: '8px',
+ lineHeight: 1.2,
+ }),
+ });
diff --git a/src/react/src/components/ProductionLocation/ContributionsDrawer/utils.js b/src/react/src/components/ProductionLocation/ContributionsDrawer/utils.js
new file mode 100644
index 000000000..60bc970b6
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/ContributionsDrawer/utils.js
@@ -0,0 +1,24 @@
+import { CONTRIBUTIONS_SECTION_LABEL } from './constants';
+
+export const getContributionsCount = contributions =>
+ Array.isArray(contributions) ? contributions.length : 0;
+
+export const getContributionsSectionLabel = contributions => {
+ const count = getContributionsCount(contributions);
+ return count > 0
+ ? `${CONTRIBUTIONS_SECTION_LABEL} (${count})`
+ : CONTRIBUTIONS_SECTION_LABEL;
+};
+
+export const getContributorCount = contributions => {
+ if (!Array.isArray(contributions) || contributions.length === 0) {
+ return 0;
+ }
+ const definedIds = contributions
+ .map(contribution => contribution?.userId)
+ .filter(id => id != null);
+
+ const uniqueCount = new Set(definedIds).size;
+ const anonymousCount = contributions.length - definedIds.length;
+ return uniqueCount + anonymousCount;
+};
diff --git a/src/react/src/components/ProductionLocation/DataPoint/DataPoint.jsx b/src/react/src/components/ProductionLocation/DataPoint/DataPoint.jsx
new file mode 100644
index 000000000..e42b8b67d
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/DataPoint/DataPoint.jsx
@@ -0,0 +1,220 @@
+import React from 'react';
+import {
+ object,
+ string,
+ oneOfType,
+ node,
+ instanceOf,
+ oneOf,
+ shape,
+ array,
+ func,
+ number,
+} from 'prop-types';
+import Typography from '@material-ui/core/Typography';
+import Chip from '@material-ui/core/Chip';
+import Grid from '@material-ui/core/Grid';
+import { withStyles } from '@material-ui/core/styles';
+import { Link } from 'react-router-dom';
+import ScheduleIcon from '@material-ui/icons/Schedule';
+import PersonIcon from '@material-ui/icons/PersonOutline';
+
+import IconComponent from '../../Shared/IconComponent/IconComponent';
+import { profileRoute, DATE_FORMATS } from '../../../util/constants';
+import { formatDate } from '../../../util/util';
+import getSourcesCount from './utils';
+import SourcesButton from './SourcesButton/SourcesButton';
+import { STATUS_CLAIMED, STATUS_CROWDSOURCED } from './constants';
+import dataPointStyles from './styles';
+
+const DataPoint = ({
+ classes,
+ label,
+ value,
+ tooltipText,
+ statusLabel,
+ contributorName,
+ userId,
+ date,
+ drawerData,
+ onOpenDrawer,
+ renderDrawer,
+}) => {
+ const sourcesCount = getSourcesCount(drawerData);
+ const showSourcesButton = sourcesCount > 0 && onOpenDrawer && renderDrawer;
+
+ const getStatusChipClass = () => {
+ if (statusLabel === STATUS_CLAIMED) return classes.claimedChip;
+ if (statusLabel === STATUS_CROWDSOURCED)
+ return classes.crowdsourcedChip;
+ return null;
+ };
+
+ const tooltipIcon = tooltipText ? (
+
+ ) : null;
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ {tooltipIcon}
+
+
+
+
+
+ {value}
+
+
+
+
+ {(contributorName || statusLabel) && (
+
+ {statusLabel ? (
+
+
+
+ ) : null}
+ {contributorName ? (
+
+
+
+ {userId != null ? (
+
+ {contributorName}
+
+ ) : (
+
+ {contributorName}
+
+ )}
+
+
+ ) : null}
+
+ )}
+ {(date || showSourcesButton) && (
+
+ {date ? (
+ <>
+
+
+
+
+ {formatDate(
+ date,
+ DATE_FORMATS.LONG,
+ )}
+
+
+
+ >
+ ) : null}
+ {showSourcesButton && (
+
+
+
+ )}
+
+ )}
+
+
+ {renderDrawer && typeof renderDrawer === 'function'
+ ? renderDrawer()
+ : null}
+
+ );
+};
+
+DataPoint.propTypes = {
+ classes: object.isRequired,
+ label: string.isRequired,
+ value: oneOfType([string, node]).isRequired,
+ tooltipText: string,
+ statusLabel: oneOf([STATUS_CLAIMED, STATUS_CROWDSOURCED]),
+ contributorName: string,
+ userId: oneOfType([string, number]),
+ date: oneOfType([string, instanceOf(Date)]),
+ drawerData: shape({
+ promotedContribution: object,
+ contributions: array,
+ title: string,
+ subtitle: oneOfType([string, node]),
+ }),
+ onOpenDrawer: func,
+ renderDrawer: func,
+};
+
+DataPoint.defaultProps = {
+ tooltipText: null,
+ statusLabel: null,
+ contributorName: null,
+ userId: null,
+ date: null,
+ drawerData: null,
+ onOpenDrawer: null,
+ renderDrawer: null,
+};
+
+export default withStyles(dataPointStyles)(DataPoint);
diff --git a/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/SourcesButton.jsx b/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/SourcesButton.jsx
new file mode 100644
index 000000000..346daab5a
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/SourcesButton.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { object, number, func } from 'prop-types';
+import Button from '@material-ui/core/Button';
+import { withStyles } from '@material-ui/core/styles';
+
+import getSourcesButtonLabel from './utils';
+import sourcesButtonStyles from './styles';
+
+const SourcesButton = ({ classes, sourcesCount, onOpenDrawer }) => {
+ const sourcesButtonLabel = getSourcesButtonLabel(sourcesCount);
+
+ return (
+
+ );
+};
+
+SourcesButton.propTypes = {
+ classes: object.isRequired,
+ sourcesCount: number.isRequired,
+ onOpenDrawer: func.isRequired,
+};
+
+export default withStyles(sourcesButtonStyles)(SourcesButton);
diff --git a/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/styles.js b/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/styles.js
new file mode 100644
index 000000000..edead9d23
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/styles.js
@@ -0,0 +1,18 @@
+import COLOURS from '../../../../util/COLOURS';
+
+export default () =>
+ Object.freeze({
+ button: Object.freeze({
+ textTransform: 'none',
+ fontWeight: 600,
+ fontSize: '1.125rem',
+ lineHeight: 1.7,
+ padding: '0px',
+ minHeight: '0px',
+ color: COLOURS.PURPLE,
+ '&:hover': Object.freeze({
+ backgroundColor: 'transparent',
+ textDecoration: 'underline',
+ }),
+ }),
+ });
diff --git a/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/utils.js b/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/utils.js
new file mode 100644
index 000000000..921b81fe1
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/DataPoint/SourcesButton/utils.js
@@ -0,0 +1,6 @@
+const getSourcesButtonLabel = sourcesCount => {
+ const sourceWord = sourcesCount === 1 ? 'source' : 'sources';
+ return `+${sourcesCount} data ${sourceWord}`;
+};
+
+export default getSourcesButtonLabel;
diff --git a/src/react/src/components/ProductionLocation/DataPoint/constants.js b/src/react/src/components/ProductionLocation/DataPoint/constants.js
new file mode 100644
index 000000000..5fc2e7665
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/DataPoint/constants.js
@@ -0,0 +1,2 @@
+export const STATUS_CLAIMED = 'Claimed';
+export const STATUS_CROWDSOURCED = 'Crowdsourced';
diff --git a/src/react/src/components/ProductionLocation/DataPoint/styles.js b/src/react/src/components/ProductionLocation/DataPoint/styles.js
new file mode 100644
index 000000000..a2c386003
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/DataPoint/styles.js
@@ -0,0 +1,165 @@
+import COLOURS from '../../../util/COLOURS';
+
+export default () =>
+ Object.freeze({
+ root: Object.freeze({
+ paddingTop: '12px',
+ paddingBottom: '12px',
+ flexWrap: 'nowrap',
+ '&:hover $tooltipIcon': Object.freeze({
+ opacity: 1,
+ '& > svg': Object.freeze({
+ '&:hover': Object.freeze({
+ color: COLOURS.PURPLE,
+ }),
+ }),
+ }),
+ }),
+ labelColumn: Object.freeze({
+ width: '165px',
+ flexDirection: 'row',
+ }),
+ labelItem: Object.freeze({
+ width: '80%',
+ }),
+ label: Object.freeze({
+ fontSize: '1.125rem',
+ lineHeight: 1.7,
+ color: COLOURS.DARK_GREY,
+ }),
+ tooltipIconItem: Object.freeze({
+ display: 'flex',
+ alignItems: 'flex-start',
+ width: '20%',
+ }),
+ tooltipIcon: Object.freeze({
+ opacity: 0,
+ }),
+ valueColumn: Object.freeze({
+ minWidth: 0,
+ flex: 1,
+ overflow: 'hidden',
+ flexDirection: 'column',
+ }),
+ valueWithTooltip: Object.freeze({
+ display: 'inline-flex',
+ alignItems: 'center',
+ marginBottom: '4px',
+ }),
+ value: Object.freeze({
+ fontSize: '1.125rem',
+ fontWeight: 600,
+ lineHeight: 1.4,
+ }),
+ statusChip: Object.freeze({
+ height: '24px',
+ fontSize: '1rem',
+ fontWeight: 600,
+ borderRadius: 0,
+ marginRight: '8px',
+ '& .MuiChip-label': Object.freeze({
+ paddingLeft: '8px',
+ paddingRight: '8px',
+ }),
+ }),
+ claimedChip: Object.freeze({
+ backgroundColor: COLOURS.CLAIMED_CHIP_BG,
+ color: COLOURS.DARK_GREEN,
+ }),
+ crowdsourcedChip: Object.freeze({
+ backgroundColor: COLOURS.CROWDSOURCED_CHIP_BG,
+ color: COLOURS.CROWDSOURCED_CHIP_TEXT,
+ }),
+ metaRowContainer: Object.freeze({
+ marginLeft: '-16px',
+ }),
+ metaRow: Object.freeze({
+ fontSize: '1rem',
+ width: 'fit-content',
+ lineHeight: 1.43,
+ color: COLOURS.DARK_GREY,
+ marginLeft: '16px',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ }),
+ metaRowSecondary: Object.freeze({
+ fontSize: '1rem',
+ lineHeight: 1.43,
+ color: COLOURS.DARK_GREY,
+ width: 'fit-content',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ }),
+ contributorItem: Object.freeze({
+ width: 'fit-content',
+ }),
+ contributor: Object.freeze({
+ display: 'inline-flex',
+ alignItems: 'center',
+ }),
+ personIcon: Object.freeze({
+ marginRight: '4px',
+ marginTop: '2px',
+ fontSize: '1rem',
+ color: COLOURS.DARK_GREY,
+ }),
+ contributorName: Object.freeze({
+ fontSize: '1.125rem',
+ lineHeight: 1.7,
+ color: COLOURS.DARK_GREY,
+ }),
+ contributorNameLink: Object.freeze({
+ textDecoration: 'none',
+ cursor: 'pointer',
+ fontWeight: 500,
+ '&:hover': Object.freeze({
+ textDecoration: 'underline',
+ }),
+ }),
+ dateItem: Object.freeze({
+ position: 'relative',
+ marginLeft: '16px',
+ '&::before': Object.freeze({
+ content: "'·'",
+ fontSize: '1.25rem',
+ position: 'absolute',
+ left: '-9px',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ color: COLOURS.DARK_GREY,
+ }),
+ }),
+ dateBlock: Object.freeze({
+ display: 'inline-flex',
+ alignItems: 'center',
+ }),
+ dateIcon: Object.freeze({
+ marginRight: '4px',
+ fontSize: '0.8125rem',
+ color: COLOURS.DARK_GREY,
+ marginTop: '3px',
+ }),
+ dateText: Object.freeze({
+ fontSize: '1.125rem',
+ lineHeight: 1.7,
+ color: COLOURS.DARK_GREY,
+ }),
+ metaSeparator: Object.freeze({
+ marginLeft: '4px',
+ marginRight: '4px',
+ color: COLOURS.DARK_GREY,
+ }),
+ sourcesButtonItem: Object.freeze({
+ marginLeft: '16px',
+ position: 'relative',
+ '&::before': Object.freeze({
+ content: "'·'",
+ fontSize: '1.25rem',
+ position: 'absolute',
+ left: '-9px',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ color: COLOURS.DARK_GREY,
+ }),
+ }),
+ });
diff --git a/src/react/src/components/ProductionLocation/DataPoint/utils.js b/src/react/src/components/ProductionLocation/DataPoint/utils.js
new file mode 100644
index 000000000..a0708fe97
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/DataPoint/utils.js
@@ -0,0 +1,6 @@
+export default function getSourcesCount(drawerData) {
+ if (!drawerData) return 0;
+ return Array.isArray(drawerData.contributions)
+ ? drawerData.contributions.length
+ : 0;
+}
diff --git a/src/react/src/components/ProductionLocation/Heading/ClaimFlag/ClaimStatusRow.jsx b/src/react/src/components/ProductionLocation/Heading/ClaimFlag/ClaimStatusRow.jsx
index 30a69a532..6b9861a4a 100644
--- a/src/react/src/components/ProductionLocation/Heading/ClaimFlag/ClaimStatusRow.jsx
+++ b/src/react/src/components/ProductionLocation/Heading/ClaimFlag/ClaimStatusRow.jsx
@@ -2,11 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
-import IconButton from '@material-ui/core/IconButton';
-import InfoIcon from '@material-ui/icons/Info';
+import InfoOutlined from '@material-ui/icons/InfoOutlined';
import BadgeClaimed from '../../../BadgeClaimed';
-import DialogTooltip from '../../../Contribute/DialogTooltip';
+import IconComponent from '../../../Shared/IconComponent/IconComponent';
+import LearnMoreLink from '../../Shared/LearnMoreLink/LearnMoreLink';
import { getMainText, getClaimFlagStateClassName } from './utils';
import {
@@ -58,41 +58,17 @@ const ClaimStatusRow = ({ classes, isClaimed, isPending }) => {
{getMainText(isClaimed, isPending)}
{isClaimed && (
-
-
+ {CLAIMED_PROFILE_TOOLTIP_TEXT}
+
- Learn more →
-
-
- }
- interactive
- childComponent={
-
-
-
+ >
}
+ icon={InfoOutlined}
+ className={classes.infoButton}
/>
)}
diff --git a/src/react/src/components/ProductionLocation/Heading/DataSourcesInfo/DataSourcesInfo.jsx b/src/react/src/components/ProductionLocation/Heading/DataSourcesInfo/DataSourcesInfo.jsx
index 6dac4ff06..93bcb08b7 100644
--- a/src/react/src/components/ProductionLocation/Heading/DataSourcesInfo/DataSourcesInfo.jsx
+++ b/src/react/src/components/ProductionLocation/Heading/DataSourcesInfo/DataSourcesInfo.jsx
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import Typography from '@material-ui/core/Typography';
-import IconButton from '@material-ui/core/IconButton';
import Grid from '@material-ui/core/Grid';
import Switch from '@material-ui/core/Switch';
import { withStyles } from '@material-ui/core/styles';
-import InfoIcon from '@material-ui/icons/Info';
+import InfoOutlined from '@material-ui/icons/InfoOutlined';
-import DialogTooltip from '../../../Contribute/DialogTooltip';
+import IconComponent from '../../../Shared/IconComponent/IconComponent';
+import LearnMoreLink from '../../Shared/LearnMoreLink/LearnMoreLink';
import DataSourceItem from './DataSourceItem';
import {
DATA_SOURCES_TOOLTIP_TEXT,
@@ -28,31 +28,16 @@ const ProductionLocationDetailsDataSourcesInfo = ({ classes, className }) => {
>
Understanding Data Sources
-
-
- Learn more →
-
-
- }
- interactive
- childComponent={
-
-
-
+
+ {DATA_SOURCES_TOOLTIP_TEXT}
+
+ >
}
+ icon={InfoOutlined}
+ className={classes.infoButton}
+ data-testid="data-sources-info-tooltip"
/>
{
/>
-
+
{DATA_SOURCES_ITEMS.map(item => (
{
}),
titleAccent: Object.freeze({
...typography.bodyText,
+ fontSize: '1.15rem',
}),
});
};
diff --git a/src/react/src/components/ProductionLocation/Heading/osIdBadge/OsIdBadge.jsx b/src/react/src/components/ProductionLocation/Heading/OsIdBadge/OsIdBadge.jsx
similarity index 71%
rename from src/react/src/components/ProductionLocation/Heading/osIdBadge/OsIdBadge.jsx
rename to src/react/src/components/ProductionLocation/Heading/OsIdBadge/OsIdBadge.jsx
index 2e26cdcf8..d86273d02 100644
--- a/src/react/src/components/ProductionLocation/Heading/osIdBadge/OsIdBadge.jsx
+++ b/src/react/src/components/ProductionLocation/Heading/OsIdBadge/OsIdBadge.jsx
@@ -2,15 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
-import IconButton from '@material-ui/core/IconButton';
import { withStyles } from '@material-ui/core/styles';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { toast } from 'react-toastify';
-import InfoIcon from '@material-ui/icons/Info';
+import InfoOutlined from '@material-ui/icons/InfoOutlined';
import CopySearch from '../../../CopySearch';
import ContentCopyIcon from '../../../ContentCopyIcon';
-import DialogTooltip from '../../../Contribute/DialogTooltip';
+import IconComponent from '../../../Shared/IconComponent/IconComponent';
+import LearnMoreLink from '../../Shared/LearnMoreLink/LearnMoreLink';
import productionLocationDetailsOsIdBadgeStyles from './styles';
import { OS_ID_TOOLTIP_TEXT, OS_ID_LEARN_MORE_URL } from './constants';
@@ -25,31 +25,16 @@ const ProductionLocationDetailsOsIdBadge = ({ classes, osId }) => (
>
OS ID: {osId}
-
-
- Learn more →
-
-
- }
- interactive
- childComponent={
-
-
-
+
+ {OS_ID_TOOLTIP_TEXT}
+
+ >
}
+ icon={InfoOutlined}
+ className={classes.osIdInfoButton}
+ data-testid="os-id-badge-info"
/>
{osId && (
diff --git a/src/react/src/components/ProductionLocation/Heading/osIdBadge/constants.js b/src/react/src/components/ProductionLocation/Heading/OsIdBadge/constants.js
similarity index 100%
rename from src/react/src/components/ProductionLocation/Heading/osIdBadge/constants.js
rename to src/react/src/components/ProductionLocation/Heading/OsIdBadge/constants.js
diff --git a/src/react/src/components/ProductionLocation/Heading/osIdBadge/styles.js b/src/react/src/components/ProductionLocation/Heading/OsIdBadge/styles.js
similarity index 100%
rename from src/react/src/components/ProductionLocation/Heading/osIdBadge/styles.js
rename to src/react/src/components/ProductionLocation/Heading/OsIdBadge/styles.js
diff --git a/src/react/src/components/ProductionLocation/ProductionLocationDetails/styles.js b/src/react/src/components/ProductionLocation/ProductionLocationDetails/styles.js
index 692ecc0f3..610940824 100644
--- a/src/react/src/components/ProductionLocation/ProductionLocationDetails/styles.js
+++ b/src/react/src/components/ProductionLocation/ProductionLocationDetails/styles.js
@@ -1,6 +1,8 @@
+import COLOURS from '../../../util/COLOURS';
+
export default theme => ({
container: {
- backgroundColor: '#F9F7F7',
+ backgroundColor: COLOURS.LIGHT_GREY,
height: '100%',
display: 'flex',
flexDirection: 'column',
diff --git a/src/react/src/components/ProductionLocation/ProductionLocationDetailsContent/ProductionLocationDetailsContent.jsx b/src/react/src/components/ProductionLocation/ProductionLocationDetailsContent/ProductionLocationDetailsContent.jsx
index 46e59c711..c7d9354f2 100644
--- a/src/react/src/components/ProductionLocation/ProductionLocationDetailsContent/ProductionLocationDetailsContent.jsx
+++ b/src/react/src/components/ProductionLocation/ProductionLocationDetailsContent/ProductionLocationDetailsContent.jsx
@@ -17,7 +17,7 @@ import DetailsMap from '../ProductionLocationDetailsMap/ProductionLocationDetail
import { facilityClaimStatusChoicesEnum } from '../../../util/constants';
import productionLocationDetailsContentStyles from './styles';
-import OsIdBadge from '../Heading/osIdBadge/OsIdBadge';
+import OsIdBadge from '../Heading/OsIdBadge/OsIdBadge';
const ProductionLocationDetailsContent = ({
classes,
@@ -53,7 +53,7 @@ const ProductionLocationDetailsContent = ({
-
+
diff --git a/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/LearnMoreLink.jsx b/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/LearnMoreLink.jsx
new file mode 100644
index 000000000..211d48c2c
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/LearnMoreLink.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { string, node, object } from 'prop-types';
+import { withStyles } from '@material-ui/core/styles';
+
+import learnMoreLinkStyles from './styles';
+import DEFAULT_LINK_TEXT from './constants';
+
+const LearnMoreLink = ({ href, children, classes }) => (
+
+);
+
+LearnMoreLink.propTypes = {
+ href: string.isRequired,
+ children: node,
+ classes: object.isRequired,
+};
+
+LearnMoreLink.defaultProps = {
+ children: DEFAULT_LINK_TEXT,
+};
+
+export default withStyles(learnMoreLinkStyles)(LearnMoreLink);
diff --git a/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/constants.js b/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/constants.js
new file mode 100644
index 000000000..edc95d4c3
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/constants.js
@@ -0,0 +1,3 @@
+export const DEFAULT_LINK_TEXT = 'Learn more →';
+
+export default DEFAULT_LINK_TEXT;
diff --git a/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/styles.js b/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/styles.js
new file mode 100644
index 000000000..8f42e9333
--- /dev/null
+++ b/src/react/src/components/ProductionLocation/Shared/LearnMoreLink/styles.js
@@ -0,0 +1,16 @@
+import COLOURS from '../../../../util/COLOURS';
+
+export default () =>
+ Object.freeze({
+ linkContainer: Object.freeze({
+ marginTop: '8px',
+ marginBottom: '0px',
+ }),
+ link: Object.freeze({
+ color: COLOURS.PURPLE,
+ textDecoration: 'none',
+ '&:hover': Object.freeze({
+ textDecoration: 'underline',
+ }),
+ }),
+ });
diff --git a/src/react/src/components/ProductionLocation/Sidebar/ContributeFields/ContributeFields.jsx b/src/react/src/components/ProductionLocation/Sidebar/ContributeFields/ContributeFields.jsx
index 8580350b6..c10bf0076 100644
--- a/src/react/src/components/ProductionLocation/Sidebar/ContributeFields/ContributeFields.jsx
+++ b/src/react/src/components/ProductionLocation/Sidebar/ContributeFields/ContributeFields.jsx
@@ -46,7 +46,7 @@ const ProductionLocationDetailsContributeFields = ({
Contribute to this profile
- Help improve supply chain transparency
+ Ways you can improve data on this page
@@ -118,7 +118,9 @@ const ProductionLocationDetailsContributeFields = ({
variant="body1"
className={classes.actionLabel}
>
- {isClosed ? 'Report Reopened' : 'Report Closed'}
+ {isClosed
+ ? 'Report Reopened'
+ : 'Report Closure / Move'}
diff --git a/src/react/src/components/ProductionLocation/Sidebar/ContributeFields/styles.js b/src/react/src/components/ProductionLocation/Sidebar/ContributeFields/styles.js
index b96125d25..ff5c0c5aa 100644
--- a/src/react/src/components/ProductionLocation/Sidebar/ContributeFields/styles.js
+++ b/src/react/src/components/ProductionLocation/Sidebar/ContributeFields/styles.js
@@ -9,11 +9,11 @@ export default theme =>
}),
title: Object.freeze({
fontWeight: 600,
- fontSize: '1.125rem',
+ fontSize: '1.25rem',
}),
subtitle: Object.freeze({
color: theme.palette.text.secondary,
- fontSize: '0.875rem',
+ fontSize: '1rem',
marginBottom: '12px',
}),
actionsList: Object.freeze({
@@ -50,6 +50,6 @@ export default theme =>
}),
actionLabel: Object.freeze({
fontWeight: 500,
- fontSize: '1rem',
+ fontSize: '1.15rem',
}),
});
diff --git a/src/react/src/components/ProductionLocation/Sidebar/NavBar/styles.js b/src/react/src/components/ProductionLocation/Sidebar/NavBar/styles.js
index 309edc583..a2b0a6912 100644
--- a/src/react/src/components/ProductionLocation/Sidebar/NavBar/styles.js
+++ b/src/react/src/components/ProductionLocation/Sidebar/NavBar/styles.js
@@ -10,7 +10,7 @@ export default theme =>
}),
title: Object.freeze({
fontWeight: 600,
- fontSize: '1.125rem',
+ fontSize: '1.25rem',
margin: '0 0 12px 0',
}),
menuList: Object.freeze({
@@ -42,8 +42,8 @@ export default theme =>
color: theme.palette.primary.main,
}),
menuLabel: Object.freeze({
- fontSize: '1rem',
- color: COLOURS.BLACK,
+ fontSize: '1.15rem',
+ fontWeight: 500,
}),
menuLabelActive: Object.freeze({
fontWeight: 600,
diff --git a/src/react/src/components/Shared/IconComponent/IconComponent.jsx b/src/react/src/components/Shared/IconComponent/IconComponent.jsx
new file mode 100644
index 000000000..1907cc2ef
--- /dev/null
+++ b/src/react/src/components/Shared/IconComponent/IconComponent.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { string, number, shape, func, oneOfType, node } from 'prop-types';
+import Tooltip from '@material-ui/core/Tooltip';
+import HelpOutline from '@material-ui/icons/HelpOutline';
+import { withStyles } from '@material-ui/core/styles';
+
+import useInteractiveTooltip from './hooks';
+import iconComponentStyles from './styles';
+
+const IconComponent = ({
+ title,
+ placement,
+ enterDelay,
+ classes,
+ className,
+ icon: Icon,
+ 'data-testid': dataTestId,
+}) => {
+ const {
+ open,
+ setOpen,
+ handleTriggerEnter,
+ handleTriggerLeave,
+ handlePopperEnter,
+ handlePopperLeave,
+ } = useInteractiveTooltip();
+
+ return (
+ setOpen(false)}
+ classes={{ popper: classes.popper, tooltip: classes.tooltip }}
+ PopperProps={{
+ onMouseEnter: handlePopperEnter,
+ onMouseLeave: handlePopperLeave,
+ }}
+ >
+
+
+
+
+ );
+};
+
+IconComponent.defaultProps = {
+ placement: 'top',
+ enterDelay: 0,
+ className: undefined,
+ icon: HelpOutline,
+ 'data-testid': undefined,
+};
+
+IconComponent.propTypes = {
+ title: oneOfType([string, node]).isRequired,
+ placement: string,
+ enterDelay: number,
+ classes: shape({
+ popper: string,
+ tooltip: string.isRequired,
+ defaultTooltipIcon: string,
+ icon: string.isRequired,
+ }).isRequired,
+ className: string,
+ icon: func,
+ 'data-testid': string,
+};
+
+export default withStyles(iconComponentStyles)(IconComponent);
diff --git a/src/react/src/components/Shared/IconComponent/constants.js b/src/react/src/components/Shared/IconComponent/constants.js
new file mode 100644
index 000000000..f0c1cee07
--- /dev/null
+++ b/src/react/src/components/Shared/IconComponent/constants.js
@@ -0,0 +1,3 @@
+const LEAVE_TRIGGER_DELAY_MS = 150;
+
+export default LEAVE_TRIGGER_DELAY_MS;
diff --git a/src/react/src/components/Shared/IconComponent/hooks.js b/src/react/src/components/Shared/IconComponent/hooks.js
new file mode 100644
index 000000000..0d6885529
--- /dev/null
+++ b/src/react/src/components/Shared/IconComponent/hooks.js
@@ -0,0 +1,49 @@
+import { useState, useRef, useCallback, useEffect } from 'react';
+
+import LEAVE_TRIGGER_DELAY_MS from './constants';
+
+const useInteractiveTooltip = () => {
+ const [open, setOpen] = useState(false);
+ const closeTimerRef = useRef(null);
+
+ const clearCloseTimer = useCallback(() => {
+ if (closeTimerRef.current) {
+ clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = null;
+ }
+ }, []);
+
+ const handleTriggerEnter = useCallback(() => {
+ clearCloseTimer();
+ setOpen(true);
+ }, [clearCloseTimer]);
+
+ const handleTriggerLeave = useCallback(() => {
+ clearCloseTimer();
+ closeTimerRef.current = setTimeout(
+ () => setOpen(false),
+ LEAVE_TRIGGER_DELAY_MS,
+ );
+ }, [clearCloseTimer]);
+
+ const handlePopperEnter = useCallback(() => {
+ clearCloseTimer();
+ setOpen(true);
+ }, [clearCloseTimer]);
+
+ const handlePopperLeave = useCallback(() => setOpen(false), []);
+
+ useEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
+
+ return {
+ open,
+ setOpen,
+ clearCloseTimer,
+ handleTriggerEnter,
+ handleTriggerLeave,
+ handlePopperEnter,
+ handlePopperLeave,
+ };
+};
+
+export default useInteractiveTooltip;
diff --git a/src/react/src/components/Shared/IconComponent/styles.js b/src/react/src/components/Shared/IconComponent/styles.js
new file mode 100644
index 000000000..3837b0340
--- /dev/null
+++ b/src/react/src/components/Shared/IconComponent/styles.js
@@ -0,0 +1,27 @@
+import COLOURS from '../../../util/COLOURS';
+
+export default theme =>
+ Object.freeze({
+ popper: Object.freeze({
+ opacity: 1,
+ }),
+ defaultTooltipIcon: Object.freeze({
+ display: 'inline-flex',
+ }),
+ tooltip: Object.freeze({
+ fontSize: '0.875rem',
+ backgroundColor: COLOURS.WHITE,
+ color: COLOURS.BLACK,
+ border: `1px solid ${COLOURS.GREY}`,
+ padding: `${theme.spacing.unit * 1.5}px`,
+ maxWidth: '300px',
+ }),
+ tooltipVisible: Object.freeze({
+ opacity: '1 !important',
+ }),
+ icon: Object.freeze({
+ fontSize: '1rem',
+ color: COLOURS.DARK_GREY,
+ cursor: 'pointer',
+ }),
+ });
diff --git a/src/react/src/util/COLOURS.js b/src/react/src/util/COLOURS.js
index 1944e9ba3..3f5d81652 100644
--- a/src/react/src/util/COLOURS.js
+++ b/src/react/src/util/COLOURS.js
@@ -6,11 +6,13 @@ export default {
LIGHT_MATERIAL_BLUE: '#1565c0',
EXTRA_LIGHT_BLUE: '#e3f2fd',
LIGHT_BLUE_BORDER: '#90caf9',
+ LIGHT_SKY_BLUE: '#C0DBFE',
DARK_BLUE: '#0d47a1',
// Greens
GREEN: '#E0F5E3',
DARK_GREEN: '#4A9957',
+ CLAIMED_CHIP_BG: '#F0FDF480',
MINT_GREEN: '#C0EBC7',
OLIVA_GREEN: '#799679',
MATERIAL_GREEN: '#388e3c',
@@ -34,12 +36,16 @@ export default {
PURPLE_TEXT: '#6a1b9a',
LIGHT_PURPLE_BG: '#f3e5f5',
LIGHT_PURPLE_BORDER: '#ce93d8',
+ LIGHT_LAVENDER: '#F8F5FB',
+ LIGHT_LAVENDER_BORDER: '#E2D1F0',
// Oranges/Ambers
AMBER: '#ffc107',
DARK_AMBER: '#ffa000',
DEEP_ORANGE: '#e65100',
ORANGE: '#f57c00',
+ CROWDSOURCED_CHIP_BG: '#FFF7ED80',
+ CROWDSOURCED_CHIP_TEXT: '#C2410C',
AMBER_YELLOW: '#fbc02d',
LIGHT_AMBER: '#fff8e1',
AMBER_50: '#fffbeb',