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;