diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md
index 1d994eaf8..70bffbbe6 100644
--- a/doc/release/RELEASE-NOTES.md
+++ b/doc/release/RELEASE-NOTES.md
@@ -31,6 +31,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html
* [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.
* [OSDEV-2225](https://opensupplyhub.atlassian.net/browse/OSDEV-2225) - Added a clear button to the opening and closing date inputs in the environmental data section within the claim form to allow users to remove selected opening and closing dates.
+* [OSDEV-2268](https://opensupplyhub.atlassian.net/browse/OSDEV-2268) - Added URI-Reference format support for partner fields with JSON schema. Properties defined with `"format": "uri-reference"` are rendered on production location profile pages as clickable links wrapped by the text from admin `Partner field` configuration page.
* [OSDEV-2292](https://opensupplyhub.atlassian.net/browse/OSDEV-2292) - Improved ISIC-4 validation for `POST /v1/production-locations/` and `PATCH /v1/production-locations/{os_id}` endpoints to provide clearer error messages when invalid data is submitted, including rejection of empty ISIC-4 objects, non-string values, unrecognized fields, and duplicate entries. API consumers will now receive specific validation errors for previously accepted but invalid ISIC-4 taxonomy data, ensuring only properly formatted ISIC-4 classification objects (with string values for section, division, group, or class fields) are stored in the system.
### Release instructions
diff --git a/src/django/api/admin.py b/src/django/api/admin.py
index e5e16ec62..5abda3c0b 100644
--- a/src/django/api/admin.py
+++ b/src/django/api/admin.py
@@ -262,7 +262,16 @@ class PartnerFieldAdminForm(forms.ModelForm):
class Meta:
model = PartnerField
- fields = ['name', 'type', 'unit', 'label', 'source_by', 'json_schema']
+ fields = [
+ 'name',
+ 'type',
+ 'unit',
+ 'label',
+ 'source_by',
+ 'base_url',
+ 'display_text',
+ 'json_schema'
+ ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/src/django/api/migrations/0189_add_base_url_and_text_field_to_partner_field.py b/src/django/api/migrations/0189_add_base_url_and_text_field_to_partner_field.py
new file mode 100644
index 000000000..4ee87327f
--- /dev/null
+++ b/src/django/api/migrations/0189_add_base_url_and_text_field_to_partner_field.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.17 on 2025-12-03 12:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0188_introduce_indexing_for_new_operational_and_environmental_data'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='partnerfield',
+ name='base_url',
+ field=models.URLField(blank=True, max_length=2000),
+ ),
+ migrations.AddField(
+ model_name='partnerfield',
+ name='display_text',
+ field=models.CharField(blank=True, max_length=500),
+ ),
+ ]
diff --git a/src/django/api/models/partner_field.py b/src/django/api/models/partner_field.py
index 5474740d7..08736a8cb 100644
--- a/src/django/api/models/partner_field.py
+++ b/src/django/api/models/partner_field.py
@@ -49,7 +49,14 @@ class Meta:
max_length=200,
blank=True,
help_text=('The partner field label.'))
-
+ base_url = models.URLField(
+ max_length=2000,
+ blank=True,
+ )
+ display_text = models.CharField(
+ max_length=500,
+ blank=True,
+ )
source_by = RichTextField(
blank=True,
null=True,
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 668d2fd70..654885fd4 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,8 @@ def __init__(self,
'updated_at', 'contributor_name',
'contributor_id', 'value_count', 'is_from_claim',
'field_name', 'verified_count', 'source_by',
- 'unit', 'label', 'json_schema']
+ 'unit', 'label', 'base_url', 'display_text',
+ 'json_schema']
self.data: list = []
if exclude_fields:
@@ -37,7 +38,14 @@ 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', 'json_schema'}
+ context_overrides = {
+ 'source_by',
+ 'unit',
+ 'label',
+ 'base_url',
+ 'display_text',
+ '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 5dd3f9d7b..730568306 100644
--- a/src/django/api/serializers/facility/facility_index_serializer.py
+++ b/src/django/api/serializers/facility/facility_index_serializer.py
@@ -107,6 +107,8 @@ def __serialize_and_sort_partner_fields(
source_by = field.source_by
unit = field.unit
label = field.label
+ base_url = field.base_url
+ display_text = field.display_text
json_schema = field.json_schema
fields = grouped_fields.get(field_name, [])
if not fields:
@@ -121,6 +123,8 @@ def __serialize_and_sort_partner_fields(
'source_by': source_by,
'unit': unit,
'label': label,
+ 'base_url': base_url,
+ 'display_text': display_text,
'json_schema': json_schema
},
exclude_fields=(
diff --git a/src/django/api/static/admin/js/partner_field_admin.js b/src/django/api/static/admin/js/partner_field_admin.js
index b035c1b6d..133b94524 100644
--- a/src/django/api/static/admin/js/partner_field_admin.js
+++ b/src/django/api/static/admin/js/partner_field_admin.js
@@ -1,63 +1,83 @@
(function($) {
'use strict';
- function findJsonSchemaFieldRow() {
- const jsonSchemaField = $('#id_json_schema');
-
- if (jsonSchemaField.length) {
- return jsonSchemaField.closest('.field-json_schema, tr, .form-row').first();
- }
-
- return $('.field-json_schema').first();
- }
+ function findFieldRow(fieldSelector, fieldClassSelector) {
+ const field = $(fieldSelector);
- function toggleJsonSchemaField() {
- const typeField = $('#id_type');
- const jsonSchemaFieldRow = findJsonSchemaFieldRow();
-
- if (!typeField.length) {
- return false;
+ if (field.length) {
+ const row = field
+ .closest(`${fieldClassSelector}, tr, .form-row`)
+ .first();
+ if (row.length) {
+ return row;
+ }
}
-
- if (!jsonSchemaFieldRow.length) {
- return false;
- }
-
- const currentType = typeField.val();
-
- if (currentType === 'object') {
- jsonSchemaFieldRow.show();
- return true;
- } else {
- jsonSchemaFieldRow.hide();
- return true;
+
+ if (fieldClassSelector) {
+ const fallbackRow = $(fieldClassSelector).first();
+ if (fallbackRow.length) {
+ return fallbackRow;
+ }
}
+
+ return $();
}
- function setupJsonSchemaToggle() {
+ function setupPartnerFieldOptionsToggle() {
const typeField = $('#id_type');
- const jsonSchemaField = $('#id_json_schema');
-
- if (!typeField.length || !jsonSchemaField.length) {
+
+ if (!typeField.length) {
return false;
}
-
- const jsonSchemaFieldRow = findJsonSchemaFieldRow();
- if (!jsonSchemaFieldRow.length) {
+
+ const jsonSchemaFieldRow = findFieldRow(
+ '#id_json_schema',
+ '.field-json_schema'
+ );
+ const baseUrlFieldRow = findFieldRow(
+ '#id_base_url',
+ '.field-base_url'
+ );
+ const displayTextFieldRow = findFieldRow(
+ '#id_display_text',
+ '.field-display_text'
+ );
+
+ if (
+ !jsonSchemaFieldRow.length &&
+ !baseUrlFieldRow.length &&
+ !displayTextFieldRow.length
+ ) {
return false;
}
-
- toggleJsonSchemaField();
-
+
+ const toggleFields = function() {
+ const shouldShow = typeField.val() === 'object';
+ [jsonSchemaFieldRow, baseUrlFieldRow, displayTextFieldRow].forEach(
+ function(row) {
+ if (!row.length) {
+ return;
+ }
+ if (shouldShow) {
+ row.show();
+ } else {
+ row.hide();
+ }
+ }
+ );
+ };
+
+ toggleFields();
+
typeField.on('change', function() {
- toggleJsonSchemaField();
+ toggleFields();
});
-
+
return true;
}
$(document).ready(function() {
- setupJsonSchemaToggle();
+ setupPartnerFieldOptionsToggle();
});
})(django.jQuery || jQuery);
diff --git a/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js
index a339159d9..a399184c8 100644
--- a/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js
+++ b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js
@@ -117,4 +117,88 @@ describe('PartnerFieldSchemaValue', () => {
const urlTextElements = screen.queryAllByText('View Report');
expect(urlTextElements.length).toBe(1);
});
+
+ it('renders uri-reference fields using partner configuration display text', () => {
+ const value = {
+ value: 'report-42',
+ status: 'active',
+ };
+ const jsonSchema = {
+ type: 'object',
+ properties: {
+ value: {
+ type: 'string',
+ format: 'uri-reference',
+ title: 'Partner Report',
+ description: 'Latest partner-provided report.',
+ },
+ value_text: {
+ type: 'string',
+ },
+ status: {
+ type: 'string',
+ title: 'Status',
+ },
+ },
+ };
+ const partnerConfigFields = {
+ baseUrl: 'https://portal.example.com/reports',
+ displayText: 'Open report',
+ };
+
+ render(
+ ,
+ );
+
+ const link = screen.getByRole('link', { name: 'Open report' });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute(
+ 'href',
+ 'https://portal.example.com/reports/report-42',
+ );
+ expect(screen.getByText('Status: active')).toBeInTheDocument();
+ });
+
+ it('falls back to default renderer when schema loses uri-reference format', () => {
+ const value = {
+ partner_field: 'report-42',
+ };
+ const jsonSchema = {
+ type: 'object',
+ properties: {
+ partner_field: {
+ type: 'string',
+ title: 'Partner field',
+ },
+ },
+ };
+ const partnerConfigFields = {
+ baseUrl: 'https://portal.example.com/reports',
+ displayText: 'Open report',
+ };
+
+ render(
+ ,
+ );
+
+ const text = screen.getByText('Partner field: report-42');
+ expect(text).toBeInTheDocument();
+ expect(text.tagName).toBe('SPAN');
+ });
+
+ it('returns primitive values when schema or object data is missing', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toHaveTextContent('Raw text value');
+ });
});
diff --git a/src/react/src/__tests__/components/UriReferenceProperty.test.js b/src/react/src/__tests__/components/UriReferenceProperty.test.js
new file mode 100644
index 000000000..52ebd9f01
--- /dev/null
+++ b/src/react/src/__tests__/components/UriReferenceProperty.test.js
@@ -0,0 +1,132 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+import UriReferenceProperty from '../../components/PartnerFields/UriReferenceProperty';
+
+const theme = createMuiTheme();
+
+const renderWithTheme = props =>
+ render(
+
+
+ ,
+ );
+
+describe('UriReferenceProperty', () => {
+ const schemaProperties = {
+ value: {
+ type: 'string',
+ title: 'Reference',
+ description: 'See the partner-managed details.',
+ },
+ value_text: {
+ type: 'string',
+ },
+ };
+
+ it('renders description and uses partner display text with base url', () => {
+ const value = {
+ value: 'report-123',
+ value_text: 'report-123',
+ };
+ const partnerConfigFields = {
+ baseUrl: 'https://portal.example.com/reports',
+ displayText: 'Open report',
+ };
+
+ renderWithTheme({
+ propertyKey: 'value',
+ value,
+ schemaProperties,
+ partnerConfigFields,
+ });
+
+ expect(
+ screen.getByText('See the partner-managed details.'),
+ ).toBeInTheDocument();
+
+ const link = screen.getByRole('link', { name: 'Open report' });
+ expect(link).toHaveAttribute(
+ 'href',
+ 'https://portal.example.com/reports/report-123',
+ );
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('falls back to absolute url text when display text is missing', () => {
+ const value = {
+ value: 'abc123',
+ };
+ const partnerConfigFields = {
+ baseUrl: 'https://example.com/base/',
+ };
+
+ renderWithTheme({
+ propertyKey: 'value',
+ value,
+ schemaProperties,
+ partnerConfigFields,
+ });
+
+ const expectedHref = 'https://example.com/base/abc123';
+ const link = screen.getByRole('link', { name: 'abc123' });
+ expect(link).toHaveAttribute('href', expectedHref);
+ });
+
+ it('ignores display text when base url is not provided and shows raw value', () => {
+ const value = {
+ value: 'local-id',
+ };
+ const partnerConfigFields = {
+ displayText: 'Should not be used',
+ };
+
+ renderWithTheme({
+ propertyKey: 'value',
+ value,
+ schemaProperties: {
+ value: { type: 'string' },
+ value_text: { type: 'string' },
+ },
+ partnerConfigFields,
+ });
+
+ const link = screen.getByText('local-id');
+ expect(link).toHaveTextContent('local-id');
+ expect(link).not.toHaveAttribute('href');
+ });
+
+ it('shows raw value text when neither base url nor display text are provided', () => {
+ const value = {
+ value: 'plain-value',
+ };
+
+ renderWithTheme({
+ propertyKey: 'value',
+ value,
+ schemaProperties: {
+ value: { type: 'string' },
+ value_text: { type: 'string' },
+ },
+ partnerConfigFields: {},
+ });
+
+ const link = screen.getByText('plain-value');
+ expect(link).toHaveTextContent('plain-value');
+ expect(link).not.toHaveAttribute('href');
+ });
+
+ it('renders nothing when the field value is missing', () => {
+ const { container } = renderWithTheme({
+ propertyKey: 'value',
+ value: {},
+ schemaProperties,
+ partnerConfigFields: {},
+ });
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
+
+
diff --git a/src/react/src/components/FacilityDetailsDetail.jsx b/src/react/src/components/FacilityDetailsDetail.jsx
index 99b03f325..5ce4889c9 100644
--- a/src/react/src/components/FacilityDetailsDetail.jsx
+++ b/src/react/src/components/FacilityDetailsDetail.jsx
@@ -1,4 +1,5 @@
import React from 'react';
+import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Tooltip from '@material-ui/core/Tooltip';
@@ -68,6 +69,7 @@ const FacilityDetailsDetail = ({
isVerified,
isFromClaim,
classes,
+ partnerConfigFields,
}) => (
@@ -90,6 +92,7 @@ const FacilityDetailsDetail = ({
) : (
primary || locationLabeled
@@ -112,4 +115,43 @@ const FacilityDetailsDetail = ({
);
+FacilityDetailsDetail.propTypes = {
+ primary: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.node,
+ PropTypes.object,
+ ]),
+ locationLabeled: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ secondary: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ sourceBy: PropTypes.string,
+ unit: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ jsonSchema: PropTypes.object,
+ isVerified: PropTypes.bool,
+ isFromClaim: PropTypes.bool,
+ partnerConfigFields: PropTypes.shape({
+ baseUrl: PropTypes.string,
+ displayText: PropTypes.string,
+ }),
+ classes: PropTypes.shape({
+ root: PropTypes.string,
+ badgeWrapper: PropTypes.string,
+ primaryText: PropTypes.string,
+ secondaryText: PropTypes.string,
+ sourceText: PropTypes.string,
+ unitText: PropTypes.string,
+ }).isRequired,
+};
+
+FacilityDetailsDetail.defaultProps = {
+ primary: null,
+ locationLabeled: null,
+ secondary: null,
+ sourceBy: null,
+ unit: null,
+ jsonSchema: null,
+ isVerified: false,
+ isFromClaim: false,
+ partnerConfigFields: null,
+};
+
export default withStyles(detailsStyles)(FacilityDetailsDetail);
diff --git a/src/react/src/components/FacilityDetailsGeneralFields.jsx b/src/react/src/components/FacilityDetailsGeneralFields.jsx
index 4743a6d05..b662bf4ec 100644
--- a/src/react/src/components/FacilityDetailsGeneralFields.jsx
+++ b/src/react/src/components/FacilityDetailsGeneralFields.jsx
@@ -95,6 +95,7 @@ const FacilityDetailsGeneralFields = ({
const renderExtendedField = ({ label, fieldName, formatValue }) => {
let values = get(data, `properties.extended_fields.${fieldName}`, []);
+ if (!values.length || !values[0]) return null;
const formatField = item =>
formatExtendedField({ ...item, formatValue });
@@ -106,8 +107,6 @@ const FacilityDetailsGeneralFields = ({
);
}
- if (!values.length || !values[0]) return null;
-
if (fieldName === 'isic_4') {
const groupedContributions = [];
values.forEach(item => {
@@ -231,17 +230,22 @@ const FacilityDetailsGeneralFields = ({
);
};
- const renderPartnerField = ({ label, fieldName, formatValue }) => {
+ const renderPartnerField = ({
+ label,
+ fieldName,
+ formatValue,
+ partnerConfigFields,
+ }) => {
const values = get(data, `properties.partner_fields.${fieldName}`, []);
+ if (!values.length || !values[0]) return null;
+
const formatField = item =>
formatExtendedField({
...item,
formatValue,
});
- if (!values.length || !values[0]) return null;
-
const topValue = formatField(values[0]);
return (
@@ -251,6 +255,7 @@ const FacilityDetailsGeneralFields = ({
label={topValue.label ? topValue.label : label}
additionalContent={values.slice(1).map(formatField)}
embed={embed}
+ partnerConfigFields={partnerConfigFields}
/>
);
@@ -260,6 +265,7 @@ const FacilityDetailsGeneralFields = ({
get(data, 'properties.contributor_fields', null),
field => field.value !== null,
);
+
const renderEmbedFields = () => {
const fields = embedConfig?.embed_fields?.filter(f => f.visible) || [];
return fields.map(({ column_name: fieldName, display_name: label }) => {
@@ -294,13 +300,30 @@ const FacilityDetailsGeneralFields = ({
get(data, 'properties.partner_fields', {}),
);
- const partnerFields = partnerFieldNames.map(fieldName => ({
- fieldName,
- label: fieldName
- .replace(/_/g, ' ')
- .replace(/\b\w/g, l => l.toUpperCase()),
- formatValue: formatPartnerFieldValue,
- }));
+ const partnerFields = partnerFieldNames.map(fieldName => {
+ /*
+ We have to rely on the first element of the partner-field list
+ because the backend isn’t configured to store partner-specific
+ settings in a separate metadata object.
+ */
+ const firstEntry =
+ get(data, `properties.partner_fields.${fieldName}[0]`) || {};
+ const {
+ base_url: baseUrl, // eslint-disable-line camelcase
+ display_text: displayText, // eslint-disable-line camelcase
+ } = firstEntry;
+
+ const partnerConfigFields = { baseUrl, displayText };
+
+ return {
+ fieldName,
+ label: fieldName
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, l => l.toUpperCase()),
+ formatValue: formatPartnerFieldValue,
+ partnerConfigFields,
+ };
+ });
return (
{
const [isOpen, setIsOpen] = useState(false);
const hasAdditionalContent = !embed && !!additionalContent?.length;
@@ -68,6 +70,7 @@ const FacilityDetailsItem = ({
jsonSchema={!embed ? jsonSchema : null}
isVerified={isVerified}
isFromClaim={isFromClaim}
+ partnerConfigFields={partnerConfigFields}
/>
{isOpen &&
additionalContent.map(item => (
-
+
))}
@@ -118,4 +125,54 @@ const FacilityDetailsItem = ({
);
};
+FacilityDetailsItem.propTypes = {
+ additionalContent: PropTypes.arrayOf(
+ PropTypes.shape({
+ key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ }),
+ ),
+ label: PropTypes.string,
+ primary: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.node,
+ PropTypes.object,
+ ]),
+ lat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ lng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ locationLabeled: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ secondary: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ sourceBy: PropTypes.string,
+ unit: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ jsonSchema: PropTypes.object,
+ classes: PropTypes.object.isRequired,
+ embed: PropTypes.bool,
+ isVerified: PropTypes.bool,
+ isFromClaim: PropTypes.bool,
+ additionalContentText: PropTypes.string,
+ additionalContentTextPlural: PropTypes.string,
+ partnerConfigFields: PropTypes.shape({
+ baseUrl: PropTypes.string,
+ displayText: PropTypes.string,
+ }),
+};
+
+FacilityDetailsItem.defaultProps = {
+ additionalContent: [],
+ label: '',
+ primary: null,
+ lat: null,
+ lng: null,
+ locationLabeled: null,
+ secondary: null,
+ sourceBy: null,
+ unit: null,
+ jsonSchema: null,
+ embed: false,
+ isVerified: false,
+ isFromClaim: false,
+ additionalContentText: 'entry',
+ additionalContentTextPlural: 'entries',
+ partnerConfigFields: null,
+};
+
export default withStyles(detailsStyles)(FacilityDetailsItem);
diff --git a/src/react/src/components/PartnerFields/DefaultProperty.jsx b/src/react/src/components/PartnerFields/DefaultProperty.jsx
index f6cdd95ec..93e2d270a 100644
--- a/src/react/src/components/PartnerFields/DefaultProperty.jsx
+++ b/src/react/src/components/PartnerFields/DefaultProperty.jsx
@@ -1,14 +1,16 @@
import React from 'react';
+import { showFieldDefaultDisplayText } from './utils';
/**
* 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;
+ const displayText = showFieldDefaultDisplayText(
+ schemaProperties,
+ propertyValue,
+ propertyKey,
+ );
return (
diff --git a/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx
index 7e8e6eaa9..33fc35d8d 100644
--- a/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx
+++ b/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx
@@ -1,5 +1,7 @@
import React from 'react';
+import PropTypes from 'prop-types';
import UriProperty from './UriProperty';
+import UriReferenceProperty from './UriReferenceProperty';
import DefaultProperty from './DefaultProperty';
import { FORMAT_TYPES, getFormatFromSchema, shouldSkipProperty } from './utils';
@@ -9,6 +11,7 @@ import { FORMAT_TYPES, getFormatFromSchema, shouldSkipProperty } from './utils';
*/
const FORMAT_COMPONENTS = {
[FORMAT_TYPES.URI]: UriProperty,
+ [FORMAT_TYPES.URI_REFERENCE]: UriReferenceProperty,
// Add more format components here in the future
};
@@ -25,7 +28,12 @@ const getFormatComponent = format => {
/**
* Renders a single property based on format strategy.
*/
-const renderProperty = (propertyKey, value, schemaProperties) => {
+const renderProperty = (
+ propertyKey,
+ value,
+ schemaProperties,
+ partnerConfigFields,
+) => {
const propertySchema = schemaProperties[propertyKey] || {};
const format = getFormatFromSchema(propertySchema);
const FormatComponent = getFormatComponent(format);
@@ -36,6 +44,7 @@ const renderProperty = (propertyKey, value, schemaProperties) => {
propertyKey={propertyKey}
value={value}
schemaProperties={schemaProperties}
+ partnerConfigFields={partnerConfigFields}
/>
);
};
@@ -43,7 +52,11 @@ const renderProperty = (propertyKey, value, schemaProperties) => {
/**
* Component to render partner field values based on JSON schema.
*/
-const PartnerFieldSchemaValue = ({ value, jsonSchema }) => {
+const PartnerFieldSchemaValue = ({
+ value,
+ jsonSchema,
+ partnerConfigFields,
+}) => {
if (
!jsonSchema ||
!value ||
@@ -59,7 +72,12 @@ const PartnerFieldSchemaValue = ({ value, jsonSchema }) => {
if (shouldSkipProperty(propertyKey, schemaProperties)) {
return acc;
}
- const rendered = renderProperty(propertyKey, value, schemaProperties);
+ const rendered = renderProperty(
+ propertyKey,
+ value,
+ schemaProperties,
+ partnerConfigFields,
+ );
if (rendered) {
acc.push(rendered);
}
@@ -69,4 +87,25 @@ const PartnerFieldSchemaValue = ({ value, jsonSchema }) => {
return <>{renderedItems}>;
};
+PartnerFieldSchemaValue.propTypes = {
+ value: PropTypes.oneOfType([
+ PropTypes.object,
+ PropTypes.string,
+ PropTypes.number,
+ PropTypes.bool,
+ ]).isRequired,
+ jsonSchema: PropTypes.shape({
+ properties: PropTypes.object,
+ }),
+ partnerConfigFields: PropTypes.shape({
+ baseUrl: PropTypes.string,
+ displayText: PropTypes.string,
+ }),
+};
+
+PartnerFieldSchemaValue.defaultProps = {
+ jsonSchema: null,
+ partnerConfigFields: null,
+};
+
export default PartnerFieldSchemaValue;
diff --git a/src/react/src/components/PartnerFields/UriProperty.jsx b/src/react/src/components/PartnerFields/UriProperty.jsx
index 8fa9c493f..a0839e250 100644
--- a/src/react/src/components/PartnerFields/UriProperty.jsx
+++ b/src/react/src/components/PartnerFields/UriProperty.jsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { getLinkTextFromSchema } from './utils';
/**
* Component for rendering URI format properties.
@@ -9,12 +10,11 @@ const UriProperty = ({ propertyKey, value, schemaProperties }) => {
return null;
}
- const textKey = `${propertyKey}_text`;
- const textPropertyDefined = schemaProperties && schemaProperties[textKey];
- const linkText =
- textPropertyDefined && textKey in value
- ? value[textKey]
- : propertyValue;
+ const linkText = getLinkTextFromSchema(
+ propertyKey,
+ value,
+ schemaProperties,
+ );
return (
({});
+
+/**
+ * Component for rendering URI-reference format properties.
+ */
+const UriReferenceProperty = ({
+ propertyKey,
+ value,
+ schemaProperties: incomingSchemaProperties,
+ partnerConfigFields: incomingPartnerConfigFields,
+ classes,
+}) => {
+ const propertyValue = value[propertyKey];
+ if (!propertyValue) {
+ return null;
+ }
+
+ const schemaProperties = incomingSchemaProperties || {};
+ const partnerConfigFields = incomingPartnerConfigFields || {};
+ const { description } = schemaProperties[propertyKey] || {};
+ const linkText = getLinkTextFromSchema(
+ propertyKey,
+ value,
+ schemaProperties,
+ );
+
+ const { baseUrl, displayText } = partnerConfigFields;
+
+ const absoluteURI = baseUrl
+ ? constructUrlFromPartnerField(baseUrl, propertyValue)
+ : undefined;
+
+ return (
+ <>
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {baseUrl
+ ? displayText || linkText || absoluteURI
+ : showFieldDefaultDisplayText(
+ schemaProperties,
+ propertyValue,
+ propertyKey,
+ )}
+
+ >
+ );
+};
+
+export default withStyles(styles)(UriReferenceProperty);
+
+UriReferenceProperty.propTypes = {
+ propertyKey: PropTypes.string.isRequired,
+ value: PropTypes.object.isRequired,
+ schemaProperties: PropTypes.object,
+ partnerConfigFields: PropTypes.shape({
+ baseUrl: PropTypes.string,
+ displayText: PropTypes.string,
+ }),
+ classes: PropTypes.shape({
+ primaryText: PropTypes.string,
+ }).isRequired,
+};
+
+UriReferenceProperty.defaultProps = {
+ schemaProperties: {},
+ partnerConfigFields: null,
+};
diff --git a/src/react/src/components/PartnerFields/utils.js b/src/react/src/components/PartnerFields/utils.js
index 19be755f9..5f6384b6e 100644
--- a/src/react/src/components/PartnerFields/utils.js
+++ b/src/react/src/components/PartnerFields/utils.js
@@ -1,8 +1,10 @@
+import endsWith from 'lodash/endsWith';
/**
* Format constants for JSON Schema.
*/
export const FORMAT_TYPES = {
URI: 'uri',
+ URI_REFERENCE: 'uri-reference',
};
/**
@@ -31,3 +33,29 @@ export const shouldSkipProperty = (propertyKey, schemaProperties) => {
return false;
};
+
+export const showFieldDefaultDisplayText = (
+ schemaProperties,
+ propertyValue,
+ propertyKey,
+) => {
+ const propertySchema = schemaProperties[propertyKey] || {};
+ const { title } = propertySchema;
+ const stringValue = propertyValue == null ? '' : String(propertyValue);
+ return title ? `${title}: ${stringValue}` : stringValue;
+};
+
+export const constructUrlFromPartnerField = (baseUrl, value = '') => {
+ if (endsWith(baseUrl, '/')) return baseUrl + value.trim();
+ return `${baseUrl}/${value.trim()}`;
+};
+
+export const getLinkTextFromSchema = (propertyKey, value, schemaProperties) => {
+ const textKey = `${propertyKey}_text`;
+ const textPropertyDefined =
+ schemaProperties && Boolean(schemaProperties[textKey]);
+ if (textPropertyDefined && textKey in value) {
+ return value[textKey];
+ }
+ return value[propertyKey];
+};