diff --git a/SQL/0000-00-02-Modules.sql b/SQL/0000-00-02-Modules.sql
index 5ed6697522d..72860421577 100644
--- a/SQL/0000-00-02-Modules.sql
+++ b/SQL/0000-00-02-Modules.sql
@@ -14,6 +14,7 @@ INSERT INTO modules (Name, Active) VALUES ('brainbrowser', 'Y');
INSERT INTO modules (Name, Active) VALUES ('bvl_feedback', 'Y');
INSERT INTO modules (Name, Active) VALUES ('candidate_list', 'Y');
INSERT INTO modules (Name, Active) VALUES ('candidate_parameters', 'Y');
+INSERT INTO modules (Name, Active) VALUES ('candidate_profile', 'Y');
INSERT INTO modules (Name, Active) VALUES ('configuration', 'Y');
INSERT INTO modules (Name, Active) VALUES ('conflict_resolver', 'Y');
INSERT INTO modules (Name, Active) VALUES ('create_timepoint', 'Y');
diff --git a/SQL/New_patches/2020-02-24-CandidateProfileModule.sql b/SQL/New_patches/2020-02-24-CandidateProfileModule.sql
new file mode 100644
index 00000000000..fe35827887b
--- /dev/null
+++ b/SQL/New_patches/2020-02-24-CandidateProfileModule.sql
@@ -0,0 +1 @@
+INSERT INTO modules (Name, Active) VALUES ('candidate_profile', 'Y');
diff --git a/modules/candidate_profile/.gitignore b/modules/candidate_profile/.gitignore
new file mode 100644
index 00000000000..d5a65bf3098
--- /dev/null
+++ b/modules/candidate_profile/.gitignore
@@ -0,0 +1 @@
+js/*
diff --git a/modules/candidate_profile/README.md b/modules/candidate_profile/README.md
new file mode 100644
index 00000000000..cd2fc337bb5
--- /dev/null
+++ b/modules/candidate_profile/README.md
@@ -0,0 +1,41 @@
+# Candidate Profile
+
+## Purpose
+
+The `candidate_profile` module is intended to provide
+a dashboard in which a user can see an overview of all data related
+to a specific candidate in LORIS across all installed and active
+modules.
+
+## Intended Users
+
+Any user interacting with candidate data.
+
+## Scope
+
+The candidate profile module is only concerned with candidate level
+data.
+
+NOT IN SCOPE:
+
+The candidate profile is not intended to be used for data entry, only
+for a candidate overview. Modules which provide widgets should link to
+appropriate pages in the module for data entry.
+
+## Permissions
+
+None, but modules may implement their own permissions.
+
+## Configurations
+
+None, but see interactions with LORIS.
+
+## Interactions with LORIS
+- The `candidate_profile` module depends on the LORIS API to retrieve
+ basic candidate and visit level data to pass to widgets.
+- The `candidate_profile` will call `getWidgets` on each module to
+ get a list of widgets. It uses the widget type of `candidate`. The
+ options provided include a 'candidate' key which is the `\Candidate`
+ object for the candidate whose dashboard is being displayed. It expects
+ an array of `\LORIS\candidate_profile\CandidateWidget` widget types
+ as a result.
diff --git a/modules/candidate_profile/jsx/CandidateInfo.js b/modules/candidate_profile/jsx/CandidateInfo.js
new file mode 100644
index 00000000000..34608d2a3a6
--- /dev/null
+++ b/modules/candidate_profile/jsx/CandidateInfo.js
@@ -0,0 +1,190 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+
+/**
+ * CandidateInfo is a React component which is used for the
+ * CandidateInfo table providing an overview of the candidate.
+ */
+export class CandidateInfo extends Component {
+ /**
+ * Construct the object.
+ *
+ * @param {array} props - The React props
+ */
+ constructor(props) {
+ super(props);
+
+ this.calcAge = this.calcAge.bind(this);
+ this.getSubprojects = this.getSubprojects.bind(this);
+ this.getVisitList = this.getVisitList.bind(this);
+ }
+
+ /**
+ * Calculate the age (as of today) based on the date of birth
+ * passed as an argument. If the age is less than or equal to
+ * 3 years old, it will return a string describing the age in
+ * months. Otherwise, it will return an age in years.
+ *
+ * @param {string} dob - The date of birth in format YYYY-MM-DD
+ *
+ * @return {string} - A human readable string of the age.
+ */
+ calcAge(dob) {
+ const dobdate = new Date(dob);
+ const now = new Date();
+ const years = now.getFullYear()-dobdate.getFullYear();
+ const months = years*12 + now.getMonth() - dobdate.getMonth();
+
+ if (months <= 36) {
+ return months + ' months old';
+ }
+ return years + ' years old';
+ }
+
+ /**
+ * Return a list of the unique subprojects contained in the
+ * visits passed.
+ *
+ * @param {array} visits - An array of visits in the format of
+ * the LORIS API
+ *
+ * @return {array} - The unique list of subprojects as a string.
+ */
+ getSubprojects(visits) {
+ let mapped = [...new Set(visits.map( (visit) => {
+ return visit.Meta.Battery;
+ }))];
+ return mapped;
+ }
+
+ /**
+ * Converts the list of visits passed as a parameter to a React element
+ * that can be rendered. The React element is a comma separated list
+ * of visits, each of which link to the timepoint.
+ *
+ * The instrument_list is currently used as the 'timepoint' because
+ * that's where you end up when going via Access Profile, but eventually
+ * it would be a good idea to have a non-modality specific visit dashboard
+ * similar to the candidate dashboard.
+ *
+ * @param {array} visits - List of visits in the format returned by the LORIS API.
+ *
+ * @return {object} - A React element containing a comma separated list of links.
+ */
+ getVisitList(visits) {
+ let visitlinks = visits.map( (visit) => {
+ const sessionID = this.props.VisitMap[visit.Meta.Visit];
+ const candID = this.props.Candidate.Meta.CandID;
+ return {visit.Meta.Visit};
+ });
+ return
+ {
+ // Equivalent of .join(', ') that doesn't convert the React
+ // element into the string [object Object].
+ // See https://stackoverflow.com/questions/33577448/is-there-a-way-to-do-array-join-in-react
+ visitlinks.reduce(
+ (acc, el) => {
+ if (acc === null) {
+ return [el];
+ }
+ return [acc, ', ', el];
+ },
+ null,
+ )
+ }
+
;
+ }
+
+ /**
+ * Render the React component
+ *
+ * @return {object} - The rendered react component
+ */
+ render() {
+ const subprojects = this.getSubprojects(this.props.Visits);
+ const subprojlabel = subprojects.length == 1 ? 'Subproject'
+ : 'Subprojects';
+
+ const data = [
+ {
+ label: 'PSCID',
+ value: this.props.Candidate.Meta.PSCID,
+ },
+ {
+ label: 'DCCID',
+ value: this.props.Candidate.Meta.CandID,
+ },
+ {
+ label: 'Date of Birth',
+ value: this.props.Candidate.Meta.DoB,
+ valueWhitespace: 'nowrap',
+ },
+ {
+ label: 'Age',
+ value: this.calcAge(this.props.Candidate.Meta.DoB),
+ },
+ {
+ label: 'Sex',
+ value: this.props.Candidate.Meta.Sex,
+ },
+ {
+ label: 'Project',
+ value: this.props.Candidate.Meta.Project,
+ },
+ {
+ label: subprojlabel,
+ value: subprojects,
+ },
+ {
+ label: 'Site',
+ value: this.props.Candidate.Meta.Site,
+ },
+ {
+ label: 'Visits',
+ value: this.getVisitList(this.props.Visits),
+ width: '12em',
+ },
+ ];
+
+ const cardInfo = data.map((info, index) => {
+ const cardStyle = {
+ width: info.width || '6em',
+ padding: '1em',
+ marginLeft: '1ex',
+ marginRight: '1ex',
+ };
+ let valueStyle = {};
+ if (info.valueWhitespace) {
+ valueStyle.whiteSpace = info.valueWhitespace;
+ }
+
+ return (
+
+
{info.label}
+ {info.value}
+
+ );
+ });
+ return (
+
+ );
+ }
+}
+
+
+CandidateInfo.propTypes = {
+ BaseURL: PropTypes.string.isRequired,
+ Candidate: PropTypes.object.isRequired,
+ Visits: PropTypes.array.isRequired,
+ VisitMap: PropTypes.object.isRequired,
+};
diff --git a/modules/candidate_profile/php/candidate_profile.class.inc b/modules/candidate_profile/php/candidate_profile.class.inc
new file mode 100644
index 00000000000..0a0b8a1e459
--- /dev/null
+++ b/modules/candidate_profile/php/candidate_profile.class.inc
@@ -0,0 +1,152 @@
+candidate === null) {
+ // If we don't know what to do, assume no permissions.
+ return false;
+ }
+ return $this->candidate->isAccessibleBy($user);
+ }
+
+ /**
+ * Ensure $this->candidate is set so that _hasAccess is valid
+ * before calling the parent middleware.
+ *
+ * @param ServerRequestInterface $request The PSR7 request being processed.
+ * @param RequestHandlerInterface $handler The handler to handle the request
+ * after processing the middleware.
+ *
+ * @return ResponseInterface
+ */
+ public function process(
+ ServerRequestInterface $request,
+ RequestHandlerInterface $handler
+ ) : ResponseInterface {
+ $this->candidate = $request->getAttribute('candidate');
+ return parent::process($request, $handler);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request.
+ *
+ * @return ResponseInterface
+ */
+ public function handle(ServerRequestInterface $request) : ResponseInterface
+ {
+ $this->candidate = $request->getAttribute('candidate');
+ if ($this->candidate === null) {
+ // This should be impossible, because the Module router added the
+ // attribute before loading this page.
+ throw new \LorisException("No candidate provided");
+ }
+
+ $factory = \NDB_Factory::singleton();
+ $DB = $factory->database();
+ $user = $factory->user();
+
+ $modules = \Module::getActiveModules($DB);
+
+ $widgets = [];
+ foreach ($modules as $module) {
+ if ($module->hasAccess($user)) {
+ $mwidgets = $module->getWidgets(
+ 'candidate',
+ $user,
+ [ 'candidate' => $this->candidate ],
+ );
+ foreach ($mwidgets as $widget) {
+ if (!($widget instanceof CandidateWidget)) {
+ continue;
+ }
+ $widgets[] = $widget;
+ }
+ }
+ }
+
+ $this->tpl_data['widgets'] = $widgets;
+ $this->tpl_data['candidate'] = $this->candidate;
+
+ $this->tpl_data['visitmap']
+ = array_flip($this->candidate->getListOfVisitLabels());
+
+ return parent::handle($request);
+ }
+
+ /**
+ * Add CSSGrid dependency for the module.
+ *
+ * @return array
+ */
+ function getJSDependencies()
+ {
+ $factory = \NDB_Factory::singleton();
+ $baseURL = $factory->settings()->getBaseURL();
+ $deps = parent::getJSDependencies();
+ return array_merge(
+ $deps,
+ array($baseURL . '/js/components/CSSGrid.js')
+ );
+ }
+
+ /**
+ * Generate a breadcrumb trail for this page.
+ *
+ * @return \LORIS\BreadcrumbTrail
+ */
+ public function getBreadcrumbs(): \LORIS\BreadcrumbTrail
+ {
+ if ($this->candidate === null) {
+ return new \LORIS\BreadcrumbTrail(
+ new \LORIS\Breadcrumb(
+ 'Access Profile',
+ '/candidate_list'
+ ),
+ );
+ }
+ $candid = $this->candidate->getCandID();
+ $pscid = $this->candidate->getPSCID();
+
+ return new \LORIS\BreadcrumbTrail(
+ new \LORIS\Breadcrumb(
+ 'Access Profile',
+ '/candidate_list'
+ ),
+ new \LORIS\Breadcrumb(
+ "Candidate Dashboard $candid / $pscid",
+ "/$candid"
+ )
+ );
+ }
+}
diff --git a/modules/candidate_profile/php/candidatewidget.class.inc b/modules/candidate_profile/php/candidatewidget.class.inc
new file mode 100644
index 00000000000..9854f38e896
--- /dev/null
+++ b/modules/candidate_profile/php/candidatewidget.class.inc
@@ -0,0 +1,131 @@
+title = $title;
+ $this->url = $jsurl;
+ $this->width = $width;
+ $this->height = $height;
+ $this->componentname = $componentname;
+ $this->props = $props;
+ $this->order = $order;
+ }
+
+ /**
+ * Renders the widget within a dashboard panel and implements
+ * the \LORIS\GUI\Widget interface.
+ *
+ * @return string the HTML content of the widget to be rendered
+ */
+ public function __toString()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Return the Card title
+ *
+ * @return string
+ */
+ public function getTitle() : string
+ {
+ return $this->title;
+ }
+
+ /**
+ * Return the Card width.
+ *
+ * @return ?int
+ */
+ public function getWidth() : ?int
+ {
+ return $this->width;
+ }
+
+ /**
+ * Return the Card height.
+ *
+ * @return ?int
+ */
+ public function getHeight() : ?int
+ {
+ return $this->height;
+ }
+
+ /**
+ * Return the Card order.
+ *
+ * @return ?int
+ */
+ public function getOrder() : ?int
+ {
+ return $this->order;
+ }
+
+ /**
+ * Return the URL which contains the React
+ * component for the Card's body.
+ *
+ * @return string
+ */
+ public function getJSURL() : string
+ {
+ return $this->url;
+ }
+
+ /**
+ * Return the name of the React component to
+ * render.
+ *
+ * @return string
+ */
+ public function getComponentName() : string
+ {
+ return $this->componentname;
+ }
+
+ /**
+ * Return additional React props to pass to the
+ * React component
+ *
+ * @return array
+ */
+ public function getComponentProps() : array
+ {
+ return $this->props;
+ }
+}
diff --git a/modules/candidate_profile/php/module.class.inc b/modules/candidate_profile/php/module.class.inc
new file mode 100644
index 00000000000..6592658869d
--- /dev/null
+++ b/modules/candidate_profile/php/module.class.inc
@@ -0,0 +1,87 @@
+getURI()->getPath(), '/');
+ try {
+ $candID = new CandID($candIDStr);
+ $candidate = \Candidate::singleton($candID);
+
+ $request = $request->withAttribute('candidate', $candidate);
+ $page = $this->loadPage("candidate_profile");
+ return $page->process($request, $page);
+ } catch (\DomainException | \LORISException $e) {
+ // A LORISException means the \Candidate::singleton couldn't
+ // load the candidate (ie because the CandID doesn't exist
+ // in the database.) A \DomainException means the CandID format
+ // was invalid. In either case, let the parent handle the request
+ // and return a 404 if appropriate. (or it could be a js or css
+ // or other type of URL for the module..)
+ return parent::handle($request);
+ }
+ }
+
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return string
+ */
+ public function getLongName() : string
+ {
+ return "Candidate Profile";
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param string $type The type of widgets to get.
+ * @param \User $user The user widgets are being retrieved for.
+ * @param array $options A type dependent list of options to provide
+ * to the widget.
+ *
+ * @return \LORIS\GUI\Widget[]
+ */
+ public function getWidgets(string $type, \User $user, array $options) : array
+ {
+ switch($type) {
+ case 'candidate':
+ $factory = \NDB_Factory::singleton();
+ $baseurl = $factory->settings()->getBaseURL();
+
+ return [
+ new CandidateWidget(
+ "Candidate Info",
+ $baseurl . "/candidate_profile/js/CandidateInfo.js",
+ "lorisjs.candidate_profile.CandidateInfo.CandidateInfo",
+ [],
+ 1,
+ 1,
+ -100,
+ ),
+ ];
+ }
+ return [];
+
+ }
+}
diff --git a/modules/candidate_profile/templates/form_candidate_profile.tpl b/modules/candidate_profile/templates/form_candidate_profile.tpl
new file mode 100644
index 00000000000..8a387874bb2
--- /dev/null
+++ b/modules/candidate_profile/templates/form_candidate_profile.tpl
@@ -0,0 +1,77 @@
+
+{* First load all the javascript URLs for the widgets so that component
+ names are resolvable *}
+{section name=widget loop=$widgets}
+
+{/section}
+
diff --git a/webpack.config.js b/webpack.config.js
index a0994f107f3..216a06b02d2 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -83,7 +83,7 @@ function lorisModule(mname, entries) {
output: {
path: path.resolve(__dirname, 'modules') + '/' + mname + '/js/',
filename: '[name].js',
- library: ['lorisjs', mname],
+ library: ['lorisjs', mname, '[name]'],
libraryTarget: 'window',
},
externals: {
@@ -104,21 +104,20 @@ const config = [
// Core components
{
entry: {
- './htdocs/js/components/DynamicDataTable.js':
- './jsx/DynamicDataTable.js',
- './htdocs/js/components/PaginationLinks.js':
- './jsx/PaginationLinks.js',
- './htdocs/js/components/StaticDataTable.js':
- './jsx/StaticDataTable.js',
- './htdocs/js/components/MultiSelectDropdown.js':
- './jsx/MultiSelectDropdown.js',
- './htdocs/js/components/Breadcrumbs.js': './jsx/Breadcrumbs.js',
- './htdocs/js/components/Form.js': './jsx/Form.js',
- './htdocs/js/components/Markdown.js': './jsx/Markdown.js',
+ DynamicDataTable: './jsx/DynamicDataTable.js',
+ PaginationLinks: './jsx/PaginationLinks.js',
+ StaticDataTable: './jsx/StaticDataTable.js',
+ MultiSelectDropdown: './jsx/MultiSelectDropdown.js',
+ Breadcrumbs: './jsx/Breadcrumbs.js',
+ Form: './jsx/Form.js',
+ Markdown: './jsx/Markdown.js',
+ CSSGrid: './jsx/CSSGrid.js',
},
output: {
- path: __dirname + '/',
- filename: '[name]',
+ path: __dirname + '/htdocs/js/components/',
+ filename: '[name].js',
+ library: ['lorisjs', '[name]'],
+ libraryTarget: 'window',
},
externals: {
react: 'React',
@@ -198,6 +197,8 @@ const config = [
lorisModule('module_manager', ['modulemanager']),
lorisModule('imaging_qc', ['imagingQCIndex']),
lorisModule('server_processes_manager', ['server_processes_managerIndex']),
+ // lorisModule('instruments', ['instrumentlistwidget']),
+ lorisModule('candidate_profile', ['CandidateInfo']),
];
// Support project overrides