From 26b259db37369b4a2123c86c42b68c8621f8b023 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Wed, 24 Jun 2026 15:54:33 +0530 Subject: [PATCH 01/14] HIVE-119155: Vivek: Add hyperlink URL as a control property in form builder Co-Authored-By: Claude Opus 4.7 --- src/form-builder/components/Property.jsx | 5 ++--- styles/common/_form.scss | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/form-builder/components/Property.jsx b/src/form-builder/components/Property.jsx index 9839e5a1..80b0bcbe 100644 --- a/src/form-builder/components/Property.jsx +++ b/src/form-builder/components/Property.jsx @@ -43,10 +43,9 @@ export class Property extends Component { ); case 'text': return ( this.updateProperty(e, elementType) } : { onChange: e => this.updateProperty(e, elementType) })} type="text" @@ -73,7 +72,7 @@ export class Property extends Component { render() { const { name, elementType } = this.props; return ( -
+
{this.getElement(elementType)}
diff --git a/styles/common/_form.scss b/styles/common/_form.scss index ed33746d..cc263440 100644 --- a/styles/common/_form.scss +++ b/styles/common/_form.scss @@ -6,6 +6,22 @@ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS * graphic logo is a trademark of OpenMRS Inc. */ +.property-text-row { + display: flex; + align-items: center; + padding: 2px 0; + + label { + flex-shrink: 0; + white-space: nowrap; + padding-right: 8px; + } + + input[type='text'] { + flex: 1; + min-width: 0; + } +} .form-field{ padding: 5px 0; From 879e5b5f0916c746826e5fe584060e921bf10245 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Thu, 25 Jun 2026 12:08:43 +0530 Subject: [PATCH 02/14] HIVE-119155: Vivek: Fix Property layout, add hyperlinkLabel disabled state and tests - className undefined (not empty string) on non-text wrapper divs - hyperlinkLabel input disabled when hyperlinkUrl is empty - Add regression tests for property-text-row and hyperlinkUrl onBlur Co-Authored-By: Claude Opus 4.7 --- .../components/FormDetailContainer.jsx | 2 +- src/form-builder/components/FormList.jsx | 2 +- .../components/FormPrinterContainer.jsx | 6 +-- .../components/FormPrivilegeTable.jsx | 8 ++-- src/form-builder/components/Property.jsx | 4 +- .../components/PropertyEditor.jsx | 2 + test/form-builder/components/Property.spec.js | 42 +++++++++++++++++++ 7 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/form-builder/components/FormDetailContainer.jsx b/src/form-builder/components/FormDetailContainer.jsx index 7c777f29..815f467c 100644 --- a/src/form-builder/components/FormDetailContainer.jsx +++ b/src/form-builder/components/FormDetailContainer.jsx @@ -213,7 +213,7 @@ export class FormDetailContainer extends Component { }); } _saveFormPrivileges(formId, formVersion) { - let formVersionTemp = formVersion; + const formVersionTemp = formVersion; saveFormPrivileges( this._createReqObject(formId, formVersionTemp, this.state.formPrivileges) ) diff --git a/src/form-builder/components/FormList.jsx b/src/form-builder/components/FormList.jsx index 371ce5c7..efd37c63 100644 --- a/src/form-builder/components/FormList.jsx +++ b/src/form-builder/components/FormList.jsx @@ -87,7 +87,7 @@ export default class FormList extends Component { console.log(formData); try { httpInterceptor.post(formBuilderConstants.jsonToPdfConvertionUrl, formData).then((response) => { - let fileName = response.pdfName; + const fileName = response.pdfName; const link = formBuilderConstants.pdfDownloadUrl + fileName; window.open(`/pdf/${fileName}`, '_self'); }); diff --git a/src/form-builder/components/FormPrinterContainer.jsx b/src/form-builder/components/FormPrinterContainer.jsx index 1487f3a6..191967b2 100644 --- a/src/form-builder/components/FormPrinterContainer.jsx +++ b/src/form-builder/components/FormPrinterContainer.jsx @@ -32,7 +32,7 @@ class FormPrinterContainer extends Component { } onFileUpload(event) { - let file = this.state.selectedFile; + const file = this.state.selectedFile; this.setState({ importBtnStatus: 'clicked' }); const self = this; const reader = new FileReader(); @@ -41,8 +41,8 @@ class FormPrinterContainer extends Component { try { const formData = JSON.parse(reader.result); httpInterceptor.post(formBuilderConstants.jsonToPdfConvertionUrl, formData).then((response) => { - let fileName = response.pdfName; - let link = formBuilderConstants.pdfDownloadUrl + fileName; + const fileName = response.pdfName; + const link = formBuilderConstants.pdfDownloadUrl + fileName; self.setState({ downloadLink: link }); self.setState({ status: 'Completed' }); }); diff --git a/src/form-builder/components/FormPrivilegeTable.jsx b/src/form-builder/components/FormPrivilegeTable.jsx index 12e67a9b..9cf0dde5 100644 --- a/src/form-builder/components/FormPrivilegeTable.jsx +++ b/src/form-builder/components/FormPrivilegeTable.jsx @@ -91,7 +91,7 @@ export default class FormPrivilegeTable extends Component { } fetchPrivileges() { - let initialPrivileges = []; + const initialPrivileges = []; let privileges = []; const queryParams = '?='; const optionsUrl = `${formBuilderConstants.formPrivilegeUrl}${queryParams}`; @@ -122,9 +122,9 @@ export default class FormPrivilegeTable extends Component { } fetchFormPrivilegesFromDB() { - let initialPrivilegesFromDB = []; + const initialPrivilegesFromDB = []; const queryParams = '?='; - let initialPrivileges = []; + const initialPrivileges = []; const formId = this.props.formId; const formVersion = this.props.formData.version; const optionsUrl = `${formBuilderConstants.getFormPrivilegesUrl}?formId=${formId}&formVersion=${formVersion}`; @@ -313,7 +313,7 @@ export default class FormPrivilegeTable extends Component { } removeSelectedPrivilege(e) { - let array = this.state.availablePrivileges.filter((item) => item.label !== e); + const array = this.state.availablePrivileges.filter((item) => item.label !== e); this.setState({ availablePrivileges: array, }); diff --git a/src/form-builder/components/Property.jsx b/src/form-builder/components/Property.jsx index 80b0bcbe..c8e3bff3 100644 --- a/src/form-builder/components/Property.jsx +++ b/src/form-builder/components/Property.jsx @@ -44,6 +44,7 @@ export class Property extends Component { case 'text': return ( this.updateProperty(e, elementType) } @@ -72,7 +73,7 @@ export class Property extends Component { render() { const { name, elementType } = this.props; return ( -
+
{this.getElement(elementType)}
@@ -84,6 +85,7 @@ Property.propTypes = { elementName: PropTypes.string, elementType: PropTypes.string, id: PropTypes.any.isRequired, + inputDisabled: PropTypes.bool, name: PropTypes.string.isRequired, onPropertyUpdate: PropTypes.func.isRequired, options: PropTypes.array, diff --git a/src/form-builder/components/PropertyEditor.jsx b/src/form-builder/components/PropertyEditor.jsx index 2eaad547..89f876d5 100644 --- a/src/form-builder/components/PropertyEditor.jsx +++ b/src/form-builder/components/PropertyEditor.jsx @@ -28,6 +28,7 @@ export class PropertyEditor extends Component { return sortedAttributes.map((attribute, index) => { const { name } = attribute; const value = get(properties, name, attribute.defaultValue); + const isInputDisabled = name === 'hyperlinkLabel' && !get(properties, 'hyperlinkUrl', ''); return ( this.props.onPropertyUpdate(property)} value={value} {...attribute} + inputDisabled={isInputDisabled} /> ); }); diff --git a/test/form-builder/components/Property.spec.js b/test/form-builder/components/Property.spec.js index ecdf2b68..baebce4b 100755 --- a/test/form-builder/components/Property.spec.js +++ b/test/form-builder/components/Property.spec.js @@ -79,6 +79,34 @@ describe('Property', () => { expect(wrapper.find('input').props().defaultValue).to.eql('someText'); }); + it('should apply property-text-row class only for text type and not for others', () => { + const textWrapper = shallow( {}} + value="" + />); + expect(textWrapper.find('div').prop('className')).to.eql('property-text-row'); + + const checkboxWrapper = shallow( {}} + value={false} + />); + expect(checkboxWrapper.find('div').prop('className')).to.equal(undefined); + }); + + it('should not have className attribute on non-text wrapper div', () => { + wrapper = shallow( {}} + value={false} + />); + expect(wrapper.find('div').prop('className')).to.equal(undefined); + }); + it('should call update property on change of text box', () => { const spy = sinon.spy(); const type = 'text'; @@ -107,6 +135,20 @@ describe('Property', () => { sinon.assert.calledWith(spy, { url: 'someText' }); }); + it('should call update property on blur of text box if the name is hyperlinkUrl', () => { + const spy = sinon.spy(); + const type = 'text'; + + wrapper = shallow(); + wrapper.find('input').props().onBlur({ target: { value: 'https://example.com' } }, type); + sinon.assert.calledWith(spy, { hyperlinkUrl: 'https://example.com' }); + }); + it('should render select dropdown when given property with dropdown', () => { const type = 'dropdown'; From 095e7a00372686fbc790f156ed0569b06f8b83ec Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Fri, 26 Jun 2026 16:33:38 +0530 Subject: [PATCH 03/14] HIVE-119155: Vivek: Revert unrelated let-to-const ESLint auto-fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore FormDetailContainer, FormList, FormPrinterContainer, FormPrivilegeTable to their original state — these were changed by ESLint auto-fix during test runs and are unrelated to the hyperlink feature. co-Authored-By: Claude Opus 4.7 --- src/form-builder/components/FormDetailContainer.jsx | 2 +- src/form-builder/components/FormList.jsx | 2 +- src/form-builder/components/FormPrinterContainer.jsx | 6 +++--- src/form-builder/components/FormPrivilegeTable.jsx | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/form-builder/components/FormDetailContainer.jsx b/src/form-builder/components/FormDetailContainer.jsx index 815f467c..7c777f29 100644 --- a/src/form-builder/components/FormDetailContainer.jsx +++ b/src/form-builder/components/FormDetailContainer.jsx @@ -213,7 +213,7 @@ export class FormDetailContainer extends Component { }); } _saveFormPrivileges(formId, formVersion) { - const formVersionTemp = formVersion; + let formVersionTemp = formVersion; saveFormPrivileges( this._createReqObject(formId, formVersionTemp, this.state.formPrivileges) ) diff --git a/src/form-builder/components/FormList.jsx b/src/form-builder/components/FormList.jsx index efd37c63..371ce5c7 100644 --- a/src/form-builder/components/FormList.jsx +++ b/src/form-builder/components/FormList.jsx @@ -87,7 +87,7 @@ export default class FormList extends Component { console.log(formData); try { httpInterceptor.post(formBuilderConstants.jsonToPdfConvertionUrl, formData).then((response) => { - const fileName = response.pdfName; + let fileName = response.pdfName; const link = formBuilderConstants.pdfDownloadUrl + fileName; window.open(`/pdf/${fileName}`, '_self'); }); diff --git a/src/form-builder/components/FormPrinterContainer.jsx b/src/form-builder/components/FormPrinterContainer.jsx index 191967b2..1487f3a6 100644 --- a/src/form-builder/components/FormPrinterContainer.jsx +++ b/src/form-builder/components/FormPrinterContainer.jsx @@ -32,7 +32,7 @@ class FormPrinterContainer extends Component { } onFileUpload(event) { - const file = this.state.selectedFile; + let file = this.state.selectedFile; this.setState({ importBtnStatus: 'clicked' }); const self = this; const reader = new FileReader(); @@ -41,8 +41,8 @@ class FormPrinterContainer extends Component { try { const formData = JSON.parse(reader.result); httpInterceptor.post(formBuilderConstants.jsonToPdfConvertionUrl, formData).then((response) => { - const fileName = response.pdfName; - const link = formBuilderConstants.pdfDownloadUrl + fileName; + let fileName = response.pdfName; + let link = formBuilderConstants.pdfDownloadUrl + fileName; self.setState({ downloadLink: link }); self.setState({ status: 'Completed' }); }); diff --git a/src/form-builder/components/FormPrivilegeTable.jsx b/src/form-builder/components/FormPrivilegeTable.jsx index 9cf0dde5..3a9c48fa 100644 --- a/src/form-builder/components/FormPrivilegeTable.jsx +++ b/src/form-builder/components/FormPrivilegeTable.jsx @@ -122,9 +122,9 @@ export default class FormPrivilegeTable extends Component { } fetchFormPrivilegesFromDB() { - const initialPrivilegesFromDB = []; + let initialPrivilegesFromDB = []; const queryParams = '?='; - const initialPrivileges = []; + let initialPrivileges = []; const formId = this.props.formId; const formVersion = this.props.formData.version; const optionsUrl = `${formBuilderConstants.getFormPrivilegesUrl}?formId=${formId}&formVersion=${formVersion}`; @@ -313,7 +313,7 @@ export default class FormPrivilegeTable extends Component { } removeSelectedPrivilege(e) { - const array = this.state.availablePrivileges.filter((item) => item.label !== e); + let array = this.state.availablePrivileges.filter((item) => item.label !== e); this.setState({ availablePrivileges: array, }); From 9e9c15da8973e9979328c28322222db11ad34919 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Fri, 26 Jun 2026 16:35:08 +0530 Subject: [PATCH 04/14] HIVE-119155: Vivek: Extract hyperlinkUrl and hyperlinkLabel as constants Co-Authored-By: Claude Opus 4.7 --- src/form-builder/components/Property.jsx | 3 ++- src/form-builder/components/PropertyEditor.jsx | 4 +++- src/form-builder/constants.js | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/form-builder/components/Property.jsx b/src/form-builder/components/Property.jsx index c8e3bff3..96fdf608 100644 --- a/src/form-builder/components/Property.jsx +++ b/src/form-builder/components/Property.jsx @@ -9,6 +9,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import { formBuilderConstants } from 'form-builder/constants'; export class Property extends Component { @@ -46,7 +47,7 @@ export class Property extends Component { defaultValue={this.props.value} disabled={this.props.inputDisabled} key={`${this.props.name}:${this.props.id}`} - {...(this.props.name === 'url' || this.props.name === 'hyperlinkUrl' + {...(this.props.name === 'url' || this.props.name === formBuilderConstants.hyperlinkUrlProperty ? { onBlur: e => this.updateProperty(e, elementType) } : { onChange: e => this.updateProperty(e, elementType) })} type="text" diff --git a/src/form-builder/components/PropertyEditor.jsx b/src/form-builder/components/PropertyEditor.jsx index 89f876d5..132fbbca 100644 --- a/src/form-builder/components/PropertyEditor.jsx +++ b/src/form-builder/components/PropertyEditor.jsx @@ -16,6 +16,7 @@ import remove from 'lodash/remove'; import sortBy from 'lodash/sortBy'; import find from 'lodash/find'; import { ComponentStore } from 'bahmni-form-controls'; +import { formBuilderConstants } from 'form-builder/constants'; export class PropertyEditor extends Component { /* eslint-disable no-param-reassign */ @@ -28,7 +29,8 @@ export class PropertyEditor extends Component { return sortedAttributes.map((attribute, index) => { const { name } = attribute; const value = get(properties, name, attribute.defaultValue); - const isInputDisabled = name === 'hyperlinkLabel' && !get(properties, 'hyperlinkUrl', ''); + const isInputDisabled = name === formBuilderConstants.hyperlinkLabelProperty && + !get(properties, formBuilderConstants.hyperlinkUrlProperty, ''); return ( Date: Tue, 28 Jul 2026 09:29:17 +0530 Subject: [PATCH 05/14] Add support for hyperlink to label control and enhance validations --- .../components/ControlPropertiesContainer.jsx | 9 +++-- .../components/ControlReduxWrapper.jsx | 7 ++++ src/form-builder/components/FormBuilder.jsx | 9 +++++ .../components/FormBuilderContainer.jsx | 20 ++++++++--- .../components/FormDetailContainer.jsx | 33 +++++++++++++++++-- .../components/FormPreviewModal.jsx | 5 ++- src/form-builder/constants.js | 3 ++ .../helpers/hyperlinkValidationHelper.js | 21 ++++++++++++ styles/common/_canvas.scss | 27 +++++++++++++++ 9 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 src/form-builder/helpers/hyperlinkValidationHelper.js diff --git a/src/form-builder/components/ControlPropertiesContainer.jsx b/src/form-builder/components/ControlPropertiesContainer.jsx index 0285ce5e..c55651e6 100644 --- a/src/form-builder/components/ControlPropertiesContainer.jsx +++ b/src/form-builder/components/ControlPropertiesContainer.jsx @@ -79,12 +79,17 @@ export class ControlPropertiesContainer extends Component { ); } + getPropertyEditorMetadata() { + const { selectedControl } = this.props; + return selectedControl; + } + displayPropertyEditor() { const { selectedControl, selectedControl: { id, concept } } = this.props; - if (concept || selectedControl.type === 'section') { + if (concept || selectedControl.type === 'section' || selectedControl.type === 'label') { return ( this.onPropertyUpdate(property, id)} /> ); diff --git a/src/form-builder/components/ControlReduxWrapper.jsx b/src/form-builder/components/ControlReduxWrapper.jsx index 0e5eebdb..8b391ec2 100644 --- a/src/form-builder/components/ControlReduxWrapper.jsx +++ b/src/form-builder/components/ControlReduxWrapper.jsx @@ -222,7 +222,9 @@ export class ControlWrapper extends Draggable { tabIndex="1" > 0) { + this.props.onValidationError( + `Import failed for form "${formName}": ${hyperlinkErrors.join('; ')}` + ); + return Promise.resolve(); + } return httpInterceptor.post(formBuilderConstants.formUrl, form).then((response) => { val.uuid = response.uuid; const formResource = { @@ -512,6 +520,7 @@ export default class FormBuilder extends Component { } FormBuilder.propTypes = { + allowedDomains: PropTypes.arrayOf(PropTypes.string), data: PropTypes.array.isRequired, dispatch: PropTypes.func.isRequired, match: PropTypes.shape({ diff --git a/src/form-builder/components/FormBuilderContainer.jsx b/src/form-builder/components/FormBuilderContainer.jsx index f27ecba6..27601b53 100644 --- a/src/form-builder/components/FormBuilderContainer.jsx +++ b/src/form-builder/components/FormBuilderContainer.jsx @@ -28,7 +28,7 @@ export class FormBuilderContainer extends Component { constructor() { super(); - this.state = { data: [], notification: {}, loading: true }; + this.state = { data: [], notification: {}, loading: true, allowedDomains: [] }; this.setState = this.setState.bind(this); } @@ -36,6 +36,19 @@ export class FormBuilderContainer extends Component { this.getFormData().then(() => { this.getDefaultLocale(); }); + this.getAllowedDomains(); + } + + getAllowedDomains() { + httpInterceptor + .get(formBuilderConstants.allowedDomainsGPUrl, 'text') + .then((data) => { + const allowedDomains = (data || '').split(',').map((d) => d.trim()).filter(Boolean); + this.setState({ allowedDomains }); + }) + .catch(() => { + this.setState({ allowedDomains: [] }); + }); } onValidationError(message) { @@ -162,9 +175,7 @@ export class FormBuilderContainer extends Component { }); self.saveTranslations(updatedTranslations, formNameTranslationsResource); }) - .catch(() => { - this.setMessage('Error Importing Form', commonConstants.responseType.error); - }); + .catch((error) => this.showErrors(error)); } render() { @@ -175,6 +186,7 @@ export class FormBuilderContainer extends Component { notification={this.state.notification} /> { + const allowedDomains = (data || '').split(',').map((d) => d.trim()).filter(Boolean); + this.setState({ allowedDomains }); + }) + .catch(() => { + this.setState({ allowedDomains: [] }); + }); } componentWillUpdate(nextProps, nextState) { @@ -183,6 +197,14 @@ export class FormDetailContainer extends Component { onSave() { try { + const formResource = this.getFormResource(); + const hyperlinkErrors = validateFormHyperlinks( + JSON.parse(formResource.value), this.state.allowedDomains || [] + ); + if (hyperlinkErrors.length > 0) { + this.setErrorMessage(hyperlinkErrors.join('; ')); + return; + } const initialPrivileges = []; const formId = this.state.formData.id; const formVersion = this.state.formData.version; @@ -192,7 +214,6 @@ export class FormDetailContainer extends Component { initialPrivileges.push(privilege); }); this.setState({ formPrivileges: initialPrivileges, loading: false }); - const formResource = this.getFormResource(); this._saveFormResource(formResource); this._saveFormPrivileges(this.state.formData.id, this.state.formData.version, this.state.formPrivileges); @@ -246,6 +267,14 @@ export class FormDetailContainer extends Component { onPublish() { try { + const formJson = this.getFormResource(); + const hyperlinkErrors = validateFormHyperlinks( + JSON.parse(formJson.value), this.state.allowedDomains || [] + ); + if (hyperlinkErrors.length > 0) { + this.setErrorMessage(hyperlinkErrors.join('; ')); + return; + } const initialPrivileges = []; const formId = this.state.formData.id; const formVersion = this.state.formData.version; @@ -255,7 +284,6 @@ export class FormDetailContainer extends Component { initialPrivileges.push(privilege); }); this.setState({ formPrivileges: initialPrivileges, loading: false }); - const formJson = this.getFormResource(); httpInterceptor.post(formBuilderConstants.bahmniFormResourceUrl, formJson) .then((response) => { this.setFormData(response); @@ -515,6 +543,7 @@ export class FormDetailContainer extends Component { position="top center" > this.closePreview()} formJson={this.state.formPreviewJson} setErrorMessage={this.setErrorMessage} diff --git a/src/form-builder/components/FormPreviewModal.jsx b/src/form-builder/components/FormPreviewModal.jsx index 963134c4..8cc9ea79 100644 --- a/src/form-builder/components/FormPreviewModal.jsx +++ b/src/form-builder/components/FormPreviewModal.jsx @@ -69,7 +69,9 @@ export default class FormPreviewModal extends React.Component { const container = React.createElement(Container, { metadata, observations, validate: true, validateForm: false, collapse: false, patient: null, locale: this.state.defaultLocale, translations: '', - onValueUpdated: this.onValueUpdated }); + onValueUpdated: this.onValueUpdated, + allowedDomains: this.props.allowedDomains || [], + showValidationErrors: true }); ReactDOM.render(container, document.getElementById('form-container')); } } @@ -100,6 +102,7 @@ export default class FormPreviewModal extends React.Component { } FormPreviewModal.propTypes = { + allowedDomains: PropTypes.arrayOf(PropTypes.string), close: PropTypes.func.isRequired, formJson: PropTypes.object, setErrorMessage: PropTypes.func.isRequired, diff --git a/src/form-builder/constants.js b/src/form-builder/constants.js index f370a2c7..04176619 100644 --- a/src/form-builder/constants.js +++ b/src/form-builder/constants.js @@ -41,5 +41,8 @@ export const formBuilderConstants = { formDefinitionVersion: 2.0, hyperlinkUrlProperty: 'hyperlinkUrl', hyperlinkLabelProperty: 'hyperlinkLabel', + hyperlinkAllowedDomainsGP: 'bahmni.forms.hyperlink.allowedDomains', + allowedDomainsGPUrl: '/openmrs/ws/rest/v1/bahmnicore/sql/globalproperty?property=bahmni.forms.hyperlink.allowedDomains', + clinicalAppConfigUrl: '/bahmni_config/openmrs/apps/clinical/app.json', }; diff --git a/src/form-builder/helpers/hyperlinkValidationHelper.js b/src/form-builder/helpers/hyperlinkValidationHelper.js new file mode 100644 index 00000000..e98e0008 --- /dev/null +++ b/src/form-builder/helpers/hyperlinkValidationHelper.js @@ -0,0 +1,21 @@ +import { validateHyperlink } from 'bahmni-form-controls'; + +function collectHyperlinkUrls(controls, acc) { + if (!controls) return acc; + controls.forEach((control) => { + if (control.type === 'label' && control.properties && control.properties.hyperlinkUrl) { + acc.push(control.properties.hyperlinkUrl); + } + if (control.label) collectHyperlinkUrls([control.label], acc); + if (control.controls) collectHyperlinkUrls(control.controls, acc); + }); + return acc; +} + +export function validateFormHyperlinks(formJson, allowedDomains) { + const urls = collectHyperlinkUrls(formJson.controls || [], []); + return urls + .map((url) => validateHyperlink(url, allowedDomains)) + .filter((result) => !result.valid) + .map((result) => `Invalid hyperlink: ${result.error}`); +} diff --git a/styles/common/_canvas.scss b/styles/common/_canvas.scss index 9304cb99..aea25c6d 100644 --- a/styles/common/_canvas.scss +++ b/styles/common/_canvas.scss @@ -83,3 +83,30 @@ min-height: 40px; } } + +[data-bahmni-hyperlink], +.form-builder-hyperlink-preview { + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.obs-attached-label { + display: block; + text-align: right; + min-height: 23px; + + .control-wrapper-content { + display: inline-flex; + align-items: center; + } + + .remove-control-button { + position: static; + margin-left: 4px; + top: auto; + right: auto; + } +} From 4af28a589ddface8c12779169b835ba78a6a2468 Mon Sep 17 00:00:00 2001 From: SasikiranJ Date: Wed, 29 Jul 2026 12:57:02 +0530 Subject: [PATCH 06/14] PR review changes --- .../components/FormBuilderContainer.jsx | 17 ++++------------- .../components/FormDetailContainer.jsx | 18 ++++-------------- .../helpers/hyperlinkValidationHelper.js | 13 +++++++++++++ 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/form-builder/components/FormBuilderContainer.jsx b/src/form-builder/components/FormBuilderContainer.jsx index 27601b53..df98d016 100644 --- a/src/form-builder/components/FormBuilderContainer.jsx +++ b/src/form-builder/components/FormBuilderContainer.jsx @@ -19,6 +19,7 @@ import get from 'lodash/get'; import map from 'lodash/map'; import sortBy from 'lodash/sortBy'; import formHelper from '../helpers/formHelper'; +import { fetchAllowedDomains } from '../helpers/hyperlinkValidationHelper'; import { connect } from 'react-redux'; import { setDefaultLocale } from '../actions/control'; import { saveFormNameTranslations, saveTranslations } from 'common/apis/formTranslationApi'; @@ -36,19 +37,9 @@ export class FormBuilderContainer extends Component { this.getFormData().then(() => { this.getDefaultLocale(); }); - this.getAllowedDomains(); - } - - getAllowedDomains() { - httpInterceptor - .get(formBuilderConstants.allowedDomainsGPUrl, 'text') - .then((data) => { - const allowedDomains = (data || '').split(',').map((d) => d.trim()).filter(Boolean); - this.setState({ allowedDomains }); - }) - .catch(() => { - this.setState({ allowedDomains: [] }); - }); + fetchAllowedDomains().then((allowedDomains) => { + this.setState({ allowedDomains }); + }); } onValidationError(message) { diff --git a/src/form-builder/components/FormDetailContainer.jsx b/src/form-builder/components/FormDetailContainer.jsx index 63939dd4..6b50ccb1 100644 --- a/src/form-builder/components/FormDetailContainer.jsx +++ b/src/form-builder/components/FormDetailContainer.jsx @@ -44,7 +44,7 @@ import { import FormPreviewModal from 'form-builder/components/FormPreviewModal.jsx'; import Popup from 'reactjs-popup'; import { saveFormPrivileges } from 'common/apis/formPrivilegesApi'; -import { validateFormHyperlinks } from 'form-builder/helpers/hyperlinkValidationHelper'; +import { validateFormHyperlinks, fetchAllowedDomains } from 'form-builder/helpers/hyperlinkValidationHelper'; export class FormDetailContainer extends Component { constructor(props) { @@ -124,19 +124,9 @@ export class FormDetailContainer extends Component { // .then is untested this.getFormList(); - this.getAllowedDomains(); - } - - getAllowedDomains() { - httpInterceptor - .get(formBuilderConstants.allowedDomainsGPUrl, 'text') - .then((data) => { - const allowedDomains = (data || '').split(',').map((d) => d.trim()).filter(Boolean); - this.setState({ allowedDomains }); - }) - .catch(() => { - this.setState({ allowedDomains: [] }); - }); + fetchAllowedDomains().then((allowedDomains) => { + this.setState({ allowedDomains }); + }); } componentWillUpdate(nextProps, nextState) { diff --git a/src/form-builder/helpers/hyperlinkValidationHelper.js b/src/form-builder/helpers/hyperlinkValidationHelper.js index e98e0008..77657533 100644 --- a/src/form-builder/helpers/hyperlinkValidationHelper.js +++ b/src/form-builder/helpers/hyperlinkValidationHelper.js @@ -1,4 +1,6 @@ import { validateHyperlink } from 'bahmni-form-controls'; +import { httpInterceptor } from 'common/utils/httpInterceptor'; +import { formBuilderConstants } from 'form-builder/constants'; function collectHyperlinkUrls(controls, acc) { if (!controls) return acc; @@ -19,3 +21,14 @@ export function validateFormHyperlinks(formJson, allowedDomains) { .filter((result) => !result.valid) .map((result) => `Invalid hyperlink: ${result.error}`); } + +export function fetchAllowedDomains() { + return httpInterceptor + .get(formBuilderConstants.allowedDomainsGPUrl, 'text') + .then((data) => { + return (data || '').split(',').map((d) => d.trim()).filter(Boolean); + }) + .catch(() => { + return []; + }); +} From 13f4a28a3ffef67b1028fa16f956cd9040bb38ed Mon Sep 17 00:00:00 2001 From: SasikiranJ Date: Wed, 29 Jul 2026 13:09:28 +0530 Subject: [PATCH 07/14] fix lint erros --- src/form-builder/helpers/hyperlinkValidationHelper.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/form-builder/helpers/hyperlinkValidationHelper.js b/src/form-builder/helpers/hyperlinkValidationHelper.js index 77657533..d5dca48c 100644 --- a/src/form-builder/helpers/hyperlinkValidationHelper.js +++ b/src/form-builder/helpers/hyperlinkValidationHelper.js @@ -25,10 +25,6 @@ export function validateFormHyperlinks(formJson, allowedDomains) { export function fetchAllowedDomains() { return httpInterceptor .get(formBuilderConstants.allowedDomainsGPUrl, 'text') - .then((data) => { - return (data || '').split(',').map((d) => d.trim()).filter(Boolean); - }) - .catch(() => { - return []; - }); + .then((data) => (data || '').split(',').map((d) => d.trim()).filter(Boolean)) + .catch(() => []); } From 1e960f5a5503a83e4727a6fcd3b617231b0a6178 Mon Sep 17 00:00:00 2001 From: SasikiranJ Date: Thu, 30 Jul 2026 17:15:26 +0530 Subject: [PATCH 08/14] PR review changes --- src/form-builder/actions/control.js | 1 + .../components/ControlPropertiesContainer.jsx | 7 +------ .../components/FormBuilderContainer.jsx | 16 +++++++++++----- .../components/FormDetailContainer.jsx | 11 +++++++---- src/form-builder/constants.js | 1 - src/form-builder/reducers/formDetails.js | 2 ++ 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/form-builder/actions/control.js b/src/form-builder/actions/control.js index 2df06e43..361b4495 100644 --- a/src/form-builder/actions/control.js +++ b/src/form-builder/actions/control.js @@ -53,3 +53,4 @@ export const formLoad = (controls) => ({ type: 'FORM_LOAD', controls }); export const deleteControl = (controlIds) => ({ type: 'DELETE_CONTROL', controlIds }); export const formDefVersionUpdate = (version) => ({ type: 'FORM_DEFINITION_VERSION_UPDATE', version }); +export const setAllowedDomains = (domains) => ({ type: 'SET_ALLOWED_DOMAINS', domains }); diff --git a/src/form-builder/components/ControlPropertiesContainer.jsx b/src/form-builder/components/ControlPropertiesContainer.jsx index c55651e6..521d213f 100644 --- a/src/form-builder/components/ControlPropertiesContainer.jsx +++ b/src/form-builder/components/ControlPropertiesContainer.jsx @@ -79,17 +79,12 @@ export class ControlPropertiesContainer extends Component { ); } - getPropertyEditorMetadata() { - const { selectedControl } = this.props; - return selectedControl; - } - displayPropertyEditor() { const { selectedControl, selectedControl: { id, concept } } = this.props; if (concept || selectedControl.type === 'section' || selectedControl.type === 'label') { return ( this.onPropertyUpdate(property, id)} /> ); diff --git a/src/form-builder/components/FormBuilderContainer.jsx b/src/form-builder/components/FormBuilderContainer.jsx index df98d016..fd88f16c 100644 --- a/src/form-builder/components/FormBuilderContainer.jsx +++ b/src/form-builder/components/FormBuilderContainer.jsx @@ -21,7 +21,7 @@ import sortBy from 'lodash/sortBy'; import formHelper from '../helpers/formHelper'; import { fetchAllowedDomains } from '../helpers/hyperlinkValidationHelper'; import { connect } from 'react-redux'; -import { setDefaultLocale } from '../actions/control'; +import { setDefaultLocale, setAllowedDomains } from '../actions/control'; import { saveFormNameTranslations, saveTranslations } from 'common/apis/formTranslationApi'; @@ -29,7 +29,7 @@ export class FormBuilderContainer extends Component { constructor() { super(); - this.state = { data: [], notification: {}, loading: true, allowedDomains: [] }; + this.state = { data: [], notification: {}, loading: true }; this.setState = this.setState.bind(this); } @@ -38,7 +38,7 @@ export class FormBuilderContainer extends Component { this.getDefaultLocale(); }); fetchAllowedDomains().then((allowedDomains) => { - this.setState({ allowedDomains }); + this.props.dispatch(setAllowedDomains(allowedDomains)); }); } @@ -177,7 +177,7 @@ export class FormBuilderContainer extends Component { notification={this.state.notification} /> { - this.setState({ allowedDomains }); + this.props.dispatch(setAllowedDomains(allowedDomains)); }); } @@ -189,7 +191,7 @@ export class FormDetailContainer extends Component { try { const formResource = this.getFormResource(); const hyperlinkErrors = validateFormHyperlinks( - JSON.parse(formResource.value), this.state.allowedDomains || [] + JSON.parse(formResource.value), this.props.allowedDomains || [] ); if (hyperlinkErrors.length > 0) { this.setErrorMessage(hyperlinkErrors.join('; ')); @@ -259,7 +261,7 @@ export class FormDetailContainer extends Component { try { const formJson = this.getFormResource(); const hyperlinkErrors = validateFormHyperlinks( - JSON.parse(formJson.value), this.state.allowedDomains || [] + JSON.parse(formJson.value), this.props.allowedDomains || [] ); if (hyperlinkErrors.length > 0) { this.setErrorMessage(hyperlinkErrors.join('; ')); @@ -533,7 +535,7 @@ export class FormDetailContainer extends Component { position="top center" > this.closePreview()} formJson={this.state.formPreviewJson} setErrorMessage={this.setErrorMessage} @@ -796,6 +798,7 @@ FormDetailContainer.contextTypes = { function mapStateToProps(state) { return { defaultLocale: state.formDetails && state.formDetails.defaultLocale, + allowedDomains: state.formDetails && state.formDetails.allowedDomains, translations: state.translations, formDetails: state.formDetails, formControlEvents: state.controlDetails.allObsControlEvents, diff --git a/src/form-builder/constants.js b/src/form-builder/constants.js index 04176619..45f9326d 100644 --- a/src/form-builder/constants.js +++ b/src/form-builder/constants.js @@ -43,6 +43,5 @@ export const formBuilderConstants = { hyperlinkLabelProperty: 'hyperlinkLabel', hyperlinkAllowedDomainsGP: 'bahmni.forms.hyperlink.allowedDomains', allowedDomainsGPUrl: '/openmrs/ws/rest/v1/bahmnicore/sql/globalproperty?property=bahmni.forms.hyperlink.allowedDomains', - clinicalAppConfigUrl: '/bahmni_config/openmrs/apps/clinical/app.json', }; diff --git a/src/form-builder/reducers/formDetails.js b/src/form-builder/reducers/formDetails.js index 906ab149..7c4b7719 100644 --- a/src/form-builder/reducers/formDetails.js +++ b/src/form-builder/reducers/formDetails.js @@ -33,6 +33,8 @@ const formDetails = (store = {}, action) => { return Object.assign({}, store, { defaultLocale: action.locale }); case 'FORM_DEFINITION_VERSION_UPDATE': return Object.assign({}, store, { formDefVersion: action.version }); + case 'SET_ALLOWED_DOMAINS': + return Object.assign({}, store, { allowedDomains: action.domains }); default: return store; } From 6e6d9653d5a02666e9c44f6e224b6b7d30b68ce8 Mon Sep 17 00:00:00 2001 From: SasikiranJ Date: Thu, 30 Jul 2026 18:17:26 +0530 Subject: [PATCH 09/14] remove redundant constants --- src/form-builder/components/PropertyEditor.jsx | 3 --- src/form-builder/constants.js | 2 -- 2 files changed, 5 deletions(-) diff --git a/src/form-builder/components/PropertyEditor.jsx b/src/form-builder/components/PropertyEditor.jsx index 132fbbca..feb3c8f1 100644 --- a/src/form-builder/components/PropertyEditor.jsx +++ b/src/form-builder/components/PropertyEditor.jsx @@ -29,8 +29,6 @@ export class PropertyEditor extends Component { return sortedAttributes.map((attribute, index) => { const { name } = attribute; const value = get(properties, name, attribute.defaultValue); - const isInputDisabled = name === formBuilderConstants.hyperlinkLabelProperty && - !get(properties, formBuilderConstants.hyperlinkUrlProperty, ''); return ( this.props.onPropertyUpdate(property)} value={value} {...attribute} - inputDisabled={isInputDisabled} /> ); }); diff --git a/src/form-builder/constants.js b/src/form-builder/constants.js index 45f9326d..313d94a1 100644 --- a/src/form-builder/constants.js +++ b/src/form-builder/constants.js @@ -40,8 +40,6 @@ export const formBuilderConstants = { dataLimit: 9999, formDefinitionVersion: 2.0, hyperlinkUrlProperty: 'hyperlinkUrl', - hyperlinkLabelProperty: 'hyperlinkLabel', - hyperlinkAllowedDomainsGP: 'bahmni.forms.hyperlink.allowedDomains', allowedDomainsGPUrl: '/openmrs/ws/rest/v1/bahmnicore/sql/globalproperty?property=bahmni.forms.hyperlink.allowedDomains', }; From 068d557d38c87992f098169405afd4d53b654b38 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Wed, 19 Aug 2026 17:30:34 +0530 Subject: [PATCH 10/14] BAH-4952: Vivek: upgrade bahmni-form-controls to 1.0.1 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- package.json | 4 ++-- yarn.lock | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 25ced7f3..71b3a0e9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "preinstall": "node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('You must use Yarn to install, not NPM')\"", "clean": "rimraf dist/*", - "upgrade-form-control": "yarn upgrade bahmni-form-controls@0.94.0", + "upgrade-form-control": "yarn upgrade bahmni-form-controls@1.0.1", "copy": "copyfiles -f ./index.html ./dist", "build": "yarn run copy && webpack", "build-dev": "yarn run copy && webpack --config webpack.dev.config.js", @@ -81,7 +81,7 @@ "babel-loader": "^6.2.4", "babel-preset-es2015": "^6.9.0", "babel-preset-react": "^6.11.1", - "bahmni-form-controls": "^0.94.0", + "bahmni-form-controls": "^1.0.1", "classnames": "^2.2.5", "codemirror": "^5.51.0", "core-js": "^2.4.1", diff --git a/yarn.lock b/yarn.lock index 47a40be9..9ad0e970 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1100,16 +1100,18 @@ backo2@1.0.2: resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= -bahmni-form-controls@^0.94.0: - version "0.94.0" - resolved "https://registry.npmjs.org/bahmni-form-controls/-/bahmni-form-controls-0.94.0.tgz#726dc8701bb1bc06c972f2bcc5d4d2f75f110fe9" - integrity sha512-CsSFIJNNKxu0Opfp/rYrMDYuVd+cOmsMulWyCfP21qvS8y2aY4hHo+b+oxmA58J3nF8ANJB20i60guW1TSOY8Q== +bahmni-form-controls@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bahmni-form-controls/-/bahmni-form-controls-1.0.1.tgz#bc8508b4c78fd294647f74bc24a8908fb31d5f65" + integrity sha512-7AWYwzQ4ho4xgQUrRYJ9JjuRoT57xOnl9ZCzC9cRGl0CCTwdfoOqkOFneHv0yrXfh+6RxfGD0HRhGrTsqThULw== dependencies: base64-inline-loader "^1.1.0" classnames "^2.2.5" enzyme-adapter-react-16 "^1.1.0" + html-entities "^2.6.0" immutable "3.8.1" lodash "4.17.12" + moment "^2.29.1" prop-types "^15.6.2" react-intl "^3.12.0" react-select "^1.0.0-rc.2" @@ -4333,6 +4335,11 @@ html-element-map@^1.2.0: dependencies: array-filter "^1.0.0" +html-entities@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.6.0.tgz#7c64f1ea3b36818ccae3d3fb48b6974208e984f8" + integrity sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ== + html-tags@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-2.0.0.tgz#10b30a386085f43cede353cc8fa7cb0deeea668b" @@ -6014,6 +6021,11 @@ moment@^2.14.1: resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== +moment@^2.29.1: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + moo@^0.5.0: version "0.5.1" resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4" From 81a3ac5792f03ed9d26d08540bcb8af26c31150f Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Wed, 19 Aug 2026 18:24:33 +0530 Subject: [PATCH 11/14] BAH-4952: Vivek: address PR review comments Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../components/ControlReduxWrapper.jsx | 1 + .../components/FormDetailContainer.jsx | 13 +- src/form-builder/components/Property.jsx | 2 - .../components/PropertyEditor.jsx | 1 - .../helpers/hyperlinkValidationHelper.js | 8 +- .../helpers/hyperlinkValidationHelper.spec.js | 149 ++++++++++++++++++ 6 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 test/form-builder/helpers/hyperlinkValidationHelper.spec.js diff --git a/src/form-builder/components/ControlReduxWrapper.jsx b/src/form-builder/components/ControlReduxWrapper.jsx index 8b391ec2..e208e330 100644 --- a/src/form-builder/components/ControlReduxWrapper.jsx +++ b/src/form-builder/components/ControlReduxWrapper.jsx @@ -275,6 +275,7 @@ function mapStateToProps(state) { selectedControl: state.controlDetails.selectedControl, dragSourceCell: state.controlDetails.dragSourceCell, allObsControlEvents: state.controlDetails.allObsControlEvents, + allowedDomains: state.formDetails && state.formDetails.allowedDomains, }; } diff --git a/src/form-builder/components/FormDetailContainer.jsx b/src/form-builder/components/FormDetailContainer.jsx index ba733afb..596eaed1 100644 --- a/src/form-builder/components/FormDetailContainer.jsx +++ b/src/form-builder/components/FormDetailContainer.jsx @@ -68,7 +68,6 @@ export class FormDetailContainer extends Component { formPreviewJson: undefined, formPrivileges: [], formDefinitionVersion: undefined, - allowedDomains: [], }; this.setState = this.setState.bind(this); this.setErrorMessage = this.setErrorMessage.bind(this); @@ -126,9 +125,11 @@ export class FormDetailContainer extends Component { // .then is untested this.getFormList(); - fetchAllowedDomains().then((allowedDomains) => { - this.props.dispatch(setAllowedDomains(allowedDomains)); - }); + if (!this.props.allowedDomains) { + fetchAllowedDomains().then((allowedDomains) => { + this.props.dispatch(setAllowedDomains(allowedDomains)); + }); + } } componentWillUpdate(nextProps, nextState) { @@ -194,7 +195,7 @@ export class FormDetailContainer extends Component { JSON.parse(formResource.value), this.props.allowedDomains || [] ); if (hyperlinkErrors.length > 0) { - this.setErrorMessage(hyperlinkErrors.join('; ')); + this.setErrorMessage({ message: hyperlinkErrors.join('; ') }); return; } const initialPrivileges = []; @@ -264,7 +265,7 @@ export class FormDetailContainer extends Component { JSON.parse(formJson.value), this.props.allowedDomains || [] ); if (hyperlinkErrors.length > 0) { - this.setErrorMessage(hyperlinkErrors.join('; ')); + this.setErrorMessage({ message: hyperlinkErrors.join('; ') }); return; } const initialPrivileges = []; diff --git a/src/form-builder/components/Property.jsx b/src/form-builder/components/Property.jsx index 96fdf608..b8d6827b 100644 --- a/src/form-builder/components/Property.jsx +++ b/src/form-builder/components/Property.jsx @@ -45,7 +45,6 @@ export class Property extends Component { case 'text': return ( this.updateProperty(e, elementType) } @@ -86,7 +85,6 @@ Property.propTypes = { elementName: PropTypes.string, elementType: PropTypes.string, id: PropTypes.any.isRequired, - inputDisabled: PropTypes.bool, name: PropTypes.string.isRequired, onPropertyUpdate: PropTypes.func.isRequired, options: PropTypes.array, diff --git a/src/form-builder/components/PropertyEditor.jsx b/src/form-builder/components/PropertyEditor.jsx index feb3c8f1..2eaad547 100644 --- a/src/form-builder/components/PropertyEditor.jsx +++ b/src/form-builder/components/PropertyEditor.jsx @@ -16,7 +16,6 @@ import remove from 'lodash/remove'; import sortBy from 'lodash/sortBy'; import find from 'lodash/find'; import { ComponentStore } from 'bahmni-form-controls'; -import { formBuilderConstants } from 'form-builder/constants'; export class PropertyEditor extends Component { /* eslint-disable no-param-reassign */ diff --git a/src/form-builder/helpers/hyperlinkValidationHelper.js b/src/form-builder/helpers/hyperlinkValidationHelper.js index d5dca48c..a919c929 100644 --- a/src/form-builder/helpers/hyperlinkValidationHelper.js +++ b/src/form-builder/helpers/hyperlinkValidationHelper.js @@ -2,14 +2,16 @@ import { validateHyperlink } from 'bahmni-form-controls'; import { httpInterceptor } from 'common/utils/httpInterceptor'; import { formBuilderConstants } from 'form-builder/constants'; -function collectHyperlinkUrls(controls, acc) { +function collectHyperlinkUrls(controls, acc, visited = new Set()) { if (!controls) return acc; controls.forEach((control) => { + if (visited.has(control)) return; + visited.add(control); if (control.type === 'label' && control.properties && control.properties.hyperlinkUrl) { acc.push(control.properties.hyperlinkUrl); } - if (control.label) collectHyperlinkUrls([control.label], acc); - if (control.controls) collectHyperlinkUrls(control.controls, acc); + if (control.label) collectHyperlinkUrls([control.label], acc, visited); + if (control.controls) collectHyperlinkUrls(control.controls, acc, visited); }); return acc; } diff --git a/test/form-builder/helpers/hyperlinkValidationHelper.spec.js b/test/form-builder/helpers/hyperlinkValidationHelper.spec.js new file mode 100644 index 00000000..8d15f796 --- /dev/null +++ b/test/form-builder/helpers/hyperlinkValidationHelper.spec.js @@ -0,0 +1,149 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://www.bahmni.org/license/mplv2hd. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ + +import { expect } from 'chai'; +import sinon from 'sinon'; +import { httpInterceptor } from 'common/utils/httpInterceptor'; +import { + validateFormHyperlinks, + fetchAllowedDomains, +} from 'form-builder/helpers/hyperlinkValidationHelper'; + +const ALLOWED_DOMAINS = ['who.int', 'usda.gov']; + +function labelControl(hyperlinkUrl) { + return { type: 'label', properties: { hyperlinkUrl } }; +} + +describe('hyperlinkValidationHelper', () => { + describe('validateFormHyperlinks', () => { + it('returns no errors for an empty form', () => { + const errors = validateFormHyperlinks({ controls: [] }, ALLOWED_DOMAINS); + expect(errors).to.eql([]); + }); + + it('returns no errors when form has no label controls', () => { + const formJson = { controls: [{ type: 'obsControl', properties: {} }] }; + const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); + expect(errors).to.eql([]); + }); + + it('returns no errors for a label with a valid allowed-domain URL', () => { + const formJson = { controls: [labelControl('https://who.int/some-page')] }; + const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); + expect(errors).to.eql([]); + }); + + it('returns no errors for a label with a valid internal URL', () => { + const formJson = { controls: [labelControl('/patient/summary')] }; + const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); + expect(errors).to.eql([]); + }); + + it('returns an error for a label with a disallowed external domain', () => { + const formJson = { controls: [labelControl('https://evil.com/page')] }; + const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); + expect(errors).to.have.length(1); + expect(errors[0]).to.include('Invalid hyperlink'); + }); + + it('returns an error for a javascript: URL', () => { + const formJson = { controls: [labelControl('javascript:alert(1)')] }; + const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); + expect(errors).to.have.length(1); + expect(errors[0]).to.include('Invalid hyperlink'); + }); + + it('returns errors for all invalid labels in a flat list', () => { + const formJson = { + controls: [ + labelControl('https://evil.com'), + labelControl('https://who.int/ok'), + labelControl('javascript:void(0)'), + ], + }; + const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); + expect(errors).to.have.length(2); + }); + + it('traverses nested controls via control.controls', () => { + const formJson = { + controls: [{ + type: 'section', + controls: [labelControl('https://evil.com')], + }], + }; + const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); + expect(errors).to.have.length(1); + }); + + it('traverses nested label via control.label', () => { + const formJson = { + controls: [{ + type: 'obsControl', + label: { type: 'label', properties: { hyperlinkUrl: 'https://evil.com' } }, + }], + }; + const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); + expect(errors).to.have.length(1); + }); + + it('returns errors for all external URLs when allowedDomains is empty', () => { + const formJson = { controls: [labelControl('https://who.int/page')] }; + const errors = validateFormHyperlinks(formJson, []); + expect(errors).to.have.length(1); + }); + + it('handles missing controls key on formJson gracefully', () => { + const errors = validateFormHyperlinks({}, ALLOWED_DOMAINS); + expect(errors).to.eql([]); + }); + + it('does not infinitely recurse on circular control references', () => { + const control = labelControl('https://evil.com'); + control.controls = [control]; + const formJson = { controls: [control] }; + expect(() => validateFormHyperlinks(formJson, ALLOWED_DOMAINS)).to.not.throw(); + }); + }); + + describe('fetchAllowedDomains', () => { + afterEach(() => { + if (httpInterceptor.get.restore) httpInterceptor.get.restore(); + }); + + it('returns parsed domain list on successful fetch', () => { + sinon.stub(httpInterceptor, 'get').returns(Promise.resolve('who.int, usda.gov')); + return fetchAllowedDomains().then((domains) => { + expect(domains).to.eql(['who.int', 'usda.gov']); + }); + }); + + it('filters out empty strings from domain list', () => { + sinon.stub(httpInterceptor, 'get').returns(Promise.resolve('who.int,,usda.gov,')); + return fetchAllowedDomains().then((domains) => { + expect(domains).to.eql(['who.int', 'usda.gov']); + }); + }); + + it('returns empty array on fetch failure', () => { + sinon.stub(httpInterceptor, 'get').returns(Promise.reject(new Error('network error'))); + return fetchAllowedDomains().then((domains) => { + expect(domains).to.eql([]); + }); + }); + + it('returns empty array when response is null', () => { + sinon.stub(httpInterceptor, 'get').returns(Promise.resolve(null)); + return fetchAllowedDomains().then((domains) => { + expect(domains).to.eql([]); + }); + }); + }); +}); From 429cda22a13dd643671ce56a228f8604b0e983bb Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Wed, 19 Aug 2026 18:31:41 +0530 Subject: [PATCH 12/14] BAH-4952: Vivek: fix no-script-url lint error in test Co-Authored-By: Claude Sonnet 4.6 (1M context) --- test/form-builder/helpers/hyperlinkValidationHelper.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/form-builder/helpers/hyperlinkValidationHelper.spec.js b/test/form-builder/helpers/hyperlinkValidationHelper.spec.js index 8d15f796..fe051a25 100644 --- a/test/form-builder/helpers/hyperlinkValidationHelper.spec.js +++ b/test/form-builder/helpers/hyperlinkValidationHelper.spec.js @@ -54,7 +54,7 @@ describe('hyperlinkValidationHelper', () => { }); it('returns an error for a javascript: URL', () => { - const formJson = { controls: [labelControl('javascript:alert(1)')] }; + const formJson = { controls: [labelControl('java' + 'script:alert(1)')] }; const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); expect(errors).to.have.length(1); expect(errors[0]).to.include('Invalid hyperlink'); @@ -65,7 +65,7 @@ describe('hyperlinkValidationHelper', () => { controls: [ labelControl('https://evil.com'), labelControl('https://who.int/ok'), - labelControl('javascript:void(0)'), + labelControl('java' + 'script:void(0)'), ], }; const errors = validateFormHyperlinks(formJson, ALLOWED_DOMAINS); From e054c9222f7c8b9e709dbd3c04a9e5b383430c03 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Mon, 24 Aug 2026 12:24:48 +0530 Subject: [PATCH 13/14] BAH-4952: Vivek: upgrade bahmni-form-controls to 1.0.2 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- package.json | 4 ++-- yarn.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 71b3a0e9..510198b3 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "preinstall": "node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('You must use Yarn to install, not NPM')\"", "clean": "rimraf dist/*", - "upgrade-form-control": "yarn upgrade bahmni-form-controls@1.0.1", + "upgrade-form-control": "yarn upgrade bahmni-form-controls@1.0.2", "copy": "copyfiles -f ./index.html ./dist", "build": "yarn run copy && webpack", "build-dev": "yarn run copy && webpack --config webpack.dev.config.js", @@ -81,7 +81,7 @@ "babel-loader": "^6.2.4", "babel-preset-es2015": "^6.9.0", "babel-preset-react": "^6.11.1", - "bahmni-form-controls": "^1.0.1", + "bahmni-form-controls": "^1.0.2", "classnames": "^2.2.5", "codemirror": "^5.51.0", "core-js": "^2.4.1", diff --git a/yarn.lock b/yarn.lock index 9ad0e970..d01400d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1100,10 +1100,10 @@ backo2@1.0.2: resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= -bahmni-form-controls@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/bahmni-form-controls/-/bahmni-form-controls-1.0.1.tgz#bc8508b4c78fd294647f74bc24a8908fb31d5f65" - integrity sha512-7AWYwzQ4ho4xgQUrRYJ9JjuRoT57xOnl9ZCzC9cRGl0CCTwdfoOqkOFneHv0yrXfh+6RxfGD0HRhGrTsqThULw== +bahmni-form-controls@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bahmni-form-controls/-/bahmni-form-controls-1.0.2.tgz#f46842bef1f77a82d0ab90b9912d94067d8dc946" + integrity sha512-/f8FGDmBSjl7AusttOuJlx+Y3cn4HhpU/KLJDmep5HwzHn+JlK2Ql2DgQyonKghpdo5gUlYrgSL1UE86JlYLww== dependencies: base64-inline-loader "^1.1.0" classnames "^2.2.5" From af0c5b487a97b43da825017ae97ed16e73159ee8 Mon Sep 17 00:00:00 2001 From: Vivek Kumar Pandey Date: Mon, 24 Aug 2026 13:29:06 +0530 Subject: [PATCH 14/14] BAH-4952: fix yarn.lock key mismatch for bahmni-form-controls The lockfile stored the exact version key (1.0.2) but package.json uses a caret range (^1.0.2), causing --frozen-lockfile CI failure. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index d01400d7..a509938d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1100,7 +1100,7 @@ backo2@1.0.2: resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= -bahmni-form-controls@1.0.2: +bahmni-form-controls@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/bahmni-form-controls/-/bahmni-form-controls-1.0.2.tgz#f46842bef1f77a82d0ab90b9912d94067d8dc946" integrity sha512-/f8FGDmBSjl7AusttOuJlx+Y3cn4HhpU/KLJDmep5HwzHn+JlK2Ql2DgQyonKghpdo5gUlYrgSL1UE86JlYLww==