diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 89b8b8f322..95175b7599 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,16 @@ cgap-portal Change Log ---------- +15.1.0 +====== +`PR 737: Add notes for Case items `_ + +* Adds functionality for POSTing and PATCHing Note Items linked to Case Items +* Reveals new "Notes" column in columnExtensionMap +* New "notes available" indicator in search headers column +* New textarea popup component (CaseNotesColumn.js) for text input + + 15.0.1 ====== * Remove Dana's user from master inserts diff --git a/pyproject.toml b/pyproject.toml index d60daa5d23..65151773f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] # Note: Various modules refer to this system as "encoded", not "cgap-portal". name = "encoded" -version = "15.0.1" +version = "15.1.0" description = "Computational Genome Analysis Platform" authors = ["4DN-DCIC Team "] license = "MIT" diff --git a/src/encoded/schemas/case.json b/src/encoded/schemas/case.json index f7bd1e8d72..485cfbf83c 100644 --- a/src/encoded/schemas/case.json +++ b/src/encoded/schemas/case.json @@ -150,6 +150,12 @@ "exclude_from": [ "FFedit-create" ] + }, + "note": { + "title": "Note", + "description": "Notes for this case", + "type": "string", + "linkTo": "NoteStandard" } }, "facets": { @@ -329,6 +335,19 @@ "title": "Report Last Modified Date" } ] + }, + "notes": { + "title": "Case Notes", + "sort_fields": [ + { + "field": "note.last_text_edited.date_text_edited", + "title": "Last Edit Date" + }, + { + "field": "note.last_text_edited.text_edited_by.display_title", + "title": "Last Edit User" + } + ] } } } diff --git a/src/encoded/static/components/browse/CaseNotesColumn.js b/src/encoded/static/components/browse/CaseNotesColumn.js new file mode 100644 index 0000000000..42acbd367a --- /dev/null +++ b/src/encoded/static/components/browse/CaseNotesColumn.js @@ -0,0 +1,322 @@ +import React, { useState, forwardRef } from "react"; +import { Popover, OverlayTrigger } from "react-bootstrap"; +import { LocalizedTime } from "@hms-dbmi-bgm/shared-portal-components/es/components/ui/LocalizedTime"; +import { ajax } from "@hms-dbmi-bgm/shared-portal-components/es/components/util"; + +/** + * React-Boostrap (v1.6.7, Bootstrap 4.6 syntax) Popover component containing + * the main user actions, such as the "save" button and the textarea element + * where users add note text. + */ +const CaseNotesPopover = forwardRef(({ + note, + lastSavedText, + handleNoteSave, + currentText, + setCurrentText, + ...popoverProps +}, ref) => { + + const [isLoading, setIsLoading] = useState(false); + + // Information on the previous note, defaults to null. + const prevDate = lastSavedText.date; + const prevEditor = lastSavedText.user; + + return ( + + Case Notes + + { + lastSavedText.date ? +

+ Last Saved: { prevEditor && `by ${prevEditor}` } +

+ : + null + } + + + { lastSavedText.error &&

{lastSavedText.error}

} + { lastSavedText.warning &&

{lastSavedText.warning}

} +
+
+ ) +}); + +/** + * React-Boostrap (v1.6.7, Bootstrap 4.6 syntax) Overlay Trigger component. + * This component serves as the button for toggling the [CaseNotesPopover]. + * Shows red indicator when [currentText] === [lastSavedText.text] + */ +const CaseNotesButton = ({ + note, + lastSavedText, + handleNoteSave, + currentText, + setCurrentText +}) => ( + + } + > + + +); + + +/** + * Top-level component for rendering the Case Notes column. Props passed down + * from columnExtensionMap. + * @param {Object} result the current search result that corresponds to this + * case (the row whose render method calls this component) + * + * Note: Items with no link to a note item (no "note" field in [result]) set + * [lastSavedText] to the empty string by default. + */ +export const CaseNotesColumn = ({ result }) => { + // Initial state passed to children + const [lastSavedText, setLastSavedText] = useState((newNote) => { + + // If new note information is being provided + if (newNote) { + return { + text: newNote?.text ?? "", + date: newNote?.date ?? null, + user: newNote?.user ?? "", + userId: newNote?.userId ?? "", + warning: newNote?.warningText + } + } + // If there is a note item attached to this case with deleted status + else if (result?.note?.status === "deleted") { + return { + text: "", + date: null, + user: "", + userId: "", + } + } + + // Otherwise return whatever is received from [result] + return { + text: result?.note?.note_text ?? "", + date: result?.note?.last_text_edited?.date_text_edited ?? null, + user: result?.note?.last_text_edited?.text_edited_by?.display_title ?? "", + userId: result?.note?.last_text_edited?.text_edited_by?.uuid ?? "" + } + }); + + const [currentText, setCurrentText] = useState(lastSavedText.text); + + const caseID = result['@id']; + const noteID = result.note ? result.note['@id'] : ""; + + const warningText = "It may take some time for changes to be reflected. Please refresh or search again in a few minutes."; + const errorText = "An error has occurred. Please try again or contact an administrator." + + /** + * handleNoteSave executes a request to update the NOTE and CASE + * associate with this component. + * + * If there is no note attached to this case: + * - Executes a POST request for NOTE + * - Executes a PATCH request for CASE to link to NOTE + * If there is an exisitng note: + * - Executes a PATCH request for NOTE to update the text + * If there is an existing note AND [currentText] is the empty string + * - Executes a DELETE request for the NOTE item attached + * + * Note: The save button who triggers this function is enabled iff + * [currentText] !== [lastSavedText.text] + */ + const handleNoteSave = () => { + /** + * This the current text in the textarea input is not + * the empty string, which should be dealt with separately + */ + if (currentText !== "") { + + let payload = { + "note_text": currentText, + "project": result.project['@id'], + "institution": result.institution['@id'] + } + + /** + * IF: the last saved text is the empty string, + * 1. POST a new note + * 2. PATCH the corresponding case to link to the new note + */ + if (noteID === "") { + // 1.POST a new note + ajax.promise("/notes-standard", "POST", {}, JSON.stringify(payload)).then((res) => { + // Save the note item into the corresponding project + const newNoteId = res['@graph'][0]['@id']; + + if (!newNoteId) { + throw new Error("No note-standard @ID returned."); + } + else { + + // 2. PATCH the corresponding case to link to the new note + ajax.promise(caseID, "PATCH", {}, JSON.stringify({ + "note": "" + newNoteId + })).then((patchRes) => { + if (patchRes.status === "success") { + + // If the user has the same uuid (after extracting the uuid), don't change the user field + const new_userId = res['@graph'][0]?.last_text_edited.text_edited_by.split("/")[2]; + let new_user = (lastSavedText.userId === new_userId) ? lastSavedText.user : ""; + + setLastSavedText({ + text: currentText, + date: res['@graph'][0].last_text_edited.date_text_edited, + user: new_user, + userId: new_userId, + warning: warningText + }); + } + return res + }); + } + }).catch((e) => { + console.log(e); + + setLastSavedText({ + ...lastSavedText, + warning: "", + error: errorText + }); + }) + } + // ELSE: There is alrady a Note item linked, so simply modify its + // text and/or status + else { + ajax.promise(noteID, "PATCH", {}, JSON.stringify({ + "note_text": currentText, + "status": "current" + })).then((patchRes) => { + if (patchRes.status === "success") { + // If the user has the same uuid (after extracting the uuid), don't change the user field + const new_userId = patchRes['@graph'][0].last_text_edited.text_edited_by.split("/")[2]; + let new_user = (lastSavedText.userId === new_userId) ? lastSavedText.user : ""; + + setLastSavedText({ + text: patchRes['@graph'][0].note_text, + date: patchRes['@graph'][0].last_text_edited.date_text_edited, + user: new_user, + userId: new_userId, + warning: warningText + }); + } + return patchRes + }).catch((e) => { + console.log(e); + + setLastSavedText({ + ...lastSavedText, + warning: "", + error: errorText + }); + }); + } + } + /** + * [currentText] is the empty string and we should delete the note item + * that is attached to this case item + */ + else { + /** + * Send DELETE request for the note attached, setting the status + * to "deleted", then making sure that no note is shown on rerender + */ + ajax.promise(noteID, "DELETE", {}, JSON.stringify()).then((deleteRes) => { + if (deleteRes.status === "success") { + setLastSavedText({ + text: "", + date: "", + user: "", + userId: "", + warning: warningText + }); + } + }).catch((e) => { + console.log(e); + + setLastSavedText({ + ...lastSavedText, + warning: "", + error: errorText + }); + }); + } + } + + return ( +
+ +

{lastSavedText.text}

+
+ ); +}; \ No newline at end of file diff --git a/src/encoded/static/components/browse/columnExtensionMap.js b/src/encoded/static/components/browse/columnExtensionMap.js index a9e8cd7509..7b8022b6bd 100644 --- a/src/encoded/static/components/browse/columnExtensionMap.js +++ b/src/encoded/static/components/browse/columnExtensionMap.js @@ -17,6 +17,7 @@ import { variantSampleColumnExtensionMap, structuralVariantSampleColumnExtension import QuickPopover from '../item-pages/components/QuickPopover'; import { QCMFlag } from '../item-pages/components/QCM'; import { CurrentFamilyController, findCanonicalFamilyIndex } from '../item-pages/CaseView/CurrentFamilyController'; +import { CaseNotesColumn } from './CaseNotesColumn'; // eslint-disable-next-line no-unused-vars const { Item, ColumnDefinition } = typedefs; @@ -41,6 +42,7 @@ const MultiLevelColumn = React.memo(function MultiLevelColumn(props){ topLeft, status, statusTip = null, + note, mainTitle = null, dateTitle = "Created:", bottom = null, @@ -67,19 +69,44 @@ const MultiLevelColumn = React.memo(function MultiLevelColumn(props){ ); } + // Adds note icon functionality on case view return ( -
-
- - { topLeft } - - -
-

- { mainTitle || "-" } -

- { bottomSection } +
+
+ {topLeft} +
+ { + /** + * Only render the note icon if this is the display title + * column, and contains a [titleTip]. + */ + (titleTip && note != null) && ( + + ) + } + +
+

+ {mainTitle || "-"} +

+ {bottomSection} +
); }, function(){ return false; }); @@ -119,7 +146,8 @@ export const DisplayTitleColumnCase = React.memo(function DisplayTitleCaseDefaul case_title = null, individual = null, family = null, - sample_processing = null + sample_processing = null, + note } = result; const { uuid: indvID = null } = individual || {}; @@ -136,8 +164,9 @@ export const DisplayTitleColumnCase = React.memo(function DisplayTitleCaseDefaul statusTip = "Case exists, but some linked item is missing: Family, Individual, or Sample Processing."; } + return ( - { accession }} mainTitle={ @@ -429,7 +458,14 @@ export const columnExtensionMap = { } return { retLink }; } - } + }, + notes: { + "title": "Case Notes", + 'widthMap' : { 'lg' : 420, 'md' : 375, 'sm' : 300 }, + render: function renderNotesColumn(result) { + return ; + }, + }, }; diff --git a/src/encoded/static/scss/encoded/modules/_item-pages.scss b/src/encoded/static/scss/encoded/modules/_item-pages.scss index 738166fcd3..86f69af829 100644 --- a/src/encoded/static/scss/encoded/modules/_item-pages.scss +++ b/src/encoded/static/scss/encoded/modules/_item-pages.scss @@ -578,6 +578,18 @@ h3.submission-subtitle { background-color: #812423; } + // Identifiers for saved and unsaved states of CaseView comments hidden by default + &[data-status="note-saved"]:before { + color: #6C757D; + } + &[data-status="note-unset"]:before { + display: none; + } + &[data-status="note-dot-unsaved"]:before { + background-color: #C41700; + } + + // Bright Red &[data-status="deleted"]:before, // Applicable to Item.status &[data-status="upload failed"]:before, // Applicable to File.status @@ -616,7 +628,39 @@ h3.submission-subtitle { } } +// Styles for the status indicators note (note) and required fields (dot) +div.status-indicators { + display: flex; + flex-direction: row; + justify-content: center; + + &>.status-indicator { + margin: auto; + display: flex; + &-note { + position: relative; + &::before { + font-size: 10px; + display: inline-block; + position: relative; + } + } + &-dot { + position: relative; + &:before { + content: ""; + width: 9px; + height : 9px; + display: inline-block; + position: relative; + border-radius: 50%; + } + } + @include statusColorStyles; + } +} +// Fallback for only required fields (dot) styles i.status-indicator-dot { display: inline-block; position: relative; @@ -633,6 +677,7 @@ i.status-indicator-dot { } + @mixin acmgColorStyles { diff --git a/src/encoded/static/scss/encoded/modules/_search.scss b/src/encoded/static/scss/encoded/modules/_search.scss index f81c4aa6d9..3b6781a974 100644 --- a/src/encoded/static/scss/encoded/modules/_search.scss +++ b/src/encoded/static/scss/encoded/modules/_search.scss @@ -358,3 +358,103 @@ body[data-pathname="/search/"]:not([data-current-action="add"]) { } } } + + +/** Styling for Case Notes component */ +.search-headers-row { + & > .headers-columns-overflow-container > .columns { + & > .search-headers-column-block[data-field="notes"] { + & > .inner { + & > .column-title { + text-align: left; + margin-left: 20px; + } + } + } + } +} +.search-result-column-block[data-field="notes"] { + .case-notes { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; + position: relative; + + &-button { + height: auto; + margin: auto auto auto 30px; + background-color: transparent; + border: none; + padding:0; + + .icon-sticky-note { + font-size: 27px; + margin: 2px; + } + + &:focus { + border-radius: .25rem; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(27, 117, 185, 0.25); + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + } + + + .status-indicator-dot { + position:absolute; + + &::before { + position: absolute; + top: 0px; + right: 3px; + } + } + } + &-text { + width: 100%; + margin: auto 10px auto 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } +} + + + +/** Some additional styles for the popover */ +.popover[data-popover-category="notes"]{ + width: 307px; + + & > .popover-header { + margin-top: 0; + } + + & > .popover-body { + padding-top: 10px; + + & > button { + margin-top: 8px; + } + + & > .last-saved { + color: #9FA7AE; + margin-bottom: 2px; + } + + & > .form-control { + font-size: 0.8rem; + } + + .warning { + color: #9FA7AE; + margin-top: 5px; + } + + .error { + margin-top: 5px; + text-align: left; + } + } +} \ No newline at end of file diff --git a/src/encoded/types/case.py b/src/encoded/types/case.py index 4da1da1c72..eabe0eed10 100644 --- a/src/encoded/types/case.py +++ b/src/encoded/types/case.py @@ -253,6 +253,10 @@ def _build_case_embedded_list(): # File linkTo "structural_variant_vcf_file.file_ingestion_status", "structural_variant_vcf_file.accession", + # Note linkTo + "note.note_text", + "note.last_text_edited.date_text_edited", + "note.last_text_edited.text_edited_by.display_title", ]