diff --git a/angular/projects/admin/src/app/interfaces/edit-interface.component.html b/angular/projects/admin/src/app/interfaces/edit-interface.component.html index d94c886a..f54ca7c6 100644 --- a/angular/projects/admin/src/app/interfaces/edit-interface.component.html +++ b/angular/projects/admin/src/app/interfaces/edit-interface.component.html @@ -36,6 +36,9 @@

{{ record.dataSet }}

+ Export
diff --git a/angular/projects/admin/src/app/interfaces/elements/interface-element-form.component.html b/angular/projects/admin/src/app/interfaces/elements/interface-element-form.component.html index f7e446d8..fdb339ff 100644 --- a/angular/projects/admin/src/app/interfaces/elements/interface-element-form.component.html +++ b/angular/projects/admin/src/app/interfaces/elements/interface-element-form.component.html @@ -30,3 +30,6 @@
+
+ +
diff --git a/angular/projects/admin/src/app/interfaces/screens/edit-interface-screen.component.html b/angular/projects/admin/src/app/interfaces/screens/edit-interface-screen.component.html index 4672682e..d58db360 100644 --- a/angular/projects/admin/src/app/interfaces/screens/edit-interface-screen.component.html +++ b/angular/projects/admin/src/app/interfaces/screens/edit-interface-screen.component.html @@ -22,6 +22,10 @@

Edit Screen

{{ record.name }}

+
+ +

{{ record.position }}

+
diff --git a/angular/projects/admin/src/app/interfaces/screens/interface-screen-form.component.html b/angular/projects/admin/src/app/interfaces/screens/interface-screen-form.component.html index 593e4146..014f1d0d 100644 --- a/angular/projects/admin/src/app/interfaces/screens/interface-screen-form.component.html +++ b/angular/projects/admin/src/app/interfaces/screens/interface-screen-form.component.html @@ -1,3 +1,6 @@
+
+ +
diff --git a/angular/projects/admin/src/app/nemsis/list-nemsis.component.html b/angular/projects/admin/src/app/nemsis/list-nemsis.component.html index 67517104..dc03de07 100644 --- a/angular/projects/admin/src/app/nemsis/list-nemsis.component.html +++ b/angular/projects/admin/src/app/nemsis/list-nemsis.component.html @@ -14,7 +14,10 @@

National Repository

- {{ version }} + {{ + version + }} + {{ version }} diff --git a/lib/nemsis/emsXsdParser.js b/lib/nemsis/emsXsdParser.js index ca229f5e..9e5f020d 100644 --- a/lib/nemsis/emsXsdParser.js +++ b/lib/nemsis/emsXsdParser.js @@ -3,85 +3,121 @@ const path = require('path'); const { XmlParser } = require('./xmlParser'); class NemsisEmsXsdParser extends XmlParser { - onStartElement(name, attr) { - if (name === 'xs:include') { + getCurrentXsdPath() { + return `/${this.stack + .map((s) => { + let component = s.element; + if (s.attr.id) { + component += `[@id='${s.attr.id}']`; + } else if (s.attr.name) { + component += `[@name='${s.attr.name}']`; + } + return component; + }) + .join('/')}`; + } + + getCurrentXmlPath() { + return ( + this.xmlXPathPrefix + + this.nodes.map((n) => `${n.name}${n.maxOccurs === null || (Number.isInteger(n.maxOccurs) && n.maxOccurs > 1) ? `[]` : ''}`).join('/') + ); + } + + onStartElement(element, attr) { + this.stack.push({ xsd: this.xsd, element, attr }); + if (element === 'xs:include') { this.includes.push(attr.schemaLocation); - } - if (this.stack.length === 0) { + } else if (this.nodes.length === 0) { // watch for start element - if (attr.name.startsWith(this.startElement.name)) { - this.stack.push(this.startElement); + if (attr.name?.startsWith(this.startingNode.name)) { + this.nodes.push(this.startingNode); + this.stack[this.stack.length - 1].node = this.startingNode; + this.startingNode.xsdPath = this.getCurrentXsdPath(); + this.startingNode.xmlPath = this.getCurrentXmlPath(); } - } else if (name === 'xs:element') { + } else if (element === 'xs:element') { const minOccurs = parseInt(attr.minOccurs ?? '1', 10); const maxOccurs = parseInt(attr.maxOccurs ?? '1', 10); const node = { - name: attr.name, xsd: this.xsd, + name: attr.name, minOccurs, maxOccurs: Number.isNaN(maxOccurs) ? null : maxOccurs, + xsdPath: '', + xmlPath: '', children: [], }; if (attr.nillable) { node.isNillable = attr.nillable === 'true'; } - for (let i = this.stack.length - 1; i >= 0; i -= 1) { - if (this.stack[i].name) { - this.stack[i].children.push(node); + for (let i = this.nodes.length - 1; i >= 0; i -= 1) { + if (this.nodes[i].name) { + this.nodes[i].children?.push(node); break; } } - this.stack.push(node); - } else { - this.stack.push({ attr }); + this.nodes.push(node); + this.stack[this.stack.length - 1].node = node; + node.xsdPath = this.getCurrentXsdPath(); + node.xmlPath = this.getCurrentXmlPath(); } } - onEndElement(name) { - if (this.stack.length > 0) { - const { _text } = this.stack[this.stack.length - 1]; - let element; - for (let i = this.stack.length - 2; i >= 0; i -= 1) { - if (this.stack[i].name) { - element = this.stack[i]; - break; - } - } - if ((name === 'definition' || name === 'xs:documentation') && element && _text) { - element.definition = _text; - } else if (name === 'name' && element && _text) { - element.displayName = _text; - } else if (name === 'national' && element) { - element.isNational = _text === 'Yes'; - } else if (name === 'state' && element) { - element.isState = _text === 'Yes'; - } else if (name === 'usage' && element) { - element.usage = _text; - } - delete this.stack[this.stack.length - 1]._text; - this.stack.pop(); - if (this.stack.length === 0) { - this.resolve(); + onEndElement(element) { + const { _text } = this.stack[this.stack.length - 1]; + let node; + for (let i = this.stack.length - 2; i >= 0; i -= 1) { + if (this.stack[i].node) { + ({ node } = this.stack[i]); + break; } } + if ((element === 'definition' || element === 'xs:documentation') && node && _text) { + node.definition = _text; + } else if (element === 'name' && node && _text) { + node.displayName = _text; + } else if (element === 'national' && node) { + node.isNational = _text === 'Yes'; + } else if (element === 'state' && node) { + node.isState = _text === 'Yes'; + } else if (element === 'usage' && node) { + node.usage = _text; + } + if (this.stack[this.stack.length - 1].node) { + this.nodes.pop(); + } + this.stack.pop(); + if (this.stack.length === 0) { + this.resolve(); + } } async parsePatientCareReport() { this.includes = []; + this.nodes = []; this.xsd = path.basename(this.filePath); - this.result = { xsd: this.xsd, name: 'PatientCareReport', children: [] }; - this.startElement = this.result; + this.result = { + xsd: this.xsd, + name: 'PatientCareReport', + xsdPath: '', + xmlPath: '', + children: [], + }; + this.startingNode = this.result; + this.xmlXPathPrefix = '/'; await this.parse((_, parser) => { parser.on('startElement', (name, attr) => this.onStartElement(name, attr)); parser.on('endElement', (name) => this.onEndElement(name)); }); + this.xmlXPathPrefix = '/PatientCareReport/'; for (const child of this.result.children) { const xsd = this.includes.find((i) => i.startsWith(child.name)); if (xsd) { this.filePath = path.resolve(path.dirname(this.filePath), xsd); this.xsd = xsd; - this.startElement = child; - this.startElement.xsd = xsd; + this.startingNode = child; + this.startingNode.xsd = xsd; // eslint-disable-next-line no-await-in-loop await this.parse((_, parser) => { parser.on('startElement', (name, attr) => this.onStartElement(name, attr)); diff --git a/migrations/20250715014215-fix-interface-screens.js b/migrations/20250715014215-fix-interface-screens.js new file mode 100644 index 00000000..ff669fe3 --- /dev/null +++ b/migrations/20250715014215-fix-interface-screens.js @@ -0,0 +1,45 @@ +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.dropTable('interface_screens'); + await queryInterface.addColumn('screens', 'position', { + type: Sequelize.INTEGER, + }); + await queryInterface.sequelize.query('ALTER TABLE "screens" ALTER COLUMN "interface_id" SET NOT NULL;'); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query('ALTER TABLE "screens" ALTER COLUMN "interface_id" DROP NOT NULL;'); + await queryInterface.removeColumn('screens', 'position'); + await queryInterface.createTable('interface_screens', { + id: { + allowNull: false, + defaultValue: Sequelize.literal('gen_random_uuid()'), + primaryKey: true, + type: Sequelize.UUID, + }, + interface_id: { + type: Sequelize.UUID, + references: { + model: { + tableName: 'interfaces', + }, + key: 'id', + }, + }, + screen_id: { + type: Sequelize.UUID, + references: { + model: { + tableName: 'screens', + }, + key: 'id', + }, + }, + position: { + type: Sequelize.UUID, + }, + }); + await queryInterface.addIndex('interface_screens', ['interface_id', 'screen_id'], { unique: true }); + }, +}; diff --git a/migrations/20260329001859-add-name-to-section-elements.js b/migrations/20260329001859-add-name-to-section-elements.js new file mode 100644 index 00000000..3af2925e --- /dev/null +++ b/migrations/20260329001859-add-name-to-section-elements.js @@ -0,0 +1,12 @@ +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('section_elements', 'name', { + type: Sequelize.STRING, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('section_elements', 'name'); + }, +}; diff --git a/migrations/20260411172934-add-xsd-and-xml-paths-to-nemsis-elements.js b/migrations/20260411172934-add-xsd-and-xml-paths-to-nemsis-elements.js new file mode 100644 index 00000000..ea238cab --- /dev/null +++ b/migrations/20260411172934-add-xsd-and-xml-paths-to-nemsis-elements.js @@ -0,0 +1,17 @@ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('nemsis_elements', 'xsd_path', { + type: Sequelize.TEXT, + allowNull: true, + }); + await queryInterface.addColumn('nemsis_elements', 'xml_path', { + type: Sequelize.TEXT, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('nemsis_elements', 'xsd_path'); + await queryInterface.removeColumn('nemsis_elements', 'xml_path'); + }, +}; diff --git a/models/interface.js b/models/interface.js index 2feeb89f..5dc312ae 100644 --- a/models/interface.js +++ b/models/interface.js @@ -5,8 +5,7 @@ module.exports = (sequelize, DataTypes) => { static associate(models) { Interface.belongsTo(models.User, { as: 'createdBy' }); Interface.belongsTo(models.User, { as: 'updatedBy' }); - Interface.hasMany(models.InterfaceScreen, { as: 'interfaceScreens' }); - Interface.hasMany(models.Screen, { as: 'screens' }); + Interface.hasMany(models.Screen, { as: 'screens', foreignKey: 'interfaceId' }); } } Interface.init( diff --git a/models/interfaceScreen.js b/models/interfaceScreen.js deleted file mode 100644 index 4be2d630..00000000 --- a/models/interfaceScreen.js +++ /dev/null @@ -1,22 +0,0 @@ -const { Model } = require('sequelize'); - -module.exports = (sequelize, DataTypes) => { - class InterfaceScreen extends Model { - static associate(models) { - InterfaceScreen.belongsTo(models.Interface, { as: 'interface' }); - InterfaceScreen.belongsTo(models.Screen, { as: 'screen' }); - } - } - InterfaceScreen.init( - { - position: DataTypes.STRING, - }, - { - sequelize, - modelName: 'InterfaceScreen', - tableName: 'interface_screens', - underscored: true, - }, - ); - return InterfaceScreen; -}; diff --git a/models/nemsisElement.js b/models/nemsisElement.js index 9ee27574..da77b33e 100644 --- a/models/nemsisElement.js +++ b/models/nemsisElement.js @@ -64,6 +64,8 @@ module.exports = (sequelize, DataTypes) => { nemsisVersion: DataTypes.STRING, dataSet: DataTypes.STRING, xsd: DataTypes.STRING, + xsdPath: DataTypes.TEXT, + xmlPath: DataTypes.TEXT, name: DataTypes.STRING, displayName: DataTypes.STRING, definition: DataTypes.TEXT, diff --git a/models/screen.js b/models/screen.js index c6a2806d..8a0792db 100644 --- a/models/screen.js +++ b/models/screen.js @@ -4,15 +4,15 @@ module.exports = (sequelize, DataTypes) => { class Screen extends Model { static associate(models) { Screen.belongsTo(models.Interface, { as: 'interface' }); - Screen.hasOne(models.InterfaceScreen, { as: 'interfaceScreen' }); Screen.belongsTo(models.User, { as: 'createdBy' }); Screen.belongsTo(models.User, { as: 'updatedBy' }); - Screen.hasMany(models.Section, { as: 'sections' }); + Screen.hasMany(models.Section, { as: 'sections', foreignKey: 'screenId' }); } } Screen.init( { name: DataTypes.STRING, + position: DataTypes.INTEGER, }, { sequelize, diff --git a/models/section.js b/models/section.js index cf4b3651..a9814e52 100644 --- a/models/section.js +++ b/models/section.js @@ -6,7 +6,7 @@ module.exports = (sequelize, DataTypes) => { Section.belongsTo(models.Screen, { as: 'screen' }); Section.belongsTo(models.User, { as: 'createdBy' }); Section.belongsTo(models.User, { as: 'updatedBy' }); - Section.hasMany(models.SectionElement, { as: 'elements' }); + Section.hasMany(models.SectionElement, { as: 'elements', foreignKey: 'sectionId' }); } } Section.init( diff --git a/models/sectionElement.js b/models/sectionElement.js index 72650fa6..f0167bd6 100644 --- a/models/sectionElement.js +++ b/models/sectionElement.js @@ -15,6 +15,7 @@ module.exports = (sequelize, DataTypes) => { position: DataTypes.INTEGER, column: DataTypes.INTEGER, customId: DataTypes.STRING, + name: DataTypes.STRING, }, { sequelize, diff --git a/routes/api/interfaces.js b/routes/api/interfaces.js index 2452f786..1af4edd5 100644 --- a/routes/api/interfaces.js +++ b/routes/api/interfaces.js @@ -35,6 +35,48 @@ router.get( }), ); +router.get( + '/:id/export', + interceptors.requireAdmin, + helpers.async(async (req, res) => { + const { id } = req.params; + const record = await models.Interface.findByPk(id, { + include: [ + { + model: models.Screen, + as: 'screens', + order: [['position', 'ASC']], + include: [ + { + model: models.Section, + as: 'sections', + order: [['position', 'ASC']], + include: [ + { + model: models.SectionElement, + as: 'elements', + order: [['position', 'ASC']], + include: [ + { + model: models.NemsisElement, + as: 'nemsisElement', + }, + ], + }, + ], + }, + ], + }, + ], + }); + if (record) { + res.json(record.toJSON()); + } else { + res.status(StatusCodes.NOT_FOUND).end(); + } + }), +); + router.post( '/', interceptors.requireAdmin, diff --git a/routes/api/screens.js b/routes/api/screens.js index 5a9568c3..dfa19933 100644 --- a/routes/api/screens.js +++ b/routes/api/screens.js @@ -45,7 +45,7 @@ router.post( '/', interceptors.requireAdmin, helpers.async(async (req, res) => { - const record = models.Screen.build(_.pick(req.body, ['interfaceId', 'name'])); + const record = models.Screen.build(_.pick(req.body, ['interfaceId', 'name', 'position'])); record.createdById = req.user.id; record.updatedById = req.user.id; await record.save(); @@ -61,7 +61,7 @@ router.patch( await models.sequelize.transaction(async (transaction) => { record = await models.Screen.findByPk(req.params.id, { transaction }); if (record) { - record.set(_.pick(req.body, ['name'])); + record.set(_.pick(req.body, ['name', 'position'])); record.updatedById = req.user.id; await record.save({ transaction }); } diff --git a/test/unit/lib/nemsis/emsXsdParser.js b/test/unit/lib/nemsis/emsXsdParser.js index 2629374a..33281279 100644 --- a/test/unit/lib/nemsis/emsXsdParser.js +++ b/test/unit/lib/nemsis/emsXsdParser.js @@ -20,6 +20,8 @@ describe('lib', () => { assert.deepStrictEqual(result.children?.length, 25); assert.deepStrictEqual(result.children[0].name, 'eRecord'); assert.deepStrictEqual(result.children[0].xsd, 'eRecord_v3.xsd'); + assert.deepStrictEqual(result.children[0].xsdPath, "/xs:schema/xs:complexType[@id='eRecord.RecordInformation']"); + assert.deepStrictEqual(result.children[0].xmlPath, '/PatientCareReport/eRecord'); assert.deepStrictEqual(result.children[0].minOccurs, 1); assert.deepStrictEqual(result.children[0].maxOccurs, 1); assert.deepStrictEqual(result.children[0].definition, 'Patient Record Information');