Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/release/RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* Sidebar "Jump to" navigation links to individual partner groups; clicking a link opens the corresponding section and smoothly scrolls it into view.
* Added `UrlProperty` format component and `url` format type support for partner field JSON schemas, enabling clickable links with customizable link text.
* Includes loading state with a spinner while partner field groups are being fetched.
* [OSDEV-2372](https://opensupplyhub.atlassian.net/browse/OSDEV-2372) - Implemented the Operational Details section on the Production Location page:
* Added `ClaimDataContainer` component that displays operational details submitted by management through the claim process for claimed production locations.
* The section includes a "Claimed Profile" badge, informational tooltip with a "Learn More" link, and renders claim data fields (e.g., facility description, parent company, website, contact information) as data points with contributor metadata and timestamps.
* Each data point shows the claim status, contributor name, and claim approval/creation date, maintaining consistency with other sections on the page.
* The section only appears when the production location has an approved claim and contains displayable claim data.

### Release instructions
* Ensure that the following commands are included in the `post_deployment` command:
Expand Down
221 changes: 221 additions & 0 deletions src/react/src/__tests__/components/ClaimDataContainer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import React from 'react';
import renderWithProviders from '../../util/testUtils/renderWithProviders';
import ClaimDataContainer from '../../components/ProductionLocation/ClaimSection/ClaimDataContainer/ClaimDataContainer';
import { STATUS_CLAIMED } from '../../components/ProductionLocation/DataPoint/constants';


const makeClaimInfo = (overrides = {}) => ({
facility: {
website: 'https://example.com',
phone_number: '+1 234 567 8900',
minimum_order: '100 units',
average_lead_time: '30 days',
female_workers_percentage: 60,
affiliations: ['Fair Trade'],
certifications: ['ISO 9001'],
opening_date: '2010',
closing_date: null,
estimated_annual_throughput: 20000,
actual_annual_energy_consumption: null,
description: 'A sample facility.',
},
contact: {
name: 'Jane Doe',
email: '[email protected]',
},
office: {
name: 'Head Office',
address: '1 Office St',
country: 'US',
phone_number: '+1 800 000 0000',
},
contributor: { name: 'Test Contributor' },
approved_at: '2023-05-15T00:00:00Z',
created_at: '2023-01-01T00:00:00Z',
...overrides,
});

const renderComponent = (props = {}) => {
const claimInfo =
'claimInfo' in props ? props.claimInfo : makeClaimInfo();
return renderWithProviders(
<ClaimDataContainer
isClaimed={props.isClaimed ?? true}
claimInfo={claimInfo}
className={props.className}
/>,
);
};

describe('ClaimDataContainer — empty state', () => {
it('renders nothing when isClaimed is false', () => {
const { container } = renderComponent({ isClaimed: false });
expect(container.firstChild).toBeNull();
});

it('renders nothing when claimInfo is null', () => {
const { container } = renderComponent({
isClaimed: true,
claimInfo: null,
});
expect(container.firstChild).toBeNull();
});

it('renders nothing when all facility fields are empty', () => {
const { container } = renderComponent({
claimInfo: makeClaimInfo({
facility: {},
contact: null,
office: null,
}),
});
expect(container.firstChild).toBeNull();
});
});

describe('ClaimDataContainer — section header', () => {
it('renders the section title', () => {
const { getByText } = renderComponent();
expect(
getByText('Operational Details Submitted by Management'),
).toBeInTheDocument();
});

it('renders the info tooltip trigger', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('claim-data-info-tooltip')).toBeInTheDocument();
});
});

describe('ClaimDataContainer — field labels and values', () => {
it('renders all expected field labels', () => {
const { getByText } = renderComponent();

const expectedLabels = [
'Website',
'Contact Person',
'Contact Email',
'Phone Number',
'Minimum Order',
'Average Lead Time',
'Affiliations',
'Certifications/Standards/Regulations',
'Opening Date',
'Estimated Annual Throughput',
'Office Name',
'Office Address',
'Office Phone Number',
'Description',
];

expectedLabels.forEach(label => {
expect(getByText(label, { exact: true })).toBeInTheDocument();
});
});

it('renders plain field values', () => {
const claimInfo = makeClaimInfo();
const { getByText } = renderComponent({ claimInfo });

expect(
getByText(claimInfo.facility.phone_number, { exact: true }),
).toBeInTheDocument();
expect(
getByText(claimInfo.facility.minimum_order, { exact: true }),
).toBeInTheDocument();
expect(
getByText(claimInfo.facility.average_lead_time, { exact: true }),
).toBeInTheDocument();
expect(
getByText(claimInfo.facility.description, { exact: true }),
).toBeInTheDocument();
expect(
getByText('20000 kg/year', { exact: true }),
).toBeInTheDocument();
});

it('renders contact fields when contact is provided', () => {
const { getByText } = renderComponent();
expect(getByText('Jane Doe', { exact: true })).toBeInTheDocument();
expect(
getByText('[email protected]', { exact: true }),
).toBeInTheDocument();
});

it('omits contact fields when contact is null', () => {
const { queryByText } = renderComponent({
claimInfo: makeClaimInfo({ contact: null }),
});
expect(queryByText('Contact Person')).not.toBeInTheDocument();
expect(queryByText('Contact Email')).not.toBeInTheDocument();
});

it('renders office fields when office is provided', () => {
const { getByText } = renderComponent();
expect(getByText('Head Office', { exact: true })).toBeInTheDocument();
});

it('omits office fields when office is null', () => {
const { queryByText } = renderComponent({
claimInfo: makeClaimInfo({ office: null }),
});
expect(queryByText('Office Name')).not.toBeInTheDocument();
expect(queryByText('Office Address')).not.toBeInTheDocument();
expect(queryByText('Office Phone Number')).not.toBeInTheDocument();
});
});

describe('ClaimDataContainer — Claimed status chips', () => {
it('renders a Claimed chip for each displayed field', () => {
const { getAllByTestId, getAllByText } = renderComponent();
const dataPoints = getAllByTestId('data-point');
const claimedChips = getAllByText(STATUS_CLAIMED);
expect(claimedChips.length).toBe(dataPoints.length);
});
});

describe('ClaimDataContainer — contributor attribution', () => {
it('resolves contributor name from contributor.name object', () => {
const { getAllByTestId } = renderComponent({
claimInfo: makeClaimInfo({
contributor: { name: 'Acme Corp' },
}),
});
getAllByTestId('data-point-contributor').forEach(el => {
expect(el).toHaveTextContent('Acme Corp');
});
});

it('resolves contributor name from a plain string', () => {
const { getAllByTestId } = renderComponent({
claimInfo: makeClaimInfo({ contributor: 'String Contributor' }),
});
getAllByTestId('data-point-contributor').forEach(el => {
expect(el).toHaveTextContent('String Contributor');
});
});

it('prefers approved_at over created_at for the date', () => {
const { getAllByTestId } = renderComponent({
claimInfo: makeClaimInfo({
approved_at: '2023-05-15T00:00:00Z',
created_at: '2023-01-01T00:00:00Z',
}),
});
getAllByTestId('data-point-date').forEach(el => {
expect(el).toHaveTextContent('May 15, 2023');
});
});

it('falls back to created_at when approved_at is absent', () => {
const { getAllByTestId } = renderComponent({
claimInfo: makeClaimInfo({
approved_at: undefined,
created_at: '2022-11-09T00:00:00Z',
}),
});
getAllByTestId('data-point-date').forEach(el => {
expect(el).toHaveTextContent('November 9, 2022');
});
});
});
7 changes: 4 additions & 3 deletions src/react/src/components/BadgeClaimed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import SvgIcon from '@material-ui/core/SvgIcon';

export default function BadgeClaimed({
color,
fontSize = '24px',
viewBox = '0 0 16 20',
overflow = 'hidden',
className,
}) {
return (
<SvgIcon viewBox={viewBox} style={{ fontSize, overflow }}>
<SvgIcon viewBox={viewBox} fontSize="inherit" className={className}>
<path
fill={color}
d="M6.95 13.55L12.6 7.9L11.175 6.475L6.95 10.7L4.85 8.6L3.425 10.025L6.95 13.55ZM8 20C5.68333 19.4167 3.771 18.0873 2.263 16.012C0.754333 13.9373 0 11.6333 0 9.1V3L8 0L16 3V9.1C16 11.6333 15.246 13.9373 13.738 16.012C12.2293 18.0873 10.3167 19.4167 8 20ZM8 17.9C9.73333 17.35 11.1667 16.25 12.3 14.6C13.4333 12.95 14 11.1167 14 9.1V4.375L8 2.125L2 4.375V9.1C2 11.1167 2.56667 12.95 3.7 14.6C4.83333 16.25 6.26667 17.35 8 17.9Z"
Expand All @@ -20,8 +19,10 @@ export default function BadgeClaimed({

BadgeClaimed.defaultProps = {
color: 'currentColor',
className: undefined,
};

BadgeClaimed.propTypes = {
color: string,
className: string,
};
Original file line number Diff line number Diff line change
@@ -1,15 +1,112 @@
import React from 'react';
import { object, bool, shape, oneOfType, string } from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Divider from '@material-ui/core/Divider';
import InfoOutlined from '@material-ui/icons/InfoOutlined';

import DataPoint from '../../DataPoint/DataPoint';
import { STATUS_CLAIMED } from '../../DataPoint/constants';
import IconComponent from '../../../Shared/IconComponent/IconComponent';
import LearnMoreLink from '../../Shared/LearnMoreLink/LearnMoreLink';
import BadgeClaimed from '../../../BadgeClaimed';
import {
getLocationFieldsConfig,
hasDisplayableValue,
} from '../../../FacilityDetailsClaimedInfo/utils';

import claimDataContainerStyles from './styles';

const ClaimDataContainer = ({ classes, className }) => (
<div className={`${classes.container} ${className || ''}`}>
<Typography variant="title" className={classes.title} component="h3">
Claim Data
</Typography>
</div>
);
const ClaimDataContainer = ({ classes, className, claimInfo, isClaimed }) => {
if (!isClaimed || !claimInfo) {
return null;
}

const { facility, contact, office } = claimInfo;

const contributorName =
typeof claimInfo.contributor === 'string'
? claimInfo.contributor
: claimInfo.contributor?.name ?? null;

const claimedAt = claimInfo.approved_at ?? claimInfo.created_at ?? null;

const fieldsConfig = getLocationFieldsConfig(
facility || {},
contact || null,
office || null,
);

const displayableFields = fieldsConfig.filter(field =>
hasDisplayableValue(field.getValue()),
);

if (displayableFields.length === 0) {
return null;
}

return (
<div className={`${classes.container} ${className || ''}`}>
<div className={classes.titleRow}>
<BadgeClaimed className={classes.titleIcon} />
<Typography
variant="title"
className={classes.sectionTitle}
component="h3"
>
Operational Details Submitted by Management
</Typography>
<IconComponent
title={
<>
Data provided by the production location management
through the claim process.
<LearnMoreLink href="https://info.opensupplyhub.org/resources/claim-a-facility" />
</>
}
icon={InfoOutlined}
className={classes.infoButton}
data-testid="claim-data-info-tooltip"
/>
</div>
<div className={classes.dataPointsList}>
{displayableFields.map((field, index) => (
<React.Fragment key={field.key}>
<DataPoint
label={field.label}
value={field.getValue()}
statusLabel={STATUS_CLAIMED}
contributorName={contributorName}
date={claimedAt}
/>
{index < displayableFields.length - 1 && (
<Divider className={classes.divider} />
)}
</React.Fragment>
))}
</div>
</div>
);
};

ClaimDataContainer.propTypes = {
classes: object.isRequired,
className: string,
claimInfo: shape({
facility: object,
contact: object,
office: object,
contributor: oneOfType([string, shape({ name: string })]),
approved_at: string,
created_at: string,
}),
isClaimed: bool,
};

ClaimDataContainer.defaultProps = {
className: '',
claimInfo: null,
isClaimed: false,
};

export default withStyles(claimDataContainerStyles)(ClaimDataContainer);
Loading
Loading