diff --git a/SQL/0000-00-02-Modules.sql b/SQL/0000-00-02-Modules.sql
index ebe02de10a7..3cdf4ba0883 100644
--- a/SQL/0000-00-02-Modules.sql
+++ b/SQL/0000-00-02-Modules.sql
@@ -8,6 +8,7 @@ CREATE TABLE `modules` (
INSERT INTO modules (Name, Active) VALUES ('acknowledgements', 'Y');
INSERT INTO modules (Name, Active) VALUES ('api', 'Y');
+INSERT INTO modules (Name, Active) VALUES ('battery_manager', 'Y');
INSERT INTO modules (Name, Active) VALUES ('behavioural_qc', 'Y');
INSERT INTO modules (Name, Active) VALUES ('brainbrowser', 'Y');
INSERT INTO modules (Name, Active) VALUES ('bvl_feedback', 'Y');
diff --git a/SQL/New_patches/2018-07-23-battery_manager_permissions.sql b/SQL/New_patches/2018-07-23-battery_manager_permissions.sql
new file mode 100644
index 00000000000..25cbfdb1869
--- /dev/null
+++ b/SQL/New_patches/2018-07-23-battery_manager_permissions.sql
@@ -0,0 +1,27 @@
+-- Add view permission for battery manager
+INSERT INTO permissions (code, description, categoryID)
+ VALUES (
+ 'battery_manager_view',
+ 'View Battery Manager',
+ (SELECT ID FROM permissions_category WHERE Description = 'Permission')
+ );
+
+-- Add edit permission for battery manager
+INSERT INTO permissions (code, description, categoryID)
+ VALUES (
+ 'battery_manager_edit',
+ 'Add, activate, and deactivate entries in Test Battery',
+ (SELECT ID FROM permissions_category WHERE Description = 'Permission')
+ );
+
+-- Give view permission to admin
+INSERT INTO user_perm_rel (userID, permID)
+SELECT ID, permID FROM users u JOIN permissions p
+WHERE UserID='admin' AND code = 'battery_manager_view';
+
+-- Give edit permission to admin
+INSERT INTO user_perm_rel (userID, permID)
+SELECT ID, permID FROM users u JOIN permissions p
+WHERE UserID='admin' AND code = 'battery_manager_edit';
+
+INSERT INTO modules (Name, Active) VALUES ('battery_manager', 'Y');
diff --git a/jsx/FilterableDataTable.js b/jsx/FilterableDataTable.js
index 7804244b33c..e5484cf0329 100644
--- a/jsx/FilterableDataTable.js
+++ b/jsx/FilterableDataTable.js
@@ -111,6 +111,7 @@ FilterableDataTable.propTypes = {
name: PropTypes.string.isRequired,
title: PropTypes.string,
data: PropTypes.object.isRequired,
+ filterPresets: PropTypes.object,
fields: PropTypes.object.isRequired,
columns: PropTypes.number,
getFormattedCell: PropTypes.func,
diff --git a/jsx/Form.js b/jsx/Form.js
index 441b25fd43f..d67d464bc7f 100644
--- a/jsx/Form.js
+++ b/jsx/Form.js
@@ -1225,7 +1225,7 @@ class NumericElement extends Component {
id={this.props.id}
min={this.props.min}
max={this.props.max}
- value={this.props.value}
+ value={this.props.value || ''}
disabled={disabled}
required={required}
onChange={this.handleChange}
diff --git a/modules/battery_manager/.gitignore b/modules/battery_manager/.gitignore
new file mode 100644
index 00000000000..235c1debb3b
--- /dev/null
+++ b/modules/battery_manager/.gitignore
@@ -0,0 +1 @@
+js/batteryManagerIndex.js
\ No newline at end of file
diff --git a/modules/battery_manager/README.md b/modules/battery_manager/README.md
new file mode 100644
index 00000000000..87c7e8c49b7
--- /dev/null
+++ b/modules/battery_manager/README.md
@@ -0,0 +1,27 @@
+# Battery Manager
+
+## Purpose
+The Battery Manager module allows users to **browse**, **add**,
+**edit**, **activate** and **deactivate** entries in the Test Battery. The
+Test Battery is used to determine which Instruments are administered at
+different timepoints.
+
+## Intended Users
+The Battery Manager module is used by study
+administrators.
+
+## Scope
+The Battery Manager module provides a tool for browsing, adding,
+editing, activating, and deactivating entries in the the Test Battery.
+
+#### Interactions with LORIS
+Changes, additions and deletion of data in this module affects the test
+battery assigned to a candidate at each timepoint
+
+## Permissions
+In order to use the Battery Manager module the user needs
+one or both of the following permissions:
+- `battery_manager_view`: gives user read-only access to Battery Manager
+module (browsing the Test Battery).
+- `battery_manager_edit`: gives user edit access to Battery
+Manager module (add/edit/activate/deactivate entries in Test Battery).
diff --git a/modules/battery_manager/help/battery_manager.md b/modules/battery_manager/help/battery_manager.md
new file mode 100644
index 00000000000..c549ed19d17
--- /dev/null
+++ b/modules/battery_manager/help/battery_manager.md
@@ -0,0 +1,116 @@
+# Battery Manager
+The Battery Manager module serves as a front-end for manipulating the Test Battery.
+This includes browsing, adding, editing, activating, and deactivating entries.
+
+## Searching for an entry in the Test Battery
+Under the `Browse` tab, use the `Selection Filters` to search for entries by fields such as:
+`Instrument`,
+`Minimum age (days)`,
+`Maximum age (days)`,
+`Stage`,
+`Subproject`,
+`Visit Label`,
+`Site`,
+`First Visit`,
+`Instrument Order`,
+`Active`.
+As filters are selected, the data table below will dynamically update with relevant results.
+Click the **Clear Filters** button to reset all filters.
+
+Within the data table, results can be sorted in ascending or descending order by
+clicking on any column header.
+
+## Adding an entry to the Test Battery
+Under the `Add` tab, you can add a new entry to the Test Battery.
+You can specify information about the entry by using the searchable dropdowns, dropdown menus, and numeric text fields.
+You will have to fill out the required fields `Instrument`, `Minimum age (days)`, `Maximum age (days)`, and `Stage`.
+Finally, press the **Add entry** button to add the entry to the Test Battery.
+You cannot add an entry if it has a duplicate entry in the Test Battery.
+
+*For more information on the behaviour of each parameter refer to the [Behaviour of Parameters](#behaviour-of-parameters) section of this document*
+
+## Editing an entry to the Test Battery
+Under the `Browse` tab, you can edit an entry by clicking on the `Edit` link in the `Edit Metadata` column of the Menu Table.
+The link will display a form that is populated with the values of the entry.
+You can update information in the form by selecting from the dropdown menus and filling in the numeric text fields.
+You will have to fill out the required fields `Instrument`, `Minimum age (days)`, `Maximum age (days)`, `Stage`, and `Active`.
+Finally, press the **Edit entry** button to edit the entry in the Test Battery.
+You cannot edit an entry if you make no changes in the form.
+You cannot edit an entry if it becomes the same as another active entry in the Test Battery.
+
+*For more information on the behaviour of each parameter refer to the [Behaviour of Parameters](#behaviour-of-parameters) section of this document*
+
+## Activating/Deactivating an entry in the Test Battery
+
+### Browse tab (activate/deactivate)
+In the `Change Status` column of the Menu Table, you press the **Activate** or
+**Deactivate** button to directly change the status of an entry.
+
+### Add tab (activate)
+Under the `Add` tab, you can add an entry that already exists in the Test
+Battery but has been deactivated. A pop up will appear that will give you
+the option to activate the existing entry.
+
+### Edit window (activate/deactivate)
+Select an entry in the Menu table and click on `Edit`.
+In the `Edit` window, edit an entry and make sure the new entry has no duplicate in the Test Battery.
+This will add the new entry to the table and deactivate the original one.
+Alternatively, edit an entry so that it becomes the same as another deactivated entry in the Test Battery.
+A pop up will appear that will give you the option to activate the other entry and deactivate the original one.
+
+## Behaviour of Parameters
+
+### Subproject:
+ - If the test battery entry does NOT have a `subprojectID`
+ (`subprojectID=NULL`), the instrument gets administered to ALL subprojects.
+ - If the test battery entry has a `subprojectID` set and the `subprojectID`
+ matches the one of the timepoint, the instrument is administered only to
+ that subproject.
+
+### Stage:
+ - If the test battery entry has a `stage` set and the `stage` matches the
+ one of the timepoint, the instrument is administered at that stage.
+
+### Center:
+ - If the test battery entry does NOT have a `CenterID` (`CenterID=NULL`),
+ the instrument gets administered at ALL centers.
+ - If the test battery entry has a `CenterID` set and the `CenterID` matches
+ the one of the timepoint, the instrument is administered at that CenterID.
+
+### AgeMinDays/AgeMaxDays:
+ - If the test battery entry has `AgeMinDays` and `AgeMaxDays` set and they
+ are both set to `0`, the instrument gets administered at ALL ages;
+ - If the test battery entry has `AgeMinDays` and `AgeMaxDays` set and they
+ are set to any value other than `0`, the instrument gets administered IF AND
+ ONLY IF the age of the candidate at the timepoint is between `AgeMinDays`
+ and `AgeMaxDays`;
+
+### VisitLabel:
+ - If the test battery entry does NOT have a `Visit_label`
+ (`Visit_label=NULL`), the instrument gets administered IF AND ONLY IF no
+ other test battery entries matches the timepoint's visit label and
+ subproject.
+ - If the test battery entry has a `Visit_label` set and the `Visit_label`
+ matches the one of the timepoint, the instrument is administered at that
+ Visit.
+ - **NOTE:** *In order to administer an instrument at all visits without
+ defining each visit individually in the battery, the test battery table
+ should NOT contain any entries for the subproject/visit_label combination
+ of the timepoint. If the timepoint's subproject/visit_label combination
+ has a specified set of instruments defined in the test_battery, all entries
+ of the battery with no visit labels (`Visit_label=NULL`) will be ignored.*
+
+ ### FirstVisit:
+ - If `firstVisit` is set to `Y`, the test battery instance is applied
+ only if it is the first visit.
+ - If `firstVisit` is to `N`, the test battery instance is applied only if it
+ *not* the first visit.
+ - If `firstVisit` is set to null, the test battery instance is applied to any
+ visit.
+ - **NOTE:** *The `firstVisit` flag can allow to bypass all other rules in
+ some instances. In these instances, the `stage`, `CenterID`, `Visit_label`,
+ `AgeMinDays` and `AgeMaxDays` will not affect in any way the administration
+ of the instrument. Only the `subprojectID` value will impact if the
+ instrument gets administered or not; the `subprojectID` value must match
+ the timepoint's in order for the instrument to be administered in these
+ instances.*
diff --git a/modules/battery_manager/jsx/batteryManagerForm.js b/modules/battery_manager/jsx/batteryManagerForm.js
new file mode 100644
index 00000000000..444821d5f38
--- /dev/null
+++ b/modules/battery_manager/jsx/batteryManagerForm.js
@@ -0,0 +1,143 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+
+/**
+ * Battery Manager Form
+ *
+ * Module component rendering Add tab
+ *
+ * @author Victoria Foing
+ *
+ */
+class BatteryManagerForm extends Component {
+ /**
+ * Render function
+ *
+ * @return {*}
+ */
+ render() {
+ const {test, options, setTest, add} = this.props;
+
+ // Inform users about duplicate entries
+ const renderHelpText = () => {
+ if (add) {
+ return (
+
+ You cannot add an entry if it has a duplicate entry in the test
+ battery.
+ If the duplicate entry is inactive, you will be given the option
+ to activate it.
+
+ );
+ } else {
+ return (
+
+ Editing an entry will deactivate the current entry and create a
+ new entry.
+ You cannot edit an entry to have the same values as another active
+ entry.
+ If the duplicate entry is inactive, you will be given the option
+ to active it.
+
+
+
+ );
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+BatteryManagerForm.propTypes = {
+ test: PropTypes.object.isRequired,
+ setTest: PropTypes.func.isRequired,
+ options: PropTypes.object.isRequired,
+};
+
+export default BatteryManagerForm;
diff --git a/modules/battery_manager/jsx/batteryManagerIndex.js b/modules/battery_manager/jsx/batteryManagerIndex.js
new file mode 100644
index 00000000000..90b137aac49
--- /dev/null
+++ b/modules/battery_manager/jsx/batteryManagerIndex.js
@@ -0,0 +1,496 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+
+import Loader from 'Loader';
+import FilterableDataTable from 'FilterableDataTable';
+import Modal from 'Modal';
+import swal from 'sweetalert2';
+
+import BatteryManagerForm from './batteryManagerForm';
+
+/**
+ * Battery Manager
+ *
+ * Main module component rendering tab pane with Browse and Add tabs
+ *
+ * @author Victoria Foing
+ * @author Henri Rabalais
+ */
+class BatteryManagerIndex extends Component {
+ /**
+ * Constructor
+ *
+ * @param {object} props
+ */
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ tests: {},
+ test: {},
+ show: {testForm: false, editForm: false},
+ error: false,
+ isLoaded: false,
+ };
+
+ this.fetchData = this.fetchData.bind(this);
+ this.postData = this.postData.bind(this);
+ this.formatColumn = this.formatColumn.bind(this);
+ this.addTest = this.addTest.bind(this);
+ this.setTest = this.setTest.bind(this);
+ this.clearTest = this.clearTest.bind(this);
+ this.closeForm = this.closeForm.bind(this);
+ this.activateTest = this.activateTest.bind(this);
+ this.deactivateTest = this.deactivateTest.bind(this);
+ this.updateTest = this.updateTest.bind(this);
+ }
+
+ /**
+ * Component did mount lifecycle method.
+ */
+ componentDidMount() {
+ this.fetchData(this.props.testEndpoint, 'GET', 'tests')
+ .then(() => this.fetchData(this.props.optionEndpoint, 'GET', 'options'))
+ .then(() => this.setState({isLoaded: true}));
+ }
+
+ /**
+ * Retrieve data from the provided URL and store it in state
+ *
+ * @param {string} url
+ * @param {string} method
+ * @param {string} state
+ *
+ * @return {object} promise
+ *
+ * XXX: This should eventually be moved to a library function
+ */
+ fetchData(url, method, state) {
+ return new Promise((resolve, reject) => {
+ return fetch(url, {credentials: 'same-origin', method: method})
+ .then((resp) => resp.json())
+ .then((data) => this.setState({[state]: data}, resolve()))
+ .catch((error) => {
+ this.setState({error: true}, reject());
+ console.error(error);
+ });
+ });
+ }
+
+ /**
+ * Posts data from the provided URL to the server.
+ *
+ * @param {string} url
+ * @param {object} data
+ * @param {string} method
+ *
+ * @return {object} promise
+ *
+ * XXX: This should eventually be moved to a library function
+ */
+ postData(url, data, method) {
+ return new Promise((resolve, reject) => {
+ const dataClone = JSON.parse(JSON.stringify(data));
+ return fetch(url, {
+ credentials: 'same-origin',
+ method: method,
+ body: JSON.stringify(dataClone),
+ })
+ .then((response) => response.text()
+ .then((body) => {
+ body = JSON.parse(body);
+ const status = response.ok ? 'success' : 'error';
+ const message = response.ok ? body.message : body.error;
+ swal.fire(message, '', status);
+ response.ok ? resolve() : reject();
+ })
+ .catch(() => reject()));
+ });
+ }
+
+ /**
+ * Modify behaviour of specified column cells in the Data Table component
+ *
+ * @param {string} column - column name
+ * @param {string} cell - cell content
+ * @param {object} row - row content indexed by column
+ *
+ * @return {*} a formated table cell for a given column
+ */
+ formatColumn(column, cell, row) {
+ let result =
{cell} | ;
+ const testId = row['ID'];
+ switch (column) {
+ case 'Instrument':
+ result = {this.state.options.instruments[cell]} | ;
+ break;
+ case 'Subproject':
+ result = {this.state.options.subprojects[cell]} | ;
+ break;
+ case 'Site':
+ result = {this.state.options.sites[cell]} | ;
+ break;
+ case 'Change Status':
+ if (row.Active === 'Y') {
+ // Pass ID of row to deactivate function
+ result = {
+ this.deactivateTest(testId);
+ }}/> | ;
+ } else if (row.Active === 'N') {
+ // Pass ID of row to activate function
+ result = {
+ this.activateTest(testId);
+ }}/> | ;
+ }
+ break;
+ case 'Edit Metadata':
+ const editButton = {
+ this.loadTest(testId);
+ this.show('editForm');
+ }}/>;
+ result = {editButton} | ;
+ break;
+ }
+
+ return result;
+ }
+
+ /**
+ * Show a given form based on the passed 'state'
+ *
+ * @param {string} item - the item to be shown
+ */
+ show(item) {
+ let show = this.state.show;
+ show[item] = true;
+ this.setState({show});
+ }
+
+ /**
+ * Hige a given form based on the passed 'state'
+ *
+ * @param {string} item - the item to be hidden
+ */
+ hide(item) {
+ let show = this.state.show;
+ show[item] = false;
+ this.setState({show});
+ }
+
+ /**
+ * Set the form data based on state values of child elements/components
+ *
+ * @param {string} name - name of the selected element
+ * @param {string} value - selected value for corresponding form element
+ */
+ setTest(name, value) {
+ const test = this.state.test;
+ test[name] = value;
+ this.setState({test});
+ }
+
+ /**
+ * Loads a test into the current state based on the testId
+ *
+ * @param {string} testId
+ */
+ loadTest(testId) {
+ const test = JSON.parse(JSON.stringify(this.state.tests
+ .find((test) => test.id === testId)));
+ this.setState({test});
+ }
+
+ /**
+ * Clear the state of the current test
+ */
+ clearTest() {
+ const test = {};
+ this.setState({test});
+ }
+
+ /**
+ * Close the Form
+ */
+ closeForm() {
+ this.hide('testForm');
+ this.hide('editForm');
+ this.clearTest();
+ }
+
+ /**
+ * Display popup so user can confirm activation of row
+ * Refresh page if entry in Test Battery is successfully activated
+ *
+ * @param {int} testId
+ *
+ * @return {object}
+ */
+ activateTest(testId) {
+ return new Promise((resolve, reject) => {
+ const test = this.state.tests.find((test) => test.id === testId);
+ test.active = 'Y';
+ this.updateTest(test)
+ .then(() => resolve())
+ .then(() => reject());
+ });
+ }
+
+ /**
+ * Display popup so user can confirm deactivation of row
+ * Refresh page if entry in Test Battery is successfully deactivated
+ *
+ * @param {int} testId
+ *
+ * @return {object}
+ */
+ deactivateTest(testId) {
+ return new Promise((resolve, reject) => {
+ const test = this.state.tests.find((test) => test.id === testId);
+ test.active = 'N';
+ this.updateTest(test)
+ .then(() => resolve())
+ .catch(() => reject());
+ });
+ }
+
+ /**
+ * Updates a previously existing Test with an updated Test.
+ *
+ * @param {object} test
+ *
+ * @return {object} promise
+ */
+ updateTest(test) {
+ return new Promise((resolve, reject) => {
+ this.postData(this.props.testEndpoint+test.id, test, 'PATCH')
+ .then(() => {
+ const index = this.state.tests
+ .findIndex((element) => element.id === test.id);
+ const tests = this.state.tests;
+ tests[index] = test;
+ this.setState({tests}, resolve());
+ })
+ .catch(() => reject());
+ });
+ }
+
+ /**
+ * save test to database
+ *
+ * @return {object} promise
+ */
+ addTest() {
+ return new Promise((resolve, reject) => {
+ const test = this.state.test;
+ this.checkDuplicate(test)
+ .then(() => this.postData(this.props.testEndpoint, test, 'POST'))
+ .then(() => this.fetchData(this.props.testEndpoint, 'GET', 'tests'))
+ .then(() => test.id && this.deactivateTest(test.id))
+ .then(() => this.closeForm())
+ .then(() => resolve())
+ .catch(() => reject());
+ });
+ }
+
+ /**
+ * Checks whether the Test is a duplicate of an existing Test.
+ *
+ * @param {object} test
+ *
+ * @return {object} promise
+ */
+ checkDuplicate(test) {
+ return new Promise((resolve, reject) => {
+ let duplicate;
+ this.state.tests.forEach((testCheck) => {
+ if (
+ test.testName === testCheck.testName &&
+ test.ageMinDays === testCheck.ageMinDays &&
+ test.ageMaxDays === testCheck.ageMaxDays &&
+ test.stage === testCheck.stage &&
+ test.subproject === testCheck.subproject &&
+ test.visitLabel === testCheck.visitLabel &&
+ test.centerId === testCheck.centerId &&
+ test.firstVisit === testCheck.firstVisit &&
+ test.instrumentOrder === testCheck.instrumentOrder
+ ) {
+ duplicate = testCheck;
+ }
+ });
+
+ if (duplicate) {
+ if (duplicate.active === 'N') {
+ swal.fire({
+ title: 'Test Duplicate',
+ text: 'Would you to like activate this test?',
+ type: 'warning',
+ confirmButtonText: 'Activate',
+ showCancelButton: true,
+ }).then((result) => {
+ if (result.value) {
+ this.activateTest(duplicate.id);
+ if (test.id && (test.id !== duplicate.id)) {
+ this.deactivateTest(test.id);
+ }
+ this.closeForm();
+ }
+ });
+ } else if (duplicate.active === 'Y') {
+ swal.fire(
+ 'Test Duplicate', 'You cannot duplicate an active test', 'error'
+ );
+ }
+ reject();
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ /**
+ * Render Method
+ *
+ * @return {*}
+ */
+ render() {
+ // If error occurs, return a message.
+ // XXX: Replace this with a UI component for 500 errors.
+ if (this.state.error) {
+ return An error occured while loading the page.
;
+ }
+
+ // Waiting for async data to load
+ if (!this.state.isLoaded) {
+ return ;
+ }
+
+ /**
+ * XXX: Currently, the order of these fields MUST match the order of the
+ * queried columns in _setupVariables() in batter_manager.class.inc
+ */
+ const {options, test, tests, show} = this.state;
+ const {hasPermission} = this.props;
+ const fields = [
+ {label: 'ID', show: false},
+ {label: 'Instrument', show: true, filter: {
+ name: 'testName',
+ type: 'select',
+ options: options.instruments,
+ }},
+ {label: 'Minimum Age', show: true, filter: {
+ name: 'minimumAge',
+ type: 'text',
+ }},
+ {label: 'Maximum Age', show: true, filter: {
+ name: 'maximumAge',
+ type: 'text',
+ }},
+ {label: 'Stage', show: true, filter: {
+ name: 'stage',
+ type: 'select',
+ options: options.stages,
+ }},
+ {label: 'Subproject', show: true, filter: {
+ name: 'subproject',
+ type: 'select',
+ options: options.subprojects,
+ }},
+ {label: 'Visit Label', show: true, filter: {
+ name: 'visitLabel',
+ type: 'select',
+ options: options.visits,
+ }},
+ {label: 'Site', show: true, filter: {
+ name: 'site',
+ type: 'select',
+ options: options.sites,
+ }},
+ {label: 'First Visit', show: true, filter: {
+ name: 'firstVisit',
+ type: 'select',
+ options: options.firstVisit,
+ }},
+ {label: 'Instrument Order', show: true, filter: {
+ name: 'instrumentOrder',
+ type: 'text',
+ }},
+ {label: 'Active', show: true, filter: {
+ name: 'active',
+ type: 'select',
+ options: options.active,
+ }},
+ {label: 'Change Status', show: hasPermission('batter_manager_edit')},
+ {label: 'Edit Metadata', show: hasPermission('batter_manager_edit')},
+ ];
+
+ const testForm = (
+
+
+
+ );
+
+ const actions = [
+ {
+ label: 'New Test',
+ action: () => this.show('testForm'),
+ show: hasPermission('battery_manager_edit'),
+ },
+ ];
+
+ const testsArray = tests.map((test) => {
+ return [
+ test.id,
+ test.testName,
+ test.ageMinDays,
+ test.ageMaxDays,
+ test.stage,
+ test.subproject,
+ test.visitLabel,
+ test.centerId,
+ test.firstVisit,
+ test.instrumentOrder,
+ test.active,
+ ];
+ });
+
+ return (
+
+ {testForm}
+
+
+ );
+ }
+}
+
+BatteryManagerIndex.propTypes = {
+ dataURL: PropTypes.string.isRequired,
+ hasPermission: PropTypes.func.isRequired,
+};
+
+window.addEventListener('load', () => {
+ ReactDOM.render(
+ ,
+ document.getElementById('lorisworkspace')
+ );
+});
diff --git a/modules/battery_manager/php/battery_manager.class.inc b/modules/battery_manager/php/battery_manager.class.inc
new file mode 100644
index 00000000000..093c228aca2
--- /dev/null
+++ b/modules/battery_manager/php/battery_manager.class.inc
@@ -0,0 +1,91 @@
+
+ * @author Henri Rabalais
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+namespace LORIS\battery_manager;
+use \Psr\Http\Message\ServerRequestInterface;
+use \Psr\Http\Message\ResponseInterface;
+
+/**
+ * Main class for battery manager module corresponding to /battery_manager/ URL
+ * Admin section of the LorisMenu.
+ *
+ * Displays a list of records in the test battery and control panel to search them
+ * Allows user to add, activate, and deactivate entries in the test battery
+ *
+ * PHP Version 7
+ *
+ * @category Module
+ * @package Battery_Manager
+ * @author Victoria Foing
+ * @author Henri Rabalais
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class Battery_Manager extends \NDB_Page
+{
+ public $skipTemplate = true;
+
+ /**
+ * Returns true if user has access to this page.
+ *
+ * @param \User $user The user whose access is being checked
+ *
+ * @return bool
+ */
+ function _hasAccess(\User $user) : bool
+ {
+ return $user->hasPermission('battery_manager_view') ||
+ $user->hasPermission('battery_manager_edit');
+ }
+
+ /**
+ * This acts as an Ajax enpoint that handles all action requests from the
+ * Battery Manager Module.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request
+ *
+ * @return ResponseInterface The outgoing PSR7 response
+ */
+ public function handle(ServerRequestInterface $request) : ResponseInterface
+ {
+ $resp = parent::handle($request);
+ switch ($resp->getStatusCode()) {
+ case 200:
+ break;
+ default:
+ return $resp;
+ }
+ $this->setup();
+ return (new \LORIS\Http\Response())
+ ->withBody(new \LORIS\Http\StringStream($this->display()));
+ }
+
+ /**
+ * Include additional JS files.
+ *
+ * @return array of javascript to be inserted
+ */
+ public function getJSDependencies() : array
+ {
+ $factory = \NDB_Factory::singleton();
+ $baseURL = $factory->settings()->getBaseURL();
+ $deps = parent::getJSDependencies();
+ return array_merge(
+ $deps,
+ array(
+ $baseURL . "/battery_manager/js/batteryManagerIndex.js",
+ )
+ );
+ }
+}
+
diff --git a/modules/battery_manager/php/module.class.inc b/modules/battery_manager/php/module.class.inc
new file mode 100644
index 00000000000..ba6ae6a8f72
--- /dev/null
+++ b/modules/battery_manager/php/module.class.inc
@@ -0,0 +1,66 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris-Trunk/
+ */
+namespace LORIS\battery_manager;
+
+/**
+ * Class module implements the basic LORIS module functionality
+ *
+ * @category Behavioural
+ * @package Main
+ * @subpackage Battery_Manager
+ * @author Victoria Foing
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris-Trunk/
+ */
+class Module extends \Module
+{
+ /**
+ * {@inheritDoc}
+ *
+ * @param \User $user The user whose access is being checked.
+ *
+ * @return bool whether access is granted
+ */
+ public function hasAccess(\User $user) : bool
+ {
+ return parent::hasAccess($user) &&
+ $user->hasAnyPermission(
+ [
+ 'battery_manager_view',
+ 'battery_manager_edit'
+ ]
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return string The menu category for this module
+ */
+ public function getMenuCategory() : string
+ {
+ return "Tools";
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return string The human readable name for this module
+ */
+ public function getLongName() : string
+ {
+ return "Battery Manager";
+ }
+}
diff --git a/modules/battery_manager/php/test.class.inc b/modules/battery_manager/php/test.class.inc
new file mode 100644
index 00000000000..245eb483794
--- /dev/null
+++ b/modules/battery_manager/php/test.class.inc
@@ -0,0 +1,84 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+
+namespace LORIS\battery_manager;
+
+/**
+ * A Test represents a row in the Battery Manager menu table.
+ *
+ * @category Behavioural
+ * @package Main
+ * @subpackage Imaging
+ * @author Henri Rabalais
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class Test implements \LORIS\Data\DataInstance
+{
+ public $row;
+
+ /**
+ * Create a new Test Instance.
+ *
+ * @param array $row The row (in the same format as \Database::pselectRow returns
+ * returns
+ */
+ public function __construct(array $row)
+ {
+ $this->row = $row;
+ }
+
+ /**
+ * Implements \LORIS\Data\DataInstance interface for this row.
+ *
+ * @return string the row data.
+ */
+ public function toJSON() : string
+ {
+ return json_encode($this->row);
+ }
+
+ /**
+ * Returns the CenterID for this row, for filters such as
+ * \LORIS\Data\Filters\UserSiteMatch to match again.
+ *
+ * @return integer The CenterID
+ */
+ public function getCenterID() : int
+ {
+ return $this->row['centerId'];
+ }
+
+ /**
+ * Convert data from Instance to a format suitable for SQL.
+ *
+ * @return array
+ */
+ public function toSQL() : array
+ {
+ return array(
+ 'Test_name' => $this->row['testName'] ?? null,
+ 'AgeMinDays' => $this->row['ageMinDays'] ?? null,
+ 'AgeMaxDays' => $this->row['ageMaxDays'] ?? null,
+ 'Stage' => $this->row['stage'] ?? null,
+ 'SubprojectID' => $this->row['subproject'] ?? null,
+ 'Visit_label' => $this->row['visitLabel'] ?? null,
+ 'CenterID' => $this->row['centerId'] ?? null,
+ 'firstVisit' => $this->row['firstVisit'] ?? null,
+ 'instr_order' => $this->row['instrumentOrder'] ?? null,
+ 'Active' => $this->row['active'] ?? null,
+ );
+ }
+}
diff --git a/modules/battery_manager/php/testendpoint.class.inc b/modules/battery_manager/php/testendpoint.class.inc
new file mode 100644
index 00000000000..5fd5e5635d6
--- /dev/null
+++ b/modules/battery_manager/php/testendpoint.class.inc
@@ -0,0 +1,262 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+namespace LORIS\battery_manager;
+use \Psr\Http\Message\ServerRequestInterface;
+use \Psr\Http\Server\RequestHandlerInterface;
+use \Psr\Http\Message\ResponseInterface;
+
+/**
+ * Main class for managing Test Instances
+ *
+ * Handles requests for retrieving and saving Test Instances.
+ * Allows users to add, activate, and deactivate entries in the test battery.
+ *
+ * PHP Version 7
+ *
+ * @category Module
+ * @package Battery_Manager
+ * @author Henri Rabalais
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class TestEndpoint implements RequestHandlerInterface
+{
+
+ /**
+ * Returns true if user has access to this endpoint.
+ *
+ * @param \User $user The user whose access is being checked
+ *
+ * @return bool
+ */
+ function _hasAccess(\User $user) : bool
+ {
+ return true;
+ }
+
+ /**
+ * This function passes the request to the handler. This is necessary since
+ * the Endpoint bypass the Module class.
+ *
+ * XXX: This function should be extracted to a parent class.
+ *
+ * @param ServerRequestInterface $request The PSR7 request.
+ * @param RequestHandlerInterface $handler The request handler.
+ *
+ * @return ResponseInterface The outgoing PSR7 response.
+ */
+ public function process(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler
+ ) : ResponseInterface {
+ return $handler->handle($request);
+ }
+
+ /**
+ * This acts as an Ajax enpoint that handles all action requests from the
+ * Battery Manager Module.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request
+ *
+ * @return ResponseInterface The outgoing PSR7 response
+ */
+ public function handle(ServerRequestInterface $request) : ResponseInterface
+ {
+ $this->db = \Database::singleton();
+ $this->user = $request->getAttribute('user');
+ $method = $request->getMethod();
+
+ switch($method) {
+ case 'GET':
+ return $this->_getInstances();
+ case 'POST':
+ return $this->_postInstance($request);
+ case 'PATCH':
+ return $this->_patchInstance($request);
+ }
+ }
+
+ /**
+ * Gets the data source for battery manager tests.
+ *
+ * @return \LORIS\Data\Provisioner
+ */
+ private function _getDataProvisioner() : \LORIS\Data\Provisioner
+ {
+ $provisioner = new TestProvisioner();
+
+ if ($this->user->hasPermission('access_all_profiles') == false) {
+ $provisioner = $provisioner->filter(
+ new \LORIS\Data\Filters\UserSiteMatch()
+ );
+ }
+ return $provisioner;
+ }
+
+ /**
+ * Updates the status of the given test from Active to Deactive or
+ * viceversa.
+ *
+ * @param ServerRequestInterface $request Test to be saved
+ *
+ * @return ResponseInterface response
+ */
+ private function _patchInstance(ServerRequestInterface $request)
+ {
+ $testArray = json_decode($request->getBody()->getContents(), true);
+ $test = new Test($testArray);
+
+ // validate instance;
+ $errors = $this->_validateInstance($test);
+ if (!empty($errors)) {
+ return new \LORIS\Http\Response\JSON\BadRequest(
+ implode(' ', $errors)
+ );
+ }
+
+ $testArray = $test->toSQL();
+ try {
+ $this->db->update(
+ 'test_battery',
+ $testArray,
+ array('ID' => $testArray['ID'])
+ );
+ return new \LORIS\Http\Response\JSON\OK(
+ ['message'=>'Availability Updated Successfully']
+ );
+ } catch (\DatabaseException $e) {
+ return new \LORIS\Http\Response\JSON\InternalServerError(
+ 'Could not update entry in the Test Battery'
+ );
+ }
+ }
+
+ /**
+ * Adds a new test to the test_battery in the database.
+ *
+ * @param ServerRequestInterface $request Test to be posted
+ *
+ * @return ResponseInterface response
+ */
+ private function _postInstance(ServerRequestInterface $request)
+ {
+ $testArray = json_decode($request->getBody()->getContents(), true);
+ $test = new Test($testArray);
+
+ if (!$this->user->hasPermission('battery_manager_edit')) {
+ return new \LORIS\Http\Response\JSON\Forbidden('Edit Permission Denied');
+ }
+
+ // validate instance
+ $errors = $this->_validateInstance($test);
+ if (!empty($errors)) {
+ return new \LORIS\Http\Response\JSON\BadRequest(
+ implode(' ', $errors)
+ );
+ }
+
+ // check if instance is duplicate
+ if ($this->_isDuplicate($test)) {
+ return new \LORIS\Http\Response\JSON\Conflict(
+ 'This Test already exists in the database'
+ );
+ }
+
+ $test->row['active'] = 'Y';
+ $testArray = $test->toSQL();
+
+ try {
+ $this->db->insert('test_battery', $testArray);
+ return new \LORIS\Http\Response\JSON\OK(
+ ['message'=>'Test Submission Successful']
+ );
+ } catch (\DatabaseException $e) {
+ return new \LORIS\Http\Response\JSON\InternalServerError(
+ 'Could not add entry to the Test Battery'
+ );
+ }
+ }
+
+ /**
+ * Checks if the entry is an exact duplicate of a previous entry.
+ *
+ * @param Test $test Test to be checked.
+ *
+ * @return bool
+ */
+ private function _isDuplicate(Test $test) : bool
+ {
+ // Build SQL query based on values entered by user
+ $query = "SELECT Test_name as testName,
+ AgeMinDays as ageMinDays,
+ AgeMaxDays as ageMaxDays,
+ Stage as stage,
+ SubprojectID as subproject,
+ Visit_label as visitLabel,
+ CenterID as centerId,
+ firstVisit,
+ instr_order as instrumentOrder
+ FROM test_battery";
+ // Select duplicate entry from Test Battery
+ $entries = $this->db->pselect($query, array());
+
+ foreach ($entries as $entry) {
+ if ($test->row === $entry) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Converts the results of this menu filter to a JSON format.
+ *
+ * @return ResponseInterface The outgoing PSR7 with a string of json
+ * encoded tests as the body.
+ */
+ private function _getInstances() : ResponseInterface
+ {
+ $instances = (new \LORIS\Data\Table())
+ ->withDataFrom($this->_getDataProvisioner($this->user))
+ ->toArray($this->user);
+ return new \LORIS\Http\Response\JSON\OK($instances);
+ }
+
+ /**
+ * Validates the Test Instance and collects in errors in an array.
+ *
+ * @param Test $test The Test instance to be validated
+ *
+ * @return array $errors An array string errors.
+ */
+ private function _validateInstance(Test $test) : array
+ {
+ $errors = [];
+ if (!isset($test->row['testName'])) {
+ $errors[] = 'Test Name is a required field.';
+ }
+ if (!isset($test->row['ageMinDays'])) {
+ $errors[] = 'Minimum age is a required field.';
+ }
+ if (!isset($test->row['ageMaxDays'])) {
+ $errors[] = 'Maximum age is a required field.';
+ }
+ if (!isset($test->row['stage'])) {
+ $errors[] = 'Stage is a required field.';
+ }
+
+ return $errors;
+ }
+}
+
diff --git a/modules/battery_manager/php/testoptionsendpoint.class.inc b/modules/battery_manager/php/testoptionsendpoint.class.inc
new file mode 100644
index 00000000000..aa14c41f76e
--- /dev/null
+++ b/modules/battery_manager/php/testoptionsendpoint.class.inc
@@ -0,0 +1,102 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+namespace LORIS\battery_manager;
+use \Psr\Http\Message\ServerRequestInterface;
+use \Psr\Http\Message\ResponseInterface;
+
+/**
+ * Main class for battery manager module corresponding to /battery_manager/ URL
+ * Admin section of the LorisMenu.
+ *
+ * Displays a list of records in the test battery and control panel to search them
+ * Allows user to add, activate, and deactivate entries in the test battery
+ *
+ * PHP Version 7
+ *
+ * @category Module
+ * @package Battery_Manager
+ * @author Henri Rabalais
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class TestOptionsEndpoint extends \NDB_Page
+{
+ /**
+ * This acts as an Ajax enpoint that handles all action requests from the
+ * Battery Manager Module.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request
+ *
+ * @return ResponseInterface The outgoing PSR7 response
+ */
+ public function handle(ServerRequestInterface $request) : ResponseInterface
+ {
+ $method = $request->getMethod();
+
+ switch($method) {
+ case 'GET':
+ return new \LORIS\Http\Response\JSON\OK($this->_getOptions());
+ }
+
+ return new \LORIS\Http\Response\JSON\BadRequest('Unspecified Request');
+ }
+
+ /**
+ * Gets the field options for this module.
+ *
+ * @return array
+ */
+ private function _getOptions() : array
+ {
+ return array(
+ 'instruments' => \Utility::getAllInstruments(),
+ 'stages' => $this->_getStageList(),
+ 'subprojects' => \Utility::getSubprojectList(null),
+ 'visits' => \Utility::getVisitList(),
+ 'sites' => \Utility::getSiteList(false),
+ 'firstVisits' => $this->_getYesNoList(),
+ 'active' => $this->_getYesNoList(),
+ );
+ }
+
+ /**
+ * Return associative array of stages.
+ *
+ * @return array
+ */
+ private function _getStageList() : array
+ {
+ return array(
+ "Not Started" => 'Not Started',
+ "Screening" => 'Screening',
+ "Visit" => 'Visit',
+ "Approval" => "Approval",
+ "Subject" => "Subject",
+ "Recycling Bin" => "Recycling Bin",
+ );
+ }
+
+ /**
+ * Return associative array of yes and no values.
+ *
+ * @return array
+ */
+ private function _getYesNoList() : array
+ {
+ return array(
+ 'Y' => 'Yes',
+ 'N' => 'No',
+ );
+ }
+}
+
diff --git a/modules/battery_manager/php/testprovisioner.class.inc b/modules/battery_manager/php/testprovisioner.class.inc
new file mode 100644
index 00000000000..3dd6ad2ce2e
--- /dev/null
+++ b/modules/battery_manager/php/testprovisioner.class.inc
@@ -0,0 +1,70 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+
+namespace LORIS\battery_manager;
+
+/**
+ * This class implements a data provisioner to get all possible tests
+ * for the battery_manager menu page.
+ *
+ * PHP Version 7
+ *
+ * @category Behavioural
+ * @package Main
+ * @subpackage Imaging
+ * @author Henri Rabalais
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class TestProvisioner extends \LORIS\Data\Provisioners\DBRowProvisioner
+{
+ /**
+ * Create a Test Instance, which get rows fro mthe test_battery table.
+ */
+ public function __construct()
+ {
+ parent::__construct(
+ "SELECT b.id,
+ t.Test_name as testName,
+ b.AgeMinDays as ageMinDays,
+ b.AgeMaxDays as ageMaxDays,
+ b.Stage as stage,
+ s.SubprojectID as subproject,
+ b.Visit_label as visitLabel,
+ p.CenterID as centerId,
+ b.firstVisit,
+ b.instr_order as instrumentOrder,
+ b.Active as active
+ FROM test_battery b
+ JOIN test_names t USING (Test_name)
+ LEFT JOIN subproject s USING (SubprojectID)
+ LEFT JOIN psc p USING (CenterID)
+ ORDER BY t.Test_name",
+ array()
+ );
+ }
+
+ /**
+ * Returns an instance of a Test object for a given table row.
+ *
+ * @param array $row The database row from the LORIS Database class.
+ *
+ * @return \LORIS\Data\DataInstance An instance representing a test.
+ */
+ public function getInstance($row) : \LORIS\Data\DataInstance
+ {
+ return new Test($row);
+ }
+}
diff --git a/modules/battery_manager/test/BatteryManagerTest.php b/modules/battery_manager/test/BatteryManagerTest.php
new file mode 100644
index 00000000000..3b8210f64f7
--- /dev/null
+++ b/modules/battery_manager/test/BatteryManagerTest.php
@@ -0,0 +1,64 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://github.com/aces/Loris
+ */
+
+require_once __DIR__ .
+ "/../../../test/integrationtests/LorisIntegrationTest.class.inc";
+
+/**
+ * Battery Manager module automated integration tests
+ *
+ * PHP Version 5
+ *
+ * @category Test
+ * @package Loris
+ * @author Wang Shen
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://github.com/aces/Loris
+ */
+class BatteryManagerTest extends LorisIntegrationTest
+{
+
+ /**
+ * Tests that the page does not load if the user does not have correct
+ * permissions
+ *
+ * @return void
+ */
+ function testLoadsWithPermissionRead()
+ {
+ $this->setupPermissions(array("battery_manager_view"));
+ $this->safeGet($this->url . "/battery_manager/");
+ $bodyText = $this->webDriver->findElement(
+ WebDriverBy::cssSelector("body")
+ )->getText();
+ $this->assertNotContains("You do not have access to this page.", $bodyText);
+ $this->resetPermissions();
+ }
+ /**
+ * Tests that the page does not load if the user does not have correct
+ * permissions
+ *
+ * @return void
+ */
+ function testDoesNotLoadWithoutPermission()
+ {
+ $this->setupPermissions(array());
+ $this->safeGet($this->url . "/battery_manager/");
+ $bodyText = $this->webDriver->findElement(
+ WebDriverBy::cssSelector("body")
+ )->getText();
+ $this->assertContains("You do not have access to this page.", $bodyText);
+ $this->resetPermissions();
+ }
+
+}
diff --git a/modules/battery_manager/test/TestPlan.md b/modules/battery_manager/test/TestPlan.md
new file mode 100644
index 00000000000..aa694fa997d
--- /dev/null
+++ b/modules/battery_manager/test/TestPlan.md
@@ -0,0 +1,104 @@
+# Battery Manager Module Test Plan
+
+## Overview
+
+Battery Manager module allows users to search for, add, edit, activate, and deactivate entries in the
+Test Battery.
+
+## Features
+
+1. **Browse** entries in the Test Battery.
+2. **Add** new entry to the `test_battery` table.
+3. **Edit** entry in the `test_battery` table.
+4. **Activate** entry in the `test_battery` table.
+5. **Deactivate** entry in the `test_battery` table.
+
+---
+
+## Testing Procedure
+
+### Permissions
+
+**Testing with no permissions** [Automation Testing]
+ 1. Access the module with a regular user (without superuser permissions).
+ 2. By default, the access to module should be denied.
+
+**Testing with view permission** [Automation Testing]
+ 1. Add view permission to the aforementioned user.
+ 2. Battery Manager module should be accessible and only present with **one** tab (Browse).
+ 3. The **Change Status** column should not be in the data table.
+ 4. The **Edit Metadata** column should not be in the data table.
+
+**Testing with edit permission** [Automation Testing]
+ 1. Add edit permission.
+ 2. Battery Manager module should now have **two** tabs (Browse) and (Add).
+ 3. The **Change Status** column should be in the data table.
+ 4. The **Edit Metadata** column should be in the data table.
+ 5. Clicking on Add tab should hide the data table and display a form with the following fields:
+ `Instrument`, `Minimum age (days)`, `Maximum age(days)`, `Stage`, `Subproject`, `Visit Label`, `Site`, `First Visit`,
+ and `Instrument Order`.
+ 6. Clicking on Edit in **Edit Metadata** takes you to a new page with a form with the following fields:
+ `Instrument`, `Minimum age (days)`, `Maximum age(days)`, `Stage`, `Subproject`, `Visit Label`, `Site`, `First Visit`,
+ and `Instrument Order`.
+
+### Add tab
+
+**Testing add functionality**
+ 1. Check that you cannot add an entry without filling out the required fields: `Instrument`, `Minimum age (days)`, `Maximum age (days)`, `Stage`.
+ 2. Check that you can only enter a site that exists.
+ 3. Check that you can only enter numbers between 0 and 99999 in Minumum age (days) and Maximum age (days).
+ 4. Check that you can only enter numbers between 0 and 127 in Instrument order.
+ 5. Check that when you try to add an entry that has an active duplicate in the table (Active = 'Y'), you receive an error message.
+ 6. Try to add an entry that does not have a duplicate.
+ - Ensure that a success message appears and the page goes back to the Browse tab.
+ - Ensure the entry you just added is shown in data table.
+
+**Testing activate functionality**
+ 1. Try to add an entry that has an inactive duplicate in the table (Active = 'N').
+ - Ensure that you receive a warning message that allows you to activate the duplicate.
+ - Ensure that when you press "Yes", a success message appears and the page goes back to the Browse tab.
+ - Ensure the entry you just activated is activated in the data table.
+
+### Browse tab
+
+**Testing data table**
+ 1. After a couple of entries are added, ensure they are properly displayed in the data table.
+ 2. Ensure that information in the data table corresponds to the information in the `test_battery` table.
+ 3. Click on **column headers** to ensure sorting functionality is working as expected (Ascending/Descending).
+
+**Testing Change Status column**
+ 1. Press the `Deactivate` button in the `Change Status` column on an entry in the data table.
+ - Ensure that a warning message appears that asks you to confirm the action.
+ - Ensure that when you press "Yes", a success message appears and the page refreshes.
+ - Ensure the entry has the new Active status in the data table.
+ 2. Repeat step 1 using the `Activate` button.
+
+**Testing Edit Metadata column**
+ 1. Press the `Edit` link in the `Edit Metadata` column on an entry in the data table to edit.
+ - Ensure that you are taken to an Edit page with a form that is populated with the entry's values.
+
+**Test filters**
+ 1. Under **Browse** tab, a selection filter should be present on top of the page containing the following fields:
+ - Minimum age, Maximum age, and Instrument Order (as text fields).
+ - Instrument, Stage, Subproject, Visit Label, Site, First Visit, Instrument Order, and Active (as dropdown fields with blank default option).
+ 2. Type text in the Minimum age and verify that the table gets filtered as you type.
+ 3. Type text in the Maximum age and verify that the table gets filtered as you type.
+ 4. Select values from the dropdown filters (independently and combined) to filter table further.
+ - The table should update and display filtered records accordingly.
+
+### Edit window
+
+**Testing edit (activate/deactivate/add) functionality**
+ 1. Check that you cannot edit an entry without filling out the required fields: `Instrument`, `Minimum age (days)`, `Maximum age (days)`, `Stage`.
+ 2. Check that you can only enter a site that exists.
+ 3. Check that you can only enter numbers between 0 and 99999 in Minumum age (days) and Maximum age (days).
+ 4. Check that you can only enter numbers between 0 and 127 in Instrument order.
+ 5. Check that when you try to edit an entry without making changes to the form, you receive an error message.
+ 6. Check that when the edited entry has the same values as another active entry in the Test Battery, you receive an error message.
+ 7. Try to edit an entry so that it has the same values as another deactivated entry in the Test Battery.
+ - Ensure that a warning message appears giving the option to activate the other entry and deactivate the original entry.
+ - Ensure that when you press "Yes", a success message appears and the page goes back to the Browse tab.
+ - Ensure the original entry was deactivated and the other duplicate entry has been activated in the data table.
+ 8. Try to edit an entry so that it does not have a duplicate (i.e. itself or another entry).
+ - Ensure that a success message appears and the page goes back to the `Browse` tab.
+ - Ensure the original entry was deactivated and the new entry has been added to the data table.
diff --git a/raisinbread/RB_files/RB_modules.sql b/raisinbread/RB_files/RB_modules.sql
index aa529c88b62..a803b6326d3 100644
--- a/raisinbread/RB_files/RB_modules.sql
+++ b/raisinbread/RB_files/RB_modules.sql
@@ -39,5 +39,6 @@ INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (35,'statistics','Y');
INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (36,'survey_accounts','Y');
INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (37,'timepoint_list','Y');
INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (38,'user_accounts','Y');
+INSERT INTO `modules` (`ID`, `Name`, `Active`) VALUES (39,'battery_manager','Y');
UNLOCK TABLES;
SET FOREIGN_KEY_CHECKS=1;
diff --git a/raisinbread/RB_files/RB_permissions.sql b/raisinbread/RB_files/RB_permissions.sql
index a182842e420..6dbc2bb6aba 100644
--- a/raisinbread/RB_files/RB_permissions.sql
+++ b/raisinbread/RB_files/RB_permissions.sql
@@ -57,5 +57,7 @@ INSERT INTO `permissions` (`permID`, `code`, `description`, `categoryID`) VALUES
INSERT INTO `permissions` (`permID`, `code`, `description`, `categoryID`) VALUES (55,'publication_approve','Publication - Approve or reject proposed publication projects',2);
INSERT INTO `permissions` (`permID`, `code`, `description`, `categoryID`) VALUES (56,'data_release_view','Data Release: View releases',2);
INSERT INTO `permissions` (`permID`, `code`, `description`, `categoryID`) VALUES (57,'candidate_dob_edit','Edit dates of birth',2);
+INSERT INTO `permissions` (`permID`, `code`, `description`, `categoryID`) VALUES (58,'battery_manager_view','View Battery Manager',2);
+INSERT INTO `permissions` (`permID`, `code`, `description`, `categoryID`) VALUES (59,'battery_manager_edit','Add, activate, and deactivate entries in Test Battery',2);
UNLOCK TABLES;
SET FOREIGN_KEY_CHECKS=1;
diff --git a/raisinbread/RB_files/RB_user_perm_rel.sql b/raisinbread/RB_files/RB_user_perm_rel.sql
index 616c972b333..c2e3c0b8155 100644
--- a/raisinbread/RB_files/RB_user_perm_rel.sql
+++ b/raisinbread/RB_files/RB_user_perm_rel.sql
@@ -57,5 +57,7 @@ INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,53);
INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,54);
INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,55);
INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,56);
+INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,58);
+INSERT INTO `user_perm_rel` (`userID`, `permID`) VALUES (1,59);
UNLOCK TABLES;
SET FOREIGN_KEY_CHECKS=1;
diff --git a/webpack.config.js b/webpack.config.js
index fa00c58b532..e621887b538 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -18,6 +18,7 @@ const config = [{
'./modules/configuration/js/SubprojectRelations.js': './modules/configuration/jsx/SubprojectRelations.js',
'./modules/conflict_resolver/js/conflictResolverIndex.js': './modules/conflict_resolver/jsx/conflictResolverIndex.js',
'./modules/conflict_resolver/js/resolvedConflictsIndex.js': './modules/conflict_resolver/jsx/resolvedConflictsIndex.js',
+ './modules/battery_manager/js/batteryManagerIndex.js': './modules/battery_manager/jsx/batteryManagerIndex.js',
'./modules/bvl_feedback/js/react.behavioural_feedback_panel.js': './modules/bvl_feedback/jsx/react.behavioural_feedback_panel.js',
'./modules/behavioural_qc/js/behavioural_qc_module.js': './modules/behavioural_qc/jsx/behavioural_qc_module.js',
'./modules/candidate_list/js/openProfileForm.js': './modules/candidate_list/jsx/openProfileForm.js',