From cb180824d0cbedaa09f8027052d1f2626796c0c0 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 25 Nov 2025 15:23:49 +0200 Subject: [PATCH 01/18] basic implementation to display partner field value by json schema format "uri" --- ...ty_index_extended_field_list_serializer.py | 4 +- .../facility/facility_index_serializer.py | 4 +- .../FacilityDetailsGeneralFields.jsx | 40 ++++++-- src/react/src/util/partnerFieldFormatter.jsx | 98 +++++++++++++++++++ src/react/src/util/renderUtils.jsx | 4 + src/react/src/util/util.js | 10 +- 6 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 src/react/src/util/partnerFieldFormatter.jsx 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/react/src/components/FacilityDetailsGeneralFields.jsx b/src/react/src/components/FacilityDetailsGeneralFields.jsx index 168e7eb1f..7fba032b1 100644 --- a/src/react/src/components/FacilityDetailsGeneralFields.jsx +++ b/src/react/src/components/FacilityDetailsGeneralFields.jsx @@ -137,11 +137,21 @@ const FacilityDetailsGeneralFields = ({ ); }; - const renderPartnerField = ({ label, fieldName, formatValue }) => { + const renderPartnerField = ({ + label, + fieldName, + formatValue, + jsonSchema, + }) => { const values = get(data, `properties.partner_fields.${fieldName}`, []); - const formatField = item => - formatExtendedField({ ...item, formatValue }); + const formatField = item => { + const customFormatValue = value => formatValue(value, jsonSchema); + return formatExtendedField({ + ...item, + formatValue: customFormatValue, + }); + }; if (!values.length || !values[0]) return null; @@ -197,13 +207,23 @@ 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 => { + const firstValue = get( + data, + `properties.partner_fields.${fieldName}[0]`, + {}, + ); + const jsonSchema = firstValue.json_schema || null; + + return { + fieldName, + label: fieldName + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()), + formatValue: formatPartnerFieldValue, + jsonSchema, + }; + }); return ( + Object.keys(value).map(key => { + const propValue = value[key]; + + return ( +
+ {String(propValue || '')} +
+ ); + }); + +/** + * Formats an object value with URI fields rendered as clickable links + */ +const formatObjectWithLinks = (value, uriFields) => + Object.keys(value) + .map(key => { + const propValue = value[key]; + + if (key.endsWith('_text') && uriFields.has(key.slice(0, -5))) { + return null; + } + + if (uriFields.has(key)) { + const uriValue = propValue; + const textKey = `${key}_text`; + const linkText = value[textKey] || uriValue; + + if (uriValue) { + return ( +
+ + {linkText} + +
+ ); + } + } + + if (!key.endsWith('_text') || !uriFields.has(key.slice(0, -5))) { + return ( +
+ {String(propValue || '')} +
+ ); + } + + return null; + }) + .filter(Boolean); + +/** + * Formats partner field values based on JSON schema. + * Renders URI properties (format: "uri") as clickable links. + */ +const formatPartnerFieldWithSchema = (value, jsonSchema) => { + if ( + !jsonSchema || + !value || + typeof value !== 'object' || + Array.isArray(value) + ) { + return value; + } + + const schemaProperties = jsonSchema?.properties || {}; + const uriFields = new Set(); + + Object.keys(schemaProperties).forEach(propName => { + const propSchema = schemaProperties[propName]; + if (propSchema?.format === 'uri') { + uriFields.add(propName); + } + }); + + if (uriFields.size === 0) { + return formatPlainObjectValue(value); + } + + return formatObjectWithLinks(value, uriFields); +}; + +export default formatPartnerFieldWithSchema; diff --git a/src/react/src/util/renderUtils.jsx b/src/react/src/util/renderUtils.jsx index 9cb3b506b..0d5161fa1 100644 --- a/src/react/src/util/renderUtils.jsx +++ b/src/react/src/util/renderUtils.jsx @@ -10,6 +10,10 @@ const renderUniqueListItems = (fieldValue, fieldName = '') => { return fieldValue; } + if (fieldValue.length > 0 && React.isValidElement(fieldValue[0])) { + return fieldValue; + } + const uniqueValues = [...new Set(fieldValue)]; return uniqueValues.map(value => diff --git a/src/react/src/util/util.js b/src/react/src/util/util.js index 6d08d08c3..8577388fd 100644 --- a/src/react/src/util/util.js +++ b/src/react/src/util/util.js @@ -94,6 +94,7 @@ import { createFacilitiesCSV, formatDataForCSV } from './util.facilitiesCSV'; import formatFacilityClaimsDataForXLSX from './util.facilityClaimsXLSX'; import formatModerationEventsDataForXLSX from './util.moderationEventsXLSX'; import COLOURS from './COLOURS'; +import formatPartnerFieldWithSchema from './partnerFieldFormatter'; export function DownloadXLSX(data, fileName) { import('file-saver').then(({ saveAs }) => { @@ -1898,9 +1899,16 @@ const formatRawValues = rawValues => { return rawValues.toString().split('|'); }; -export const formatPartnerFieldValue = value => { +export const formatPartnerFieldValue = (value, jsonSchema = null) => { const { raw_values, raw_value } = value; + if (jsonSchema && (raw_value || raw_values)) { + const objectValue = raw_value || raw_values; + if (typeof objectValue === 'object' && !Array.isArray(objectValue)) { + return formatPartnerFieldWithSchema(objectValue, jsonSchema); + } + } + if (raw_values !== undefined) { return formatRawValues(raw_values); } From baf820296eade78894300cc6b0eb848c061f927d Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Wed, 26 Nov 2025 15:00:45 +0200 Subject: [PATCH 02/18] support displaying schema data with and without uri format --- src/react/src/util/partnerFieldFormatter.jsx | 24 ++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/react/src/util/partnerFieldFormatter.jsx b/src/react/src/util/partnerFieldFormatter.jsx index 3810e6032..47e3e08ae 100644 --- a/src/react/src/util/partnerFieldFormatter.jsx +++ b/src/react/src/util/partnerFieldFormatter.jsx @@ -1,15 +1,19 @@ import React from 'react'; /** - * Formats an object value, displaying only the property values + * Formats an object value, displaying property values with optional labels from schema */ -const formatPlainObjectValue = value => +const formatPlainObjectValue = (value, schemaProperties = {}) => Object.keys(value).map(key => { const propValue = value[key]; + const propSchema = schemaProperties[key] || {}; + const { title } = propSchema; return (
- {String(propValue || '')} + {title + ? `${title}: ${String(propValue || '')}` + : String(propValue || '')}
); }); @@ -17,7 +21,7 @@ const formatPlainObjectValue = value => /** * Formats an object value with URI fields rendered as clickable links */ -const formatObjectWithLinks = (value, uriFields) => +const formatObjectWithLinks = (value, uriFields, schemaProperties = {}) => Object.keys(value) .map(key => { const propValue = value[key]; @@ -50,12 +54,18 @@ const formatObjectWithLinks = (value, uriFields) => } if (!key.endsWith('_text') || !uriFields.has(key.slice(0, -5))) { + const propSchema = schemaProperties[key] || {}; + const { title } = propSchema; + const displayText = title + ? `${title}: ${String(propValue || '')}` + : String(propValue || ''); + return (
- {String(propValue || '')} + {displayText}
); } @@ -89,10 +99,10 @@ const formatPartnerFieldWithSchema = (value, jsonSchema) => { }); if (uriFields.size === 0) { - return formatPlainObjectValue(value); + return formatPlainObjectValue(value, schemaProperties); } - return formatObjectWithLinks(value, uriFields); + return formatObjectWithLinks(value, uriFields, schemaProperties); }; export default formatPartnerFieldWithSchema; From b74273b1444ef0cba6ee3cf2bc4f46439c2dbab4 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Wed, 26 Nov 2025 16:49:23 +0200 Subject: [PATCH 03/18] refactored --- src/react/src/util/partnerFieldFormatter.jsx | 136 +++++++++++-------- 1 file changed, 83 insertions(+), 53 deletions(-) diff --git a/src/react/src/util/partnerFieldFormatter.jsx b/src/react/src/util/partnerFieldFormatter.jsx index 47e3e08ae..3f539338b 100644 --- a/src/react/src/util/partnerFieldFormatter.jsx +++ b/src/react/src/util/partnerFieldFormatter.jsx @@ -1,5 +1,48 @@ import React from 'react'; +const ITEM_STYLE = Object.freeze({ + marginBottom: '8px', +}); + +/** + * Format constants for JSON Schema + * Can be extended in the future for other format types (e.g., 'uri-reference', 'email', etc.) + */ +const FORMAT_TYPES = Object.freeze({ + URI: 'uri', +}); + +/** + * Extracts properties with specific format from JSON schema + */ +const extractPropertiesByFormat = (schemaProperties, formatType) => { + const fields = new Set(); + Object.keys(schemaProperties).forEach(propName => { + const propSchema = schemaProperties[propName]; + if (propSchema?.format === formatType) { + fields.add(propName); + } + }); + return fields; +}; + +/** + * Formats a property value with optional label from schema + */ +const formatValueWithLabel = (title, value) => { + const stringValue = String(value || ''); + return title ? `${title}: ${stringValue}` : stringValue; +}; + +/** + * Renders a property value as a div element + */ +const renderPropertyDiv = (key, propValue, displayText) => ( +
+ {displayText} +
+); + /** * Formats an object value, displaying property values with optional labels from schema */ @@ -8,16 +51,36 @@ const formatPlainObjectValue = (value, schemaProperties = {}) => const propValue = value[key]; const propSchema = schemaProperties[key] || {}; const { title } = propSchema; + const displayText = formatValueWithLabel(title, propValue); - return ( -
- {title - ? `${title}: ${String(propValue || '')}` - : String(propValue || '')} -
- ); + return renderPropertyDiv(key, propValue, displayText); }); +/** + * Renders a URI property as a clickable link + */ +const renderUriLink = (key, uriValue, linkText) => ( +
+ + {linkText} + +
+); + +/** + * Checks if a key is a text property for a URI field + */ +const isUriTextProperty = (key, uriFields) => + key.endsWith('_text') && uriFields.has(key.slice(0, -5)); + +/** + * Gets the display text for a URI property, checking for _text sibling + */ +const getUriLinkText = (key, uriValue, value) => { + const textKey = `${key}_text`; + return value[textKey] || uriValue; +}; + /** * Formats an object value with URI fields rendered as clickable links */ @@ -26,57 +89,28 @@ const formatObjectWithLinks = (value, uriFields, schemaProperties = {}) => .map(key => { const propValue = value[key]; - if (key.endsWith('_text') && uriFields.has(key.slice(0, -5))) { + if (isUriTextProperty(key, uriFields)) { return null; } if (uriFields.has(key)) { - const uriValue = propValue; - const textKey = `${key}_text`; - const linkText = value[textKey] || uriValue; - - if (uriValue) { - return ( -
- - {linkText} - -
- ); + if (propValue) { + const linkText = getUriLinkText(key, propValue, value); + return renderUriLink(key, propValue, linkText); } + return null; } - if (!key.endsWith('_text') || !uriFields.has(key.slice(0, -5))) { - const propSchema = schemaProperties[key] || {}; - const { title } = propSchema; - const displayText = title - ? `${title}: ${String(propValue || '')}` - : String(propValue || ''); - - return ( -
- {displayText} -
- ); - } + const propSchema = schemaProperties[key] || {}; + const { title } = propSchema; + const displayText = formatValueWithLabel(title, propValue); - return null; + return renderPropertyDiv(key, propValue, displayText); }) .filter(Boolean); /** * Formats partner field values based on JSON schema. - * Renders URI properties (format: "uri") as clickable links. */ const formatPartnerFieldWithSchema = (value, jsonSchema) => { if ( @@ -89,14 +123,10 @@ const formatPartnerFieldWithSchema = (value, jsonSchema) => { } const schemaProperties = jsonSchema?.properties || {}; - const uriFields = new Set(); - - Object.keys(schemaProperties).forEach(propName => { - const propSchema = schemaProperties[propName]; - if (propSchema?.format === 'uri') { - uriFields.add(propName); - } - }); + const uriFields = extractPropertiesByFormat( + schemaProperties, + FORMAT_TYPES.URI, + ); if (uriFields.size === 0) { return formatPlainObjectValue(value, schemaProperties); From f6e4034bdb7cf29f98247188509817838e04dd3f Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Wed, 26 Nov 2025 17:31:15 +0200 Subject: [PATCH 04/18] simplify and refactored logic + added FE tests --- src/react/src/__tests__/utils.tests.js | 254 ++++++++++++++++-- .../FacilityDetailsGeneralFields.jsx | 33 +-- src/react/src/util/util.js | 8 +- 3 files changed, 252 insertions(+), 43 deletions(-) diff --git a/src/react/src/__tests__/utils.tests.js b/src/react/src/__tests__/utils.tests.js index 1ee3daf9d..90eea1183 100644 --- a/src/react/src/__tests__/utils.tests.js +++ b/src/react/src/__tests__/utils.tests.js @@ -2736,99 +2736,321 @@ describe('processDromoResults', () => { }); describe('formatPartnerFieldValue', () => { + const emptyItem = {}; + it('formats raw_value as a single value', () => { const value = { raw_value: 'Test Value' }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('Test Value'); }); it('formats raw_value with number', () => { const value = { raw_value: 100 }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe(100); }); it('formats raw_values as array joined by comma', () => { const value = { raw_values: ['Value 1', 'Value 2', 'Value 3'] }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('Value 1, Value 2, Value 3'); }); it('formats empty array as empty string', () => { const value = { raw_values: [] }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe(''); }); it('formats single-item array as string without comma', () => { const value = { raw_values: ['Single Value'] }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('Single Value'); }); it('formats raw_values as object with key-value pairs', () => { const value = { raw_values: { test_1: '1', test_2: '2' } }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('test_1: 1, test_2: 2'); }); it('formats empty object as empty string', () => { const value = { raw_values: {} }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe(''); }); it('formats raw_values object with nested values', () => { const value = { raw_values: { key1: 'value1', key2: 100, key3: 'value3' } }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('key1: value1, key2: 100, key3: value3'); }); it('formats pipe-delimited string into array (legacy format)', () => { const value = { raw_values: 'value1|value2|value3' }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toEqual(['value1', 'value2', 'value3']); }); it('formats single string value without pipe', () => { const value = { raw_values: 'single value' }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toEqual(['single value']); }); it('returns plain value if no raw_value or raw_values property', () => { const value = 'Plain String Value'; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('Plain String Value'); }); it('returns numeric value if no raw_value or raw_values property', () => { const value = 42; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe(42); }); it('prefers raw_values over raw_value when both exist', () => { const value = { raw_value: 'ignored', raw_values: ['preferred'] }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('preferred'); }); it('falls back to raw_value when raw_values is undefined', () => { const value = { raw_value: 'fallback value' }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('fallback value'); }); it('formats array with mixed types', () => { const value = { raw_values: ['string', 123, 'another'] }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('string, 123, another'); }); it('formats object with boolean and null values', () => { const value = { raw_values: { active: true, deleted: false, notes: null } }; - const result = formatPartnerFieldValue(value); + const result = formatPartnerFieldValue(value, emptyItem); expect(result).toBe('active: true, deleted: false, notes: null'); }); + + describe('with JSON schema formatting', () => { + it('formats URI field with _text property as clickable link', () => { + const value = { + raw_value: { + url: 'https://example.com/audit-123', + url_text: 'View Audit Report', + }, + }; + const item = { + json_schema: { + type: 'object', + properties: { + url: { + type: 'string', + format: 'uri', + }, + url_text: { + type: 'string', + }, + }, + }, + }; + const result = formatPartnerFieldValue(value, item); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(result[0].type).toBe('div'); + expect(result[0].props.children.type).toBe('a'); + expect(result[0].props.children.props.href).toBe( + 'https://example.com/audit-123', + ); + expect(result[0].props.children.props.children).toBe( + 'View Audit Report', + ); + expect(result[0].props.children.props.target).toBe('_blank'); + expect(result[0].props.children.props.rel).toBe( + 'noopener noreferrer', + ); + }); + + it('formats URI field without _text property, using URI as link text', () => { + const value = { + raw_value: { + mit_data_url: 'https://livingwage.mit.edu/locations/123', + }, + }; + const item = { + json_schema: { + type: 'object', + properties: { + mit_data_url: { + type: 'string', + format: 'uri', + }, + }, + }, + }; + const result = formatPartnerFieldValue(value, item); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0].type).toBe('div'); + expect(result[0].props.children.type).toBe('a'); + expect(result[0].props.children.props.href).toBe( + 'https://livingwage.mit.edu/locations/123', + ); + expect(result[0].props.children.props.children).toBe( + 'https://livingwage.mit.edu/locations/123', + ); + }); + + it('formats non-URI field with title as "Title: value"', () => { + const value = { + raw_value: { + internal_id: 'abc-123-xyz', + }, + }; + const item = { + json_schema: { + type: 'object', + properties: { + internal_id: { + type: 'string', + title: 'Internal ID', + }, + }, + }, + }; + const result = formatPartnerFieldValue(value, item); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0].type).toBe('div'); + expect(result[0].props.children).toBe('Internal ID: abc-123-xyz'); + }); + + it('formats non-URI field without title as plain value', () => { + const value = { + raw_value: { + notes: 'Some notes here', + }, + }; + const item = { + json_schema: { + type: 'object', + properties: { + notes: { + type: 'string', + }, + }, + }, + }; + const result = formatPartnerFieldValue(value, item); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0].type).toBe('div'); + expect(result[0].props.children).toBe('Some notes here'); + }); + + it('formats mixed URI and non-URI fields with titles', () => { + const value = { + raw_value: { + url: 'https://example.com/report', + url_text: 'View Report', + internal_id: 'ABC-123', + status: 'active', + }, + }; + const item = { + json_schema: { + type: 'object', + properties: { + url: { + type: 'string', + format: 'uri', + }, + url_text: { + type: 'string', + }, + internal_id: { + type: 'string', + title: 'Internal ID', + }, + status: { + type: 'string', + title: 'Status', + }, + }, + }, + }; + const result = formatPartnerFieldValue(value, item); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(3); // url, internal_id, status (url_text is skipped) + + const urlElement = result.find( + r => + r.props.children?.type === 'a' && + r.props.children?.props?.href === + 'https://example.com/report', + ); + expect(urlElement).toBeDefined(); + expect(urlElement.props.children.props.children).toBe( + 'View Report', + ); + + const internalIdElement = result.find( + r => + typeof r.props.children === 'string' && + r.props.children.includes('Internal ID: ABC-123'), + ); + expect(internalIdElement).toBeDefined(); + + const statusElement = result.find( + r => + typeof r.props.children === 'string' && + r.props.children.includes('Status: active'), + ); + expect(statusElement).toBeDefined(); + }); + + it('formats object without URI fields using plain formatting with titles', () => { + const value = { + raw_value: { + name: 'John Doe', + age: 30, + email: 'john@example.com', + }, + }; + const item = { + json_schema: { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + }, + age: { + type: 'integer', + title: 'Age', + }, + email: { + type: 'string', + title: 'Email Address', + }, + }, + }, + }; + const result = formatPartnerFieldValue(value, item); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(3); + + expect(result[0].props.children).toBe('Full Name: John Doe'); + expect(result[1].props.children).toBe('Age: 30'); + expect(result[2].props.children).toBe('Email Address: john@example.com'); + }); + }); }); diff --git a/src/react/src/components/FacilityDetailsGeneralFields.jsx b/src/react/src/components/FacilityDetailsGeneralFields.jsx index 7fba032b1..953d36f32 100644 --- a/src/react/src/components/FacilityDetailsGeneralFields.jsx +++ b/src/react/src/components/FacilityDetailsGeneralFields.jsx @@ -137,16 +137,11 @@ const FacilityDetailsGeneralFields = ({ ); }; - const renderPartnerField = ({ - label, - fieldName, - formatValue, - jsonSchema, - }) => { + const renderPartnerField = ({ label, fieldName, formatValue }) => { const values = get(data, `properties.partner_fields.${fieldName}`, []); const formatField = item => { - const customFormatValue = value => formatValue(value, jsonSchema); + const customFormatValue = value => formatValue(value, item); return formatExtendedField({ ...item, formatValue: customFormatValue, @@ -207,23 +202,13 @@ const FacilityDetailsGeneralFields = ({ get(data, 'properties.partner_fields', {}), ); - const partnerFields = partnerFieldNames.map(fieldName => { - const firstValue = get( - data, - `properties.partner_fields.${fieldName}[0]`, - {}, - ); - const jsonSchema = firstValue.json_schema || null; - - return { - fieldName, - label: fieldName - .replace(/_/g, ' ') - .replace(/\b\w/g, l => l.toUpperCase()), - formatValue: formatPartnerFieldValue, - jsonSchema, - }; - }); + const partnerFields = partnerFieldNames.map(fieldName => ({ + fieldName, + label: fieldName + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()), + formatValue: formatPartnerFieldValue, + })); return ( { return rawValues.toString().split('|'); }; -export const formatPartnerFieldValue = (value, jsonSchema = null) => { +export const formatPartnerFieldValue = (value, item) => { const { raw_values, raw_value } = value; - if (jsonSchema && (raw_value || raw_values)) { + const schema = item?.json_schema || null; + + if (schema && (raw_value || raw_values)) { const objectValue = raw_value || raw_values; if (typeof objectValue === 'object' && !Array.isArray(objectValue)) { - return formatPartnerFieldWithSchema(objectValue, jsonSchema); + return formatPartnerFieldWithSchema(objectValue, schema); } } From 1d6ad6b7404e4208d9aa31f44f814a54f2dfdb3d Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Wed, 26 Nov 2025 17:46:35 +0200 Subject: [PATCH 05/18] fix test --- src/react/src/__tests__/utils.tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/src/__tests__/utils.tests.js b/src/react/src/__tests__/utils.tests.js index 90eea1183..0df8f9ac4 100644 --- a/src/react/src/__tests__/utils.tests.js +++ b/src/react/src/__tests__/utils.tests.js @@ -2859,7 +2859,7 @@ describe('formatPartnerFieldValue', () => { const result = formatPartnerFieldValue(value, item); expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(2); + expect(result.length).toBe(1); expect(result[0].type).toBe('div'); expect(result[0].props.children.type).toBe('a'); expect(result[0].props.children.props.href).toBe( From 830885ad95be6f78308cadc69a3aca5c9c2f628f Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Wed, 26 Nov 2025 17:48:15 +0200 Subject: [PATCH 06/18] remove useless --- src/react/src/__tests__/utils.tests.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/react/src/__tests__/utils.tests.js b/src/react/src/__tests__/utils.tests.js index 0df8f9ac4..a337a2342 100644 --- a/src/react/src/__tests__/utils.tests.js +++ b/src/react/src/__tests__/utils.tests.js @@ -2868,10 +2868,6 @@ describe('formatPartnerFieldValue', () => { expect(result[0].props.children.props.children).toBe( 'View Audit Report', ); - expect(result[0].props.children.props.target).toBe('_blank'); - expect(result[0].props.children.props.rel).toBe( - 'noopener noreferrer', - ); }); it('formats URI field without _text property, using URI as link text', () => { From fa9e685e237791441a964de9722d21bb3a92bab3 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Thu, 27 Nov 2025 12:39:14 +0200 Subject: [PATCH 07/18] updated release notes --- doc/release/RELEASE-NOTES.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index ceb614baf..42ea1090f 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). The format is based on the `RELEASE-NOTES-TEMPLATE.md` file. +## Release 2.17.0 + +## Introduction +* Product name: Open Supply Hub +* Release date: December 13, 2025 + +### What's new +* [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: + * `migrate` + * `reindex_database` + + ## Release 2.16.0 ## Introduction @@ -43,6 +58,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 From a9152a62e72ed92f65bcc17e5354e737336385ff Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Thu, 27 Nov 2025 12:53:04 +0200 Subject: [PATCH 08/18] added simple unit tests --- .../tests/test_facility_index_serializer.py | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) 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']) From 8effb97b99a75a712b6d588ef8bb174438bd5b03 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Fri, 28 Nov 2025 14:45:47 +0200 Subject: [PATCH 09/18] small improvments --- src/react/src/__tests__/utils.tests.js | 49 +++++++------------- src/react/src/util/partnerFieldFormatter.jsx | 34 ++++---------- 2 files changed, 27 insertions(+), 56 deletions(-) diff --git a/src/react/src/__tests__/utils.tests.js b/src/react/src/__tests__/utils.tests.js index a337a2342..bc0c6d494 100644 --- a/src/react/src/__tests__/utils.tests.js +++ b/src/react/src/__tests__/utils.tests.js @@ -2860,14 +2860,9 @@ describe('formatPartnerFieldValue', () => { expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(1); - expect(result[0].type).toBe('div'); - expect(result[0].props.children.type).toBe('a'); - expect(result[0].props.children.props.href).toBe( - 'https://example.com/audit-123', - ); - expect(result[0].props.children.props.children).toBe( - 'View Audit Report', - ); + expect(result[0].type).toBe('a'); + expect(result[0].props.href).toBe('https://example.com/audit-123'); + expect(result[0].props.children).toBe('View Audit Report'); }); it('formats URI field without _text property, using URI as link text', () => { @@ -2891,12 +2886,11 @@ describe('formatPartnerFieldValue', () => { expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(1); - expect(result[0].type).toBe('div'); - expect(result[0].props.children.type).toBe('a'); - expect(result[0].props.children.props.href).toBe( + expect(result[0].type).toBe('a'); + expect(result[0].props.href).toBe( 'https://livingwage.mit.edu/locations/123', ); - expect(result[0].props.children.props.children).toBe( + expect(result[0].props.children).toBe( 'https://livingwage.mit.edu/locations/123', ); }); @@ -2922,8 +2916,7 @@ describe('formatPartnerFieldValue', () => { expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(1); - expect(result[0].type).toBe('div'); - expect(result[0].props.children).toBe('Internal ID: abc-123-xyz'); + expect(result[0]).toBe('Internal ID: abc-123-xyz'); }); it('formats non-URI field without title as plain value', () => { @@ -2946,8 +2939,7 @@ describe('formatPartnerFieldValue', () => { expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(1); - expect(result[0].type).toBe('div'); - expect(result[0].props.children).toBe('Some notes here'); + expect(result[0]).toBe('Some notes here'); }); it('formats mixed URI and non-URI fields with titles', () => { @@ -2987,29 +2979,22 @@ describe('formatPartnerFieldValue', () => { expect(result.length).toBe(3); // url, internal_id, status (url_text is skipped) const urlElement = result.find( - r => - r.props.children?.type === 'a' && - r.props.children?.props?.href === - 'https://example.com/report', + r => r.type === 'a' && r.props.href === 'https://example.com/report', ); expect(urlElement).toBeDefined(); - expect(urlElement.props.children.props.children).toBe( - 'View Report', - ); + expect(urlElement.props.children).toBe('View Report'); const internalIdElement = result.find( - r => - typeof r.props.children === 'string' && - r.props.children.includes('Internal ID: ABC-123'), + r => typeof r === 'string' && r.includes('Internal ID: ABC-123'), ); expect(internalIdElement).toBeDefined(); + expect(internalIdElement).toBe('Internal ID: ABC-123'); const statusElement = result.find( - r => - typeof r.props.children === 'string' && - r.props.children.includes('Status: active'), + r => typeof r === 'string' && r.includes('Status: active'), ); expect(statusElement).toBeDefined(); + expect(statusElement).toBe('Status: active'); }); it('formats object without URI fields using plain formatting with titles', () => { @@ -3044,9 +3029,9 @@ describe('formatPartnerFieldValue', () => { expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(3); - expect(result[0].props.children).toBe('Full Name: John Doe'); - expect(result[1].props.children).toBe('Age: 30'); - expect(result[2].props.children).toBe('Email Address: john@example.com'); + expect(result[0]).toBe('Full Name: John Doe'); + expect(result[1]).toBe('Age: 30'); + expect(result[2]).toBe('Email Address: john@example.com'); }); }); }); diff --git a/src/react/src/util/partnerFieldFormatter.jsx b/src/react/src/util/partnerFieldFormatter.jsx index 3f539338b..02df13926 100644 --- a/src/react/src/util/partnerFieldFormatter.jsx +++ b/src/react/src/util/partnerFieldFormatter.jsx @@ -1,9 +1,5 @@ import React from 'react'; -const ITEM_STYLE = Object.freeze({ - marginBottom: '8px', -}); - /** * Format constants for JSON Schema * Can be extended in the future for other format types (e.g., 'uri-reference', 'email', etc.) @@ -34,15 +30,6 @@ const formatValueWithLabel = (title, value) => { return title ? `${title}: ${stringValue}` : stringValue; }; -/** - * Renders a property value as a div element - */ -const renderPropertyDiv = (key, propValue, displayText) => ( -
- {displayText} -
-); - /** * Formats an object value, displaying property values with optional labels from schema */ @@ -51,20 +38,21 @@ const formatPlainObjectValue = (value, schemaProperties = {}) => const propValue = value[key]; const propSchema = schemaProperties[key] || {}; const { title } = propSchema; - const displayText = formatValueWithLabel(title, propValue); - - return renderPropertyDiv(key, propValue, displayText); + return formatValueWithLabel(title, propValue); }); /** * Renders a URI property as a clickable link */ const renderUriLink = (key, uriValue, linkText) => ( - + + {linkText} + ); /** @@ -103,9 +91,7 @@ const formatObjectWithLinks = (value, uriFields, schemaProperties = {}) => const propSchema = schemaProperties[key] || {}; const { title } = propSchema; - const displayText = formatValueWithLabel(title, propValue); - - return renderPropertyDiv(key, propValue, displayText); + return formatValueWithLabel(title, propValue); }) .filter(Boolean); From e5be75ba7a90e6ef1caa649701e3da31ff7d12da Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Mon, 1 Dec 2025 14:45:41 +0200 Subject: [PATCH 10/18] fix --- src/react/src/util/util.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/src/util/util.js b/src/react/src/util/util.js index 9c4aa1286..1ac997d5a 100644 --- a/src/react/src/util/util.js +++ b/src/react/src/util/util.js @@ -1915,7 +1915,7 @@ export const formatPartnerFieldValue = (fieldValueData, fieldConfig) => { return formatRawValues(raw_values); } if (raw_value !== undefined) { - return fieldValueData.raw_value; + return raw_value; } return fieldValueData; }; From 90b188a2327be3c75825af0a868d9b7b427a7966 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 2 Dec 2025 12:26:37 +0200 Subject: [PATCH 11/18] Big refactoring --- .../PartnerFieldSchemaValue.test.js | 121 ++++++++++ src/react/src/__tests__/utils.tests.js | 216 ++---------------- .../src/components/FacilityDetailsDetail.jsx | 11 +- .../FacilityDetailsGeneralFields.jsx | 8 +- .../src/components/FacilityDetailsItem.jsx | 3 + .../components/PartnerFieldSchemaValue.jsx | 153 +++++++++++++ src/react/src/util/partnerFieldFormatter.jsx | 124 ---------- src/react/src/util/util.js | 22 +- 8 files changed, 321 insertions(+), 337 deletions(-) create mode 100644 src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js create mode 100644 src/react/src/components/PartnerFieldSchemaValue.jsx delete mode 100644 src/react/src/util/partnerFieldFormatter.jsx 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..7e4cee6ba --- /dev/null +++ b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js @@ -0,0 +1,121 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import PartnerFieldSchemaValue from '../../components/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 bc0c6d494..672949adf 100644 --- a/src/react/src/__tests__/utils.tests.js +++ b/src/react/src/__tests__/utils.tests.js @@ -2834,204 +2834,30 @@ describe('formatPartnerFieldValue', () => { expect(result).toBe('active: true, deleted: false, notes: null'); }); - describe('with JSON schema formatting', () => { - it('formats URI field with _text property as clickable link', () => { - const value = { - raw_value: { - url: 'https://example.com/audit-123', - url_text: 'View Audit Report', - }, - }; - const item = { - json_schema: { - type: 'object', - properties: { - url: { - type: 'string', - format: 'uri', - }, - url_text: { - type: 'string', - }, - }, - }, - }; - const result = formatPartnerFieldValue(value, item); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(1); - expect(result[0].type).toBe('a'); - expect(result[0].props.href).toBe('https://example.com/audit-123'); - expect(result[0].props.children).toBe('View Audit Report'); - }); - - it('formats URI field without _text property, using URI as link text', () => { - const value = { - raw_value: { - mit_data_url: 'https://livingwage.mit.edu/locations/123', - }, - }; - const item = { - json_schema: { - type: 'object', - properties: { - mit_data_url: { - type: 'string', - format: 'uri', - }, - }, - }, - }; - const result = formatPartnerFieldValue(value, item); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(1); - expect(result[0].type).toBe('a'); - expect(result[0].props.href).toBe( - 'https://livingwage.mit.edu/locations/123', - ); - expect(result[0].props.children).toBe( - 'https://livingwage.mit.edu/locations/123', - ); - }); - - it('formats non-URI field with title as "Title: value"', () => { - const value = { - raw_value: { - internal_id: 'abc-123-xyz', - }, - }; - const item = { - json_schema: { - type: 'object', - properties: { - internal_id: { - type: 'string', - title: 'Internal ID', - }, - }, - }, - }; - const result = formatPartnerFieldValue(value, item); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(1); - expect(result[0]).toBe('Internal ID: abc-123-xyz'); - }); - - it('formats non-URI field without title as plain value', () => { - const value = { - raw_value: { - notes: 'Some notes here', - }, - }; - const item = { - json_schema: { - type: 'object', - properties: { - notes: { - type: 'string', - }, - }, - }, - }; - const result = formatPartnerFieldValue(value, item); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(1); - expect(result[0]).toBe('Some notes here'); - }); - - it('formats mixed URI and non-URI fields with titles', () => { - const value = { - raw_value: { - url: 'https://example.com/report', - url_text: 'View Report', - internal_id: 'ABC-123', - status: 'active', - }, - }; - const item = { - json_schema: { - type: 'object', - properties: { - url: { - type: 'string', - format: 'uri', - }, - url_text: { - type: 'string', - }, - internal_id: { - type: 'string', - title: 'Internal ID', - }, - status: { - type: 'string', - title: 'Status', - }, - }, - }, - }; - const result = formatPartnerFieldValue(value, item); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(3); // url, internal_id, status (url_text is skipped) - - const urlElement = result.find( - r => r.type === 'a' && r.props.href === 'https://example.com/report', - ); - expect(urlElement).toBeDefined(); - expect(urlElement.props.children).toBe('View Report'); - - const internalIdElement = result.find( - r => typeof r === 'string' && r.includes('Internal ID: ABC-123'), - ); - expect(internalIdElement).toBeDefined(); - expect(internalIdElement).toBe('Internal ID: ABC-123'); - - const statusElement = result.find( - r => typeof r === 'string' && r.includes('Status: active'), - ); - expect(statusElement).toBeDefined(); - expect(statusElement).toBe('Status: active'); - }); - - it('formats object without URI fields using plain formatting with titles', () => { - const value = { - raw_value: { - name: 'John Doe', - age: 30, - email: 'john@example.com', + 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', }, - }; - const item = { - json_schema: { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - }, - age: { - type: 'integer', - title: 'Age', - }, - email: { - type: 'string', - title: 'Email Address', - }, - }, + url_text: { + type: 'string', }, - }; - const result = formatPartnerFieldValue(value, item); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(3); + }, + }; + const result = formatPartnerFieldValue(value, jsonSchema); - expect(result[0]).toBe('Full Name: John Doe'); - expect(result[1]).toBe('Age: 30'); - expect(result[2]).toBe('Email Address: john@example.com'); + 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..0196b45a7 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 './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 31912c866..48d3264fb 100644 --- a/src/react/src/components/FacilityDetailsGeneralFields.jsx +++ b/src/react/src/components/FacilityDetailsGeneralFields.jsx @@ -228,13 +228,11 @@ const FacilityDetailsGeneralFields = ({ const renderPartnerField = ({ label, fieldName, formatValue }) => { const values = get(data, `properties.partner_fields.${fieldName}`, []); - const formatField = item => { - const customFormatValue = value => formatValue(value, item); - return formatExtendedField({ + const formatField = item => + formatExtendedField({ ...item, - formatValue: customFormatValue, + 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/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFieldSchemaValue.jsx new file mode 100644 index 000000000..aaec4be3d --- /dev/null +++ b/src/react/src/components/PartnerFieldSchemaValue.jsx @@ -0,0 +1,153 @@ +import React from 'react'; + +/** + * Format constants for JSON Schema. + */ +const FORMAT_TYPES = Object.freeze({ + URI: 'uri', +}); + +/** + * Component for rendering URI format properties. + */ +const UriProperty = ({ propertyKey, propertyValue, value }) => { + if (!propertyValue) { + return null; + } + + const textKey = `${propertyKey}_text`; + const linkText = Object.prototype.hasOwnProperty.call(value, textKey) + ? value[textKey] + : propertyValue; + + return ( + + {linkText} + + ); +}; + +/** + * Component for rendering default format properties (no format or unsupported format). + */ +const DefaultProperty = ({ propertyKey, propertyValue, propertySchema }) => { + const { title } = propertySchema || {}; + const stringValue = String(propertyValue || ''); + const displayText = title ? `${title}: ${stringValue}` : stringValue; + + return ( + + {displayText} + + ); +}; + +/** + * Format component registry. + * Maps format types to their component functions. + */ +const FORMAT_COMPONENTS = Object.freeze({ + [FORMAT_TYPES.URI]: UriProperty, + // Add more format components here in the future +}); + +/** + * Gets the format type from a property schema. + */ +const getFormatFromSchema = propertySchema => propertySchema?.format || null; + +/** + * Checks if a property key should be skipped (e.g., _text properties for URI fields). + */ +const shouldSkipProperty = (propertyKey, propertySchema, schemaProperties) => { + 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; +}; + +/** + * 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, propertyValue, propertySchema, value) => { + const format = getFormatFromSchema(propertySchema); + + if (!Object.prototype.hasOwnProperty.call(value, propertyKey)) { + return null; + } + + 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; + } + + const schemaProperties = jsonSchema?.properties || {}; + + const renderedItems = Object.keys(value) + .filter(propertyKey => { + const propertySchema = schemaProperties[propertyKey] || {}; + return !shouldSkipProperty( + propertyKey, + propertySchema, + schemaProperties, + ); + }) + .map(propertyKey => { + const propertyValue = value[propertyKey]; + const propertySchema = schemaProperties[propertyKey] || {}; + return renderProperty( + propertyKey, + propertyValue, + propertySchema, + value, + ); + }) + .filter(Boolean); + + return <>{renderedItems}; +}; + +export default PartnerFieldSchemaValue; diff --git a/src/react/src/util/partnerFieldFormatter.jsx b/src/react/src/util/partnerFieldFormatter.jsx deleted file mode 100644 index fac3233c2..000000000 --- a/src/react/src/util/partnerFieldFormatter.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react'; - -/** - * Format constants for JSON Schema. - * Can be extended in the future for other format types (e.g., 'uri-reference', 'email', etc.). - */ -const FORMAT_TYPES = Object.freeze({ - URI: 'uri', -}); - -/** - * Extracts properties with specific format from JSON schema. - */ -const extractPropertiesByFormat = (schemaProperties, formatType) => { - const fields = new Set(); - Object.keys(schemaProperties).forEach(propName => { - const propSchema = schemaProperties[propName]; - if (propSchema?.format === formatType) { - fields.add(propName); - } - }); - return fields; -}; - -/** - * Formats a property value with optional label from schema. - */ -const formatValueWithLabel = (title, value) => { - const stringValue = String(value || ''); - return title ? `${title}: ${stringValue}` : stringValue; -}; - -/** - * Formats an object value, displaying property values with optional labels from schema. - */ -const formatPlainObjectValue = (value, schemaProperties = {}) => - Object.keys(value).map(key => { - const propValue = value[key]; - const propSchema = schemaProperties[key] || {}; - const { title } = propSchema; - return formatValueWithLabel(title, propValue); - }); - -/** - * Renders a URI property as a clickable link. - */ -const renderUriLink = (key, uriValue, linkText) => ( - - {linkText} - -); - -/** - * Checks if a key is a text property for a URI field. - */ -const isUriTextProperty = (key, uriFields) => - key.endsWith('_text') && uriFields.has(key.slice(0, -5)); - -/** - * Gets the display text for a URI property, checking for _text sibling. - */ -const getUriLinkText = (key, uriValue, value) => { - const textKey = `${key}_text`; - return value[textKey] || uriValue; -}; - -/** - * Formats an object value with URI fields rendered as clickable links. - */ -const formatObjectWithLinks = (value, uriFields, schemaProperties = {}) => - Object.keys(value) - .map(key => { - const propValue = value[key]; - - if (isUriTextProperty(key, uriFields)) { - return null; - } - - if (uriFields.has(key)) { - if (propValue) { - const linkText = getUriLinkText(key, propValue, value); - return renderUriLink(key, propValue, linkText); - } - return null; - } - - const propSchema = schemaProperties[key] || {}; - const { title } = propSchema; - return formatValueWithLabel(title, propValue); - }) - .filter(Boolean); - -/** - * Formats partner field values based on JSON schema. - */ -const formatPartnerFieldWithSchema = (value, jsonSchema) => { - if ( - !jsonSchema || - !value || - typeof value !== 'object' || - Array.isArray(value) - ) { - return value; - } - - const schemaProperties = jsonSchema?.properties || {}; - const uriFields = extractPropertiesByFormat( - schemaProperties, - FORMAT_TYPES.URI, - ); - - if (uriFields.size === 0) { - return formatPlainObjectValue(value, schemaProperties); - } - - return formatObjectWithLinks(value, uriFields, schemaProperties); -}; - -export default formatPartnerFieldWithSchema; diff --git a/src/react/src/util/util.js b/src/react/src/util/util.js index 1ac997d5a..0acedcc78 100644 --- a/src/react/src/util/util.js +++ b/src/react/src/util/util.js @@ -94,7 +94,6 @@ import { createFacilitiesCSV, formatDataForCSV } from './util.facilitiesCSV'; import formatFacilityClaimsDataForXLSX from './util.facilityClaimsXLSX'; import formatModerationEventsDataForXLSX from './util.moderationEventsXLSX'; import COLOURS from './COLOURS'; -import formatPartnerFieldWithSchema from './partnerFieldFormatter'; export function DownloadXLSX(data, fileName) { import('file-saver').then(({ saveAs }) => { @@ -1784,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 { @@ -1798,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, @@ -1899,19 +1903,13 @@ const formatRawValues = rawValues => { return rawValues.toString().split('|'); }; -export const formatPartnerFieldValue = (fieldValueData, fieldConfig) => { +export const formatPartnerFieldValue = (fieldValueData, json_schema) => { const { raw_values, raw_value } = fieldValueData; - const schema = fieldConfig?.json_schema || null; - - if (schema && (raw_value || raw_values)) { - const objectValue = raw_value || raw_values; - if (typeof objectValue === 'object' && !Array.isArray(objectValue)) { - return formatPartnerFieldWithSchema(objectValue, schema); - } - } - if (raw_values !== undefined) { + if (json_schema && !Array.isArray(raw_values)) { + return raw_values; + } return formatRawValues(raw_values); } if (raw_value !== undefined) { From 95f86b539cfc132d883be1673e4b4800b4686eb9 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 2 Dec 2025 12:43:19 +0200 Subject: [PATCH 12/18] sonar qube fix --- src/react/src/components/PartnerFieldSchemaValue.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/src/components/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFieldSchemaValue.jsx index aaec4be3d..ae387c8cc 100644 --- a/src/react/src/components/PartnerFieldSchemaValue.jsx +++ b/src/react/src/components/PartnerFieldSchemaValue.jsx @@ -16,7 +16,7 @@ const UriProperty = ({ propertyKey, propertyValue, value }) => { } const textKey = `${propertyKey}_text`; - const linkText = Object.prototype.hasOwnProperty.call(value, textKey) + const linkText = Object.hasOwn(value, textKey) ? value[textKey] : propertyValue; @@ -94,7 +94,7 @@ const getFormatComponent = format => { const renderProperty = (propertyKey, propertyValue, propertySchema, value) => { const format = getFormatFromSchema(propertySchema); - if (!Object.prototype.hasOwnProperty.call(value, propertyKey)) { + if (!Object.hasOwn(value, propertyKey)) { return null; } From ad6bdd2743b8fe03201eba5cec626953c5524ee7 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 2 Dec 2025 12:55:07 +0200 Subject: [PATCH 13/18] small fixes --- src/react/src/components/PartnerFieldSchemaValue.jsx | 4 ++-- src/react/src/util/util.js | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/react/src/components/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFieldSchemaValue.jsx index ae387c8cc..aaec4be3d 100644 --- a/src/react/src/components/PartnerFieldSchemaValue.jsx +++ b/src/react/src/components/PartnerFieldSchemaValue.jsx @@ -16,7 +16,7 @@ const UriProperty = ({ propertyKey, propertyValue, value }) => { } const textKey = `${propertyKey}_text`; - const linkText = Object.hasOwn(value, textKey) + const linkText = Object.prototype.hasOwnProperty.call(value, textKey) ? value[textKey] : propertyValue; @@ -94,7 +94,7 @@ const getFormatComponent = format => { const renderProperty = (propertyKey, propertyValue, propertySchema, value) => { const format = getFormatFromSchema(propertySchema); - if (!Object.hasOwn(value, propertyKey)) { + if (!Object.prototype.hasOwnProperty.call(value, propertyKey)) { return null; } diff --git a/src/react/src/util/util.js b/src/react/src/util/util.js index 0acedcc78..ca966e39a 100644 --- a/src/react/src/util/util.js +++ b/src/react/src/util/util.js @@ -1907,7 +1907,12 @@ export const formatPartnerFieldValue = (fieldValueData, json_schema) => { const { raw_values, raw_value } = fieldValueData; if (raw_values !== undefined) { - if (json_schema && !Array.isArray(raw_values)) { + if ( + json_schema && + json_schema.properties && + Object.keys(json_schema.properties).length > 0 && + !Array.isArray(raw_values) + ) { return raw_values; } return formatRawValues(raw_values); From 5032a772144c70bff9a1773080a147e4630fecd1 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 2 Dec 2025 14:11:47 +0200 Subject: [PATCH 14/18] improvements --- .../components/PartnerFieldSchemaValue.jsx | 59 +++++++++---------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/src/react/src/components/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFieldSchemaValue.jsx index aaec4be3d..800a37ac3 100644 --- a/src/react/src/components/PartnerFieldSchemaValue.jsx +++ b/src/react/src/components/PartnerFieldSchemaValue.jsx @@ -10,15 +10,19 @@ const FORMAT_TYPES = Object.freeze({ /** * Component for rendering URI format properties. */ -const UriProperty = ({ propertyKey, propertyValue, value }) => { +const UriProperty = ({ propertyKey, value, schemaProperties }) => { + const propertyValue = value[propertyKey]; if (!propertyValue) { return null; } const textKey = `${propertyKey}_text`; - const linkText = Object.prototype.hasOwnProperty.call(value, textKey) - ? value[textKey] - : propertyValue; + const textPropertyDefined = schemaProperties && schemaProperties[textKey]; + const linkText = + textPropertyDefined && + Object.prototype.hasOwnProperty.call(value, textKey) + ? value[textKey] + : propertyValue; return ( { /** * Component for rendering default format properties (no format or unsupported format). */ -const DefaultProperty = ({ propertyKey, propertyValue, propertySchema }) => { - const { title } = propertySchema || {}; +const DefaultProperty = ({ propertyKey, value, schemaProperties }) => { + const propertyValue = value[propertyKey]; + const propertySchema = schemaProperties[propertyKey] || {}; + const { title } = propertySchema; const stringValue = String(propertyValue || ''); const displayText = title ? `${title}: ${stringValue}` : stringValue; @@ -62,9 +68,13 @@ const FORMAT_COMPONENTS = Object.freeze({ const getFormatFromSchema = propertySchema => propertySchema?.format || null; /** - * Checks if a property key should be skipped (e.g., _text properties for URI fields). + * Checks if a property key should be skipped. */ -const shouldSkipProperty = (propertyKey, propertySchema, schemaProperties) => { +const shouldSkipProperty = (propertyKey, schemaProperties) => { + if (!Object.prototype.hasOwnProperty.call(schemaProperties, propertyKey)) { + return true; + } + if (propertyKey.endsWith('_text')) { const baseKey = propertyKey.slice(0, -5); const baseSchema = schemaProperties[baseKey]; @@ -91,22 +101,21 @@ const getFormatComponent = format => { /** * Renders a single property based on format strategy. */ -const renderProperty = (propertyKey, propertyValue, propertySchema, value) => { - const format = getFormatFromSchema(propertySchema); - +const renderProperty = (propertyKey, value, schemaProperties) => { if (!Object.prototype.hasOwnProperty.call(value, propertyKey)) { return null; } + const propertySchema = schemaProperties[propertyKey] || {}; + const format = getFormatFromSchema(propertySchema); const FormatComponent = getFormatComponent(format); return ( ); }; @@ -127,24 +136,12 @@ const PartnerFieldSchemaValue = ({ value, jsonSchema }) => { const schemaProperties = jsonSchema?.properties || {}; const renderedItems = Object.keys(value) - .filter(propertyKey => { - const propertySchema = schemaProperties[propertyKey] || {}; - return !shouldSkipProperty( - propertyKey, - propertySchema, - schemaProperties, - ); - }) - .map(propertyKey => { - const propertyValue = value[propertyKey]; - const propertySchema = schemaProperties[propertyKey] || {}; - return renderProperty( - propertyKey, - propertyValue, - propertySchema, - value, - ); - }) + .filter( + propertyKey => !shouldSkipProperty(propertyKey, schemaProperties), + ) + .map(propertyKey => + renderProperty(propertyKey, value, schemaProperties), + ) .filter(Boolean); return <>{renderedItems}; From f41e18e4a34c0c1907bb0e829d3532bcc6fbcd55 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 2 Dec 2025 14:14:25 +0200 Subject: [PATCH 15/18] linter --- .../src/__tests__/components/PartnerFieldSchemaValue.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js index 7e4cee6ba..f5796550c 100644 --- a/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js +++ b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js @@ -118,4 +118,3 @@ describe('PartnerFieldSchemaValue', () => { expect(urlTextElements.length).toBe(1); }); }); - From fe1b620b15afcfbf26e477c098a15c2e3694e45c Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 2 Dec 2025 14:22:25 +0200 Subject: [PATCH 16/18] code rabbit note --- src/react/src/components/PartnerFieldSchemaValue.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/src/components/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFieldSchemaValue.jsx index 800a37ac3..17ff21785 100644 --- a/src/react/src/components/PartnerFieldSchemaValue.jsx +++ b/src/react/src/components/PartnerFieldSchemaValue.jsx @@ -43,7 +43,7 @@ const DefaultProperty = ({ propertyKey, value, schemaProperties }) => { const propertyValue = value[propertyKey]; const propertySchema = schemaProperties[propertyKey] || {}; const { title } = propertySchema; - const stringValue = String(propertyValue || ''); + const stringValue = propertyValue != null ? String(propertyValue) : ''; const displayText = title ? `${title}: ${stringValue}` : stringValue; return ( From 4c1f35713150e71e8739dee0c45f35db46bbeec0 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 2 Dec 2025 17:15:14 +0200 Subject: [PATCH 17/18] addressed Vadim and Vlad comments --- .../PartnerFieldSchemaValue.test.js | 2 +- src/react/src/__tests__/utils.tests.js | 34 ++-- .../src/components/FacilityDetailsDetail.jsx | 2 +- .../components/PartnerFieldSchemaValue.jsx | 150 ------------------ .../PartnerFields/DefaultProperty.jsx | 20 +++ .../PartnerFields/PartnerFieldSchemaValue.jsx | 72 +++++++++ .../components/PartnerFields/UriProperty.jsx | 31 ++++ .../src/components/PartnerFields/utils.js | 33 ++++ src/react/src/util/util.js | 28 ++-- 9 files changed, 189 insertions(+), 183 deletions(-) delete mode 100644 src/react/src/components/PartnerFieldSchemaValue.jsx create mode 100644 src/react/src/components/PartnerFields/DefaultProperty.jsx create mode 100644 src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx create mode 100644 src/react/src/components/PartnerFields/UriProperty.jsx create mode 100644 src/react/src/components/PartnerFields/utils.js diff --git a/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js index f5796550c..a339159d9 100644 --- a/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js +++ b/src/react/src/__tests__/components/PartnerFieldSchemaValue.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import PartnerFieldSchemaValue from '../../components/PartnerFieldSchemaValue'; +import PartnerFieldSchemaValue from '../../components/PartnerFields/PartnerFieldSchemaValue'; describe('PartnerFieldSchemaValue', () => { it('renders URI field with _text property as clickable link and skips _text', () => { diff --git a/src/react/src/__tests__/utils.tests.js b/src/react/src/__tests__/utils.tests.js index 672949adf..3160beaf4 100644 --- a/src/react/src/__tests__/utils.tests.js +++ b/src/react/src/__tests__/utils.tests.js @@ -2736,101 +2736,99 @@ describe('processDromoResults', () => { }); describe('formatPartnerFieldValue', () => { - const emptyItem = {}; - it('formats raw_value as a single value', () => { const value = { raw_value: 'Test Value' }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('Test Value'); }); it('formats raw_value with number', () => { const value = { raw_value: 100 }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe(100); }); it('formats raw_values as array joined by comma', () => { const value = { raw_values: ['Value 1', 'Value 2', 'Value 3'] }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('Value 1, Value 2, Value 3'); }); it('formats empty array as empty string', () => { const value = { raw_values: [] }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe(''); }); it('formats single-item array as string without comma', () => { const value = { raw_values: ['Single Value'] }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('Single Value'); }); it('formats raw_values as object with key-value pairs', () => { const value = { raw_values: { test_1: '1', test_2: '2' } }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('test_1: 1, test_2: 2'); }); it('formats empty object as empty string', () => { const value = { raw_values: {} }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe(''); }); it('formats raw_values object with nested values', () => { const value = { raw_values: { key1: 'value1', key2: 100, key3: 'value3' } }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('key1: value1, key2: 100, key3: value3'); }); it('formats pipe-delimited string into array (legacy format)', () => { const value = { raw_values: 'value1|value2|value3' }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toEqual(['value1', 'value2', 'value3']); }); it('formats single string value without pipe', () => { const value = { raw_values: 'single value' }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toEqual(['single value']); }); it('returns plain value if no raw_value or raw_values property', () => { const value = 'Plain String Value'; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('Plain String Value'); }); it('returns numeric value if no raw_value or raw_values property', () => { const value = 42; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe(42); }); it('prefers raw_values over raw_value when both exist', () => { const value = { raw_value: 'ignored', raw_values: ['preferred'] }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('preferred'); }); it('falls back to raw_value when raw_values is undefined', () => { const value = { raw_value: 'fallback value' }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('fallback value'); }); it('formats array with mixed types', () => { const value = { raw_values: ['string', 123, 'another'] }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('string, 123, another'); }); it('formats object with boolean and null values', () => { const value = { raw_values: { active: true, deleted: false, notes: null } }; - const result = formatPartnerFieldValue(value, emptyItem); + const result = formatPartnerFieldValue(value); expect(result).toBe('active: true, deleted: false, notes: null'); }); diff --git a/src/react/src/components/FacilityDetailsDetail.jsx b/src/react/src/components/FacilityDetailsDetail.jsx index 0196b45a7..99b03f325 100644 --- a/src/react/src/components/FacilityDetailsDetail.jsx +++ b/src/react/src/components/FacilityDetailsDetail.jsx @@ -6,7 +6,7 @@ import Tooltip from '@material-ui/core/Tooltip'; import ShowOnly from './ShowOnly'; import BadgeVerified from './BadgeVerified'; import FeatureFlag from './FeatureFlag'; -import PartnerFieldSchemaValue from './PartnerFieldSchemaValue'; +import PartnerFieldSchemaValue from './PartnerFields/PartnerFieldSchemaValue'; import { CLAIM_A_FACILITY } from '../util/constants'; diff --git a/src/react/src/components/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFieldSchemaValue.jsx deleted file mode 100644 index 17ff21785..000000000 --- a/src/react/src/components/PartnerFieldSchemaValue.jsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react'; - -/** - * Format constants for JSON Schema. - */ -const FORMAT_TYPES = Object.freeze({ - URI: 'uri', -}); - -/** - * 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 && - Object.prototype.hasOwnProperty.call(value, textKey) - ? value[textKey] - : propertyValue; - - return ( - - {linkText} - - ); -}; - -/** - * 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} - - ); -}; - -/** - * Format component registry. - * Maps format types to their component functions. - */ -const FORMAT_COMPONENTS = Object.freeze({ - [FORMAT_TYPES.URI]: UriProperty, - // Add more format components here in the future -}); - -/** - * Gets the format type from a property schema. - */ -const getFormatFromSchema = propertySchema => propertySchema?.format || null; - -/** - * Checks if a property key should be skipped. - */ -const shouldSkipProperty = (propertyKey, schemaProperties) => { - if (!Object.prototype.hasOwnProperty.call(schemaProperties, propertyKey)) { - 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; -}; - -/** - * 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) => { - if (!Object.prototype.hasOwnProperty.call(value, propertyKey)) { - return null; - } - - 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; - } - - const schemaProperties = jsonSchema?.properties || {}; - - const renderedItems = Object.keys(value) - .filter( - propertyKey => !shouldSkipProperty(propertyKey, schemaProperties), - ) - .map(propertyKey => - renderProperty(propertyKey, value, schemaProperties), - ) - .filter(Boolean); - - return <>{renderedItems}; -}; - -export default PartnerFieldSchemaValue; 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..a87d6c37d --- /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; + } + + 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/util.js b/src/react/src/util/util.js index ca966e39a..8c305df31 100644 --- a/src/react/src/util/util.js +++ b/src/react/src/util/util.js @@ -1903,22 +1903,24 @@ const formatRawValues = rawValues => { return rawValues.toString().split('|'); }; -export const formatPartnerFieldValue = (fieldValueData, json_schema) => { - const { raw_values, raw_value } = fieldValueData; +export const formatPartnerFieldValue = (fieldValueData, jsonSchema = null) => { + const { raw_values: rawValues, raw_value: rawValue } = fieldValueData; - if (raw_values !== undefined) { - if ( - json_schema && - json_schema.properties && - Object.keys(json_schema.properties).length > 0 && - !Array.isArray(raw_values) - ) { - return raw_values; + const hasJsonProps = + isObject(jsonSchema) && + isObject(jsonSchema.properties) && + !isEmpty(jsonSchema.properties); + + if (!isNil(rawValues)) { + if (hasJsonProps && !Array.isArray(rawValues)) { + return rawValues; } - return formatRawValues(raw_values); + return formatRawValues(rawValues); } - if (raw_value !== undefined) { - return raw_value; + + if (!isNil(rawValue)) { + return rawValue; } + return fieldValueData; }; From 6c5d72294e32bb53c97e9934510236f22c8b95b5 Mon Sep 17 00:00:00 2001 From: Roman Stolar Date: Tue, 2 Dec 2025 17:35:32 +0200 Subject: [PATCH 18/18] safe return --- .../src/components/PartnerFields/PartnerFieldSchemaValue.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx b/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx index a87d6c37d..7e8e6eaa9 100644 --- a/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx +++ b/src/react/src/components/PartnerFields/PartnerFieldSchemaValue.jsx @@ -50,7 +50,7 @@ const PartnerFieldSchemaValue = ({ value, jsonSchema }) => { typeof value !== 'object' || Array.isArray(value) ) { - return value; + return value ?? null; } const schemaProperties = jsonSchema?.properties || {};