diff --git a/src/components/institution/InstitutionPatients.jsx b/src/components/institution/InstitutionPatients.jsx index cb1d68ac..0a47da2b 100644 --- a/src/components/institution/InstitutionPatients.jsx +++ b/src/components/institution/InstitutionPatients.jsx @@ -4,6 +4,7 @@ import RecordTable from "../record/RecordTable"; import PropTypes from "prop-types"; import ExportRecordsDropdown from "../record/ExportRecordsDropdown"; import { useI18n } from "../../hooks/useI18n"; +import { COLUMNS } from "../../constants/DefaultConstants.js"; const InstitutionPatients = (props) => { const { recordsLoaded, formTemplatesLoaded, onEdit, onExport, currentUser, filterAndSort } = props; @@ -16,6 +17,7 @@ const InstitutionPatients = (props) => { { const { i18n } = useI18n(); + const history = useHistory(); + const record = props.record, formTemplateOptions = props.formTemplateOptions, deleteButton = props.disableDelete ? null : ( @@ -47,30 +49,55 @@ const RecordRow = (props) => { return ( - + {props.visibleColumns.includes(COLUMNS.ID) && ( - - - - - + )} + + {props.visibleColumns.includes(COLUMNS.NAME) && ( + + + + )} + + {props.visibleColumns.includes(COLUMNS.AUTHOR) && ( + + {record.author.firstName && record.author.lastName ? ( + + ) : ( + Not Found + )} + + )} + + {props.visibleColumns.includes(COLUMNS.INSTITUTION) && ( {record.institution.name} + )} + + {props.visibleColumns.includes(COLUMNS.TEMPLATE) && ( {getFormTemplateOptionName(record.formTemplate, formTemplateOptions)} - - - {formatDate(new Date(record.lastModified ? record.lastModified : record.dateCreated))} - - - {statusInfo ? : "N/A"} - + )} + + {props.visibleColumns.includes(COLUMNS.LAST_MODIFIED) && ( + + {formatDate(new Date(record.lastModified ? record.lastModified : record.dateCreated))} + + )} + + {props.visibleColumns.includes(COLUMNS.STATUS) && ( + + {statusInfo ? : "N/A"} + + )} - ) : null} - - {showPublishButton ? ( - - ) : null} - - - - - - ); - } - - _getFormTemplateName() { - const { formTemplatesLoaded, formTemplate, intl } = this.props; + const getFormTemplateName = () => { if (formTemplate) { const formTemplateOptions = formTemplatesLoaded.formTemplates ? processTypeaheadOptions(formTemplatesLoaded.formTemplates, intl) : []; return formTemplateOptions.find((r) => r.id === formTemplate)?.name; } - } + }; - _getPanelTitle() { - if (!hasRole(this.props.currentUser, ROLE.READ_ALL_RECORDS) && this.props.formTemplate) { - const formTemplateName = this._getFormTemplateName(); + const getPanelTitle = () => { + if (!hasRole(currentUser, ROLE.READ_ALL_RECORDS) && formTemplate) { + const formTemplateName = getFormTemplateName(); if (formTemplateName) { return formTemplateName; } } - return this.i18n("records.panel-title"); - } -} + return i18n("records.panel-title"); + }; + + const showCreateButton = STUDY_CREATE_AT_MOST_ONE_RECORD + ? !recordsLoaded.records || recordsLoaded.records.length < 1 + : true; + const showPublishButton = hasRole(currentUser, ROLE.PUBLISH_RECORDS); + const createRecordDisabled = STUDY_CLOSED_FOR_ADDITION && !hasRole(currentUser, ROLE.WRITE_ALL_RECORDS); + const createRecordTooltip = i18n( + createRecordDisabled ? "records.closed-study.create-tooltip" : "records.opened-study.create-tooltip", + ); + const onCreateWithFormTemplate = () => handlers.onCreate(formTemplate); + + const toggleColumn = (id) => { + setVisibleColumns((prev) => { + let updated; + if (prev.includes(id)) { + updated = prev.filter((colId) => colId !== id); + } else { + updated = [...prev, id]; + } + localStorage.setItem("visibleColumns", JSON.stringify(updated)); + return updated; + }); + }; + + const handleSelectAllColumns = (e) => { + if (e.target.checked) { + setVisibleColumns(Object.values(COLUMNS)); + localStorage.setItem("visibleColumns", JSON.stringify(Object.values(COLUMNS))); + } else { + setVisibleColumns([]); + localStorage.setItem("visibleColumns", JSON.stringify([])); + } + }; + + return ( + + + + {getPanelTitle()} + + Choose columns + +
+ { + if (el) { + el.indeterminate = + visibleColumns.length > 0 && visibleColumns.length < Object.keys(COLUMNS).length; + } + }} + onChange={(e) => handleSelectAllColumns(e)} + /> + +
+ + {Object.entries(COLUMNS).map(([key, value]) => ( +
+ toggleColumn(value)} + /> + +
+ ))} +
+ + } + > + +
+
+ + <> + + + + +
+
+ {showCreateButton ? ( + + ) : null} + + {showPublishButton ? ( + + ) : null} +
+ +
+
+
+ ); +}; + +Records.propTypes = { + i18n: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + recordsLoaded: PropTypes.object, + recordDeleted: PropTypes.object, + handlers: PropTypes.object.isRequired, + currentUser: PropTypes.object.isRequired, + formTemplatesLoaded: PropTypes.object.isRequired, + pagination: PropTypes.object.isRequired, + filterAndSort: PropTypes.object.isRequired, + formTemplate: PropTypes.string, +}; export default injectIntl(withI18n(Records)); diff --git a/src/components/record/filter/InstitutionFilter.jsx b/src/components/record/filter/InstitutionFilter.jsx index f631ee91..ff07666c 100644 --- a/src/components/record/filter/InstitutionFilter.jsx +++ b/src/components/record/filter/InstitutionFilter.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Button, Col, Form, Row } from "react-bootstrap"; import { loadInstitutions } from "../../../actions/InstitutionsActions"; @@ -11,12 +11,28 @@ const InstitutionFilter = ({ value, onChange }) => { const { i18n } = useI18n(); const dispatch = useDispatch(); const institutions = useSelector((state) => state.institutions.institutionsLoaded.institutions); - React.useEffect(() => { + + useEffect(() => { + dispatch(loadInstitutions()); + }, [dispatch]); + + const options = React.useMemo(() => { if (!institutions) { - dispatch(loadInstitutions()); + return []; } - }, [dispatch, institutions]); - const selected = sanitizeArray(institutions).find((o) => o.key === value); + return institutions.map((institution) => ({ + label: institution.name, + value: institution.key, + })); + }, [institutions, i18n]); + + const selected = sanitizeArray(institutions) + .filter((o) => value?.includes(o.key)) + .map((institution) => ({ + label: institution.name, + value: institution.key, + })); + return (
@@ -25,13 +41,14 @@ const InstitutionFilter = ({ value, onChange }) => { onChange({ institution: o !== null ? o.key : undefined }, {})} value={selected} + onChange={(selectedOptions) => { + const keys = selectedOptions ? selectedOptions.map((o) => o.value) : []; + onChange({ institution: keys }, {}); + }} placeholder={i18n("select.placeholder")} isClearable={false} /> @@ -63,7 +80,7 @@ const InstitutionFilter = ({ value, onChange }) => { }; InstitutionFilter.propTypes = { - value: PropTypes.string, + value: PropTypes.arrayOf(PropTypes.string), onChange: PropTypes.func.isRequired, }; diff --git a/src/constants/DefaultConstants.js b/src/constants/DefaultConstants.js index c522747f..5830a523 100644 --- a/src/constants/DefaultConstants.js +++ b/src/constants/DefaultConstants.js @@ -159,3 +159,13 @@ export const RECORD_PHASE = { }; export const STORAGE_TABLE_PAGE_SIZE_KEY = `${APP_TITLE}_TABLE_PAGE_SIZE`; + +export const COLUMNS = { + ID: "Id", + NAME: "Name", + AUTHOR: "Author", + INSTITUTION: "Institution", + TEMPLATE: "Template", + LAST_MODIFIED: "Last Modified", + STATUS: "Status", +}; diff --git a/src/i18n/cs.js b/src/i18n/cs.js index 8d11aa46..ef8acccc 100644 --- a/src/i18n/cs.js +++ b/src/i18n/cs.js @@ -38,6 +38,7 @@ export default { publish: "Publikovat", reason: "Důvod", "select.placeholder": "Vyberte...", + "select-all": "Vybrat vše", "login.title": Constants.APP_NAME + " - Přihlášení", "login.username": "Uživatelské jméno", @@ -153,6 +154,7 @@ export default { "records.id": "Id", "records.form-template": "Šablona", "records.local-name": "Název", + "records.author": "Autor", "records.completion-status": "Stav", "records.completion-status.open": "Rozpracovaný", "records.completion-status.valid": "Validní", diff --git a/src/i18n/en.js b/src/i18n/en.js index 3fd3a985..eb6ee060 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -38,6 +38,7 @@ export default { publish: "Publish", reason: "Reason", "select.placeholder": "Select...", + "select-all": "Select all", "login.title": Constants.APP_NAME + " - Login", "login.username": "Username", @@ -151,6 +152,7 @@ export default { "records.id": "Id", "records.form-template": "Template", "records.local-name": "Name", + "records.author": "Author", "records.completion-status": "Status", "records.completion-status.open": "Open", "records.completion-status.valid": "Valid", diff --git a/src/styles/record-manager.css b/src/styles/record-manager.css index adc700e6..bde68bdd 100644 --- a/src/styles/record-manager.css +++ b/src/styles/record-manager.css @@ -883,3 +883,17 @@ header { .filter-datetimepicker { max-width: 120px; } + +.btn-levitate { + transition: all 0.2s ease; +} + +.btn-levitate:hover { + transform: translate(-4px, -4px); + box-shadow: 3px 3px 0 0 black !important; +} + +.btn-levitate:active { + transform: translate(0, 0); + box-shadow: 1px 1px 0 0 black !important; +} diff --git a/src/utils/Utils.js b/src/utils/Utils.js index 52fce13e..2a1fdbd2 100644 --- a/src/utils/Utils.js +++ b/src/utils/Utils.js @@ -321,7 +321,7 @@ export function formatDateWithMilliseconds(timestamp) { } export function sanitizeArray(arr) { - return arr ? (Array.isArray(arr) ? arr : [arr]) : []; + return [].concat(arr ?? []); } /** diff --git a/tests/__tests__/components/RecordRow.spec.jsx b/tests/__tests__/components/RecordRow.spec.jsx index 17ccaea8..c684eef4 100644 --- a/tests/__tests__/components/RecordRow.spec.jsx +++ b/tests/__tests__/components/RecordRow.spec.jsx @@ -1,10 +1,11 @@ import { screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { describe, expect, vi, test } from "vitest"; +import { describe, expect, vi } from "vitest"; import { getMessageByKey, renderWithIntl } from "../../utils/utils.jsx"; import RecordRow from "../../../src/components/record/RecordRow.jsx"; -import { ROLE } from "../../../src/constants/DefaultConstants.js"; +import { COLUMNS, RECORD_PHASE as RecordPhase, ROLE } from "../../../src/constants/DefaultConstants.js"; import { admin } from "../../__mocks__/users.js"; +import { formatDate } from "../../../src/utils/Utils.js"; const defaultProps = { disableDelete: false, @@ -15,11 +16,21 @@ const defaultProps = { uri: "http://onto.fel.cvut.cz/ontologies/record-manager/patient-record#instance456619208", key: "159968282553298775", localName: "Test", - dateCreated: "1520956570035", - author: { username: "joe" }, + dateCreated: 1520956570035, + author: { username: "Thomas", firstName: "Thomas", lastName: "Shelby" }, institution: { key: 12345678, name: "Test institution" }, + lastModified: 1730034000000, + formTemplate: "Template1", + phase: RecordPhase.OPEN, }, currentUser: admin, + visibleColumns: Object.values(COLUMNS), + formTemplateOptions: [ + { + id: "Template1", + name: "Template 1", + }, + ], }; vi.mock("react-authorization", () => { @@ -44,25 +55,82 @@ describe("RecordRow", function () { expect(row).toHaveClass("position-relative"); }); - it("renders localName in second column", () => { + it("renders key if it is visible", () => { + renderComponent(); + expect(screen.getByText(defaultProps.record.key)).toBeInTheDocument(); + }); + + it("does not render key if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.ID) }); + expect(screen.queryByText(defaultProps.record.key)).not.toBeInTheDocument(); + }); + + it("renders localName if it is visible", () => { renderComponent(); expect(screen.getByText(defaultProps.record.localName)).toBeInTheDocument(); }); - it("renders key column and edit button when current user has READ_ALL_RECORDS role", () => { - renderComponent({ currentUser: { roles: [ROLE.READ_ALL_RECORDS] } }); - expect(screen.getByText(defaultProps.record.key)).toBeInTheDocument(); + it("does not render localName if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.NAME) }); + expect(screen.queryByText(defaultProps.record.localName)).not.toBeInTheDocument(); + }); + + it("renders author if it is visible and has firstName and lastName", () => { + renderComponent(); + expect(screen.getByText("Thomas Shelby")).toBeInTheDocument(); + }); + + it("does not render author if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.AUTHOR) }); + expect(screen.queryByText("Thomas Shelby")).not.toBeInTheDocument(); + }); + + it("renders 'Not Found' for author if firstName or lastName are missing", () => { + renderComponent({ record: { ...defaultProps.record, author: { username: "Thomas" } } }); + expect(screen.getByText("Not Found")).toBeInTheDocument(); + }); + + it("renders institution if it is visible", () => { + renderComponent(); expect(screen.getByText(defaultProps.record.institution.name)).toBeInTheDocument(); }); - it("does not render key column and institution when current user lacks READ_ALL_RECORDS role", () => { - renderComponent({ currentUser: { roles: [] } }); - expect(screen.queryByText(defaultProps.record.key)).not.toBeInTheDocument(); + it("does not render institution if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.INSTITUTION) }); expect(screen.queryByText(defaultProps.record.institution.name)).not.toBeInTheDocument(); - expect(screen.getByText(defaultProps.record.localName)).toBeInTheDocument(); }); - it("always renders Open button regardless of authorization", () => { + it("renders form template if it is visible", () => { + renderComponent(); + expect(screen.queryByText("Template 1")).toBeInTheDocument(); + }); + + it("does not render form template if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.TEMPLATE) }); + expect(screen.queryByText("Template 1")).not.toBeInTheDocument(); + }); + + it("renders last modified date if it is visible", () => { + renderComponent(); + expect(screen.getByText(formatDate(new Date(defaultProps.record.lastModified)))).toBeInTheDocument(); + }); + + it("does not render last modified date if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.LAST_MODIFIED) }); + expect(screen.queryByText("27-10-24 14:00")).not.toBeInTheDocument(); + }); + + it("renders delete button when delete is not disabled", () => { + renderComponent(); + expect(screen.getByText(getMessageByKey("delete"))).toBeInTheDocument(); + }); + + it("does not render delete button when delete is disabled", () => { + renderComponent({ disableDelete: true }); + expect(screen.queryByText(getMessageByKey("delete"))).not.toBeInTheDocument(); + }); + + it("renders open button", () => { renderComponent({ currentUser: { roles: [] } }); expect(screen.getByText(getMessageByKey("open"))).toBeInTheDocument(); }); diff --git a/tests/__tests__/components/RecordTable.spec.jsx b/tests/__tests__/components/RecordTable.spec.jsx index 54cbe873..bd4ddccd 100644 --- a/tests/__tests__/components/RecordTable.spec.jsx +++ b/tests/__tests__/components/RecordTable.spec.jsx @@ -1,6 +1,6 @@ import { fireEvent, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { ACTION_STATUS, RECORD_PHASE, ROLE } from "../../../src/constants/DefaultConstants"; +import { ACTION_STATUS, COLUMNS, RECORD_PHASE, ROLE } from "../../../src/constants/DefaultConstants"; import { describe, expect, vi, beforeEach, test } from "vitest"; import RecordTable from "../../../src/components/record/RecordTable.jsx"; import { getMessageByKey, renderWithIntl } from "../../utils/utils.jsx"; @@ -68,6 +68,7 @@ const defaultProps = { }, onChange: vi.fn(), }, + visibleColumns: Object.values(COLUMNS), }; vi.mock("../../../src/components/record/RecordRow", () => ({ @@ -112,24 +113,81 @@ describe("RecordTable", function () { expect(screen.getByTestId("record-row-test3")).toBeInTheDocument(); }); - it("renders id, institution, and form template header when user lacks READ_ALL_RECORDS role", () => { - renderComponent({ - currentUser: { - roles: [ROLE.READ_ALL_RECORDS], - }, - }); - expect(screen.getByText(getMessageByKey("records.id"))).toBeInTheDocument(); - expect(screen.getByText(getMessageByKey("institution.panel-title"))).toBeInTheDocument(); - expect(screen.getByText(getMessageByKey("records.form-template"))).toBeInTheDocument(); + it("renders no records message when there are no records", () => { + renderComponent({ recordsLoaded: { records: [] } }); + expect(screen.getByText(getMessageByKey("records.no-records"))).toBeInTheDocument(); }); - it("does not render id, institution, and form template header when user lacks READ_ALL_RECORDS role", () => { + it("renders key header if it is visible", () => { renderComponent(); + expect(screen.getByText(getMessageByKey("records.id"))).toBeInTheDocument(); + }); + + it("does not render key header if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.ID) }); expect(screen.queryByText(getMessageByKey("records.id"))).not.toBeInTheDocument(); + }); + + it("renders localName header if it is visible", () => { + renderComponent(); + expect(screen.getByText(getMessageByKey("records.local-name"))).toBeInTheDocument(); + }); + + it("does not render localName header if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.NAME) }); + expect(screen.queryByText(getMessageByKey("records.local-name"))).not.toBeInTheDocument(); + }); + + it("renders author header if it is visible", () => { + renderComponent(); + expect(screen.getByText(getMessageByKey("records.author"))).toBeInTheDocument(); + }); + + it("does not render author header if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.AUTHOR) }); + expect(screen.queryByText(getMessageByKey("records.author"))).not.toBeInTheDocument(); + }); + + it("renders institution header if it is visible", () => { + renderComponent(); + expect(screen.getByText(getMessageByKey("institution.panel-title"))).toBeInTheDocument(); + }); + + it("does not render institution header if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.INSTITUTION) }); expect(screen.queryByText(getMessageByKey("institution.panel-title"))).not.toBeInTheDocument(); + }); + + it("renders template header if it is visible", () => { + renderComponent(); + expect(screen.getByText(getMessageByKey("records.form-template"))).toBeInTheDocument(); + }); + + it("does not render template header if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.TEMPLATE) }); expect(screen.queryByText(getMessageByKey("records.form-template"))).not.toBeInTheDocument(); }); + it("renders last modified header if it is visible", () => { + renderComponent(); + expect(screen.getByText(getMessageByKey("records.last-modified"))).toBeInTheDocument(); + }); + + it("does not render last modified header if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.LAST_MODIFIED) }); + expect(screen.queryByText(getMessageByKey("records.last-modified"))).not.toBeInTheDocument(); + }); + + it("renders status header if it is visible", () => { + renderComponent(); + expect(screen.getByText(getMessageByKey("records.completion-status"))).toBeInTheDocument(); + }); + + it("does not render status header if it is not visible", () => { + renderComponent({ visibleColumns: Object.values(COLUMNS).filter((col) => col !== COLUMNS.STATUS) }); + expect(screen.queryByText(getMessageByKey("records.completion-status"))).not.toBeInTheDocument(); + }); + it("renders records", () => { renderComponent(); expect(screen.getByTestId("record-row-test1")).toBeInTheDocument(); @@ -148,6 +206,6 @@ describe("RecordTable", function () { it("does not render delete dialog when a record is not selected for deletion", () => { renderComponent(); - expect(screen.getByTestId("delete-item-dialog")).toBeInTheDocument(); + expect(screen.queryByText("delete-item-dialog")).not.toBeInTheDocument(); }); }); diff --git a/tests/__tests__/components/Records.spec.jsx b/tests/__tests__/components/Records.spec.jsx index b21c3b58..1471f96e 100644 --- a/tests/__tests__/components/Records.spec.jsx +++ b/tests/__tests__/components/Records.spec.jsx @@ -108,12 +108,6 @@ describe("Records", function () { expect(screen.getByTestId("pagination")).toBeInTheDocument(); }); - it("renders no records alert when records array is empty", () => { - renderComponent({ recordsLoaded: { records: [] } }); - expect(screen.getByTestId("alert")).toBeInTheDocument(); - expect(screen.getByText(getMessageByKey("records.no-records"))).toBeInTheDocument(); - }); - it("renders create button", () => { renderComponent(); const createButton = screen.getByText(getMessageByKey("records.create-tile"));