diff --git a/package.json b/package.json index 3f2078cc..2c464012 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "react-test-renderer": "^16.12.0", "react-textarea-autosize": "^4.0.5", "sinon-as-promised": "^4.0.3", + "moment": "^2.29.1", "whatwg-fetch": "^1.0.0" }, "resolutions": { diff --git a/src/components/SurgicalBlock.jsx b/src/components/SurgicalBlock.jsx new file mode 100644 index 00000000..6895d143 --- /dev/null +++ b/src/components/SurgicalBlock.jsx @@ -0,0 +1,116 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { Util } from 'src/helpers/Util'; +import ComponentStore from 'src/helpers/componentStore'; +import { AutoComplete } from 'src/components/AutoComplete.jsx'; +import { httpInterceptor } from 'src/helpers/httpInterceptor'; +import Constants from 'src/constants'; +import find from 'lodash/find'; + +export class SurgicalBlock extends Component { + + constructor(props) { + super(props); + this.state = { surgeryOptions: [] }; + this.onValueChange = this.onValueChange.bind(this); + } + + componentDidMount() { + const { properties } = this.props; + const defaultUrl = '/openmrs/ws/rest/v1/surgicalBlock' + + '?activeBlocks=true' + + '&startDatetime={NOW-30d}' + + '&endDatetime={NOW}' + + '&includeVoided=false' + + '&v=custom:(id,uuid,' + + 'provider:(uuid,person:(uuid,display),attributes:(attributeType:(display),value,voided)),' + + 'location:(uuid,name),startDatetime,endDatetime,' + + 'surgicalAppointments:(id,uuid,patient:(uuid,display,' + + 'person:(age,gender,birthdate)),' + + 'actualStartDatetime,actualEndDatetime,status,notes,sortWeight,' + + 'bedNumber,bedLocation,surgicalAppointmentAttributes,patientObservations))'; + + const url = Util.resolveUrlTokens(properties.URL || defaultUrl); + + httpInterceptor + .get(url) + .then((data) => { + const { patientUuid } = this.props; + const surgeryOptions = []; + (data.results || []).forEach((block) => { + (block.surgicalAppointments || []).forEach((surgicalAppointment) => { + if (!patientUuid || (surgicalAppointment.patient && + surgicalAppointment.patient.uuid === patientUuid)) { + surgeryOptions.push(this._formatSurgeryOption(block, surgicalAppointment)); + } + }); + }); + this.setState({ surgeryOptions }); + }) + .catch(() => { + this.props.showNotification('Failed to fetch surgical blocks', Constants.messageType.error); + }); + } + + onValueChange(value, errors) { + const updatedValue = value ? value.id : undefined; + this.props.onChange({ value: updatedValue, errors }); + } + + _formatSurgeryOption(block, surgicalAppointment) { + const date = this._formatDate(block.startDatetime); + const surgeon = block.provider && block.provider.person + ? block.provider.person.display : ''; + return { id: surgicalAppointment.uuid, name: `${date} - ${surgeon}` }; + } + + _formatDate(datetime) { + if (!datetime) return ''; + return moment(datetime).format('DD/MM/YYYY'); + } + + _getValue(savedValue) { + return find(this.state.surgeryOptions, (option) => option.id === savedValue); + } + + render() { + const value = this.props.value ? this._getValue(this.props.value) : undefined; + const { properties } = this.props; + const isSearchable = (properties.style === 'autocomplete'); + const minimumInput = isSearchable ? 2 : 0; + return ( + + ); + } +} + +SurgicalBlock.propTypes = { + addMore: PropTypes.bool, + enabled: PropTypes.bool, + formFieldPath: PropTypes.string, + onChange: PropTypes.func.isRequired, + patientUuid: PropTypes.string, + properties: PropTypes.object.isRequired, + showNotification: PropTypes.func.isRequired, + validate: PropTypes.bool.isRequired, + validations: PropTypes.array.isRequired, + value: PropTypes.string, +}; + +SurgicalBlock.defaultProps = { + autofocus: false, + enabled: true, + labelKey: 'name', + valueKey: 'id', + searchable: false, +}; + +ComponentStore.registerComponent('SurgicalBlockObsHandler', SurgicalBlock); diff --git a/src/components/designer/SurgicalBlock.jsx b/src/components/designer/SurgicalBlock.jsx new file mode 100644 index 00000000..3c020b76 --- /dev/null +++ b/src/components/designer/SurgicalBlock.jsx @@ -0,0 +1,118 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { Util } from 'src/helpers/Util'; +import ComponentStore from 'src/helpers/componentStore'; +import { AutoComplete } from 'src/components/AutoComplete.jsx'; +import { httpInterceptor } from 'src/helpers/httpInterceptor'; + +export class SurgicalBlockDesigner extends Component { + + constructor(props) { + super(props); + this.state = { surgeryOptions: [] }; + } + + componentDidMount() { + const { metadata: { properties }, setError } = this.props; + const defaultUrl = '/openmrs/ws/rest/v1/surgicalBlock' + + '?activeBlocks=true' + + '&startDatetime={NOW-30d}' + + '&endDatetime={NOW}' + + '&includeVoided=false' + + '&v=custom:(id,uuid,' + + 'provider:(uuid,person:(uuid,display),attributes:(attributeType:(display),value,voided)),' + + 'location:(uuid,name),startDatetime,endDatetime,' + + 'surgicalAppointments:(id,uuid,patient:(uuid,display,' + + 'person:(age,gender,birthdate)),' + + 'actualStartDatetime,actualEndDatetime,status,notes,sortWeight,' + + 'bedNumber,bedLocation,surgicalAppointmentAttributes,patientObservations))'; + + const url = Util.resolveUrlTokens(properties.URL || defaultUrl); + + httpInterceptor + .get(url) + .then((data) => { + const options = (data.results || []).map((block) => { + const date = moment(block.startDatetime).format('DD/MM/YYYY'); + const surgeon = block.provider && block.provider.person + ? block.provider.person.display : ''; + return { id: block.uuid, name: `${date} - ${surgeon}` }; + }); + this.setState({ surgeryOptions: options }); + }) + .catch(() => { + if (setError) { + setError({ message: 'Invalid Surgical Block URL' }); + } + }); + } + + render() { + const { properties } = this.props.metadata; + const isSearchable = (properties.style === 'autocomplete'); + const minimumInput = isSearchable ? 2 : 0; + return ( + + ); + } +} + +SurgicalBlockDesigner.propTypes = { + metadata: PropTypes.shape({ + concept: PropTypes.object.isRequired, + displayType: PropTypes.string, + id: PropTypes.string.isRequired, + properties: PropTypes.object.isRequired, + type: PropTypes.string, + }), + setError: PropTypes.func, +}; + +const descriptor = { + control: SurgicalBlockDesigner, + designProperties: { + isTopLevelComponent: false, + }, + metadata: { + attributes: [ + { + name: 'properties', + dataType: 'complex', + attributes: [ + { + name: 'URL', + dataType: 'string', + defaultValue: '/openmrs/ws/rest/v1/surgicalBlock?activeBlocks=true' + + '&startDatetime={NOW-30d}&endDatetime={NOW}&includeVoided=false' + + '&v=custom:(id,uuid,provider:(uuid,person:(uuid,display),' + + 'attributes:(attributeType:(display),value,voided)),location:(uuid,name),' + + 'startDatetime,endDatetime,' + + 'surgicalAppointments:(id,uuid,patient:(uuid,display,' + + 'person:(age,gender,birthdate)),' + + 'actualStartDatetime,actualEndDatetime,status,notes,sortWeight,' + + 'bedNumber,bedLocation,surgicalAppointmentAttributes,patientObservations))', + elementType: 'text', + }, + { + name: 'style', + dataType: 'string', + defaultValue: 'dropdown', + elementType: 'dropdown', + options: ['autocomplete', 'dropdown'], + }, + ], + }, + ], + }, +}; + +ComponentStore.registerDesignerComponent('SurgicalBlockObsHandler', descriptor); diff --git a/src/helpers/Util.js b/src/helpers/Util.js index 64606a08..bbdc7725 100644 --- a/src/helpers/Util.js +++ b/src/helpers/Util.js @@ -1,4 +1,6 @@ +import moment from 'moment'; + export class Util { static toInt(obj) { return Number.parseInt(obj, 10); @@ -92,6 +94,25 @@ export class Util { }); } + static resolveUrlTokens(url) { + const DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSZZ'; + const UNITS = { d: 'days' }; + return url.replace(/\{([^}]+)\}/g, (match, token) => { + if (token === 'NOW') { + return encodeURIComponent(moment().endOf('day').format(DATE_FORMAT)); + } + const relativeMatch = token.match(/^NOW-(\d+)(d)$/); + if (relativeMatch) { + const amount = parseInt(relativeMatch[1], 10); + const unit = UNITS[relativeMatch[2]]; + return encodeURIComponent( + moment().subtract(amount, unit).startOf('day').format(DATE_FORMAT) + ); + } + return match; + }); + } + static debounce(func, delay) { let timeoutId; return (...args) => { diff --git a/src/index.jsx b/src/index.jsx index 3891f1ed..22fb6cc9 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -19,6 +19,7 @@ export { Image } from 'components/Image.jsx'; export { Video } from 'components/Video.jsx'; export { Location } from 'components/Location.jsx'; export { Provider } from 'components/Provider.jsx'; +export { SurgicalBlock } from 'components/SurgicalBlock.jsx'; export { FreeTextAutoComplete } from 'components/FreeTextAutoComplete.jsx'; // -----------designer components------------------ @@ -45,6 +46,7 @@ export { ImageDesigner } from 'components/designer/Image.jsx'; export { VideoDesigner } from 'components/designer/Video.jsx'; export { LocationDesigner } from 'components/designer/Location.jsx'; export { ProviderDesigner } from 'components/designer/Provider.jsx'; +export { SurgicalBlockDesigner } from 'components/designer/SurgicalBlock.jsx'; // -------------------------- helpers --------------------- diff --git a/test/components/SurgicalBlock.spec.js b/test/components/SurgicalBlock.spec.js new file mode 100644 index 00000000..762350ad --- /dev/null +++ b/test/components/SurgicalBlock.spec.js @@ -0,0 +1,251 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import chaiEnzyme from 'chai-enzyme'; +import chai, { expect } from 'chai'; +import { SurgicalBlock } from 'components/SurgicalBlock.jsx'; +import sinon from 'sinon'; +import { httpInterceptor } from 'src/helpers/httpInterceptor'; +import Constants from 'src/constants'; + +chai.use(chaiEnzyme()); +const sinonStubPromise = require('sinon-stub-promise'); +sinonStubPromise(sinon); + +describe('SurgicalBlock', () => { + let wrapper; + let surgicalBlockStub; + let onChangeSpy; + let showNotificationSpy; + + const formFieldPath = 'test1.1/1-0'; + const properties = { style: 'autocomplete' }; + + const surgicalBlockData = { + results: [ + { + uuid: 'block-uuid-1', + startDatetime: '2026-05-15T08:00:00.000+0000', + provider: { person: { display: 'Dr. Smith' } }, + surgicalAppointments: [{ uuid: 'appt-uuid-1', patient: { uuid: 'patient-uuid-1' } }], + }, + { + uuid: 'block-uuid-2', + startDatetime: '2026-05-20T10:00:00.000+0000', + provider: { person: { display: 'Dr. Jones' } }, + surgicalAppointments: [{ uuid: 'appt-uuid-2', patient: { uuid: 'patient-uuid-2' } }], + }, + ], + }; + + const expectedOptions = [ + { id: 'appt-uuid-1', name: '15/05/2026 - Dr. Smith' }, + { id: 'appt-uuid-2', name: '20/05/2026 - Dr. Jones' }, + ]; + + beforeEach(() => { + onChangeSpy = sinon.spy(); + showNotificationSpy = sinon.spy(); + surgicalBlockStub = sinon.stub(httpInterceptor, 'get'); + }); + + afterEach(() => surgicalBlockStub.restore()); + + it('should render the SurgicalBlock autocomplete component with formatted options', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + wrapper = mount( + + ); + expect(wrapper).to.have.exactly(1).descendants('AutoComplete'); + expect(wrapper.find('AutoComplete')).to.have.prop('asynchronous').to.eql(false); + expect(wrapper.find('AutoComplete')).to.have.prop('options').to.eql(expectedOptions); + expect(wrapper.find('AutoComplete')).to.have.prop('searchable').to.eql(true); + expect(wrapper.find('AutoComplete')).to.have.prop('minimumInput').to.eql(2); + expect(wrapper.find('AutoComplete')).to.have.prop('labelKey').to.eql('name'); + expect(wrapper.find('AutoComplete')).to.have.prop('valueKey').to.eql('id'); + }); + + it('should render as dropdown when style is not autocomplete', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + wrapper = mount( + + ); + expect(wrapper.find('AutoComplete')).to.have.prop('searchable').to.eql(false); + expect(wrapper.find('AutoComplete')).to.have.prop('minimumInput').to.eql(0); + expect(wrapper.find('AutoComplete')).to.have.prop('value').to.eql(expectedOptions[0]); + }); + + it('should use properties.URL with resolved tokens when provided', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + mount( + + ); + const calledUrl = surgicalBlockStub.getCall(0).args[0]; + expect(calledUrl).to.include('/custom/surgicalBlock'); + expect(calledUrl).to.include('start='); + expect(calledUrl).to.include('end='); + expect(calledUrl).to.not.include('{NOW-60d}'); + expect(calledUrl).to.not.include('{NOW}'); + }); + + it('should use the default URL with resolved tokens when properties.URL is not set', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + mount( + + ); + const calledUrl = surgicalBlockStub.getCall(0).args[0]; + expect(calledUrl).to.include('/openmrs/ws/rest/v1/surgicalBlock'); + expect(calledUrl).to.not.include('{NOW-30d}'); + expect(calledUrl).to.not.include('{NOW}'); + }); + + it('should call the surgical block API with dynamic date parameters', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + mount( + + ); + sinon.assert.calledOnce(surgicalBlockStub); + const calledUrl = surgicalBlockStub.getCall(0).args[0]; + expect(calledUrl).to.include('/openmrs/ws/rest/v1/surgicalBlock'); + expect(calledUrl).to.include('activeBlocks=true'); + expect(calledUrl).to.include('startDatetime='); + expect(calledUrl).to.include('endDatetime='); + expect(calledUrl).to.include('includeVoided=false'); + }); + + it('should filter blocks by patient uuid when patient prop is provided', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + wrapper = mount( + + ); + expect(wrapper.find('AutoComplete')).to.have.prop('options') + .to.eql([{ id: 'appt-uuid-1', name: '15/05/2026 - Dr. Smith' }]); + }); + + it('should show all blocks when patient prop is not provided', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + wrapper = mount( + + ); + expect(wrapper.find('AutoComplete')).to.have.prop('options').to.eql(expectedOptions); + }); + + it('should handle empty results gracefully', () => { + surgicalBlockStub.returnsPromise().resolves({ results: [] }); + wrapper = mount( + + ); + expect(wrapper.find('AutoComplete')).to.have.prop('options').to.eql([]); + }); + + it('should show notification when API call fails', () => { + surgicalBlockStub.returnsPromise().rejects('error'); + mount( + + ); + sinon.assert.calledOnce( + showNotificationSpy.withArgs('Failed to fetch surgical blocks', Constants.messageType.error) + ); + }); + + it('should return the selected block uuid on value change', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + wrapper = mount( + + ); + const onValueChange = wrapper.find('AutoComplete').props().onValueChange; + onValueChange(expectedOptions[0], []); + sinon.assert.calledOnce(onChangeSpy.withArgs({ value: 'appt-uuid-1', errors: [] })); + }); + + it('should return undefined when selection is cleared', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + wrapper = mount( + + ); + const onValueChange = wrapper.find('AutoComplete').props().onValueChange; + onValueChange(null, []); + sinon.assert.calledOnce(onChangeSpy.withArgs({ value: undefined, errors: [] })); + }); +}); diff --git a/test/components/designer/SurgicalBlock.spec.js b/test/components/designer/SurgicalBlock.spec.js new file mode 100644 index 00000000..0832f2e2 --- /dev/null +++ b/test/components/designer/SurgicalBlock.spec.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import chaiEnzyme from 'chai-enzyme'; +import chai, { expect } from 'chai'; +import { SurgicalBlockDesigner } from 'components/designer/SurgicalBlock.jsx'; +import sinon from 'sinon'; +import { httpInterceptor } from 'src/helpers/httpInterceptor'; + +chai.use(chaiEnzyme()); +const sinonStubPromise = require('sinon-stub-promise'); +sinonStubPromise(sinon); + +describe('SurgicalBlockDesigner', () => { + let wrapper; + let metadata; + let surgicalBlockStub; + + const surgicalBlockData = { + results: [ + { + uuid: 'block-uuid-1', + startDatetime: '2026-05-15T08:00:00.000+0000', + provider: { person: { display: 'Dr. Smith' } }, + }, + ], + }; + + const expectedOptions = [ + { id: 'block-uuid-1', name: '15/05/2026 - Dr. Smith' }, + ]; + + beforeEach(() => { + metadata = { + concept: { + name: 'Select Surgery', + uuid: 'someUuid', + datatype: 'Complex', + handler: 'SurgicalBlockObsHandler', + }, + type: 'obsControl', + id: 'someId', + properties: { style: 'autocomplete' }, + }; + surgicalBlockStub = sinon.stub(httpInterceptor, 'get'); + }); + + afterEach(() => surgicalBlockStub.restore()); + + it('should render the surgical block designer autocomplete component', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + wrapper = mount(); + expect(wrapper).to.have.exactly(1).descendants('AutoComplete'); + expect(wrapper.find('AutoComplete')).to.have.prop('asynchronous').to.eql(false); + expect(wrapper.find('AutoComplete')).to.have.prop('options').to.eql(expectedOptions); + expect(wrapper.find('AutoComplete')).to.have.prop('searchable').to.eql(true); + expect(wrapper.find('AutoComplete')).to.have.prop('minimumInput').to.eql(2); + expect(wrapper.find('AutoComplete')).to.have.prop('labelKey').to.eql('name'); + expect(wrapper.find('AutoComplete')).to.have.prop('valueKey').to.eql('id'); + }); + + it('should render the surgical block designer dropdown component', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + metadata.properties.style = 'dropdown'; + wrapper = mount(); + expect(wrapper).to.have.exactly(1).descendants('AutoComplete'); + expect(wrapper.find('AutoComplete')).to.have.prop('options').to.eql(expectedOptions); + expect(wrapper.find('AutoComplete')).to.have.prop('searchable').to.eql(false); + expect(wrapper.find('AutoComplete')).to.have.prop('minimumInput').to.eql(0); + expect(wrapper.find('AutoComplete')).to.have.prop('labelKey').to.eql('name'); + expect(wrapper.find('AutoComplete')).to.have.prop('valueKey').to.eql('id'); + }); + + it('should use properties.URL with resolved tokens when provided', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + metadata.properties.URL = '/custom/surgicalBlock?start={NOW-40d}&end={NOW}'; + mount(); + const calledUrl = surgicalBlockStub.getCall(0).args[0]; + expect(calledUrl).to.include('/custom/surgicalBlock'); + expect(calledUrl).to.not.include('{NOW-40d}'); + expect(calledUrl).to.not.include('{NOW}'); + }); + + it('should use default URL with resolved tokens when properties.URL is not set', () => { + surgicalBlockStub.returnsPromise().resolves(surgicalBlockData); + mount(); + const calledUrl = surgicalBlockStub.getCall(0).args[0]; + expect(calledUrl).to.include('/openmrs/ws/rest/v1/surgicalBlock'); + expect(calledUrl).to.not.include('{NOW-30d}'); + expect(calledUrl).to.not.include('{NOW}'); + }); + + it('should call setError when API call fails', () => { + const setErrorSpy = sinon.spy(); + surgicalBlockStub.returnsPromise().rejects('error'); + wrapper = mount(); + sinon.assert.calledOnce(setErrorSpy.withArgs({ message: 'Invalid Surgical Block URL' })); + }); +}); diff --git a/test/helpers/Util.spec.js b/test/helpers/Util.spec.js index 8f1d2a37..63f354c4 100644 --- a/test/helpers/Util.spec.js +++ b/test/helpers/Util.spec.js @@ -3,6 +3,7 @@ import chai, { expect } from 'chai'; import { Util } from '../../src/helpers/Util'; import fetchMock from 'fetch-mock'; import sinon from 'sinon'; +import moment from 'moment'; chai.use(chaiEnzyme()); @@ -125,6 +126,71 @@ describe('Util', () => { }); }); + describe('Util.resolveUrlTokens', () => { + const DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSZZ'; + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers(new Date('2026-06-01T12:00:00.000Z').getTime()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should replace {NOW} with end of current day encoded', () => { + const url = 'http://example.com?end={NOW}'; + const result = Util.resolveUrlTokens(url); + const expected = encodeURIComponent(moment().endOf('day').format(DATE_FORMAT)); + expect(result).to.equal(`http://example.com?end=${expected}`); + }); + + it('should replace {NOW-30d} with start of day 30 days ago encoded', () => { + const url = 'http://example.com?start={NOW-30d}'; + const result = Util.resolveUrlTokens(url); + const expected = encodeURIComponent( + moment().subtract(30, 'days').startOf('day').format(DATE_FORMAT) + ); + expect(result).to.equal(`http://example.com?start=${expected}`); + }); + + it('should replace {NOW-Nd} with any number of days', () => { + const url = 'http://example.com?start={NOW-60d}'; + const result = Util.resolveUrlTokens(url); + const expected = encodeURIComponent( + moment().subtract(60, 'days').startOf('day').format(DATE_FORMAT) + ); + expect(result).to.equal(`http://example.com?start=${expected}`); + }); + + + it('should replace both tokens in the same URL', () => { + const url = 'http://example.com?start={NOW-30d}&end={NOW}'; + const result = Util.resolveUrlTokens(url); + const expectedStart = encodeURIComponent( + moment().subtract(30, 'days').startOf('day').format(DATE_FORMAT) + ); + const expectedEnd = encodeURIComponent(moment().endOf('day').format(DATE_FORMAT)); + expect(result).to.equal(`http://example.com?start=${expectedStart}&end=${expectedEnd}`); + }); + + it('should leave the URL unchanged when no tokens are present', () => { + const url = 'http://example.com?v=custom:(id,uuid)'; + expect(Util.resolveUrlTokens(url)).to.equal(url); + }); + + it('should leave unknown tokens verbatim', () => { + const url = 'http://example.com?foo={UNKNOWN}'; + expect(Util.resolveUrlTokens(url)).to.equal('http://example.com?foo={UNKNOWN}'); + }); + + it('should percent-encode the + in timezone offset', () => { + const url = 'http://example.com?start={NOW-30d}'; + const result = Util.resolveUrlTokens(url); + expect(result).to.not.include('+'); + }); + }); + describe('Util.debounce', () => { let clock;