diff --git a/src/react/src/__tests__/components/SupplyChainNetwork.test.jsx b/src/react/src/__tests__/components/SupplyChainNetwork.test.jsx new file mode 100644 index 000000000..8a8c09e8c --- /dev/null +++ b/src/react/src/__tests__/components/SupplyChainNetwork.test.jsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { screen, fireEvent } from '@testing-library/react'; +import renderWithProviders from '../../util/testUtils/renderWithProviders'; +import SupplyChain from '../../components/ProductionLocation/Sidebar/SupplyChain/SupplyChain'; + +const publicContributor = { + id: 1, + contributor_name: 'Acme Brands', + contributor_type: 'Brand / Retailer', + list_name: 'Acme Supplier List 2025', + is_verified: false, + count: 1, + name: 'Acme Brands', +}; + +const anotherPublicContributor = { + id: 2, + contributor_name: 'Global Auditors Inc', + contributor_type: 'Auditor', + list_name: 'Verified Facilities Q1 2025', + is_verified: true, + count: 1, + name: 'Global Auditors Inc', +}; + +const nonPublicContributor = { + contributor_type: 'Civil Society Organization', + count: 3, + name: '3 Others', +}; + +const nonPublicContributorNullType = { + contributor_type: null, + count: 2, + name: '2 Others', +}; + +const renderSection = (contributors = []) => + renderWithProviders( + + + , + ); + +describe('SupplyChainNetwork section', () => { + test('renders nothing when contributors array is empty', () => { + renderSection([]); + expect( + screen.queryByText('Supply Chain Network'), + ).not.toBeInTheDocument(); + }); + + test('renders nothing when all contributors have no name and no type', () => { + renderSection([{ id: 1, count: 1 }]); + expect( + screen.queryByText('Supply Chain Network'), + ).not.toBeInTheDocument(); + }); + + test('renders section title and subtitle when contributors exist', () => { + renderSection([publicContributor]); + + expect( + screen.getByText('Supply Chain Network'), + ).toBeInTheDocument(); + expect( + screen.getByText( + /Organizations that have shared information about this production location/, + ), + ).toBeInTheDocument(); + }); + + test('renders contributor type counts per line', () => { + renderSection([publicContributor, nonPublicContributor]); + + expect( + screen.getByText(/Brand \/ Retailer/), + ).toBeInTheDocument(); + expect( + screen.getByText(/Civil Society Organization/), + ).toBeInTheDocument(); + }); + + test('renders public contributor names as links', () => { + renderSection([publicContributor, anotherPublicContributor]); + + expect(screen.getByText('Acme Brands')).toBeInTheDocument(); + expect(screen.getByText('Global Auditors Inc')).toBeInTheDocument(); + }); + + test('renders public contributors grouped by contributor type', () => { + // Three contributors: two Brand / Retailers surrounding one Auditor in API order. + // After grouping the two Brand / Retailers must appear consecutively. + const brandA = { + id: 10, + contributor_name: 'Brand A', + contributor_type: 'Brand / Retailer', + list_name: 'List A', + count: 1, + }; + const auditor = { + id: 11, + contributor_name: 'Solo Auditor', + contributor_type: 'Auditor', + list_name: 'Audit List', + count: 1, + }; + const brandB = { + id: 12, + contributor_name: 'Brand B', + contributor_type: 'Brand / Retailer', + list_name: 'List B', + count: 1, + }; + + // API returns them interleaved: Brand A, Auditor, Brand B + renderSection([brandA, auditor, brandB]); + + const links = screen + .getAllByRole('link') + .filter(el => + ['Brand A', 'Solo Auditor', 'Brand B'].includes( + el.textContent, + ), + ); + + const names = links.map(el => el.textContent); + const brandAIndex = names.indexOf('Brand A'); + const brandBIndex = names.indexOf('Brand B'); + const auditorIndex = names.indexOf('Solo Auditor'); + + // The two Brand / Retailer contributors must not have the Auditor between them + expect(Math.abs(brandAIndex - brandBIndex)).toBe(1); + expect(auditorIndex).not.toBe( + Math.min(brandAIndex, brandBIndex) + 1, + ); + }); + + test('renders "View all N data sources" trigger button', () => { + renderSection([publicContributor, nonPublicContributor]); + + // totalCount = publicContributor.count(1) + nonPublicContributor.count(3) = 4 + expect( + screen.getByRole('button', { name: /View all 4 data sources/i }), + ).toBeInTheDocument(); + }); + + test('opens drawer when trigger button is clicked', () => { + renderSection([publicContributor]); + + // Drawer content is in the DOM but aria-hidden when closed + const trigger = screen.getByRole('button', { name: /View all/i }); + expect(trigger).toBeInTheDocument(); + + fireEvent.click(trigger); + + // After clicking, the drawer close button should be accessible + expect(screen.getByLabelText('Close')).toBeInTheDocument(); + }); + + test('filters out non-public contributors with null contributor_type', () => { + renderSection([publicContributor, nonPublicContributorNullType]); + + fireEvent.click( + screen.getByRole('button', { name: /View all/i }), + ); + + // The null-type anonymous contributor should not appear in the drawer + expect( + screen.queryByText('2 Civil Society Organization'), + ).not.toBeInTheDocument(); + }); +}); + +describe('SupplyChainNetworkDrawer', () => { + const openDrawer = contributors => { + renderSection(contributors); + fireEvent.click(screen.getByRole('button', { name: /View all/i })); + }; + + test('shows "All Data Sources" as the drawer title', () => { + openDrawer([publicContributor]); + expect(screen.getByText('All Data Sources')).toBeVisible(); + }); + + test('shows total contributor count in subtitle', () => { + openDrawer([publicContributor, nonPublicContributor]); + // total = 1 + 3 = 4 + expect( + screen.getByText(/4 organizations have shared data/i), + ).toBeInTheDocument(); + }); + + test('shows info box with explanatory text', () => { + openDrawer([publicContributor]); + expect( + screen.getByText(/Multiple organizations may have shared data/i), + ).toBeInTheDocument(); + }); + + test('shows "Learn more" link in info box', () => { + openDrawer([publicContributor]); + expect( + screen.getByText(/Learn more about our open data model/i), + ).toBeInTheDocument(); + }); + + test('shows public contributors by name under All Data Sources', () => { + openDrawer([publicContributor, anotherPublicContributor]); + // Names appear both in the section and in the drawer; getAll checks at least one + expect( + screen.getAllByText('Acme Brands').length, + ).toBeGreaterThanOrEqual(1); + }); + + test('shows contributor type label for public contributors', () => { + openDrawer([publicContributor]); + // contributor_type "Brand / Retailer" appears as type label in drawer + expect( + screen.getAllByText('Brand / Retailer').length, + ).toBeGreaterThanOrEqual(1); + }); + + test('shows Anonymized Data Sources section when non-public contributors exist', () => { + openDrawer([publicContributor, nonPublicContributor]); + expect( + screen.getByText('Anonymized Data Sources'), + ).toBeInTheDocument(); + }); + + test('does not show Anonymized Data Sources when only public contributors exist', () => { + openDrawer([publicContributor]); + expect( + screen.queryByText('Anonymized Data Sources'), + ).not.toBeInTheDocument(); + }); + + test('shows non-public contributors by type and count in anonymized section', () => { + openDrawer([publicContributor, nonPublicContributor]); + // "3 Civil Society Organization" may appear multiple times (type chips + anonymized section) + const matches = screen.getAllByText(/3 Civil Society Organization/); + expect(matches.length).toBeGreaterThanOrEqual(1); + }); + + test('drawer close button is present after opening', () => { + openDrawer([publicContributor]); + expect(screen.getByLabelText('Close')).toBeInTheDocument(); + }); +}); diff --git a/src/react/src/components/ProductionLocation/ProductionLocationDetailsContainer/ProductionLocationDetailsContainer.jsx b/src/react/src/components/ProductionLocation/ProductionLocationDetailsContainer/ProductionLocationDetailsContainer.jsx index a69e09b8c..39f68d1af 100644 --- a/src/react/src/components/ProductionLocation/ProductionLocationDetailsContainer/ProductionLocationDetailsContainer.jsx +++ b/src/react/src/components/ProductionLocation/ProductionLocationDetailsContainer/ProductionLocationDetailsContainer.jsx @@ -103,7 +103,9 @@ function ProductionLocationDetailsContainer({ - + diff --git a/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChain.jsx b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChain.jsx index 6af139ac4..c5b21d516 100644 --- a/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChain.jsx +++ b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChain.jsx @@ -1,24 +1,176 @@ -import React from 'react'; -import { withStyles } from '@material-ui/core/styles'; +import React, { useState, useRef, useEffect } from 'react'; +import { arrayOf, shape, string, number } from 'prop-types'; import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import { withStyles } from '@material-ui/core/styles'; +import { Link } from 'react-router-dom'; + +import SupplyChainNetworkDrawer from './SupplyChainNetworkDrawer/SupplyChainNetworkDrawer'; +import { + splitContributorsIntoPublicAndNonPublic, + makeProfileRouteLink, +} from '../../../../util/util'; +import pluralizeContributorType from './utils'; +import supplyChainStyles from './styles'; + +const buildTypeCounts = contributors => { + const totals = contributors.reduce((acc, contributor) => { + const type = contributor.contributor_type; + if (!type) return acc; + const count = contributor.count || 1; + acc[type] = (acc[type] || 0) + count; + return acc; + }, {}); + + return Object.entries(totals).map(([type, count]) => ({ type, count })); +}; + +// Aggregate non-public contributors by type so each type appears only once. +// This prevents duplicate React keys and duplicate rows in the drawer. +const aggregateByType = nonPublicContributors => + nonPublicContributors + .filter(c => c.contributor_type != null) + .reduce((acc, c) => { + const existing = acc.find( + x => x.contributor_type === c.contributor_type, + ); + if (existing) { + return acc.map(item => + item.contributor_type === c.contributor_type + ? { ...item, count: item.count + (c.count || 1) } + : item, + ); + } + return [ + ...acc, + { contributor_type: c.contributor_type, count: c.count || 1 }, + ]; + }, []); + +const getTotalCount = contributors => + contributors.reduce((sum, c) => sum + (c.count || 1), 0); + +const SupplyChain = ({ classes, contributors }) => { + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + const hasBeenOpenRef = useRef(false); + + useEffect(() => { + if (isOpen) { + hasBeenOpenRef.current = true; + } else if ( + hasBeenOpenRef.current && + triggerRef.current && + typeof triggerRef.current.focus === 'function' + ) { + triggerRef.current.focus(); + } + }, [isOpen]); + + const visibleContributors = contributors.filter( + c => !!c.contributor_name || !!c.contributor_type, + ); + + if (!visibleContributors.length) return null; + + const { + publicContributors, + nonPublicContributors, + } = splitContributorsIntoPublicAndNonPublic(visibleContributors); + + const sortedPublicContributors = [...publicContributors].sort((a, b) => + (a.contributor_type || '').localeCompare(b.contributor_type || ''), + ); + const aggregatedNonPublic = aggregateByType(nonPublicContributors); + const typeCounts = buildTypeCounts([ + ...sortedPublicContributors, + ...aggregatedNonPublic, + ]); + const totalCount = getTotalCount([ + ...sortedPublicContributors, + ...aggregatedNonPublic, + ]); + + return ( +
+ + Supply Chain Network + + + Organizations that have shared information about this production + location. + + + {typeCounts.length > 0 && ( +
+ {typeCounts.map(({ type, count }) => ( + + {count}{' '} + {pluralizeContributorType(type, count)} + + ))} +
+ )} + + {sortedPublicContributors.length > 0 && ( +
+ {sortedPublicContributors.map(contributor => ( + + {contributor.contributor_name} + + ))} +
+ )} + + {totalCount > 0 && ( + + )} + + setIsOpen(false)} + totalCount={totalCount} + typeCounts={typeCounts} + publicContributors={sortedPublicContributors} + nonPublicContributors={aggregatedNonPublic} + /> +
+ ); +}; + +SupplyChain.propTypes = { + contributors: arrayOf( + shape({ + id: number, + contributor_name: string, + contributor_type: string, + list_name: string, + count: number, + }), + ), +}; + +SupplyChain.defaultProps = { + contributors: [], +}; -import productionLocationDetailsSupplyChainStyles from './styles'; - -const ProductionLocationDetailsSupplyChain = ({ classes }) => ( -
- - Supply Chain Network - - - Organizations contributing data to this production location - -
-); - -export default withStyles(productionLocationDetailsSupplyChainStyles)( - ProductionLocationDetailsSupplyChain, -); +export default withStyles(supplyChainStyles)(SupplyChain); diff --git a/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/SupplyChainNetworkDrawer.jsx b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/SupplyChainNetworkDrawer.jsx new file mode 100644 index 000000000..b5e4cb75f --- /dev/null +++ b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/SupplyChainNetworkDrawer.jsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { object, bool, func, arrayOf, shape, string, 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 Divider from '@material-ui/core/Divider'; +import { withStyles } from '@material-ui/core/styles'; +import CloseIcon from '@material-ui/icons/Close'; +import PeopleOutlineIcon from '@material-ui/icons/PeopleOutline'; +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import ListIcon from '@material-ui/icons/List'; +import { Link } from 'react-router-dom'; + +import { makeProfileRouteLink } from '../../../../../util/util'; +import pluralizeContributorType from '../utils'; +import { + DRAWER_TITLE, + ANONYMIZED_SECTION_TITLE, + ALL_DATA_SOURCES_LABEL, + INFO_TEXT, + LEARN_MORE_LABEL, + LEARN_MORE_URL, + UPLOADED_VIA_LIST_LABEL, +} from './constants'; +import supplyChainNetworkDrawerStyles from './styles'; + +const SupplyChainNetworkDrawer = ({ + classes, + open, + onClose, + totalCount, + typeCounts, + publicContributors, + nonPublicContributors, +}) => { + const allSourcesLabel = + totalCount > 0 + ? `${ALL_DATA_SOURCES_LABEL} (${totalCount})` + : ALL_DATA_SOURCES_LABEL; + + return ( + +
+
+
+ + + {DRAWER_TITLE} + +
+ + + +
+ + + {totalCount}{' '} + {totalCount === 1 + ? 'organization has' + : 'organizations have'}{' '} + shared data about this production location + + + + + + {allSourcesLabel} + + +
+
+ + +
+
+ + {typeCounts.length > 0 && ( +
+ {typeCounts.map(({ type, count }) => ( + + {count}{' '} + {pluralizeContributorType(type, count)} + + ))} +
+ )} + +
+ {publicContributors.map(contributor => ( +
+ + {contributor.contributor_name} + + {contributor.contributor_type && ( + + {contributor.contributor_type} + + )} + {contributor.list_names && + contributor.list_names + .map((name, i) => ({ + name, + key: `${contributor.id}-${i}`, + })) + .filter(({ name }) => name) + .map(({ name: listName, key }) => ( +
+ + + {UPLOADED_VIA_LIST_LABEL} + + + {listName} + +
+ ))} +
+ ))} +
+ + {nonPublicContributors.length > 0 && ( + <> + + {ANONYMIZED_SECTION_TITLE} + + {nonPublicContributors.map(contributor => ( + + {contributor.count}{' '} + {pluralizeContributorType( + contributor.contributor_type, + contributor.count, + )} + + ))} + + )} +
+
+ ); +}; + +SupplyChainNetworkDrawer.propTypes = { + classes: object.isRequired, + open: bool.isRequired, + onClose: func.isRequired, + totalCount: number, + typeCounts: arrayOf( + shape({ + type: string.isRequired, + count: number.isRequired, + }), + ), + publicContributors: arrayOf( + shape({ + id: number.isRequired, + contributor_name: string.isRequired, + contributor_type: string, + list_names: arrayOf(string), + }), + ), + nonPublicContributors: arrayOf( + shape({ + contributor_type: string.isRequired, + count: number.isRequired, + }), + ), +}; + +SupplyChainNetworkDrawer.defaultProps = { + totalCount: 0, + typeCounts: [], + publicContributors: [], + nonPublicContributors: [], +}; + +export default withStyles(supplyChainNetworkDrawerStyles)( + SupplyChainNetworkDrawer, +); diff --git a/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/constants.js b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/constants.js new file mode 100644 index 000000000..15bdcaf91 --- /dev/null +++ b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/constants.js @@ -0,0 +1,9 @@ +export const DRAWER_TITLE = 'All Data Sources'; +export const ALL_DATA_SOURCES_LABEL = 'All Data Sources'; +export const ANONYMIZED_SECTION_TITLE = 'Anonymized Data Sources'; +export const INFO_TEXT = + 'Multiple organizations may have shared data for this production location. Sharing data often indicates a relationship between the organization (e.g., a brand) and the production location (e.g., a supplier). The list name may provide additional context about the relationship type and timeframe. Click on the organization name to learn more about them and the data they have shared.'; +export const UPLOADED_VIA_LIST_LABEL = 'Uploaded via list'; +export const LEARN_MORE_LABEL = 'Learn more about our open data model'; +export const LEARN_MORE_URL = + 'https://info.opensupplyhub.org/resources/an-open-data-model'; diff --git a/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/styles.js b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/styles.js new file mode 100644 index 000000000..111db47e0 --- /dev/null +++ b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/SupplyChainNetworkDrawer/styles.js @@ -0,0 +1,175 @@ +import COLOURS from '../../../../../util/COLOURS'; + +export default () => + Object.freeze({ + drawerPaper: Object.freeze({ + width: '100%', + maxWidth: '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', + }), + subtitle: Object.freeze({ + marginTop: '8px', + marginBottom: '0px', + fontSize: '1rem', + lineHeight: 1.4, + color: COLOURS.DARK_GREY, + paddingBottom: '16px', + }), + 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, + }), + infoBox: Object.freeze({ + backgroundColor: COLOURS.EXTRA_LIGHT_BLUE, + border: `1px solid #C0DBFE`, + padding: '16px', + marginBottom: '16px', + }), + 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, + }), + infoText: Object.freeze({ + fontSize: '1rem', + lineHeight: 1.5, + color: COLOURS.DARK_GREY, + }), + learnMoreLink: Object.freeze({ + marginTop: '8px', + display: 'inline-flex', + alignItems: 'center', + color: COLOURS.MATERIAL_BLUE, + fontSize: '1rem', + textDecoration: 'none', + '&:hover': Object.freeze({ + textDecoration: 'underline', + }), + }), + learnMoreArrow: Object.freeze({ + marginLeft: '4px', + marginTop: '2px', + }), + typeSummary: Object.freeze({ + display: 'flex', + flexWrap: 'wrap', + gap: 6, + marginBottom: '16px', + }), + typeChip: Object.freeze({ + display: 'inline-block', + fontSize: '0.85rem', + color: COLOURS.NEAR_BLACK, + backgroundColor: COLOURS.LIGHT_GREY, + padding: '2px 10px', + }), + listScroll: Object.freeze({ + maxHeight: '320px', + overflowY: 'auto', + }), + contributorEntry: Object.freeze({ + backgroundColor: COLOURS.WHITE, + border: `1px solid ${COLOURS.LIGHT_BORDER_GREY}`, + padding: '16px', + marginBottom: '8px', + }), + contributorName: Object.freeze({ + display: 'inline-flex', + alignItems: 'center', + gap: 4, + fontSize: '1rem', + fontWeight: 600, + color: COLOURS.PURPLE, + textDecoration: 'none', + marginBottom: 2, + overflowWrap: 'break-word', + '&:hover': Object.freeze({ + textDecoration: 'underline', + }), + }), + contributorType: Object.freeze({ + fontSize: '0.85rem', + color: COLOURS.DARK_GREY, + marginBottom: 6, + }), + listEntry: Object.freeze({ + border: `1px solid ${COLOURS.LIGHT_BORDER_GREY}`, + padding: '8px 12px', + marginBottom: '8px', + backgroundColor: COLOURS.WHITE, + }), + listEntryLabel: Object.freeze({ + display: 'flex', + alignItems: 'center', + gap: 4, + fontSize: '0.75rem', + color: COLOURS.DARK_GREY, + marginBottom: 2, + }), + listIcon: Object.freeze({ + fontSize: 14, + color: COLOURS.DARK_GREY, + flexShrink: 0, + }), + listName: Object.freeze({ + fontSize: '0.875rem', + color: COLOURS.NEAR_BLACK, + fontWeight: 500, + }), + anonymizedType: Object.freeze({ + fontSize: '1rem', + color: COLOURS.NEAR_BLACK, + lineHeight: 1.8, + }), + }); diff --git a/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/styles.js b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/styles.js index 65fa1494c..8a238efc6 100644 --- a/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/styles.js +++ b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/styles.js @@ -1,9 +1,62 @@ +import COLOURS from '../../../../util/COLOURS'; +import commonStyles from '../../commonStyles'; + export default theme => Object.freeze({ container: Object.freeze({ - backgroundColor: 'white', + ...commonStyles(theme).container, + padding: '12px', + marginBottom: theme.spacing.unit, + minWidth: 0, + wordBreak: 'break-word', }), title: Object.freeze({ + fontWeight: 700, + fontSize: '1rem', + color: COLOURS.NEAR_BLACK, marginBottom: theme.spacing.unit, }), + subtitle: Object.freeze({ + fontSize: '0.9rem', + color: COLOURS.DARK_GREY, + marginBottom: theme.spacing.unit * 2, + }), + typeCounts: Object.freeze({ + display: 'flex', + flexWrap: 'wrap', + gap: '6px', + marginBottom: theme.spacing.unit * 2, + }), + typeCount: Object.freeze({ + display: 'inline-block', + fontSize: '0.85rem', + color: COLOURS.NEAR_BLACK, + backgroundColor: COLOURS.LIGHT_GREY, + padding: '2px 10px', + }), + contributorList: Object.freeze({ + marginBottom: theme.spacing.unit * 2, + }), + contributorLink: Object.freeze({ + display: 'block', + fontSize: '0.9rem', + color: COLOURS.NEAR_BLACK, + textDecoration: 'none', + lineHeight: 1.8, + '&:hover': Object.freeze({ + textDecoration: 'underline', + }), + }), + triggerButton: Object.freeze({ + fontWeight: 400, + fontSize: '0.9rem', + textTransform: 'none', + color: COLOURS.PURPLE, + padding: 0, + minHeight: 'auto', + '&:hover': Object.freeze({ + backgroundColor: 'transparent', + textDecoration: 'underline', + }), + }), }); diff --git a/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/utils.js b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/utils.js new file mode 100644 index 000000000..69f9756a6 --- /dev/null +++ b/src/react/src/components/ProductionLocation/Sidebar/SupplyChain/utils.js @@ -0,0 +1,20 @@ +const PLURAL_MAP = { + 'Academic / Researcher / Journalist / Student': + 'Academics / Researchers / Journalists / Students', + 'Auditor / Certification Scheme / Service Provider': + 'Auditors / Certification Schemes / Service Providers', + 'Brand / Retailer': 'Brands / Retailers', + 'Civil Society Organization': 'Civil Society Organizations', + 'Facility / Factory / Manufacturing Group / Supplier / Vendor': + 'Facilities / Factories / Manufacturing Groups / Suppliers / Vendors', + 'Multi-Stakeholder Initiative': 'Multi-Stakeholder Initiatives', + Union: 'Unions', + Other: 'Others', +}; + +const pluralizeContributorType = (type, count) => { + if (count === 1) return type; + return PLURAL_MAP[type] || `${type}s`; +}; + +export default pluralizeContributorType;