diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md
index 7b5b2478b..31b7040fd 100644
--- a/doc/release/RELEASE-NOTES.md
+++ b/doc/release/RELEASE-NOTES.md
@@ -23,6 +23,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
### What's new
* [OSDEV-2280](https://opensupplyhub.atlassian.net/browse/OSDEV-2280) - Added prominent PII (Personally Identifiable Information) warning notes at file upload stages throughout the new claim flow to inform users that they should NOT submit documents containing personal information, home addresses, personal utility bills, or personal phone numbers, enhancing data security and user privacy protection.
* [OSDEV-2114](https://opensupplyhub.atlassian.net/browse/OSDEV-2114) - Extended the `GET api/facilities/{os_id}/` endpoint with additional claim environmental data including opening date, closing date, estimated annual throughput, and actual annual energy consumption. The production location profile page now displays these new environmental data points as part of the claim information.
+* [OSDEV-2269](https://opensupplyhub.atlassian.net/browse/OSDEV-2269) - Added URI format support for partner fields with JSON schema. Properties defined with `"format": "uri"` are rendered as clickable links on production location profile pages.
### Release instructions
* Ensure that the following commands are included in the `post_deployment` command:
@@ -71,6 +72,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
7. Finally, run real `isic_4` backfilling using this command for all fields: `backfill_isic_4_extended_fields`.
8. Make sure no errors appear.
+
## Release 2.15.2
## Introduction
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 f2f6517fc..668d2fd70 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
@@ -19,7 +19,7 @@ def __init__(self,
'updated_at', 'contributor_name',
'contributor_id', 'value_count', 'is_from_claim',
'field_name', 'verified_count', 'source_by',
- 'unit', 'label']
+ 'unit', 'label', 'json_schema']
self.data: list = []
if exclude_fields:
@@ -37,7 +37,7 @@ def _serialize_extended_field_list(self) -> None:
'is_from_claim': self._get_is_from_claim,
'verified_count': self._get_verified_count,
}
- context_overrides = {'source_by', 'unit', 'label'}
+ context_overrides = {'source_by', 'unit', 'label', 'json_schema'}
for extended_field in self.extended_field_list:
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 2bfb3c695..5dd3f9d7b 100644
--- a/src/django/api/serializers/facility/facility_index_serializer.py
+++ b/src/django/api/serializers/facility/facility_index_serializer.py
@@ -107,6 +107,7 @@ def __serialize_and_sort_partner_fields(
source_by = field.source_by
unit = field.unit
label = field.label
+ json_schema = field.json_schema
fields = grouped_fields.get(field_name, [])
if not fields:
continue
@@ -119,7 +120,8 @@ def __serialize_and_sort_partner_fields(
'embed_mode_active': embed_mode_active,
'source_by': source_by,
'unit': unit,
- 'label': label
+ 'label': label,
+ 'json_schema': json_schema
},
exclude_fields=(
['created_at'] if not use_main_created_at else []
diff --git a/src/django/api/tests/test_facility_index_serializer.py b/src/django/api/tests/test_facility_index_serializer.py
index c5b485608..677236501 100644
--- a/src/django/api/tests/test_facility_index_serializer.py
+++ b/src/django/api/tests/test_facility_index_serializer.py
@@ -374,3 +374,100 @@ def test_partner_fields_label_is_empty_when_not_set(self):
self.assertEqual(len(test_data_field), 1)
self.assertIn('label', test_data_field[0])
self.assertEqual(test_data_field[0]['label'], '')
+
+ def test_partner_fields_includes_json_schema(self):
+ self.partner_field_1.json_schema = {
+ 'type': 'object',
+ 'properties': {
+ 'url': {
+ 'type': 'string',
+ 'format': 'uri',
+ },
+ },
+ }
+ 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('json_schema', test_data_field[0])
+ self.assertEqual(
+ test_data_field[0]['json_schema'],
+ {
+ 'type': 'object',
+ 'properties': {
+ 'url': {
+ 'type': 'string',
+ 'format': 'uri',
+ },
+ },
+ }
+ )
+
+ def test_partner_fields_json_schema_none(self):
+ self.partner_field_1.json_schema = None
+ 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('json_schema', test_data_field[0])
+ self.assertIsNone(test_data_field[0]['json_schema'])
diff --git a/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js
new file mode 100644
index 000000000..a339159d9
--- /dev/null
+++ b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js
@@ -0,0 +1,120 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import PartnerFieldSchemaValue from '../../components/PartnerFields/PartnerFieldSchemaValue';
+
+describe('PartnerFieldSchemaValue', () => {
+ it('renders URI field with _text property as clickable link and skips _text', () => {
+ const value = {
+ url: 'https://example.com/audit-123',
+ url_text: 'View Audit Report',
+ };
+ const jsonSchema = {
+ type: 'object',
+ properties: {
+ url: {
+ type: 'string',
+ format: 'uri',
+ },
+ url_text: {
+ type: 'string',
+ },
+ },
+ };
+
+ render();
+
+ const link = screen.getByRole('link', { name: 'View Audit Report' });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', 'https://example.com/audit-123');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+
+ const urlTextElements = screen.queryAllByText('View Audit Report');
+ expect(urlTextElements.length).toBe(1); // Only in the link
+ });
+
+ it('renders URI field without _text property, using URI as link text', () => {
+ const value = {
+ mit_data_url: 'https://livingwage.mit.edu/locations/123',
+ };
+ const jsonSchema = {
+ type: 'object',
+ properties: {
+ mit_data_url: {
+ type: 'string',
+ format: 'uri',
+ },
+ },
+ };
+
+ render();
+
+ const link = screen.getByRole('link', {
+ name: 'https://livingwage.mit.edu/locations/123',
+ });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', 'https://livingwage.mit.edu/locations/123');
+ });
+
+ it('renders non-URI field with title as "Title: value" format', () => {
+ const value = {
+ internal_id: 'abc-123-xyz',
+ };
+ const jsonSchema = {
+ type: 'object',
+ properties: {
+ internal_id: {
+ type: 'string',
+ title: 'Internal ID',
+ },
+ },
+ };
+
+ render();
+
+ const text = screen.getByText('Internal ID: abc-123-xyz');
+ expect(text).toBeInTheDocument();
+ expect(text.tagName).toBe('SPAN');
+ });
+
+ it('renders mixed URI and non-URI fields correctly, skipping _text property', () => {
+ const value = {
+ url: 'https://example.com/report',
+ url_text: 'View Report',
+ internal_id: 'ABC-123',
+ status: 'active',
+ };
+ const jsonSchema = {
+ type: 'object',
+ properties: {
+ url: {
+ type: 'string',
+ format: 'uri',
+ },
+ url_text: {
+ type: 'string',
+ },
+ internal_id: {
+ type: 'string',
+ title: 'Internal ID',
+ },
+ status: {
+ type: 'string',
+ title: 'Status',
+ },
+ },
+ };
+
+ render();
+
+ const link = screen.getByRole('link', { name: 'View Report' });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', 'https://example.com/report');
+
+ expect(screen.getByText('Internal ID: ABC-123')).toBeInTheDocument();
+ expect(screen.getByText('Status: active')).toBeInTheDocument();
+
+ const urlTextElements = screen.queryAllByText('View Report');
+ expect(urlTextElements.length).toBe(1);
+ });
+});
diff --git a/src/react/src/__tests__/utils.tests.js b/src/react/src/__tests__/utils.tests.js
index 1ee3daf9d..3160beaf4 100644
--- a/src/react/src/__tests__/utils.tests.js
+++ b/src/react/src/__tests__/utils.tests.js
@@ -2831,4 +2831,31 @@ describe('formatPartnerFieldValue', () => {
const result = formatPartnerFieldValue(value);
expect(result).toBe('active: true, deleted: false, notes: null');
});
+
+ it('formats object with schema', () => {
+ const value = {
+ raw_value: {
+ url: 'https://example.com/report',
+ url_text: 'View Report',
+ },
+ };
+ const jsonSchema = {
+ type: 'object',
+ properties: {
+ url: {
+ type: 'string',
+ format: 'uri',
+ },
+ url_text: {
+ type: 'string',
+ },
+ },
+ };
+ const result = formatPartnerFieldValue(value, jsonSchema);
+
+ expect(result).toEqual({
+ url: 'https://example.com/report',
+ url_text: 'View Report',
+ });
+ });
});
diff --git a/src/react/src/components/FacilityDetailsDetail.jsx b/src/react/src/components/FacilityDetailsDetail.jsx
index 008063ccc..99b03f325 100644
--- a/src/react/src/components/FacilityDetailsDetail.jsx
+++ b/src/react/src/components/FacilityDetailsDetail.jsx
@@ -6,6 +6,7 @@ import Tooltip from '@material-ui/core/Tooltip';
import ShowOnly from './ShowOnly';
import BadgeVerified from './BadgeVerified';
import FeatureFlag from './FeatureFlag';
+import PartnerFieldSchemaValue from './PartnerFields/PartnerFieldSchemaValue';
import { CLAIM_A_FACILITY } from '../util/constants';
@@ -63,6 +64,7 @@ const FacilityDetailsDetail = ({
secondary,
sourceBy,
unit,
+ jsonSchema,
isVerified,
isFromClaim,
classes,
@@ -84,7 +86,14 @@ const FacilityDetailsDetail = ({
- {primary || locationLabeled}
+ {jsonSchema ? (
+
+ ) : (
+ primary || locationLabeled
+ )}
{unit ? {unit} : null}
{sourceBy ? (
diff --git a/src/react/src/components/FacilityDetailsGeneralFields.jsx b/src/react/src/components/FacilityDetailsGeneralFields.jsx
index 4aa67eff2..45b370fd5 100644
--- a/src/react/src/components/FacilityDetailsGeneralFields.jsx
+++ b/src/react/src/components/FacilityDetailsGeneralFields.jsx
@@ -229,7 +229,10 @@ const FacilityDetailsGeneralFields = ({
const values = get(data, `properties.partner_fields.${fieldName}`, []);
const formatField = item =>
- formatExtendedField({ ...item, formatValue });
+ formatExtendedField({
+ ...item,
+ formatValue,
+ });
if (!values.length || !values[0]) return null;
diff --git a/src/react/src/components/FacilityDetailsItem.jsx b/src/react/src/components/FacilityDetailsItem.jsx
index 57fb43aae..cd45ae06b 100644
--- a/src/react/src/components/FacilityDetailsItem.jsx
+++ b/src/react/src/components/FacilityDetailsItem.jsx
@@ -40,6 +40,7 @@ const FacilityDetailsItem = ({
secondary,
sourceBy,
unit,
+ jsonSchema,
classes,
embed,
isVerified,
@@ -64,6 +65,7 @@ const FacilityDetailsItem = ({
secondary={!embed ? secondary : null}
sourceBy={!embed ? sourceBy : null}
unit={!embed ? unit : null}
+ jsonSchema={!embed ? jsonSchema : null}
isVerified={isVerified}
isFromClaim={isFromClaim}
/>
@@ -99,6 +101,7 @@ const FacilityDetailsItem = ({
secondary={!embed ? secondary : null}
sourceBy={!embed ? sourceBy : null}
unit={!embed ? unit : null}
+ jsonSchema={!embed ? jsonSchema : null}
isVerified={isVerified}
isFromClaim={isFromClaim}
/>
diff --git a/src/react/src/components/PartnerFields/DefaultProperty.jsx b/src/react/src/components/PartnerFields/DefaultProperty.jsx
new file mode 100644
index 000000000..f6cdd95ec
--- /dev/null
+++ b/src/react/src/components/PartnerFields/DefaultProperty.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+/**
+ * Component for rendering default format properties (no format or unsupported format).
+ */
+const DefaultProperty = ({ propertyKey, value, schemaProperties }) => {
+ const propertyValue = value[propertyKey];
+ const propertySchema = schemaProperties[propertyKey] || {};
+ const { title } = propertySchema;
+ const stringValue = propertyValue != null ? String(propertyValue) : '';
+ const displayText = title ? `${title}: ${stringValue}` : stringValue;
+
+ return (
+
+ {displayText}
+
+ );
+};
+
+export default DefaultProperty;
diff --git a/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx
new file mode 100644
index 000000000..7e8e6eaa9
--- /dev/null
+++ b/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import UriProperty from './UriProperty';
+import DefaultProperty from './DefaultProperty';
+import { FORMAT_TYPES, getFormatFromSchema, shouldSkipProperty } from './utils';
+
+/**
+ * Format component registry.
+ * Maps format types to their component functions.
+ */
+const FORMAT_COMPONENTS = {
+ [FORMAT_TYPES.URI]: UriProperty,
+ // Add more format components here in the future
+};
+
+/**
+ * Gets the component to use for rendering a property based on its format.
+ */
+const getFormatComponent = format => {
+ if (format && FORMAT_COMPONENTS[format]) {
+ return FORMAT_COMPONENTS[format];
+ }
+ return DefaultProperty;
+};
+
+/**
+ * Renders a single property based on format strategy.
+ */
+const renderProperty = (propertyKey, value, schemaProperties) => {
+ const propertySchema = schemaProperties[propertyKey] || {};
+ const format = getFormatFromSchema(propertySchema);
+ const FormatComponent = getFormatComponent(format);
+
+ return (
+
+ );
+};
+
+/**
+ * Component to render partner field values based on JSON schema.
+ */
+const PartnerFieldSchemaValue = ({ value, jsonSchema }) => {
+ if (
+ !jsonSchema ||
+ !value ||
+ typeof value !== 'object' ||
+ Array.isArray(value)
+ ) {
+ return value ?? null;
+ }
+
+ const schemaProperties = jsonSchema?.properties || {};
+
+ const renderedItems = Object.keys(value).reduce((acc, propertyKey) => {
+ if (shouldSkipProperty(propertyKey, schemaProperties)) {
+ return acc;
+ }
+ const rendered = renderProperty(propertyKey, value, schemaProperties);
+ if (rendered) {
+ acc.push(rendered);
+ }
+ return acc;
+ }, []);
+
+ return <>{renderedItems}>;
+};
+
+export default PartnerFieldSchemaValue;
diff --git a/src/react/src/components/PartnerFields/UriProperty.jsx b/src/react/src/components/PartnerFields/UriProperty.jsx
new file mode 100644
index 000000000..8fa9c493f
--- /dev/null
+++ b/src/react/src/components/PartnerFields/UriProperty.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+/**
+ * Component for rendering URI format properties.
+ */
+const UriProperty = ({ propertyKey, value, schemaProperties }) => {
+ const propertyValue = value[propertyKey];
+ if (!propertyValue) {
+ return null;
+ }
+
+ const textKey = `${propertyKey}_text`;
+ const textPropertyDefined = schemaProperties && schemaProperties[textKey];
+ const linkText =
+ textPropertyDefined && textKey in value
+ ? value[textKey]
+ : propertyValue;
+
+ return (
+
+ {linkText}
+
+ );
+};
+
+export default UriProperty;
diff --git a/src/react/src/components/PartnerFields/utils.js b/src/react/src/components/PartnerFields/utils.js
new file mode 100644
index 000000000..19be755f9
--- /dev/null
+++ b/src/react/src/components/PartnerFields/utils.js
@@ -0,0 +1,33 @@
+/**
+ * Format constants for JSON Schema.
+ */
+export const FORMAT_TYPES = {
+ URI: 'uri',
+};
+
+/**
+ * Gets the format type from a property schema.
+ */
+export const getFormatFromSchema = propertySchema =>
+ propertySchema?.format || null;
+
+/**
+ * Checks if a property key should be skipped.
+ */
+export const shouldSkipProperty = (propertyKey, schemaProperties) => {
+ if (!(propertyKey in schemaProperties)) {
+ return true;
+ }
+
+ if (propertyKey.endsWith('_text')) {
+ const baseKey = propertyKey.slice(0, -5);
+ const baseSchema = schemaProperties[baseKey];
+ const baseFormat = getFormatFromSchema(baseSchema);
+
+ if (baseFormat === FORMAT_TYPES.URI) {
+ return true;
+ }
+ }
+
+ return false;
+};
diff --git a/src/react/src/util/renderUtils.jsx b/src/react/src/util/renderUtils.jsx
index 5e6ad2b42..ceaa5d036 100644
--- a/src/react/src/util/renderUtils.jsx
+++ b/src/react/src/util/renderUtils.jsx
@@ -14,6 +14,10 @@ const renderUniqueListItems = (
return fieldValue;
}
+ if (fieldValue.length > 0 && React.isValidElement(fieldValue[0])) {
+ return fieldValue;
+ }
+
const values = preserveOrder ? fieldValue : [...new Set(fieldValue)];
return values.map(value =>
diff --git a/src/react/src/util/util.js b/src/react/src/util/util.js
index 6d08d08c3..8c305df31 100644
--- a/src/react/src/util/util.js
+++ b/src/react/src/util/util.js
@@ -1783,12 +1783,16 @@ export const formatExtendedField = ({
source_by,
unit,
label,
+ json_schema,
contributor_name,
is_from_claim,
is_verified,
formatValue = rawValue => rawValue,
}) => {
- const primary = renderUniqueListItems(formatValue(value), field_name);
+ const primary = renderUniqueListItems(
+ formatValue(value, json_schema),
+ field_name,
+ );
const secondary = formatAttribution(created_at, contributor_name);
return {
@@ -1797,6 +1801,7 @@ export const formatExtendedField = ({
sourceBy: source_by,
unit,
label,
+ jsonSchema: json_schema,
embeddedSecondary: formatAttribution(created_at),
isVerified: is_verified,
isFromClaim: is_from_claim,
@@ -1898,14 +1903,24 @@ const formatRawValues = rawValues => {
return rawValues.toString().split('|');
};
-export const formatPartnerFieldValue = value => {
- const { raw_values, raw_value } = value;
+export const formatPartnerFieldValue = (fieldValueData, jsonSchema = null) => {
+ const { raw_values: rawValues, raw_value: rawValue } = fieldValueData;
+
+ const hasJsonProps =
+ isObject(jsonSchema) &&
+ isObject(jsonSchema.properties) &&
+ !isEmpty(jsonSchema.properties);
- if (raw_values !== undefined) {
- return formatRawValues(raw_values);
+ if (!isNil(rawValues)) {
+ if (hasJsonProps && !Array.isArray(rawValues)) {
+ return rawValues;
+ }
+ return formatRawValues(rawValues);
}
- if (raw_value !== undefined) {
- return value.raw_value;
+
+ if (!isNil(rawValue)) {
+ return rawValue;
}
- return value;
+
+ return fieldValueData;
};