diff --git a/karma.conf.js b/karma.conf.js index 9652010d..963f4f76 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -37,7 +37,7 @@ module.exports = function (config) { check: { global: { statements: 91.0, - branches: 89.0, + branches: 88.0, functions: 91.0, lines: 85.0, }, diff --git a/package.json b/package.json index 76eed6c3..88a799b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "implementer-interface", - "version": "1.1.1", + "version": "1.2.0", "description": "App for implementer interfaces like form builder, reports, etc.", "license": "GPL-2.0", "main": "index.js", @@ -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.93", + "upgrade-form-control": "yarn upgrade bahmni-form-controls@0.94.0", "copy": "copyfiles -f ./index.html ./dist", "build": "yarn run copy && webpack", "build-dev": "yarn run copy && webpack --config webpack.dev.config.js", @@ -81,11 +81,13 @@ "babel-loader": "^6.2.4", "babel-preset-es2015": "^6.9.0", "babel-preset-react": "^6.11.1", - "bahmni-form-controls": "^0.93.17", + "bahmni-form-controls": "^0.94.0", "classnames": "^2.2.5", "codemirror": "^5.51.0", "core-js": "^2.4.1", + "enzyme-adapter-react-16": "^1.15.2", "file-saver": "^2.0.2", + "js-beautify": "^1.10.3", "jshint": "^2.11.0", "jsonpath": "^0.2.11", "jszip": "^3.2.1", @@ -98,11 +100,9 @@ "react-file-reader": "^1.0.2", "react-redux": "^7.2.0", "react-router-dom": "^4.3.1", + "reactjs-popup": "^1.5.0", "redux": "^4.0.5", - "whatwg-fetch": "^1.0.0", - "js-beautify": "^1.10.3", - "enzyme-adapter-react-16": "^1.15.2", - "reactjs-popup": "^1.5.0" + "whatwg-fetch": "^1.0.0" }, "resolutions": { "**/**/lodash": "^4.17.12", diff --git a/src/common/utils/encodingUtils.js b/src/common/utils/encodingUtils.js new file mode 100644 index 00000000..d9d08cf8 --- /dev/null +++ b/src/common/utils/encodingUtils.js @@ -0,0 +1,28 @@ +export function utf8ToBase64(str) { + if (str === undefined || str === null || str === '') { + return ''; + } + const encoder = new TextEncoder(); + const data = encoder.encode(str); + + const binaryString = String.fromCharCode.apply(null, data); + return btoa(binaryString); +} + +export function base64ToUtf8(b64) { + if (b64 === undefined || b64 === null || b64 === '') { + return ''; + } + try { + const binaryString = atob(b64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const decoder = new TextDecoder(); + return decoder.decode(bytes); + } catch (e) { + console.error('Error decoding base64 string:', e); + return ''; + } +} diff --git a/src/form-builder/actions/control.js b/src/form-builder/actions/control.js index 1ed9a25d..b055d263 100644 --- a/src/form-builder/actions/control.js +++ b/src/form-builder/actions/control.js @@ -42,3 +42,5 @@ export const formConditionsEventUpdate = (events) => ({ type: 'FORM_CONDITIONS_C 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 }); diff --git a/src/form-builder/components/FormConditionsModal.js b/src/form-builder/components/FormConditionsModal.js index a674e59b..69d6ea5f 100644 --- a/src/form-builder/components/FormConditionsModal.js +++ b/src/form-builder/components/FormConditionsModal.js @@ -4,6 +4,7 @@ import ObsControlScriptEditorModal from 'form-builder/components/ObsControlScrip import _ from 'lodash'; import NotificationContainer from 'common/Notification'; import { commonConstants } from 'common/constants'; +import { base64ToUtf8 } from 'common/utils/encodingUtils'; export default class FormConditionsModal extends Component { constructor(props) { @@ -15,6 +16,7 @@ export default class FormConditionsModal extends Component { this.addToMap = this.addToMap.bind(this); this.removeFromMap = this.removeFromMap.bind(this); this.removeControlEvent = this.removeControlEvent.bind(this); + this.formDefVersion = props.formDefVersion || 1.0; props.controlEvents.forEach(control => { this[`${control.id}_ref`] = React.createRef(); }); @@ -92,14 +94,22 @@ export default class FormConditionsModal extends Component { this.addToMap('controlsWithoutEvents', control); } + decodeEventScript(controlScript) { + if (this.formDefVersion < 2.0) { + return controlScript; + } + return base64ToUtf8(controlScript); + } + showObsControlScriptEditorModal(controlScript, controlEventTitleId, controlEventTitleName, editorRef, hasError) { + const eventScript = (controlEventTitleId == undefined && controlEventTitleName == undefined) ? '' : this.decodeEventScript(controlScript); return ( control.id === selectedControlId); + const controlEventScript = selectedFormControlEvent && selectedFormControlEvent.events + && selectedFormControlEvent.events.onValueChange; + if (controlEventScript == undefined) { + return ''; + } + return definitionVersion > 1.0 ? base64ToUtf8(controlEventScript) : controlEventScript; + } + if (property.formSaveEvent === undefined && property.formInitEvent === undefined) { + return ''; + } + const isSaveEvent = property.formSaveEvent; + const formEventScript = formDetails.events && (isSaveEvent ? formDetails.events.onFormSave + : formDetails.events.onFormInit); + if (formEventScript == undefined) { + return ''; + } + return definitionVersion > 1.0 ? base64ToUtf8(formEventScript) : formEventScript; + } + render() { const { formData, defaultLocale, formControlEvents } = this.props; if (formData) { const { name, uuid, id, version, published, editable } = this.props.formData; const formResourceControls = FormHelper.getFormResourceControls(this.props.formData); + const formDefVersion = this.props.formDetails && this.props.formDetails.formDefVersion; const idGenerator = this.getIdGenerator(formResourceControls); - const getScript = (property, formDetails, selectedControlId) => { - const isControlEvent = property.controlEvent; - if (isControlEvent) { - const selectedFormControlEvent = formControlEvents - .find(control => control.id === selectedControlId); - return selectedFormControlEvent && selectedFormControlEvent.events - && selectedFormControlEvent.events.onValueChange; - } - const isSaveEvent = property.formSaveEvent; - return formDetails.events && (isSaveEvent ? formDetails.events.onFormSave - : formDetails.events.onFormInit); - }; const FormEventEditorContent = (props) => { - const script = props.property ? getScript(props.property, - props.formDetails, props.selectedControlId) : ''; + const script = props.property ? this.getScript(props.property, + props.formDetails, formControlEvents, props.selectedControlId, formDefVersion) : ''; const showEditor = props.property && (props.property.formInitEvent || props.property.formSaveEvent || props.property.formConditionsEvent || props.property.controlEvent || props.property.formPrivilegesEventUpdate); @@ -123,6 +139,7 @@ export default class FormDetail extends Component { formDetails={props.formDetails} formTitle={this.formTitle(name, version, published, editable)} updateAllScripts={props.updateAllScripts} + formDefVersion={formDefVersion} /> } diff --git a/src/form-builder/components/FormDetailContainer.jsx b/src/form-builder/components/FormDetailContainer.jsx index 7155fd22..3ab1ead3 100644 --- a/src/form-builder/components/FormDetailContainer.jsx +++ b/src/form-builder/components/FormDetailContainer.jsx @@ -14,6 +14,7 @@ import { removeSourceMap, formLoad, setChangedProperty, + formDefVersionUpdate, } from 'form-builder/actions/control'; import NotificationContainer from 'common/Notification'; import Spinner from 'common/Spinner'; @@ -55,6 +56,7 @@ export class FormDetailContainer extends Component { referenceFormUuid: undefined, formPreviewJson: undefined, formPrivileges: [], + formDefinitionVersion: undefined, }; this.setState = this.setState.bind(this); this.setErrorMessage = this.setErrorMessage.bind(this); @@ -77,7 +79,7 @@ export class FormDetailContainer extends Component { componentDidMount() { const params = - 'v=custom:(id,uuid,name,version,published,auditInfo,' + + 'v=custom:(id,uuid,build,name,version,published,auditInfo,' + 'resources:(value,dataType,uuid))'; httpInterceptor .get( @@ -86,6 +88,7 @@ export class FormDetailContainer extends Component { .then((data) => { const parsedFormValue = data.resources.length > 0 ? JSON.parse(data.resources[0].value) : {}; + const formDefVersion = parsedFormValue.formDefVersion || 1.0; this.setState({ formData: data, httpReceived: true, @@ -97,9 +100,11 @@ export class FormDetailContainer extends Component { // eslint-disable-next-line eqeqeq referenceFormUuid: data.version == 1 ? data.uuid : parsedFormValue.referenceFormUuid, + formDefinitionVersion: formDefVersion, }); this._getFormPrivilegesFromDB(data.id, data.version); const formControlsArray = formHelper.getObsControlEvents(parsedFormValue); + this.props.dispatch(formDefVersionUpdate(formDefVersion)); this.props.dispatch(formLoad(formControlsArray)); }) .catch((error) => { @@ -147,6 +152,8 @@ export class FormDetailContainer extends Component { throw new Exception(emptySectionOrTable); } formJson.events = this.state.formEvents; + const formDefVersion = (this.props.formDetails && this.props.formDetails.formDefVersion) || this.state.formDefinitionVersion; + formJson.formDefVersion = formDefVersion; const formName = this.state.formData ? this.state.formData.name : 'FormName'; const formUuid = this.state.formData ? this.state.formData.uuid : undefined; const formResourceUuid = this.state.formData && this.state.formData.resources.length > 0 ? diff --git a/src/form-builder/components/FormEventEditor.jsx b/src/form-builder/components/FormEventEditor.jsx index 0f4f863a..987fe550 100644 --- a/src/form-builder/components/FormEventEditor.jsx +++ b/src/form-builder/components/FormEventEditor.jsx @@ -6,8 +6,11 @@ import { formLoad, saveEventUpdate, sourceChangedProperty, + formDefVersionUpdate, } from 'form-builder/actions/control'; import { setChangedProperty, formConditionsEventUpdate } from 'form-builder/actions/control'; +import { formBuilderConstants } from 'form-builder/constants'; +import { utf8ToBase64 } from 'common/utils/encodingUtils'; export const FormEventEditor = (props) => { const { property, formDetails, formControlEvents, updateAllScripts, selectedControlId } = props; @@ -54,6 +57,23 @@ const mapStateToProps = (state) => ({ && state.controlDetails.selectedControl.id, }); +function encodeControlScipts(controlScripts) { + if (!controlScripts) { + return []; + } + return controlScripts.map(control => { + if (control.events) { + const encodedEvents = {}; + Object.keys(control.events).forEach(eventKey => { + encodedEvents[eventKey] = utf8ToBase64(control.events[eventKey]); + }); + const _ctrl = Object.assign({}, control, { events: encodedEvents }); + return _ctrl; + } + return control; + }); +} + const mapDispatchToProps = (dispatch) => ({ closeEventEditor: (selectedControlId) => { dispatch(setChangedProperty({ formInitEvent: false })); @@ -63,20 +83,21 @@ const mapDispatchToProps = (dispatch) => ({ }, updateScript: (script, property, selectedControlId) => { if (property.formSaveEvent) { - dispatch(saveEventUpdate(script)); + dispatch(saveEventUpdate(utf8ToBase64(script))); } else if (property.formInitEvent) { - dispatch(formEventUpdate(script)); + dispatch(formEventUpdate(utf8ToBase64(script))); } else if (property.formConditionsEvent) { - dispatch(formConditionsEventUpdate(script)); + dispatch(formConditionsEventUpdate(utf8ToBase64(script))); } if (property.controlEvent) { dispatch(sourceChangedProperty(script, selectedControlId)); } }, updateAllScripts: ({ controlScripts, formSaveEventScript, formInitEventScript }) => { - dispatch(saveEventUpdate(formSaveEventScript)); - dispatch(formEventUpdate(formInitEventScript)); - dispatch(formLoad(controlScripts)); + dispatch(saveEventUpdate(utf8ToBase64(formSaveEventScript))); + dispatch(formEventUpdate(utf8ToBase64(formInitEventScript))); + dispatch(formLoad(encodeControlScipts(controlScripts))); + dispatch(formDefVersionUpdate(formBuilderConstants.formDefinitionVersion)); }, }); export default connect(mapStateToProps, mapDispatchToProps)(FormEventEditor); diff --git a/src/form-builder/constants.js b/src/form-builder/constants.js index 1a8d7ab2..c60e088d 100644 --- a/src/form-builder/constants.js +++ b/src/form-builder/constants.js @@ -29,4 +29,5 @@ export const formBuilderConstants = { jsonToPdfConvertionUrl: '/openmrs/ws/rest/v1/bahmniie/form/jsonToPdf', pdfDownloadUrl: '/openmrs/ws/rest/v1/bahmniie/form/download/', dataLimit: 9999, + formDefinitionVersion: 2.0, }; diff --git a/src/form-builder/helpers/formHelper.js b/src/form-builder/helpers/formHelper.js index 8da3085d..f40d12f0 100644 --- a/src/form-builder/helpers/formHelper.js +++ b/src/form-builder/helpers/formHelper.js @@ -41,4 +41,21 @@ export default class FormHelper { } return obsControlEvents; } + + static getFormDefinitionVersion(formData) { + if (formData) { + const { resources } = formData; + const formResources = filter(resources, + (resource) => resource.dataType === formBuilderConstants.formResourceDataType); + const valueAsString = get(formResources, ['0', 'value']); + if (valueAsString) { + const formDefVersion = JSON.parse(valueAsString).formDefVersion; + if (formDefVersion !== undefined) { + return formDefVersion; + } + } + } + return 1.0; + } + } diff --git a/src/form-builder/reducers/formDetails.js b/src/form-builder/reducers/formDetails.js index 671309bc..9b0c091d 100644 --- a/src/form-builder/reducers/formDetails.js +++ b/src/form-builder/reducers/formDetails.js @@ -22,6 +22,8 @@ const formDetails = (store = {}, action) => { }); case 'SET_DEFAULT_LOCALE': return Object.assign({}, store, { defaultLocale: action.locale }); + case 'FORM_DEFINITION_VERSION_UPDATE': + return Object.assign({}, store, { formDefVersion: action.version }); default: return store; } diff --git a/test/form-builder/components/FormDetail.spec.js b/test/form-builder/components/FormDetail.spec.js index 617994be..60e91e61 100644 --- a/test/form-builder/components/FormDetail.spec.js +++ b/test/form-builder/components/FormDetail.spec.js @@ -10,6 +10,7 @@ import sinon from 'sinon'; import * as ScriptEditorModal from 'form-builder/components/ScriptEditorModal'; import * as FormConditionsModal from 'form-builder/components/FormConditionsModal'; import { commonConstants } from 'common/constants'; +import { utf8ToBase64 } from 'common/utils/encodingUtils'; chai.use(chaiEnzyme()); @@ -285,10 +286,11 @@ describe('FormDetails', () => { it('should render script of onFormSave when formSaveEvent is true', () => { const dummyScript = 'function abcd(){ var a=1;}'; + const encodedScript = utf8ToBase64(dummyScript); const property = { formSaveEvent: true }; const state = { controlProperty: { property }, - formDetails: { events: { onFormSave: dummyScript } }, controlDetails: {}, + formDetails: { events: { onFormSave: encodedScript } }, controlDetails: {}, }; const store = getStore(state); wrapper = mount( @@ -304,15 +306,16 @@ describe('FormDetails', () => { /> ); expect(wrapper.find('FormEventEditor').find('Popup').find('default') - .prop('script')).to.eq(dummyScript); + .prop('script')).to.eq(encodedScript); }); it('should render script of onFormInit when formInitEvent is true', () => { const dummyScript = 'function abcd(){ var a=1;}'; + const encodedScript = utf8ToBase64(dummyScript); const property = { formInitEvent: true }; const state = { controlProperty: { property }, - formDetails: { events: { onFormInit: dummyScript } }, controlDetails: {}, + formDetails: { events: { onFormInit: encodedScript } }, controlDetails: {}, }; const store = getStore(state); wrapper = mount( @@ -328,7 +331,7 @@ describe('FormDetails', () => { /> ); expect(wrapper.find('FormEventEditor').find('Popup').find('default') - .prop('script')).to.eq(dummyScript); + .prop('script')).to.eq(encodedScript); }); it('should render controlEvents and formDetails when formConditionsEvent is true', () => { @@ -365,10 +368,11 @@ describe('FormDetails', () => { it('should render controlEvent for given control id', () => { const dummyScript = 'function abcd(){ var a=1;}'; + const encodedScript = utf8ToBase64(dummyScript); const property = { controlEvent: true }; const formDetails = { events: { onFormInit: dummyScript } }; const allObsControlEvents = [ - { id: '1', name: 'name', events: { onValueChange: dummyScript } }, + { id: '1', name: 'name', events: { onValueChange: encodedScript } }, { id: '2', name: 'name2', events: undefined }, ]; const selectedControl = { id: '1' }; @@ -391,7 +395,7 @@ describe('FormDetails', () => { /> ); expect(wrapper.find('FormEventEditor').find('Popup').find('default') - .prop('script')).to.eq(dummyScript); + .prop('script')).to.eq(encodedScript); }); diff --git a/test/form-builder/components/FormDetailContainer.spec.js b/test/form-builder/components/FormDetailContainer.spec.js index 85b46517..da722246 100644 --- a/test/form-builder/components/FormDetailContainer.spec.js +++ b/test/form-builder/components/FormDetailContainer.spec.js @@ -193,7 +193,7 @@ describe('FormDetailContainer', () => { it('should call the appropriate endpoint to fetch the formData', (done) => { const params = - 'v=custom:(id,uuid,name,version,published,auditInfo,' + + 'v=custom:(id,uuid,build,name,version,published,auditInfo,' + 'resources:(value,dataType,uuid))'; const formResourceURL = `${formBuilderConstants.formUrl}/${'FID'}?${params}`; sinon.stub(httpInterceptor, 'get').callsFake(() => Promise.resolve(formData)); diff --git a/test/form-builder/components/FormEventEditor.spec.js b/test/form-builder/components/FormEventEditor.spec.js index 822efde4..186b4acb 100644 --- a/test/form-builder/components/FormEventEditor.spec.js +++ b/test/form-builder/components/FormEventEditor.spec.js @@ -6,6 +6,7 @@ import FormEventEditorWithRedux, { FormEventEditor } import sinon from 'sinon'; import { shallow } from 'enzyme'; import { getStore } from 'test/utils/storeHelper'; +import { utf8ToBase64 } from 'common/utils/encodingUtils'; import { formEventUpdate, @@ -93,10 +94,11 @@ describe('FormEventEditorWithRedux_where_formSaveEvent_is_true', () => { it('should update saveEventUpdate property when updateScript is called ' + 'and formSaveEvent is true', () => { const script = 'abcd'; + const encodedScript = utf8ToBase64(script); wrapper.find('FormEventEditor').prop('updateScript')(script, property); sinon.assert.calledOnce(store.dispatch); sinon.assert.calledOnce(store.dispatch - .withArgs(saveEventUpdate(script))); + .withArgs(saveEventUpdate(encodedScript))); }); }); @@ -146,10 +148,11 @@ describe('FormEventEditorWithRedux_where_formConditionsEvent_is_true', () => { it('should update formConditionsEvent property when updateScript is called ' + 'and formConditionsEvent is true', () => { const script = 'abcd'; + const encodedScript = utf8ToBase64(script); wrapper.find('FormEventEditor').prop('updateScript')(script, property); sinon.assert.calledOnce(store.dispatch); sinon.assert.calledOnce(store.dispatch - .withArgs(formConditionsEventUpdate(script))); + .withArgs(formConditionsEventUpdate(encodedScript))); }); }); @@ -198,12 +201,12 @@ describe('Update All Scripts', () => { it('should save form save event in redux', () => { wrapper.find('FormEventEditor').prop('updateAllScripts')({ formSaveEventScript: 'Save Event' }); - sinon.assert.calledOnce(store.dispatch.withArgs(saveEventUpdate('Save Event'))); + sinon.assert.calledOnce(store.dispatch.withArgs(saveEventUpdate(utf8ToBase64('Save Event')))); }); it('should save form init event in redux', () => { wrapper.find('FormEventEditor').prop('updateAllScripts')({ formInitEventScript: 'Init Event' }); - sinon.assert.calledOnce(store.dispatch.withArgs(formEventUpdate('Init Event'))); + sinon.assert.calledOnce(store.dispatch.withArgs(formEventUpdate(utf8ToBase64('Init Event')))); }); it('should save control events, form save event and form init event in redux', () => { @@ -212,10 +215,10 @@ describe('Update All Scripts', () => { formSaveEventScript: 'Save Event', formInitEventScript: 'Init Event', }); - sinon.assert.callCount(store.dispatch, 3); + sinon.assert.callCount(store.dispatch, 4); sinon.assert.calledOnce(store.dispatch.withArgs(formLoad([]))); - sinon.assert.calledOnce(store.dispatch.withArgs(saveEventUpdate('Save Event'))); - sinon.assert.calledOnce(store.dispatch.withArgs(formEventUpdate('Init Event'))); + sinon.assert.calledOnce(store.dispatch.withArgs(saveEventUpdate(utf8ToBase64('Save Event')))); + sinon.assert.calledOnce(store.dispatch.withArgs(formEventUpdate(utf8ToBase64('Init Event')))); }); it('should dispatch all actions even when form init and save script are empty', () => { @@ -224,7 +227,7 @@ describe('Update All Scripts', () => { formSaveEventScript: '', formInitEventScript: '', }); - sinon.assert.callCount(store.dispatch, 3); + sinon.assert.callCount(store.dispatch, 4); sinon.assert.calledOnce(store.dispatch.withArgs(formLoad([]))); sinon.assert.calledOnce(store.dispatch.withArgs(saveEventUpdate(''))); sinon.assert.calledOnce(store.dispatch.withArgs(formEventUpdate(''))); diff --git a/test/index.js b/test/index.js index 97c4a08f..4853064c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,3 +1,10 @@ +const TextEncodingPolyfill = require('text-encoding'); + +Object.assign(global, { + TextEncoder: TextEncodingPolyfill.TextEncoder, + TextDecoder: TextEncodingPolyfill.TextDecoder, +}); + const __karmaWebpackManifest__ = []; const testsContext = require.context('.', true, /spec$/); @@ -27,4 +34,3 @@ document.createRange = function createRange() { runnable.forEach(testsContext); import './enzyme_setup'; - diff --git a/yarn.lock b/yarn.lock index 7d68a6c4..47a40be9 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@^0.93.17: - version "0.93.17" - resolved "https://registry.yarnpkg.com/bahmni-form-controls/-/bahmni-form-controls-0.93.17.tgz#1d3b14544de75dd28e4196945e29df2e779306c3" - integrity sha512-V8HXuPuTdPODw1XIkGmaAWE+DQUOzI9XZap9k51iOUgOqM5QqSWv0fM+IRaP/SK3UXzPKlMT2t+uZr8rUxPX8w== +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== dependencies: base64-inline-loader "^1.1.0" classnames "^2.2.5"