Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
72af906
Add data fetching to the PartnerDataContainer
protsack-stephan Mar 6, 2026
7d23e5d
Merge branch 'main' of github.com:opensupplyhub/open-supply-hub into …
protsack-stephan Mar 9, 2026
ae3bf4a
Add rendering of Partner fields
protsack-stephan Mar 9, 2026
7c5c2f0
Add stying updates for the partner field sections
protsack-stephan Mar 9, 2026
8baa024
Add the ability to scroll the section into the view and toggle it open
protsack-stephan Mar 9, 2026
6212f39
Add two column layout
protsack-stephan Mar 10, 2026
b8a98d8
Add basic styling for the partners section
protsack-stephan Mar 10, 2026
6af9290
Add loading for the partner fields section
protsack-stephan Mar 10, 2026
6e78210
Add transition sycnhronization
protsack-stephan Mar 10, 2026
62d1dff
Add proper scroll into view logic
protsack-stephan Mar 10, 2026
3c93985
Add unit tests for the fields
protsack-stephan Mar 10, 2026
c37dcb2
Add unit tests for the fields
protsack-stephan Mar 10, 2026
9d195b8
Fix order for partner field schema
protsack-stephan Mar 10, 2026
9189fca
Add unit tests for the util functions
protsack-stephan Mar 10, 2026
0992088
Add unit test for the URL rendering
protsack-stephan Mar 10, 2026
70e4e40
Fix liting issues
protsack-stephan Mar 10, 2026
ac90791
Add release notes
protsack-stephan Mar 10, 2026
d6230c9
Fix section rendering and add default icon
protsack-stephan Mar 11, 2026
5046c5d
Fix the URL rednering issue
protsack-stephan Mar 11, 2026
db33467
Mmove has partner fields to a separate function
protsack-stephan Mar 11, 2026
cac2f18
Move scroll into a separate hook
protsack-stephan Mar 11, 2026
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
8 changes: 8 additions & 0 deletions doc/release/RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
#### Migrations
* 0199_add_production_location_page_switch.py - Adds `enable_production_location_page` feature flag to redirect FE route of `facilities/:osID` to the `production-locations/:osID`.
* 0200_introduce_indexing_of_the_creation_date_of_the_claim_request.py - Updated the `index_claim_info` function to include the claim request creation date in the `api_facilityindex.claim_info` column.
* 0202_add_alter_partnerfield_to_use_json.py - Alters `PartnerField.json_schema` from `jsonb` to PostgreSQL `json` type (via the new `JSONTextField`) to preserve the key order defined in partner field schemas, ensuring consistent field rendering on the frontend.

### Code/API changes
* [OSDEV-2355](https://opensupplyhub.atlassian.net/browse/OSDEV-2355) - The following changes have been made:
Expand All @@ -24,6 +25,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* 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`.
* [OSDEV-2368](https://opensupplyhub.atlassian.net/browse/OSDEV-2368) - Introduced a custom `JSONTextField` (`api/fields.py`) that uses PostgreSQL `json` type instead of `jsonb` to preserve the key ordering defined in partner field schemas, ensuring fields render in the intended order on the frontend.

### 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.
Expand All @@ -43,6 +45,12 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* [OSDEV-2369](https://opensupplyhub.atlassian.net/browse/OSDEV-2369) - As part of the Production Location page redesign, implemented the "Contribute to this profile" section in the sidebar. The section includes: Suggest Correction (link to the contribute flow), Report Duplicate and Dispute Claim (mailto links; Dispute Claim is shown only when the facility is claimed by someone else), and Report Closed / Report Reopened. Report Closed/Reopened opens a dialog where logged-in users can submit a reason; anonymous users see a prompt to log in.
* [OSDEV-2375](https://opensupplyhub.atlassian.net/browse/OSDEV-2375) - Created UI for the location name, OS ID, and "Understanding Data Sources" sections. Introduced `doc/frontend.md` with UI development considerations.
* [OSDEV-2366](https://opensupplyhub.atlassian.net/browse/OSDEV-2366) - Added "Jump to" section to the sidebar with links to the different sections of the Production Location page.
* [OSDEV-2368](https://opensupplyhub.atlassian.net/browse/OSDEV-2368) - Integrated the Partner Data section into the Production Location page:
* Added `PartnerDataContainer` that fetches partner field groups from the API and renders them when partner data is available for a production location.
* Each partner group is displayed as a collapsible `PartnerSectionItem` with a toggle switch, partner icon, helper text tooltip, and a two-column layout of partner fields.
* 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.

### Release instructions
* Ensure that the following commands are included in the `post_deployment` command:
Expand Down
54 changes: 54 additions & 0 deletions src/django/api/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
Custom JSONTextField for Django.
Needed to preserve the key order when serializing to JSON.
"""

from django.db import models
from psycopg2.extras import Json


class JSONTextField(models.JSONField):
"""JSONField that uses PostgreSQL 'json' type instead of 'jsonb'.

Two issues with using Django's JSONField with db_type='json':

1. Read path: psycopg2's typecaster for the 'json' OID auto-deserializes
values to Python objects, unlike 'jsonb' which returns raw strings.
Django's from_db_value would call json.loads on the already-deserialized
value, causing a TypeError.

2. Write path: Django's adapt_json_value wraps values in psycopg2's Jsonb
adapter, which casts as ::jsonb in SQL. This causes PostgreSQL to parse
the value through jsonb (losing key order) before storing in the json
column. We use psycopg2's Json adapter instead, which casts as ::json.
"""

def db_type(self, connection):
"""
Use the 'json' type instead of 'jsonb'.
"""
return "json"

def from_db_value(self, value, expression, connection):
"""
Prevent Django from calling json.loads on a deserialized value.
"""
if value is None:
return value

if not isinstance(value, str):
return value

return super().from_db_value(value, expression, connection)

def get_db_prep_value(self, value, connection, prepared=False):
"""
Use psycopg2's Json adapter instead of Django's JSONField adapter.
"""
if not prepared:
value = self.get_prep_value(value)

if value is None:
return value

return Json(value)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.2.10 on 2026-03-10 16:21

import api.fields
from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("api", "0201_add_partnerfieldgroup_alter_partnerfield"),
]

operations = [
migrations.AlterField(
model_name="partnerfield",
name="json_schema",
field=api.fields.JSONTextField(
blank=True,
help_text='JSON Schema for validating object type partner fields. Used when type is "object".',
null=True,
),
),
]
3 changes: 2 additions & 1 deletion src/django/api/models/partner_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django_ckeditor_5.fields import CKEditor5Field

from api.constants import PARTNER_FIELD_LIST_KEY, PARTNER_FIELD_NAMES_KEY
from api.fields import JSONTextField
from api.models.partner_field_manager import PartnerFieldManager

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -75,7 +76,7 @@ class Meta:
"Rich text field describing the source of this partner field."
),
)
json_schema = models.JSONField(
json_schema = JSONTextField(
blank=True,
null=True,
help_text=(
Expand Down
8 changes: 2 additions & 6 deletions src/react/src/__tests__/components/NavBar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,14 @@ describe('NavBar', () => {
test('clicking a link scrolls to the matching section', () => {
renderNavBar();

const scrollIntoView = jest.fn();
window.scrollTo = jest.fn();
const target = document.createElement('div');
target.id = 'overview';
target.scrollIntoView = scrollIntoView;
document.body.appendChild(target);

fireEvent.click(screen.getByText('Overview'));

expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
});
expect(window.scrollTo).toHaveBeenCalled();

document.body.removeChild(target);
});
Expand Down
98 changes: 98 additions & 0 deletions src/react/src/__tests__/components/PartnerDataContainer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from 'react';
import renderWithProviders from '../../util/testUtils/renderWithProviders';
import PartnerDataContainer from '../../components/ProductionLocation/PartnerSection/PartnerDataContainer/PartnerDataContainer';

const makePartnerFieldEntry = (fieldName, value) => ({
value,
created_at: '2025-06-01T00:00:00Z',
contributor_name: 'Test Partner',
contributor_id: 1,
is_from_claim: false,
is_verified: false,
field_name: fieldName,
label: null,
source_by: null,
});

const makeGroup = (fieldNames, overrides = {}) => ({
uuid: 'group-1',
name: 'Test Group',
icon_file: null,
helper_text: '<p>Helper</p>',
description: '',
partner_fields: fieldNames,
...overrides,
});

const buildState = (facilityData, groups = [], fetching = false) => ({
facilities: { singleFacility: { data: facilityData } },
partnerFieldGroups: {
data: { results: groups },
fetching,
scrollTargetId: null,
openSectionIds: {},
},
});

describe('PartnerDataContainer component', () => {
test('renders nothing when facilityData is null', () => {
const state = buildState(null, [makeGroup(['climate_trace'])]);
const { container } = renderWithProviders(
<PartnerDataContainer />,
{ preloadedState: state },
);
expect(container.firstChild).toBeNull();
});

test('renders nothing when facilityData has no partner_fields property', () => {
const facilityData = { properties: {} };
const state = buildState(facilityData, [makeGroup(['climate_trace'])]);
const { container } = renderWithProviders(
<PartnerDataContainer />,
{ preloadedState: state },
);
expect(container.firstChild).toBeNull();
});

test('renders nothing when all partner_fields have empty arrays', () => {
const facilityData = {
properties: { partner_fields: { climate_trace: [] } },
};
const state = buildState(facilityData, [makeGroup(['climate_trace'])]);
const { container } = renderWithProviders(
<PartnerDataContainer />,
{ preloadedState: state },
);
expect(container.firstChild).toBeNull();
});

test('renders nothing when partner_fields first entry is falsy', () => {
const facilityData = {
properties: { partner_fields: { climate_trace: [null] } },
};
const state = buildState(facilityData, [makeGroup(['climate_trace'])]);
const { container } = renderWithProviders(
<PartnerDataContainer />,
{ preloadedState: state },
);
expect(container.firstChild).toBeNull();
});

test('renders the section when facilityData has valid partner field values', () => {
const facilityData = {
properties: {
partner_fields: {
climate_trace: [
makePartnerFieldEntry('climate_trace', 'CO2: 500t'),
],
},
},
};
const state = buildState(facilityData, [makeGroup(['climate_trace'])]);
const { getByText } = renderWithProviders(
<PartnerDataContainer />,
{ preloadedState: state },
);
expect(getByText('Partner Data')).toBeInTheDocument();
});
});
85 changes: 85 additions & 0 deletions src/react/src/__tests__/components/PartnerFieldItem.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React from 'react';
import renderWithProviders from '../../util/testUtils/renderWithProviders';
import PartnerFieldItem from '../../components/ProductionLocation/PartnerSection/PartnerSectionItem/PartnerFieldItem';

describe('PartnerFieldItem component', () => {
const defaultField = {
fieldName: 'climate_trace',
formatValue: undefined,
label: 'Climate Data',
partnerConfigFields: null,
};

const makeFacilityData = (values) => ({
properties: {
partner_fields: {
climate_trace: values,
},
},
});

test('renders nothing when values array is empty', () => {
const { container } = renderWithProviders(
<PartnerFieldItem
field={defaultField}
facilityData={makeFacilityData([])}
/>,
);
expect(container.firstChild).toBeNull();
});

test('renders nothing when first value is falsy', () => {
const { container } = renderWithProviders(
<PartnerFieldItem
field={defaultField}
facilityData={makeFacilityData([null])}
/>,
);
expect(container.firstChild).toBeNull();
});

test('renders the primary value and label', () => {
const facilityData = makeFacilityData([
{
value: 'Scope 1 emissions: 100',
created_at: '2025-06-01T00:00:00Z',
contributor_name: 'Climate TRACE',
contributor_id: 1,
is_from_claim: false,
is_verified: false,
field_name: 'climate_trace',
label: null,
source_by: null,
},
]);

const { getByText } = renderWithProviders(
<PartnerFieldItem field={defaultField} facilityData={facilityData} />,
);

expect(getByText('Climate Data')).toBeInTheDocument();
expect(getByText('Scope 1 emissions: 100')).toBeInTheDocument();
});

test('uses label from top value when available', () => {
const facilityData = makeFacilityData([
{
value: 'CO2: 500t',
created_at: '2025-06-01T00:00:00Z',
contributor_name: 'Partner',
contributor_id: 2,
is_from_claim: false,
is_verified: false,
field_name: 'climate_trace',
label: 'Top Value Label',
source_by: null,
},
]);

const { getByText } = renderWithProviders(
<PartnerFieldItem field={defaultField} facilityData={facilityData} />,
);

expect(getByText('Top Value Label')).toBeInTheDocument();
});
});
Loading
Loading