diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index 8ab0570ff..6e6052d91 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -7,7 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html ## Introduction * Product name: Open Supply Hub -* Release date: November 15, 2025 +* Release date: November 29, 2025 ### What's new * [OSDEV-2112](https://opensupplyhub.atlassian.net/browse/OSDEV-2112) - Moved "Recruitment Agency" (previously classified as a location type) under the "Office / HQ" location type as a processing type. Also introduced a new processing type, "Union Headquarters/Office", under the "Office / HQ" location type. This update affects both search and newly contributed data: from now on, "Union Headquarters/Office" and "Recruitment Agency" will appear under the "Office / HQ" location type when displayed in search dropdowns or shown on location profiles for **newly** added locations. @@ -18,6 +18,27 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html * `reindex_database` +## Release 2.15.1 + +## Introduction +* Product name: Open Supply Hub +* Release date: November 14, 2025 + +### Database changes + +#### Migrations +* 0185_add_source_by_to_partner_field.py - This migration adds a `source_by` RichTextField to the `PartnerField` model, allowing administrators to document the data source for each partner field using rich text formatting (bold, italic, links, lists). The field is optional and uses CKEditor for content editing. + +### What's new +* [OSDEV-2185](https://opensupplyhub.atlassian.net/browse/OSDEV-2185) - Enhanced partner field display on production location profiles by adding a `source_by` field to the `PartnerField` model. This allows administrators to provide rich text descriptions of data sources. The source information is displayed on the facility details page below each partner field value, supporting HTML formatting for links, emphasis, and lists. Updated the facility index serializer to include `source_by` in the partner fields response only when the field contains content. +* [OSDEV-2199](https://opensupplyhub.atlassian.net/browse/OSDEV-2199) - Added `unit` and `label` metadata from `PartnerField` to the serialized partner fields payload. Production Location detail pages now render the `unit` inline with field values and display custom partner field `label`. + +### Release instructions +* Ensure that the following commands are included in the `post_deployment` command: + * `migrate` + * `reindex_database` + + ## Release 2.15.0 ## Introduction diff --git a/src/django/api/admin.py b/src/django/api/admin.py index 12ed03a95..6cbe21a2d 100644 --- a/src/django/api/admin.py +++ b/src/django/api/admin.py @@ -1,5 +1,6 @@ import json +from django import forms from django.urls import path from django.contrib import admin, messages from django.contrib.admin import AdminSite @@ -15,6 +16,7 @@ from simple_history.admin import SimpleHistoryAdmin from waffle.models import Flag, Sample, Switch from waffle.admin import FlagAdmin, SampleAdmin, SwitchAdmin +from ckeditor.widgets import CKEditorWidget from api import models @@ -242,9 +244,21 @@ def get_ordering(self, request): return ['name'] +class PartnerFieldAdminForm(forms.ModelForm): + source_by = forms.CharField( + required=False, + widget=CKEditorWidget() + ) + + class Meta: + model = PartnerField + fields = ['name', 'type', 'unit', 'label', 'source_by'] + + class PartnerFieldAdmin(admin.ModelAdmin): - list_display = ('name', 'type', 'label', 'unit', 'created_at') - search_fields = ('name', 'type', 'label', 'unit') + form = PartnerFieldAdminForm + list_display = ('name', 'type', 'label', 'unit', 'source_by', 'created_at') + search_fields = ('name', 'type', 'label', 'unit', 'source_by') readonly_fields = ('uuid', 'created_at', 'updated_at') diff --git a/src/django/api/constants.py b/src/django/api/constants.py index 7a9409185..12be04290 100644 --- a/src/django/api/constants.py +++ b/src/django/api/constants.py @@ -299,4 +299,4 @@ class APIV1MatchTypes: # Use this for frontend compatibility. JS_MAX_SAFE_INTEGER = 9007199254740991 -PARTNER_FIELD_NAMES_LIST_KEY = 'partner_field_names_list' +PARTNER_FIELD_LIST_KEY = 'partner_field_list' diff --git a/src/django/api/migrations/0185_add_source_by_to_partner_field.py b/src/django/api/migrations/0185_add_source_by_to_partner_field.py new file mode 100644 index 000000000..0650f569c --- /dev/null +++ b/src/django/api/migrations/0185_add_source_by_to_partner_field.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.17 on 2025-11-04 11:47 + +from django.db import migrations +import ckeditor.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0184_remove_facilityclaim_facility_type_choices'), + ] + + operations = [ + migrations.AddField( + model_name='partnerfield', + name='source_by', + field=ckeditor.fields.RichTextField(blank=True, null=True, config_name='default'), + ), + ] + diff --git a/src/django/api/models/partner_field.py b/src/django/api/models/partner_field.py index 247e6fe11..72e32630c 100644 --- a/src/django/api/models/partner_field.py +++ b/src/django/api/models/partner_field.py @@ -1,7 +1,8 @@ import uuid from django.db import models from django.core.cache import cache -from api.constants import PARTNER_FIELD_NAMES_LIST_KEY +from ckeditor.fields import RichTextField +from api.constants import PARTNER_FIELD_LIST_KEY class PartnerField(models.Model): @@ -49,6 +50,16 @@ class Meta: blank=True, help_text=('The partner field label.')) + source_by = RichTextField( + blank=True, + null=True, + config_name='default', + help_text=( + 'Rich text field describing ' + 'the source of this partner field.' + ) + ) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -57,9 +68,9 @@ def __str__(self): def save(self, *args, **kwargs): super().save(*args, **kwargs) - cache.delete(PARTNER_FIELD_NAMES_LIST_KEY) + cache.delete(PARTNER_FIELD_LIST_KEY) def delete(self, *args, **kwargs): result = super().delete(*args, **kwargs) - cache.delete(PARTNER_FIELD_NAMES_LIST_KEY) + cache.delete(PARTNER_FIELD_LIST_KEY) return result diff --git a/src/django/api/serializers/facility/facility_index_extended_field_list_serializer.py b/src/django/api/serializers/facility/facility_index_extended_field_list_serializer.py index 1afba8f27..f2f6517fc 100644 --- a/src/django/api/serializers/facility/facility_index_extended_field_list_serializer.py +++ b/src/django/api/serializers/facility/facility_index_extended_field_list_serializer.py @@ -18,7 +18,8 @@ def __init__(self, self.fields: list = ['id', 'is_verified', 'value', 'created_at', 'updated_at', 'contributor_name', 'contributor_id', 'value_count', 'is_from_claim', - 'field_name', 'verified_count'] + 'field_name', 'verified_count', 'source_by', + 'unit', 'label'] self.data: list = [] if exclude_fields: @@ -28,31 +29,34 @@ def __init__(self, self._serialize_extended_field_list() def _serialize_extended_field_list(self) -> None: + field_serializers = { + 'created_at': self._get_created_at, + 'updated_at': self._get_updated_at, + 'contributor_name': self._get_contributor_name, + 'contributor_id': self._get_contributor_id, + 'is_from_claim': self._get_is_from_claim, + 'verified_count': self._get_verified_count, + } + context_overrides = {'source_by', 'unit', 'label'} + for extended_field in self.extended_field_list: serialized_extended_field = {} for field in self.fields: - if field == 'created_at': - serialized_extended_field[field] = \ - self._get_created_at(extended_field) - elif field == 'updated_at': - serialized_extended_field[field] = \ - self._get_updated_at(extended_field) - elif field == 'contributor_name': - serialized_extended_field[field] = \ - self._get_contributor_name(extended_field) - elif field == 'contributor_id': - serialized_extended_field[field] = \ - self._get_contributor_id(extended_field) - elif field == 'is_from_claim': - serialized_extended_field[field] = \ - self._get_is_from_claim(extended_field) - elif field == 'verified_count': + if field in field_serializers: serialized_extended_field[field] = \ - self._get_verified_count(extended_field) - else: - serialized_extended_field[field] = extended_field.get( - field) + field_serializers[field]( + extended_field + ) + continue + + if field in context_overrides: + context_value = self.context.get(field) + if context_value is not None: + serialized_extended_field[field] = context_value + continue + + serialized_extended_field[field] = extended_field.get(field) self.data.append(serialized_extended_field) diff --git a/src/django/api/serializers/facility/facility_index_serializer.py b/src/django/api/serializers/facility/facility_index_serializer.py index 1dc8a42b7..2bfb3c695 100644 --- a/src/django/api/serializers/facility/facility_index_serializer.py +++ b/src/django/api/serializers/facility/facility_index_serializer.py @@ -13,7 +13,7 @@ ) from countries.lib.countries import COUNTRY_NAMES -from api.constants import PARTNER_FIELD_NAMES_LIST_KEY +from api.constants import PARTNER_FIELD_LIST_KEY from ...models import Contributor from ...models.facility.facility_index import FacilityIndex from ...models.embed_config import EmbedConfig @@ -95,14 +95,18 @@ def __get_request(self): def __serialize_and_sort_partner_fields( self, grouped_fields: Dict[str, List[Dict[str, Any]]], - partner_field_names: List[str], + partner_fields: List[PartnerField], user_can_see_detail: bool, embed_mode_active: bool, use_main_created_at: bool, date_field_to_sort: str ) -> Dict[str, List[Dict[str, Any]]]: grouped_data = {} - for field_name in partner_field_names: + for field in partner_fields: + field_name = field.name + source_by = field.source_by + unit = field.unit + label = field.label fields = grouped_fields.get(field_name, []) if not fields: continue @@ -113,6 +117,9 @@ def __serialize_and_sort_partner_fields( context={ 'user_can_see_detail': user_can_see_detail, 'embed_mode_active': embed_mode_active, + 'source_by': source_by, + 'unit': unit, + 'label': label }, exclude_fields=( ['created_at'] if not use_main_created_at else [] @@ -190,19 +197,19 @@ def __group_fields_by_name( return grouped @staticmethod - def __get_partner_field_names(): - cached_names = cache.get(PARTNER_FIELD_NAMES_LIST_KEY) + def __get_cached_partner_fields(): + cached_names = cache.get(PARTNER_FIELD_LIST_KEY) if cached_names is not None: return cached_names - names = list( - PartnerField.objects.values_list("name", flat=True) + partner_fields = list( + PartnerField.objects.all() ) - cache.set(PARTNER_FIELD_NAMES_LIST_KEY, names, 600) + cache.set(PARTNER_FIELD_LIST_KEY, partner_fields, 60) - return names + return partner_fields def get_location(self, facility): return facility.location @@ -429,11 +436,11 @@ def get_partner_fields(self, facility): user_can_see_detail = can_user_see_detail(self) embed_mode_active = is_embed_mode_active(self) - field_names = self.__get_partner_field_names() + partner_fields = self.__get_cached_partner_fields() return self.__serialize_and_sort_partner_fields( grouped_fields, - field_names, + partner_fields, user_can_see_detail, embed_mode_active, use_main_created_at, diff --git a/src/django/api/tests/test_facility_index_serializer.py b/src/django/api/tests/test_facility_index_serializer.py index 733756230..c5b485608 100644 --- a/src/django/api/tests/test_facility_index_serializer.py +++ b/src/django/api/tests/test_facility_index_serializer.py @@ -136,3 +136,241 @@ def test_partner_fields_exist(self): test_data_field[0]['value'], {'raw_value': 'Transport Data'} ) + + def test_partner_fields_includes_source_by(self): + self.partner_field_1.source_by = \ + 'Climate TRACE source' + self.partner_field_1.save() + + extended_field = ExtendedField.objects.create( + facility=self.facility, + field_name='test_data_field', + value={'raw_value': 'Test Value'}, + contributor=self.contrib_one + ) + + facility_index = FacilityIndex.objects.get(id=self.facility.id) + facility_index.extended_fields.append({ + 'id': extended_field.id, + 'field_name': 'test_data_field', + 'value': {'raw_value': 'Test Value'}, + 'contributor': { + 'id': self.contrib_one.id, + 'name': self.contrib_one.name, + 'is_verified': self.contrib_one.is_verified, + }, + 'created_at': extended_field.created_at.isoformat(), + 'updated_at': extended_field.updated_at.isoformat(), + 'is_verified': False, + 'facility_list_item_id': None, + 'should_display_association': True, + 'value_count': 1, + }) + facility_index.save() + facility_index.refresh_from_db() + + data = FacilityIndexSerializer(facility_index).data + partner_fields = data["properties"]["partner_fields"] + test_data_field = partner_fields['test_data_field'] + + self.assertEqual(len(test_data_field), 1) + self.assertIn('source_by', test_data_field[0]) + self.assertEqual( + test_data_field[0]['source_by'], + 'Climate TRACE source' + ) + + def test_partner_fields_source_by_is_none_when_not_set(self): + extended_field = ExtendedField.objects.create( + facility=self.facility, + field_name='test_data_field', + value={'raw_value': 'Test Value'}, + contributor=self.contrib_one + ) + + facility_index = FacilityIndex.objects.get(id=self.facility.id) + facility_index.extended_fields.append({ + 'id': extended_field.id, + 'field_name': 'test_data_field', + 'value': {'raw_value': 'Test Value'}, + 'contributor': { + 'id': self.contrib_one.id, + 'name': self.contrib_one.name, + 'is_verified': self.contrib_one.is_verified, + }, + 'created_at': extended_field.created_at.isoformat(), + 'updated_at': extended_field.updated_at.isoformat(), + 'is_verified': False, + 'facility_list_item_id': None, + 'should_display_association': True, + 'value_count': 1, + }) + facility_index.save() + facility_index.refresh_from_db() + + data = FacilityIndexSerializer(facility_index).data + partner_fields = data["properties"]["partner_fields"] + test_data_field = partner_fields['test_data_field'] + + self.assertEqual(len(test_data_field), 1) + self.assertIn('source_by', test_data_field[0]) + self.assertEqual( + test_data_field[0]['source_by'], + None + ) + + def test_partner_fields_includes_unit(self): + self.partner_field_1.unit = 'kg' + self.partner_field_1.save() + + extended_field = ExtendedField.objects.create( + facility=self.facility, + field_name='test_data_field', + value={'raw_value': 'Test Value'}, + contributor=self.contrib_one + ) + + facility_index = FacilityIndex.objects.get(id=self.facility.id) + facility_index.extended_fields.append({ + 'id': extended_field.id, + 'field_name': 'test_data_field', + 'value': {'raw_value': 'Test Value'}, + 'contributor': { + 'id': self.contrib_one.id, + 'name': self.contrib_one.name, + 'is_verified': self.contrib_one.is_verified, + }, + 'created_at': extended_field.created_at.isoformat(), + 'updated_at': extended_field.updated_at.isoformat(), + 'is_verified': False, + 'facility_list_item_id': None, + 'should_display_association': True, + 'value_count': 1, + }) + facility_index.save() + facility_index.refresh_from_db() + + data = FacilityIndexSerializer(facility_index).data + partner_fields = data["properties"]["partner_fields"] + test_data_field = partner_fields['test_data_field'] + + self.assertEqual(len(test_data_field), 1) + self.assertIn('unit', test_data_field[0]) + self.assertEqual(test_data_field[0]['unit'], 'kg') + + def test_partner_fields_unit_is_none_when_not_set(self): + self.partner_field_1.unit = '' + self.partner_field_1.save() + + extended_field = ExtendedField.objects.create( + facility=self.facility, + field_name='test_data_field', + value={'raw_value': 'Test Value'}, + contributor=self.contrib_one + ) + + facility_index = FacilityIndex.objects.get(id=self.facility.id) + facility_index.extended_fields.append({ + 'id': extended_field.id, + 'field_name': 'test_data_field', + 'value': {'raw_value': 'Test Value'}, + 'contributor': { + 'id': self.contrib_one.id, + 'name': self.contrib_one.name, + 'is_verified': self.contrib_one.is_verified, + }, + 'created_at': extended_field.created_at.isoformat(), + 'updated_at': extended_field.updated_at.isoformat(), + 'is_verified': False, + 'facility_list_item_id': None, + 'should_display_association': True, + 'value_count': 1, + }) + facility_index.save() + facility_index.refresh_from_db() + + data = FacilityIndexSerializer(facility_index).data + partner_fields = data["properties"]["partner_fields"] + test_data_field = partner_fields['test_data_field'] + + self.assertEqual(len(test_data_field), 1) + self.assertIn('unit', test_data_field[0]) + self.assertEqual(test_data_field[0]['unit'], '') + + def test_partner_fields_includes_label(self): + self.partner_field_1.label = 'Emissions Data' + self.partner_field_1.save() + + extended_field = ExtendedField.objects.create( + facility=self.facility, + field_name='test_data_field', + value={'raw_value': 'Test Value'}, + contributor=self.contrib_one + ) + + facility_index = FacilityIndex.objects.get(id=self.facility.id) + facility_index.extended_fields.append({ + 'id': extended_field.id, + 'field_name': 'test_data_field', + 'value': {'raw_value': 'Test Value'}, + 'contributor': { + 'id': self.contrib_one.id, + 'name': self.contrib_one.name, + 'is_verified': self.contrib_one.is_verified, + }, + 'created_at': extended_field.created_at.isoformat(), + 'updated_at': extended_field.updated_at.isoformat(), + 'is_verified': False, + 'facility_list_item_id': None, + 'should_display_association': True, + 'value_count': 1, + }) + facility_index.save() + facility_index.refresh_from_db() + + data = FacilityIndexSerializer(facility_index).data + partner_fields = data["properties"]["partner_fields"] + test_data_field = partner_fields['test_data_field'] + + self.assertEqual(len(test_data_field), 1) + self.assertIn('label', test_data_field[0]) + self.assertEqual(test_data_field[0]['label'], 'Emissions Data') + + def test_partner_fields_label_is_empty_when_not_set(self): + self.partner_field_1.label = '' + self.partner_field_1.save() + + extended_field = ExtendedField.objects.create( + facility=self.facility, + field_name='test_data_field', + value={'raw_value': 'Test Value'}, + contributor=self.contrib_one + ) + + facility_index = FacilityIndex.objects.get(id=self.facility.id) + facility_index.extended_fields.append({ + 'id': extended_field.id, + 'field_name': 'test_data_field', + 'value': {'raw_value': 'Test Value'}, + 'contributor': { + 'id': self.contrib_one.id, + 'name': self.contrib_one.name, + 'is_verified': self.contrib_one.is_verified, + }, + 'created_at': extended_field.created_at.isoformat(), + 'updated_at': extended_field.updated_at.isoformat(), + 'is_verified': False, + 'facility_list_item_id': None, + 'should_display_association': True, + 'value_count': 1, + }) + facility_index.save() + facility_index.refresh_from_db() + + data = FacilityIndexSerializer(facility_index).data + partner_fields = data["properties"]["partner_fields"] + test_data_field = partner_fields['test_data_field'] + + self.assertEqual(len(test_data_field), 1) + self.assertIn('label', test_data_field[0]) + self.assertEqual(test_data_field[0]['label'], '') diff --git a/src/django/oar/settings.py b/src/django/oar/settings.py index 8eec1b652..f4afdaf39 100644 --- a/src/django/oar/settings.py +++ b/src/django/oar/settings.py @@ -135,6 +135,7 @@ 'web', 'ecsmanage', 'django_bleach', + 'ckeditor', ] # For allauth @@ -501,6 +502,24 @@ }, } +CKEDITOR_CONFIGS = { + 'default': { + 'toolbar': [ + ['Bold', 'Italic', 'Underline', 'Strike'], + ['NumberedList', 'BulletedList'], + ['Link', 'Unlink'], + ['RemoveFormat', 'Source'], + ], + 'height': 250, + 'width': '100%', + 'removePlugins': 'uploadimage,uploadfile,image,flash,smiley', + 'enterMode': 2, + 'shiftEnterMode': 2, + 'autoParagraph': False, + 'fillEmptyBlocks': False, + } +} + # Application settings MAX_UPLOADED_FILE_SIZE_IN_BYTES = 5242880 MAX_ATTACHMENT_SIZE_IN_BYTES = 5 * 1024 * 1024 # 5 MB diff --git a/src/django/requirements.txt b/src/django/requirements.txt index 5727d3539..0d243d69c 100644 --- a/src/django/requirements.txt +++ b/src/django/requirements.txt @@ -18,6 +18,7 @@ django-storages==1.13.1 django-waffle==2.5.0 django-watchman==1.3.0 drf-yasg==1.20.0 +django-ckeditor==6.5.1 djangorestframework-gis==1.0.0 djangorestframework==3.13.1 flake8==4.0.1 diff --git a/src/react/src/__tests__/components/FacilityDetailsDetail.test.js b/src/react/src/__tests__/components/FacilityDetailsDetail.test.js index 7843e2543..b57c581e0 100644 --- a/src/react/src/__tests__/components/FacilityDetailsDetail.test.js +++ b/src/react/src/__tests__/components/FacilityDetailsDetail.test.js @@ -45,6 +45,123 @@ describe('FacilityDetailsDetail', () => { const longitudeIndex = allText.indexOf(`Longitude: ${facilityLng}`); expect(latitudeIndex).toBeLessThan(longitudeIndex); - }); + }); + + test('renders with sourceBy HTML content', () => { + const props = { + primary: 'Test Primary', + sourceBy: 'Climate TRACE API', + classes: { + detailsContainer: 'detailsContainer', + primaryText: 'primaryText', + sourceText: 'sourceText', + unitText: 'unitText', + }, + }; + + const { container } = render(); + + expect(screen.getByText('Test Primary')).toBeInTheDocument(); + + const sourceElement = container.querySelector('.sourceText'); + expect(sourceElement).toBeInTheDocument(); + expect(sourceElement.innerHTML).toBe('Climate TRACE API'); + }); + + test('renders sourceBy with link HTML', () => { + const props = { + primary: 'Test Primary', + sourceBy: 'Data from Climate TRACE', + classes: { + detailsContainer: 'detailsContainer', + primaryText: 'primaryText', + sourceText: 'sourceText', + unitText: 'unitText', + }, + }; + + const { container } = render(); + + const sourceElement = container.querySelector('.sourceText'); + expect(sourceElement).toBeInTheDocument(); + expect(sourceElement.innerHTML).toContain(''); + expect(sourceElement.innerHTML).toContain('Climate TRACE'); + }); + + test('does not render sourceBy when null', () => { + const props = { + primary: 'Test Primary', + sourceBy: null, + classes: { + detailsContainer: 'detailsContainer', + primaryText: 'primaryText', + sourceText: 'sourceText', + unitText: 'unitText', + }, + }; + + const { container } = render(); + + expect(screen.getByText('Test Primary')).toBeInTheDocument(); + + const sourceElement = container.querySelector('.sourceText'); + expect(sourceElement).not.toBeInTheDocument(); + }); + + test('does not render sourceBy when empty string', () => { + const props = { + primary: 'Test Primary', + sourceBy: '', + classes: { + detailsContainer: 'detailsContainer', + primaryText: 'primaryText', + sourceText: 'sourceText', + unitText: 'unitText', + }, + }; + + const { container } = render(); + + const sourceElement = container.querySelector('.sourceText'); + expect(sourceElement).not.toBeInTheDocument(); + }); + + test('renders unit text inline with primary value', () => { + const props = { + primary: '100', + unit: 'kg', + classes: { + detailsContainer: 'detailsContainer', + primaryText: 'primaryText', + unitText: 'unitText', + }, + }; + + const { container } = render(); + + const primaryElement = container.querySelector('.primaryText'); + expect(primaryElement).toBeInTheDocument(); + expect(primaryElement.textContent).toBe('100kg'); + + const unitElement = container.querySelector('.unitText'); + expect(unitElement).toBeInTheDocument(); + expect(unitElement.textContent).toBe('kg'); + }); + + test('does not render unit text when unit is null', () => { + const props = { + primary: 'Test Primary', + unit: null, + classes: { + detailsContainer: 'detailsContainer', + primaryText: 'primaryText', + unitText: 'unitText', + }, + }; + + const { container } = render(); + + expect(container.querySelector('.unitText')).not.toBeInTheDocument(); + }); }); diff --git a/src/react/src/__tests__/components/FacilityDetailsGeneralFields.test.js b/src/react/src/__tests__/components/FacilityDetailsGeneralFields.test.js index 12d443d89..96ce3ceee 100644 --- a/src/react/src/__tests__/components/FacilityDetailsGeneralFields.test.js +++ b/src/react/src/__tests__/components/FacilityDetailsGeneralFields.test.js @@ -311,4 +311,69 @@ describe('FacilityDetailsGeneralFields component', () => { expect(getByText('Name')).toBeInTheDocument(); expect(getByText('Nice production location LTD')).toBeInTheDocument(); }); + + test('uses partner field label from top value when provided', () => { + const partnerFieldLabel = 'Climate TRACE Data 2024'; + const dataWithPartnerField = { + ...mockData, + properties: { + ...mockData.properties, + partner_fields: { + ...mockData.properties.partner_fields, + climate_trace: [ + { + value: { raw_value: 'Scope 1 emissions: 123' }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-02T00:00:00Z', + contributor_name: 'Climate TRACE', + contributor_id: 1139, + is_from_claim: false, + is_verified: false, + field_name: 'climate_trace', + value_count: 1, + label: partnerFieldLabel, + source_by: '

Reported via Climate TRACE platform

', + }, + ], + }, + }, + }; + + const { getByText } = renderComponent({ data: dataWithPartnerField }); + + expect(getByText(partnerFieldLabel)).toBeInTheDocument(); + expect(getByText('Scope 1 emissions: 123')).toBeInTheDocument(); + }); + + test('falls back to generated partner field label when top value label missing', () => { + const dataWithPartnerField = { + ...mockData, + properties: { + ...mockData.properties, + partner_fields: { + ...mockData.properties.partner_fields, + climate_trace: [ + { + value: { raw_value: 'Scope 2 emissions: 456' }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-02T00:00:00Z', + contributor_name: 'Climate TRACE', + contributor_id: 1139, + is_from_claim: false, + is_verified: false, + field_name: 'climate_trace', + value_count: 1, + label: null, + source_by: null, + }, + ], + }, + }, + }; + + const { getByText } = renderComponent({ data: dataWithPartnerField }); + + expect(getByText('Climate Trace')).toBeInTheDocument(); + expect(getByText('Scope 2 emissions: 456')).toBeInTheDocument(); + }); }); diff --git a/src/react/src/components/FacilityDetailsDetail.jsx b/src/react/src/components/FacilityDetailsDetail.jsx index a15800835..008063ccc 100644 --- a/src/react/src/components/FacilityDetailsDetail.jsx +++ b/src/react/src/components/FacilityDetailsDetail.jsx @@ -33,6 +33,21 @@ const detailsStyles = theme => lineHeight: '17px', paddingTop: theme.spacing.unit, }, + sourceText: { + overflowWrap: 'anywhere', + fontWeight: 500, + fontSize: '16px', + lineHeight: '19px', + paddingTop: theme.spacing.unit, + }, + unitText: { + display: 'inline-block', + marginLeft: theme.spacing.unit, + fontWeight: 400, + fontSize: '18px', + lineHeight: '21px', + verticalAlign: 'baseline', + }, }); const CLAIM_EXPLANATORY_TEXT = @@ -46,6 +61,8 @@ const FacilityDetailsDetail = ({ primary, locationLabeled, secondary, + sourceBy, + unit, isVerified, isFromClaim, classes, @@ -66,9 +83,17 @@ const FacilityDetailsDetail = ({
- + {primary || locationLabeled} + {unit ? {unit} : null} + {sourceBy ? ( + + ) : null} {secondary ? ( {secondary} diff --git a/src/react/src/components/FacilityDetailsGeneralFields.jsx b/src/react/src/components/FacilityDetailsGeneralFields.jsx index 11d7448f5..168e7eb1f 100644 --- a/src/react/src/components/FacilityDetailsGeneralFields.jsx +++ b/src/react/src/components/FacilityDetailsGeneralFields.jsx @@ -151,7 +151,7 @@ const FacilityDetailsGeneralFields = ({ @@ -227,8 +227,8 @@ const FacilityDetailsGeneralFields = ({ diff --git a/src/react/src/components/FacilityDetailsItem.jsx b/src/react/src/components/FacilityDetailsItem.jsx index 1213d77fc..57fb43aae 100644 --- a/src/react/src/components/FacilityDetailsItem.jsx +++ b/src/react/src/components/FacilityDetailsItem.jsx @@ -38,6 +38,8 @@ const FacilityDetailsItem = ({ lng, locationLabeled, secondary, + sourceBy, + unit, classes, embed, isVerified, @@ -60,6 +62,8 @@ const FacilityDetailsItem = ({ lng={lng} locationLabeled={locationLabeled} secondary={!embed ? secondary : null} + sourceBy={!embed ? sourceBy : null} + unit={!embed ? unit : null} isVerified={isVerified} isFromClaim={isFromClaim} /> @@ -93,6 +97,8 @@ const FacilityDetailsItem = ({ diff --git a/src/react/src/components/FacilityDetailsLocationFields.jsx b/src/react/src/components/FacilityDetailsLocationFields.jsx index 2ef402c62..9453acd4e 100644 --- a/src/react/src/components/FacilityDetailsLocationFields.jsx +++ b/src/react/src/components/FacilityDetailsLocationFields.jsx @@ -73,8 +73,8 @@ const FacilityDetailsLocationFields = ({