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 { + 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 ( +
+
+ {cardInfo} +
+
+ ); + } +} + + +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