From 70d69699cbbdaae3feee6b31fba0a53f489eb479 Mon Sep 17 00:00:00 2001 From: Frank Niessink <frank@niessink.com> Date: Tue, 7 Jan 2025 10:41:35 +0100 Subject: [PATCH] Migrate SUIR components to MUI. Closes #9796. --- components/frontend/package-lock.json | 189 +--------- components/frontend/package.json | 3 - components/frontend/src/App.css | 20 +- components/frontend/src/App.js | 12 +- components/frontend/src/AppUI.js | 106 +++--- components/frontend/src/PageContent.js | 35 +- components/frontend/src/PageContent.test.js | 2 +- components/frontend/src/app_ui_settings.js | 26 -- .../frontend/src/context/Permissions.js | 4 +- .../frontend/src/context/Permissions.test.js | 14 + .../src/dashboard/CardDashboard.test.js | 13 +- .../frontend/src/dashboard/ExportCard.css | 35 -- .../frontend/src/dashboard/ExportCard.js | 79 ---- .../src/dashboard/FilterCardWithTable.js | 6 +- .../frontend/src/dashboard/IssuesCard.js | 23 +- .../frontend/src/dashboard/LegendCard.js | 9 +- .../src/dashboard/MetricSummaryCard.js | 9 +- .../dashboard/MetricsRequiringActionCard.js | 34 +- .../frontend/src/dashboard/PageHeader.js | 43 +++ ...{ExportCard.test.js => PageHeader.test.js} | 28 +- components/frontend/src/errorMessage.js | 26 -- components/frontend/src/fields/Comment.js | 21 -- .../frontend/src/fields/Comment.test.js | 8 - .../frontend/src/fields/CommentField.js | 23 ++ components/frontend/src/fields/DateInput.css | 16 - components/frontend/src/fields/DateInput.js | 60 ---- .../frontend/src/fields/DateInput.test.js | 69 ---- components/frontend/src/fields/Input.js | 54 --- components/frontend/src/fields/Input.test.js | 70 ---- .../frontend/src/fields/IntegerInput.js | 102 ------ .../frontend/src/fields/IntegerInput.test.js | 121 ------- .../src/fields/MultipleChoiceField.js | 64 ++++ .../src/fields/MultipleChoiceInput.js | 84 ----- .../src/fields/MultipleChoiceInput.test.js | 81 ----- .../frontend/src/fields/PasswordInput.js | 26 -- .../frontend/src/fields/PasswordInput.test.js | 17 - .../frontend/src/fields/ReadOnlyInput.js | 34 -- .../frontend/src/fields/ReadOnlyInput.test.js | 34 -- .../frontend/src/fields/SingleChoiceInput.js | 70 ---- .../src/fields/SingleChoiceInput.test.js | 107 ------ components/frontend/src/fields/StringInput.js | 78 ---- .../frontend/src/fields/StringInput.test.js | 95 ----- components/frontend/src/fields/TextField.js | 99 ++++++ components/frontend/src/fields/TextInput.js | 76 ---- .../frontend/src/fields/TextInput.test.js | 76 ---- .../frontend/src/header_footer/Footer.css | 11 - .../frontend/src/header_footer/Footer.js | 9 +- .../src/header_footer/buttons/HomeButton.js | 2 +- components/frontend/src/index.js | 1 - components/frontend/src/issue/IssueStatus.js | 141 +++++--- .../frontend/src/issue/IssueStatus.test.js | 48 +-- components/frontend/src/issue/IssuesRows.js | 131 +++---- .../frontend/src/issue/IssuesRows.test.js | 8 +- .../src/measurement/MeasurementSources.js | 3 +- .../measurement/MeasurementSources.test.js | 3 +- .../src/measurement/MeasurementTarget.js | 11 +- .../src/measurement/MeasurementValue.js | 97 ++--- .../src/measurement/MeasurementValue.test.js | 3 - .../frontend/src/measurement/Overrun.js | 115 +++--- .../frontend/src/measurement/SourceStatus.js | 22 +- .../frontend/src/measurement/StatusIcon.js | 4 +- .../frontend/src/measurement/TimeLeft.js | 14 +- .../metric/MetricConfigurationParameters.js | 242 ++++++------- .../MetricConfigurationParameters.test.js | 4 +- .../src/metric/MetricDebtParameters.js | 160 ++++----- .../src/metric/MetricDebtParameters.test.js | 69 ++-- .../frontend/src/metric/MetricDetails.js | 115 +++--- .../frontend/src/metric/MetricDetails.test.js | 59 +-- components/frontend/src/metric/MetricType.js | 24 +- .../frontend/src/metric/MetricType.test.js | 12 +- .../frontend/src/metric/MetricTypeHeader.js | 17 +- components/frontend/src/metric/Target.js | 314 ++-------------- components/frontend/src/metric/Target.test.js | 266 +------------- .../frontend/src/metric/TargetVisualiser.js | 225 ++++++++++++ .../src/metric/TargetVisualiser.test.js | 250 +++++++++++++ components/frontend/src/metric/TrendGraph.js | 17 +- components/frontend/src/metric/status.js | 9 - .../notification/NotificationDestinations.js | 102 +++--- .../NotificationDestinations.test.js | 2 +- .../frontend/src/report/IssueTracker.js | 336 +++++++++--------- .../frontend/src/report/IssueTracker.test.js | 48 +-- components/frontend/src/report/Report.js | 50 +-- .../frontend/src/report/ReportErrorMessage.js | 33 -- components/frontend/src/report/ReportTitle.js | 221 ++++++------ .../frontend/src/report/ReportTitle.test.js | 76 ++-- .../frontend/src/report/ReportsOverview.js | 19 +- .../src/report/ReportsOverview.test.js | 2 +- .../src/report/ReportsOverviewTitle.js | 164 +++++---- .../src/report/ReportsOverviewTitle.test.js | 6 +- .../src/semantic_ui_react_wrappers.js | 10 - .../src/semantic_ui_react_wrappers/Card.css | 11 - .../src/semantic_ui_react_wrappers/Card.js | 17 - .../semantic_ui_react_wrappers/Dropdown.css | 3 - .../semantic_ui_react_wrappers/Dropdown.js | 16 - .../src/semantic_ui_react_wrappers/Form.css | 59 --- .../src/semantic_ui_react_wrappers/Form.js | 23 -- .../semantic_ui_react_wrappers/Form.test.js | 28 -- .../src/semantic_ui_react_wrappers/Header.css | 3 - .../src/semantic_ui_react_wrappers/Header.js | 13 - .../src/semantic_ui_react_wrappers/Label.css | 15 - .../src/semantic_ui_react_wrappers/Label.js | 13 - .../src/semantic_ui_react_wrappers/Message.js | 14 - .../src/semantic_ui_react_wrappers/Popup.css | 14 - .../src/semantic_ui_react_wrappers/Popup.js | 12 - .../semantic_ui_react_wrappers/Segment.css | 12 - .../src/semantic_ui_react_wrappers/Segment.js | 10 - .../src/semantic_ui_react_wrappers/Tab.css | 3 - .../src/semantic_ui_react_wrappers/Tab.js | 17 - .../src/semantic_ui_react_wrappers/Table.css | 20 -- .../src/semantic_ui_react_wrappers/Table.js | 17 - .../semantic_ui_react_wrappers/dark_mode.js | 8 - .../dark_mode.test.js | 15 - components/frontend/src/sharedPropTypes.js | 2 + components/frontend/src/source/Logo.js | 13 +- components/frontend/src/source/Source.js | 115 +++--- components/frontend/src/source/Source.test.js | 2 +- .../frontend/src/source/SourceEntities.css | 8 - .../frontend/src/source/SourceEntities.js | 102 +++--- .../src/source/SourceEntities.test.js | 2 +- .../frontend/src/source/SourceEntity.css | 43 --- .../frontend/src/source/SourceEntity.js | 28 +- .../frontend/src/source/SourceEntity.test.js | 37 +- .../src/source/SourceEntityDetails.js | 133 +++---- .../src/source/SourceEntityDetails.test.js | 62 ++-- .../frontend/src/source/SourceParameter.js | 207 ++++++----- .../src/source/SourceParameter.test.js | 90 +++-- .../frontend/src/source/SourceParameters.js | 25 +- .../src/source/SourceParameters.test.js | 4 +- components/frontend/src/source/SourceType.js | 42 ++- .../frontend/src/source/SourceType.test.js | 7 +- .../frontend/src/source/SourceTypeHeader.js | 24 +- components/frontend/src/source/Sources.js | 15 +- .../frontend/src/source/Sources.test.js | 10 +- components/frontend/src/subject/Subject.css | 2 +- components/frontend/src/subject/Subject.js | 6 +- .../frontend/src/subject/SubjectParameters.js | 84 ++--- .../frontend/src/subject/SubjectTable.css | 120 +------ .../frontend/src/subject/SubjectTable.js | 60 ++-- .../frontend/src/subject/SubjectTable.test.js | 7 +- .../frontend/src/subject/SubjectTableBody.js | 6 +- .../src/subject/SubjectTableFooter.js | 14 +- .../src/subject/SubjectTableFooter.test.js | 2 +- .../src/subject/SubjectTableHeader.js | 76 ++-- .../src/subject/SubjectTableHeader.test.js | 12 +- .../frontend/src/subject/SubjectTableRow.js | 75 ++-- .../src/subject/SubjectTableRow.test.js | 6 +- .../frontend/src/subject/SubjectTitle.js | 61 ++-- .../frontend/src/subject/SubjectTitle.test.js | 11 +- .../frontend/src/subject/SubjectType.js | 29 +- .../frontend/src/subject/SubjectsButtonRow.js | 2 +- components/frontend/src/theme.js | 136 +++++++ components/frontend/src/utils.js | 1 - .../frontend/src/widgets/CommentSegment.js | 9 +- .../frontend/src/widgets/DatePicker.css | 3 - components/frontend/src/widgets/DatePicker.js | 38 -- .../frontend/src/widgets/ErrorMessage.js | 23 ++ components/frontend/src/widgets/Header.js | 18 + .../src/widgets/HeaderWithDetails.css | 9 - .../frontend/src/widgets/HeaderWithDetails.js | 65 ++-- .../src/widgets/HeaderWithDetails.test.js | 10 +- components/frontend/src/widgets/HyperLink.js | 1 + components/frontend/src/widgets/Label.js | 28 ++ .../frontend/src/widgets/LabelWithDate.js | 38 -- .../frontend/src/widgets/LabelWithDropdown.js | 31 -- .../src/widgets/LabelWithDropdown.test.js | 80 ----- .../frontend/src/widgets/LabelWithHelp.js | 27 -- .../src/widgets/LabelWithHelp.test.js | 17 - .../src/widgets/LabelWithHyperLink.js | 21 -- .../src/widgets/LabelWithHyperLink.test.js | 8 - .../frontend/src/widgets/ReadTheDocsLink.js | 7 +- components/frontend/src/widgets/TabPane.css | 14 - components/frontend/src/widgets/TabPane.js | 57 --- .../frontend/src/widgets/TabPane.test.js | 41 --- .../frontend/src/widgets/TableHeaderCell.js | 30 +- .../src/widgets/TableHeaderCell.test.js | 18 +- .../src/widgets/TableRowWithDetails.js | 33 +- .../src/widgets/TableRowWithDetails.test.js | 6 +- components/frontend/src/widgets/Tabs.js | 51 +++ .../frontend/src/widgets/WarningMessage.js | 37 +- .../src/widgets/WarningMessage.test.js | 6 +- components/frontend/src/widgets/icons.js | 2 +- tests/application_tests/src/test_report.py | 6 +- 182 files changed, 3168 insertions(+), 5266 deletions(-) delete mode 100644 components/frontend/src/dashboard/ExportCard.css delete mode 100644 components/frontend/src/dashboard/ExportCard.js create mode 100644 components/frontend/src/dashboard/PageHeader.js rename components/frontend/src/dashboard/{ExportCard.test.js => PageHeader.test.js} (65%) delete mode 100644 components/frontend/src/errorMessage.js delete mode 100644 components/frontend/src/fields/Comment.js delete mode 100644 components/frontend/src/fields/Comment.test.js create mode 100644 components/frontend/src/fields/CommentField.js delete mode 100644 components/frontend/src/fields/DateInput.css delete mode 100644 components/frontend/src/fields/DateInput.js delete mode 100644 components/frontend/src/fields/DateInput.test.js delete mode 100644 components/frontend/src/fields/Input.js delete mode 100644 components/frontend/src/fields/Input.test.js delete mode 100644 components/frontend/src/fields/IntegerInput.js delete mode 100644 components/frontend/src/fields/IntegerInput.test.js create mode 100644 components/frontend/src/fields/MultipleChoiceField.js delete mode 100644 components/frontend/src/fields/MultipleChoiceInput.js delete mode 100644 components/frontend/src/fields/MultipleChoiceInput.test.js delete mode 100644 components/frontend/src/fields/PasswordInput.js delete mode 100644 components/frontend/src/fields/PasswordInput.test.js delete mode 100644 components/frontend/src/fields/ReadOnlyInput.js delete mode 100644 components/frontend/src/fields/ReadOnlyInput.test.js delete mode 100644 components/frontend/src/fields/SingleChoiceInput.js delete mode 100644 components/frontend/src/fields/SingleChoiceInput.test.js delete mode 100644 components/frontend/src/fields/StringInput.js delete mode 100644 components/frontend/src/fields/StringInput.test.js create mode 100644 components/frontend/src/fields/TextField.js delete mode 100644 components/frontend/src/fields/TextInput.js delete mode 100644 components/frontend/src/fields/TextInput.test.js delete mode 100644 components/frontend/src/header_footer/Footer.css create mode 100644 components/frontend/src/metric/TargetVisualiser.js create mode 100644 components/frontend/src/metric/TargetVisualiser.test.js delete mode 100644 components/frontend/src/report/ReportErrorMessage.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Card.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Card.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Dropdown.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Dropdown.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Form.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Form.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Form.test.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Header.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Header.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Label.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Label.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Message.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Popup.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Popup.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Segment.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Segment.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Tab.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Tab.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Table.css delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/Table.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/dark_mode.js delete mode 100644 components/frontend/src/semantic_ui_react_wrappers/dark_mode.test.js delete mode 100644 components/frontend/src/source/SourceEntity.css create mode 100644 components/frontend/src/theme.js delete mode 100644 components/frontend/src/widgets/DatePicker.css delete mode 100644 components/frontend/src/widgets/DatePicker.js create mode 100644 components/frontend/src/widgets/ErrorMessage.js create mode 100644 components/frontend/src/widgets/Header.js delete mode 100644 components/frontend/src/widgets/HeaderWithDetails.css create mode 100644 components/frontend/src/widgets/Label.js delete mode 100644 components/frontend/src/widgets/LabelWithDate.js delete mode 100644 components/frontend/src/widgets/LabelWithDropdown.js delete mode 100644 components/frontend/src/widgets/LabelWithDropdown.test.js delete mode 100644 components/frontend/src/widgets/LabelWithHelp.js delete mode 100644 components/frontend/src/widgets/LabelWithHelp.test.js delete mode 100644 components/frontend/src/widgets/LabelWithHyperLink.js delete mode 100644 components/frontend/src/widgets/LabelWithHyperLink.test.js delete mode 100644 components/frontend/src/widgets/TabPane.css delete mode 100644 components/frontend/src/widgets/TabPane.js delete mode 100644 components/frontend/src/widgets/TabPane.test.js create mode 100644 components/frontend/src/widgets/Tabs.js diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index c797dc518d..8cd6e4461a 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -16,18 +16,15 @@ "@mui/x-date-pickers": "^7.23.6", "crypto-js": "^4.2.0", "dayjs": "^1.11.13", - "fomantic-ui-css": "^2.9.3", "history": "^5.3.0", "prop-types": "^15.8.1", "react": "^18.3.1", - "react-datepicker": "^7.6.0", "react-dom": "^18.3.1", "react-grid-layout": "^1.5.0", "react-hash-link": "1.0.2", "react-is": "^18.3.1", "react-timeago": "^7.2.0", "react-toastify": "^11.0.2", - "semantic-ui-react": "^2.1.5", "victory": "^37.3.5" }, "devDependencies": { @@ -2474,21 +2471,6 @@ "@floating-ui/utils": "^0.2.8" } }, - "node_modules/@floating-ui/react": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.3.tgz", - "integrity": "sha512-CLHnes3ixIFFKVQDdICjel8muhFLOBdQH7fgtHNPY8UbCNqbeKZ262G7K66lGQOUQWWnYocf7ZbUsLJgGfsLHg==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.1.2", - "@floating-ui/utils": "^0.2.9", - "tabbable": "^6.0.0" - }, - "peerDependencies": { - "react": ">=17.0.0", - "react-dom": ">=17.0.0" - } - }, "node_modules/@floating-ui/react-dom": { "version": "2.1.2", "license": "MIT", @@ -2501,42 +2483,7 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", - "license": "MIT" - }, - "node_modules/@fluentui/react-component-event-listener": { - "version": "0.63.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-component-event-listener/-/react-component-event-listener-0.63.1.tgz", - "integrity": "sha512-gSMdOh6tI3IJKZFqxfQwbTpskpME0CvxdxGM2tdglmf6ZPVDi0L4+KKIm+2dN8nzb8Ya1A8ZT+Ddq0KmZtwVQg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.4" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18", - "react-dom": "^16.8.0 || ^17 || ^18" - } - }, - "node_modules/@fluentui/react-component-ref": { - "version": "0.63.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-component-ref/-/react-component-ref-0.63.1.tgz", - "integrity": "sha512-8MkXX4+R3i80msdbD4rFpEB4WWq2UDvGwG386g3ckIWbekdvN9z2kWAd9OXhRGqB7QeOsoAGWocp6gAMCivRlw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.4", - "react-is": "^16.6.3" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18", - "react-dom": "^16.8.0 || ^17 || ^18" - } - }, - "node_modules/@fluentui/react-component-ref/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "0.2.8", "license": "MIT" }, "node_modules/@humanfs/core": { @@ -3696,20 +3643,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@semantic-ui-react/event-stack": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@semantic-ui-react/event-stack/-/event-stack-3.1.3.tgz", - "integrity": "sha512-FdTmJyWvJaYinHrKRsMLDrz4tTMGdFfds299Qory53hBugiDvGC0tEJf+cHsi5igDwWb/CLOgOiChInHwq8URQ==", - "license": "MIT", - "dependencies": { - "exenv": "^1.2.2", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "dev": true, @@ -6853,6 +6786,8 @@ "node_modules/date-fns": { "version": "3.6.0", "license": "MIT", + "optional": true, + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -8184,12 +8119,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exenv": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", - "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", - "license": "BSD-3-Clause" - }, "node_modules/exit": { "version": "0.1.2", "dev": true, @@ -8537,15 +8466,6 @@ } } }, - "node_modules/fomantic-ui-css": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/fomantic-ui-css/-/fomantic-ui-css-2.9.3.tgz", - "integrity": "sha512-7bM6p3QRpfZFofg7Fd3crzox2E/nBsPyyWDN+N4lnTjNMxgKltSaXJTfhLoK5xBA+wEoNtcmm6w6FQ5Drj+27A==", - "license": "MIT", - "dependencies": { - "jquery": "^3.4.0" - } - }, "node_modules/for-each": { "version": "0.3.3", "dev": true, @@ -10946,12 +10866,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -11155,12 +11069,6 @@ "node": ">=4.0" } }, - "node_modules/keyboard-key": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz", - "integrity": "sha512-qkBzPTi3rlAKvX7k0/ub44sqOfXeLc/jcnGGmj5c7BJpU8eDrEVPyhCvNYAaoubbsLm9uGWwQJO1ytQK1a9/dQ==", - "license": "MIT" - }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -11308,12 +11216,6 @@ "version": "4.17.21", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "dev": true, @@ -13753,21 +13655,6 @@ "node": ">=14" } }, - "node_modules/react-datepicker": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-7.6.0.tgz", - "integrity": "sha512-9cQH6Z/qa4LrGhzdc3XoHbhrxNcMi9MKjZmYgF/1MNNaJwvdSjv3Xd+jjvrEEbKEf71ZgCA3n7fQbdwd70qCRw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react": "^0.27.0", - "clsx": "^2.1.1", - "date-fns": "^3.6.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", - "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" - } - }, "node_modules/react-dev-utils": { "version": "12.0.1", "dev": true, @@ -13889,21 +13776,6 @@ "react": "*" } }, - "node_modules/react-popper": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", - "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", - "license": "MIT", - "dependencies": { - "react-fast-compare": "^3.0.1", - "warning": "^4.0.2" - }, - "peerDependencies": { - "@popperjs/core": "^2.0.0", - "react": "^16.8.0 || ^17 || ^18", - "react-dom": "^16.8.0 || ^17 || ^18" - } - }, "node_modules/react-refresh": { "version": "0.11.0", "dev": true, @@ -15195,40 +15067,6 @@ "node": ">=10" } }, - "node_modules/semantic-ui-react": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.1.5.tgz", - "integrity": "sha512-nIqmmUNpFHfovEb+RI2w3E2/maZQutd8UIWyRjf1SLse+XF51hI559xbz/sLN3O6RpLjr/echLOOXwKCirPy3Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.5", - "@fluentui/react-component-event-listener": "~0.63.0", - "@fluentui/react-component-ref": "~0.63.0", - "@popperjs/core": "^2.6.0", - "@semantic-ui-react/event-stack": "^3.1.3", - "clsx": "^1.1.1", - "keyboard-key": "^1.1.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "prop-types": "^15.7.2", - "react-is": "^16.8.6 || ^17.0.0 || ^18.0.0", - "react-popper": "^2.3.0", - "shallowequal": "^1.1.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/semantic-ui-react/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/semver": { "version": "6.3.1", "dev": true, @@ -15408,12 +15246,6 @@ "dev": true, "license": "ISC" }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "dev": true, @@ -16261,12 +16093,6 @@ "url": "https://opencollective.com/unts" } }, - "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", - "license": "MIT" - }, "node_modules/tailwindcss": { "version": "3.4.14", "dev": true, @@ -17363,15 +17189,6 @@ "makeerror": "1.0.12" } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/watchpack": { "version": "2.4.2", "dev": true, diff --git a/components/frontend/package.json b/components/frontend/package.json index 4c7bbb8275..8657e6d14a 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -12,18 +12,15 @@ "@mui/x-date-pickers": "^7.23.6", "crypto-js": "^4.2.0", "dayjs": "^1.11.13", - "fomantic-ui-css": "^2.9.3", "history": "^5.3.0", "prop-types": "^15.8.1", "react": "^18.3.1", - "react-datepicker": "^7.6.0", "react-dom": "^18.3.1", "react-grid-layout": "^1.5.0", "react-hash-link": "1.0.2", "react-is": "^18.3.1", "react-timeago": "^7.2.0", "react-toastify": "^11.0.2", - "semantic-ui-react": "^2.1.5", "victory": "^37.3.5" }, "scripts": { diff --git a/components/frontend/src/App.css b/components/frontend/src/App.css index 610c057fed..95ccba6113 100644 --- a/components/frontend/src/App.css +++ b/components/frontend/src/App.css @@ -1,21 +1,3 @@ -.MainContainer { - flex: 1; - margin-top: 6em; - padding-left: 1em; - padding-right: 1em; -} - -@media print { - .MainContainer { - margin-top: 0em; - } -} - html { - scroll-padding-top: 163px; /* height of sticky header */ -} - -:root { - --inverted-menu-background-color: #1b1c1d; - --selection-color: #2185d0; + scroll-padding-top: 176px; /* height of sticky header */ } diff --git a/components/frontend/src/App.js b/components/frontend/src/App.js index 6ee06731d5..db92afcc38 100644 --- a/components/frontend/src/App.js +++ b/components/frontend/src/App.js @@ -1,6 +1,7 @@ import "./App.css" -import { createTheme, ThemeProvider } from "@mui/material/styles" +import { CssBaseline } from "@mui/material" +import { ThemeProvider } from "@mui/material/styles" import { Action } from "history" import history from "history/browser" import { Component } from "react" @@ -11,16 +12,10 @@ import { nr_measurements_api } from "./api/measurement" import { get_report, get_reports_overview } from "./api/report" import { AppUI } from "./AppUI" import { registeredURLSearchParams } from "./hooks/url_search_query" +import { theme } from "./theme" import { isValidDate_YYYYMMDD, toISODateStringInCurrentTZ } from "./utils" import { showConnectionMessage, showMessage } from "./widgets/toast" -const theme = createTheme({ - colorSchemes: { - dark: true, // Add a dark theme (light theme is available by default) - }, - components: { MuiTooltip: { defaultProps: { arrow: true }, styleOverrides: { tooltip: { fontSize: "1em" } } } }, -}) - class App extends Component { constructor(props) { super(props) @@ -245,6 +240,7 @@ class App extends Component { render() { return ( <ThemeProvider theme={theme}> + <CssBaseline enableColorScheme /> <AppUI changed_fields={this.changed_fields} dataModel={this.state.dataModel} diff --git a/components/frontend/src/AppUI.js b/components/frontend/src/AppUI.js index 4b35645ba8..7494a249df 100644 --- a/components/frontend/src/AppUI.js +++ b/components/frontend/src/AppUI.js @@ -66,65 +66,55 @@ export function AppUI({ } const darkMode = userPrefersDarkMode(mode) - const backgroundColor = darkMode ? "rgb(40, 40, 40)" : "white" return ( - <div - style={{ - display: "flex", - minHeight: "100vh", - flexDirection: "column", - backgroundColor: backgroundColor, - }} - > - <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}> - <DarkMode.Provider value={darkMode}> - <HashLinkObserver /> - <Menubar - email={email} - handleDateChange={handleDateChange} - openReportsOverview={openReportsOverview} - onDate={handleDateChange} - report_date={report_date} - report_uuid={report_uuid} - set_user={set_user} - user={user} - panel={ - <SettingsPanel - atReportsOverview={atReportsOverview} - handleSort={handleSort} - settings={settings} - tags={getReportsTags(reports)} - /> - } - settings={settings} - setUIMode={setMode} - uiMode={mode} - /> - <ToastContainer theme="colored" /> - <Permissions.Provider value={user_permissions}> - <DataModel.Provider value={dataModel}> - <PageContent - changed_fields={changed_fields} - current_report={current_report} - handleSort={handleSort} - lastUpdate={lastUpdate} - loading={loading} - nrMeasurements={nrMeasurements} - openReportsOverview={openReportsOverview} - openReport={openReport} - reload={reload} - report_date={report_date} - report_uuid={report_uuid} - reports={reports} - reports_overview={reports_overview} - settings={settings} - /> - </DataModel.Provider> - </Permissions.Provider> - <Footer lastUpdate={lastUpdate} report={current_report} /> - </DarkMode.Provider> - </LocalizationProvider> - </div> + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}> + <DarkMode.Provider value={darkMode}> + <HashLinkObserver /> + <Menubar + email={email} + handleDateChange={handleDateChange} + openReportsOverview={openReportsOverview} + onDate={handleDateChange} + report_date={report_date} + report_uuid={report_uuid} + set_user={set_user} + user={user} + panel={ + <SettingsPanel + atReportsOverview={atReportsOverview} + handleSort={handleSort} + settings={settings} + tags={getReportsTags(reports)} + /> + } + settings={settings} + setUIMode={setMode} + uiMode={mode} + /> + <ToastContainer theme="colored" /> + <Permissions.Provider value={user_permissions}> + <DataModel.Provider value={dataModel}> + <PageContent + changed_fields={changed_fields} + current_report={current_report} + handleSort={handleSort} + lastUpdate={lastUpdate} + loading={loading} + nrMeasurements={nrMeasurements} + openReportsOverview={openReportsOverview} + openReport={openReport} + reload={reload} + report_date={report_date} + report_uuid={report_uuid} + reports={reports} + reports_overview={reports_overview} + settings={settings} + /> + </DataModel.Provider> + </Permissions.Provider> + <Footer lastUpdate={lastUpdate} report={current_report} /> + </DarkMode.Provider> + </LocalizationProvider> ) } AppUI.propTypes = { diff --git a/components/frontend/src/PageContent.js b/components/frontend/src/PageContent.js index 87c998c4ef..dd13ef09f6 100644 --- a/components/frontend/src/PageContent.js +++ b/components/frontend/src/PageContent.js @@ -1,11 +1,11 @@ +import { Box, Container } from "@mui/material" +import CircularProgress from "@mui/material/CircularProgress" import { bool, func, number, string } from "prop-types" import { useEffect, useState } from "react" -import { Container, Loader } from "semantic-ui-react" import { get_measurements } from "./api/measurement" import { Report } from "./report/Report" import { ReportsOverview } from "./report/ReportsOverview" -import { Segment } from "./semantic_ui_react_wrappers" import { datePropType, optionalDatePropType, @@ -72,9 +72,17 @@ export function PageContent({ let content if (loading) { content = ( - <Segment basic placeholder aria-label="Loading..."> - <Loader active size="massive" /> - </Segment> + <Box + sx={{ + alignItems: "center", + display: "flex", + width: "100%", + height: "60vh", + justifyContent: "center", + }} + > + <CircularProgress aria-label="Loading..." size="6rem" /> + </Box> ) } else { const commonProps = { @@ -108,7 +116,22 @@ export function PageContent({ } } return ( - <Container fluid className="MainContainer"> + <Container + disableGutters + maxWidth={false} + sx={{ + bgcolor: "background.default", + flex: 1, + paddingBottom: "50px", + paddingTop: "10px", + paddingLeft: "20px", + paddingRight: "20px", + marginTop: "60px", + marginBottom: "0px", + marginLeft: "0px", + marginRight: "0px", + }} + > {content} </Container> ) diff --git a/components/frontend/src/PageContent.test.js b/components/frontend/src/PageContent.test.js index 7f1c28c37d..1b7007f209 100644 --- a/components/frontend/src/PageContent.test.js +++ b/components/frontend/src/PageContent.test.js @@ -63,7 +63,7 @@ it("shows that the report was missing", async () => { it("shows the loading spinner", async () => { await renderPageContent({ loading: true }) - expect(screen.getAllByLabelText(/Loading/).length).toBe(1) + expect(screen.getAllByRole("progressbar").length).toBe(1) }) function expectMeasurementsCall(date, offset = 0) { diff --git a/components/frontend/src/app_ui_settings.js b/components/frontend/src/app_ui_settings.js index 4c6de39fb7..46e968df92 100644 --- a/components/frontend/src/app_ui_settings.js +++ b/components/frontend/src/app_ui_settings.js @@ -1,12 +1,9 @@ -import { string } from "prop-types" - import { useArrayURLSearchQuery, useBooleanURLSearchQuery, useIntegerURLSearchQuery, useStringURLSearchQuery, } from "./hooks/url_search_query" -import { stringsURLSearchQueryPropType } from "./sharedPropTypes" function urlSearchQueryKey(key, report_uuid) { // Make the settings changeable per report (and separately for the reports overview) by adding the report UUID as @@ -147,26 +144,3 @@ export function allSettingsAreDefault(settings) { settings.sortDirection.isDefault() ) } - -export function tabChangeHandler(expandedItems, uuid) { - // Return an event handler for Tab.onTabChange that updates the active tab - return function onTabChange(_event, data) { - const oldItem = expandedItems.value.filter((item) => item?.startsWith(uuid))[0] - const newItem = `${uuid}:${data.activeIndex}` - expandedItems.toggle(oldItem, newItem) - } -} -tabChangeHandler.propTypes = { - expandedItems: stringsURLSearchQueryPropType, - uuid: string, -} - -export function activeTabIndex(expandedItems, uuid) { - // Return the active tab index of the expanded item, defaults to 0 - const item = expandedItems.value.filter((item) => item?.startsWith(uuid))[0] ?? `${uuid}:0` - return Number(item.split(":")[1]) -} -activeTabIndex.propTypes = { - expandedItems: stringsURLSearchQueryPropType, - uuid: string, -} diff --git a/components/frontend/src/context/Permissions.js b/components/frontend/src/context/Permissions.js index 2fafa48e1d..4eee655e70 100644 --- a/components/frontend/src/context/Permissions.js +++ b/components/frontend/src/context/Permissions.js @@ -10,10 +10,10 @@ export const PERMISSIONS = [EDIT_REPORT_PERMISSION, EDIT_ENTITY_PERMISSION] export const Permissions = React.createContext(null) export function accessGranted(permissions, requiredPermissions) { - if (!requiredPermissions) { + if ((requiredPermissions ?? []).length === 0) { return true } - if (!permissions) { + if ((permissions ?? []).length === 0) { return false } return requiredPermissions.every((permission) => permissions.includes(permission)) diff --git a/components/frontend/src/context/Permissions.test.js b/components/frontend/src/context/Permissions.test.js index 5b559ae4b9..a8fcdc3145 100644 --- a/components/frontend/src/context/Permissions.test.js +++ b/components/frontend/src/context/Permissions.test.js @@ -50,3 +50,17 @@ it("shows the editable only component", () => { expect(screen.queryAllByText("One").length).toBe(0) expect(screen.queryAllByText("Two").length).toBe(1) }) + +it("shows the editable only component if no permissions are needed", () => { + render( + <Permissions.Provider value={["mockPermission"]}> + <ReadOnlyOrEditable + requiredPermissions={[]} + readOnlyComponent={<MockComponent1 />} + editableComponent={<MockComponent2 />} + /> + </Permissions.Provider>, + ) + expect(screen.queryAllByText("One").length).toBe(0) + expect(screen.queryAllByText("Two").length).toBe(1) +}) diff --git a/components/frontend/src/dashboard/CardDashboard.test.js b/components/frontend/src/dashboard/CardDashboard.test.js index 00406dfc6d..e484628668 100644 --- a/components/frontend/src/dashboard/CardDashboard.test.js +++ b/components/frontend/src/dashboard/CardDashboard.test.js @@ -1,6 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react" -import { DarkMode } from "../context/DarkMode" import { EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" import { CardDashboard } from "./CardDashboard" import { MetricSummaryCard } from "./MetricSummaryCard" @@ -12,13 +11,11 @@ afterEach(() => jest.restoreAllMocks()) function renderCardDashboard({ cards = [], initialLayout = [], saveLayout = jest.fn } = {}) { return render( - <DarkMode.Provider value={false}> - <Permissions.Provider value={[EDIT_REPORT_PERMISSION]}> - <div id="dashboard"> - <CardDashboard cards={cards} initialLayout={initialLayout} saveLayout={saveLayout} /> - </div> - </Permissions.Provider> - </DarkMode.Provider>, + <Permissions.Provider value={[EDIT_REPORT_PERMISSION]}> + <div id="dashboard"> + <CardDashboard cards={cards} initialLayout={initialLayout} saveLayout={saveLayout} /> + </div> + </Permissions.Provider>, ) } diff --git a/components/frontend/src/dashboard/ExportCard.css b/components/frontend/src/dashboard/ExportCard.css deleted file mode 100644 index 7aeabb7464..0000000000 --- a/components/frontend/src/dashboard/ExportCard.css +++ /dev/null @@ -1,35 +0,0 @@ -.ui.card.export-data-card { - display: none; -} - -@media print { - .reportHeader { - display: flex; - align-items: center; - justify-content: space-between; - margin-right: -13px; - } - - .ui.card.export-data-card { - display: block; - min-width: 270px; - flex-shrink: 0; - } - - .ui.card.export-data-card.list .item { - display: flex; - overflow: hidden; - white-space: normal; - padding: 2px; - line-height: 1.4em; - } - - .ui.card.export-data-card .header { - overflow: hidden; - white-space: nowrap; - } - - .ui.card.export-data-card .list { - margin-top: 0.5em; - } -} diff --git a/components/frontend/src/dashboard/ExportCard.js b/components/frontend/src/dashboard/ExportCard.js deleted file mode 100644 index b6eec93f08..0000000000 --- a/components/frontend/src/dashboard/ExportCard.js +++ /dev/null @@ -1,79 +0,0 @@ -import "./ExportCard.css" - -import { bool, string } from "prop-types" -import { Card, List } from "semantic-ui-react" - -import { childrenPropType, datePropType, reportPropType } from "../sharedPropTypes" -import { DOCUMENTATION_URL } from "../utils" - -function ExportCardItem({ children, url }) { - const item = children - return url ? ( - <List.Item as="a" href={url}> - {item} - </List.Item> - ) : ( - <List.Item>{item}</List.Item> - ) -} -ExportCardItem.propTypes = { - children: childrenPropType, - url: string, -} - -export function ExportCard({ lastUpdate, report, reportDate, isOverview = false }) { - const reportURL = new URLSearchParams(window.location.search).get("report_url") ?? window.location.href - const title = isOverview ? "About these reports" : "About this report" - const listItems = [ - <List.Item key={"reportURL"} data-testid={"reportUrl"}> - <List.Content verticalAlign={"middle"}> - <ExportCardItem url={reportURL}>{report.title}</ExportCardItem> - </List.Content> - </List.Item>, - <List.Item key={"date"}> - <List.Content verticalAlign={"middle"}> - <ExportCardItem>{"Report date: " + formatDate(reportDate ?? new Date())}</ExportCardItem> - </List.Content> - </List.Item>, - <List.Item key={"generated"}> - <List.Content verticalAlign={"middle"}> - <ExportCardItem> - {"Generated: " + formatDate(lastUpdate) + ", " + formatTime(lastUpdate)} - </ExportCardItem> - </List.Content> - </List.Item>, - <List.Item key={"version"} data-testid={"version"}> - <List.Content verticalAlign={"middle"}> - <ExportCardItem url={`${DOCUMENTATION_URL}/changelog.html`}> - <em>Quality-time</em> v{process.env.REACT_APP_VERSION} - </ExportCardItem> - </List.Content> - </List.Item>, - ] - return ( - <Card tabIndex="0" className="export-data-card"> - <Card.Content> - <Card.Header title={title} textAlign="center"> - {title} - </Card.Header> - <List size="small">{listItems}</List> - </Card.Content> - </Card> - ) -} -ExportCard.propTypes = { - isOverview: bool, - lastUpdate: datePropType, - report: reportPropType, - reportDate: datePropType, -} - -// Hard code en-GB to get European style dates and times. See https://github.com/ICTU/quality-time/issues/8381. - -function formatDate(date) { - return date.toLocaleDateString("en-GB", { year: "numeric", month: "2-digit", day: "2-digit" }).replace(/\//g, "-") -} - -function formatTime(date) { - return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) -} diff --git a/components/frontend/src/dashboard/FilterCardWithTable.js b/components/frontend/src/dashboard/FilterCardWithTable.js index 12bde1f378..c294f5cb5b 100644 --- a/components/frontend/src/dashboard/FilterCardWithTable.js +++ b/components/frontend/src/dashboard/FilterCardWithTable.js @@ -1,14 +1,14 @@ +import { Table, TableBody } from "@mui/material" import { bool, func, string } from "prop-types" -import { Table } from "../semantic_ui_react_wrappers" import { childrenPropType } from "../sharedPropTypes" import { DashboardCard } from "./DashboardCard" export function FilterCardWithTable({ children, onClick, selected, title }) { return ( <DashboardCard onClick={onClick} selected={selected} title={title} titleFirst={true}> - <Table basic="very" compact="very" size="small"> - <Table.Body>{children}</Table.Body> + <Table size="small"> + <TableBody>{children}</TableBody> </Table> </DashboardCard> ) diff --git a/components/frontend/src/dashboard/IssuesCard.js b/components/frontend/src/dashboard/IssuesCard.js index 20576ce725..39e2325eac 100644 --- a/components/frontend/src/dashboard/IssuesCard.js +++ b/components/frontend/src/dashboard/IssuesCard.js @@ -1,9 +1,8 @@ -import { Chip } from "@mui/material" +import { Chip, TableCell, TableRow } from "@mui/material" import { bool, func } from "prop-types" -import { Table } from "../semantic_ui_react_wrappers" import { reportPropType } from "../sharedPropTypes" -import { capitalize, ISSUE_STATUS_THEME_COLORS } from "../utils" +import { capitalize } from "../utils" import { FilterCardWithTable } from "./FilterCardWithTable" function issueStatuses(report) { @@ -33,18 +32,12 @@ issueStatuses.propTypes = { function tableRows(report) { const statuses = issueStatuses(report) return Object.keys(statuses).map((status) => ( - <Table.Row key={status}> - <Table.Cell>{capitalize(status)}</Table.Cell> - <Table.Cell textAlign="right"> - <Chip - color={ISSUE_STATUS_THEME_COLORS[status]} - label={`${statuses[status]}`} - size="small" - sx={{ borderRadius: 1 }} - variant={ISSUE_STATUS_THEME_COLORS[status] ? "" : "outlined"} - /> - </Table.Cell> - </Table.Row> + <TableRow key={status}> + <TableCell sx={{ fontSize: "12px", paddingLeft: "0px" }}>{capitalize(status)}</TableCell> + <TableCell sx={{ paddingRight: "0px", textAlign: "right" }}> + <Chip color={status} label={`${statuses[status]}`} size="small" sx={{ borderRadius: 1 }} /> + </TableCell> + </TableRow> )) } tableRows.propTypes = { diff --git a/components/frontend/src/dashboard/LegendCard.js b/components/frontend/src/dashboard/LegendCard.js index 3b57f71028..d086fe0008 100644 --- a/components/frontend/src/dashboard/LegendCard.js +++ b/components/frontend/src/dashboard/LegendCard.js @@ -8,14 +8,17 @@ export function LegendCard() { const listItems = STATUSES.map((status) => ( <ListItem key={status} dense={true} sx={{ padding: "0px" }}> <StatusIcon status={status} size="small" /> - - <ListItemText primary={STATUS_SHORT_NAME[status]} primaryTypographyProps={{ noWrap: true }} /> + <ListItemText + primary={STATUS_SHORT_NAME[status]} + slotProps={{ primary: { typography: { fontSize: "11px" } } }} + sx={{ marginLeft: "10px" }} + /> </ListItem> )) return ( <DashboardCard title="Legend" titleFirst={true}> - <List sx={{ padding: "0px", paddingLeft: "16px" }}>{listItems}</List> + <List sx={{ padding: "0px", paddingLeft: "0px" }}>{listItems}</List> </DashboardCard> ) } diff --git a/components/frontend/src/dashboard/MetricSummaryCard.js b/components/frontend/src/dashboard/MetricSummaryCard.js index 8bda21569c..bb17643c3b 100644 --- a/components/frontend/src/dashboard/MetricSummaryCard.js +++ b/components/frontend/src/dashboard/MetricSummaryCard.js @@ -1,10 +1,8 @@ import "./MetricSummaryCard.css" import { bool, func, number, object, oneOfType, string } from "prop-types" -import { useContext } from "react" import { VictoryContainer, VictoryLabel, VictoryTooltip } from "victory" -import { DarkMode } from "../context/DarkMode" import { useBoundingBox } from "../hooks/boundingbox" import { STATUS_COLORS_RGB, STATUSES } from "../metric/status" import { pluralize, sum } from "../utils" @@ -48,8 +46,6 @@ function ariaChartLabel(summary) { export function MetricSummaryCard({ header, onClick, selected, summary, maxY }) { const [boundingBox, ref] = useBoundingBox() - const labelColor = useContext(DarkMode) ? "darkgrey" : "rgba(120, 120, 120)" - const flyoutBgColor = useContext(DarkMode) ? "rgba(60, 65, 70)" : "white" const animate = { duration: 0, onLoad: { duration: 0 } } const colors = STATUSES.map((status) => STATUS_COLORS_RGB[status]) const bbWidth = boundingBox.width ?? 0 @@ -60,9 +56,8 @@ export function MetricSummaryCard({ header, onClick, selected, summary, maxY }) constrainToVisibleArea={true} cornerRadius={4} flyoutHeight={54} // If we don't pass this, a height is calculated by Victory, but it's much too high - flyoutStyle={{ fill: flyoutBgColor }} renderInPortal={false} - style={{ fontFamily: "Arial", fontSize: 16, fill: labelColor }} + style={{ fontFamily: "Arial", fontSize: 16 }} /> ) const dates = Object.keys(summary) @@ -72,7 +67,7 @@ export function MetricSummaryCard({ header, onClick, selected, summary, maxY }) height: Math.max(bbHeight, 1), // Prevent "Failed prop type: Invalid prop range supplied to VictoryBar" label: ( <VictoryLabel - style={{ fill: labelColor }} + style={{ fill: "grey" }} text={nrMetricsLabel(sum(summary[dates[0]]))} textAnchor="middle" x={bbWidth / 2} diff --git a/components/frontend/src/dashboard/MetricsRequiringActionCard.js b/components/frontend/src/dashboard/MetricsRequiringActionCard.js index b51abde1cf..4b7c09306b 100644 --- a/components/frontend/src/dashboard/MetricsRequiringActionCard.js +++ b/components/frontend/src/dashboard/MetricsRequiringActionCard.js @@ -1,7 +1,7 @@ +import { Chip, TableCell, TableRow } from "@mui/material" import { bool, func } from "prop-types" -import { STATUS_COLORS, STATUS_NAME, STATUSES_REQUIRING_ACTION } from "../metric/status" -import { Label, Table } from "../semantic_ui_react_wrappers" +import { STATUS_NAME, STATUSES_REQUIRING_ACTION } from "../metric/status" import { reportsPropType } from "../sharedPropTypes" import { getMetricStatus, sum } from "../utils" import { FilterCardWithTable } from "./FilterCardWithTable" @@ -30,26 +30,22 @@ metricStatuses.propTypes = { function tableRows(reports) { const statuses = metricStatuses(reports) const rows = Object.keys(statuses).map((status) => ( - <Table.Row key={status}> - <Table.Cell>{STATUS_NAME[status]}</Table.Cell> - <Table.Cell textAlign="right"> - <Label size="small" color={STATUS_COLORS[status] === "white" ? null : STATUS_COLORS[status]}> - {statuses[status]} - </Label> - </Table.Cell> - </Table.Row> + <TableRow key={status}> + <TableCell sx={{ fontSize: "12px", paddingLeft: "0px" }}>{STATUS_NAME[status]}</TableCell> + <TableCell sx={{ paddingRight: "0px", textAlign: "right" }}> + <Chip color={status} label={statuses[status]} size="small" sx={{ borderRadius: 1 }} /> + </TableCell> + </TableRow> )) rows.push( - <Table.Row key="total"> - <Table.Cell> + <TableRow key="total"> + <TableCell sx={{ fontSize: "12px", paddingLeft: "0px" }}> <b>Total</b> - </Table.Cell> - <Table.Cell textAlign="right"> - <Label size="small" color="black"> - {sum(Object.values(statuses))} - </Label> - </Table.Cell> - </Table.Row>, + </TableCell> + <TableCell sx={{ paddingRight: "0px", textAlign: "right" }}> + <Chip color="total" label={sum(Object.values(statuses))} size="small" sx={{ borderRadius: 1 }} /> + </TableCell> + </TableRow>, ) return rows } diff --git a/components/frontend/src/dashboard/PageHeader.js b/components/frontend/src/dashboard/PageHeader.js new file mode 100644 index 0000000000..7b0fae2992 --- /dev/null +++ b/components/frontend/src/dashboard/PageHeader.js @@ -0,0 +1,43 @@ +import { Stack, Typography } from "@mui/material" + +import { datePropType, reportPropType } from "../sharedPropTypes" +import { HyperLink } from "../widgets/HyperLink" + +export function PageHeader({ lastUpdate, report, reportDate }) { + const reportURL = new URLSearchParams(window.location.search).get("report_url") ?? window.location.href + const title = report?.title ?? "Reports overview" + const changelogURL = `https://quality-time.readthedocs.io/en/v${process.env.REACT_APP_VERSION}/changelog.html` + return ( + <Stack + direction="row" + spacing={2} + sx={{ display: "none", displayPrint: "inline-flex", justifyContent: "space-between", width: "100%" }} + > + <Typography key={"reportURL"} data-testid={"reportUrl"}> + <HyperLink url={reportURL}>{title}</HyperLink> + </Typography> + <Typography key={"date"}>{"Report date: " + formatDate(reportDate ?? new Date())}</Typography> + <Typography key={"generated"}> + {"Generated: " + formatDate(lastUpdate) + ", " + formatTime(lastUpdate)} + </Typography> + <Typography key={"version"} data-testid={"version"}> + <HyperLink url={changelogURL}>Quality-time v{process.env.REACT_APP_VERSION}</HyperLink> + </Typography> + </Stack> + ) +} +PageHeader.propTypes = { + lastUpdate: datePropType, + report: reportPropType, + reportDate: datePropType, +} + +// Hard code en-GB to get European style dates and times. See https://github.com/ICTU/quality-time/issues/8381. + +function formatDate(date) { + return date.toLocaleDateString("en-GB", { year: "numeric", month: "2-digit", day: "2-digit" }).replace(/\//g, "-") +} + +function formatTime(date) { + return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) +} diff --git a/components/frontend/src/dashboard/ExportCard.test.js b/components/frontend/src/dashboard/PageHeader.test.js similarity index 65% rename from components/frontend/src/dashboard/ExportCard.test.js rename to components/frontend/src/dashboard/PageHeader.test.js index 69c1e09bdf..f3e019e44d 100644 --- a/components/frontend/src/dashboard/ExportCard.test.js +++ b/components/frontend/src/dashboard/PageHeader.test.js @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react" -import { ExportCard } from "./ExportCard" import { mockGetAnimations } from "./MockAnimations" +import { PageHeader } from "./PageHeader" beforeEach(() => mockGetAnimations()) @@ -15,6 +15,7 @@ const mockDateOfToday = new Date() const report = { report_uuid: "report_uuid", + title: "Title", subjects: { subject_uuid: { type: "subject_type", @@ -32,37 +33,38 @@ const report = { }, } -function renderExportCard({ isOverview = false, lastUpdate = new Date(), report = null, reportDate = null } = {}) { - render(<ExportCard isOverview={isOverview} lastUpdate={lastUpdate} report={report} reportDate={reportDate} />) +function renderPageHeader({ lastUpdate = new Date(), report = null, reportDate = null } = {}) { + render(<PageHeader lastUpdate={lastUpdate} report={report} reportDate={reportDate} />) } -it("displays correct title for an overview report", () => { - renderExportCard({ isOverview: true, report: report }) - expect(screen.getByText(/About these reports/)).toBeInTheDocument() +it("displays correct title for the reports overview", () => { + renderPageHeader({}) + expect(screen.getByText(/Reports overview/)).toBeInTheDocument() }) -it("displays correct title for a detailed report", () => { - renderExportCard({ report: report }) - expect(screen.getByText(/About this report/)).toBeInTheDocument() +it("displays correct title for a report", () => { + window.location.search = "?report_url=https://report/" + renderPageHeader({ report: report }) + expect(screen.getByText(/Title/)).toBeInTheDocument() }) it("displays dates in en-GB format", () => { - renderExportCard({ lastUpdate: mockLastUpdate, report: report, reportDate: mockReportDate }) + renderPageHeader({ lastUpdate: mockLastUpdate, report: report, reportDate: mockReportDate }) expect(screen.getByText(/Report date: 24-03-2024/)).toBeInTheDocument() expect(screen.getByText(/Generated: 26-03-2024, 12:34/)).toBeInTheDocument() }) it("displays report URL", () => { - renderExportCard({ report: report }) + renderPageHeader({ report: report }) expect(screen.getByTestId("reportUrl")).toBeInTheDocument() }) it("displays version link", () => { - renderExportCard({ lastUpdate: mockLastUpdate, report: report }) + renderPageHeader({ lastUpdate: mockLastUpdate, report: report }) expect(screen.getByTestId("version")).toBeInTheDocument() }) it("displays today as report date if no report date is provided", () => { - renderExportCard({ lastUpdate: mockLastUpdate, report: report }) + renderPageHeader({ lastUpdate: mockLastUpdate, report: report }) expect(screen.getByText(`Report date: ${mockDateOfToday}`)).toBeInTheDocument() }) diff --git a/components/frontend/src/errorMessage.js b/components/frontend/src/errorMessage.js deleted file mode 100644 index 39a8e16017..0000000000 --- a/components/frontend/src/errorMessage.js +++ /dev/null @@ -1,26 +0,0 @@ -import { bool, object, oneOfType, string } from "prop-types" -import { Grid } from "semantic-ui-react" - -import { Message } from "./semantic_ui_react_wrappers" - -export function ErrorMessage({ formatAsText, message, title }) { - return ( - <Grid.Row> - <Grid.Column> - <Message negative> - <Message.Header>{title}</Message.Header> - {formatAsText ? ( - message - ) : ( - <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>{message}</pre> - )} - </Message> - </Grid.Column> - </Grid.Row> - ) -} -ErrorMessage.propTypes = { - formatAsText: bool, - message: oneOfType([object, string]), - title: string, -} diff --git a/components/frontend/src/fields/Comment.js b/components/frontend/src/fields/Comment.js deleted file mode 100644 index 62ae029e70..0000000000 --- a/components/frontend/src/fields/Comment.js +++ /dev/null @@ -1,21 +0,0 @@ -import { useId } from "react" - -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { permissionsPropType } from "../sharedPropTypes" -import { TextInput } from "./TextInput" - -export function Comment(props) { - const labelId = useId() - return ( - <TextInput - aria-labelledby={labelId} - label={<label id={labelId}>Comment</label>} - placeholder="Enter comments here (HTML allowed; URL's are transformed into links)" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - {...props} - /> - ) -} -Comment.propTypes = { - requiredPermissions: permissionsPropType, -} diff --git a/components/frontend/src/fields/Comment.test.js b/components/frontend/src/fields/Comment.test.js deleted file mode 100644 index 75adc993a7..0000000000 --- a/components/frontend/src/fields/Comment.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from "@testing-library/react" - -import { Comment } from "./Comment" - -it("has the comment label", () => { - render(<Comment />) - expect(screen.getAllByText(/Comment/).length).toBe(1) -}) diff --git a/components/frontend/src/fields/CommentField.js b/components/frontend/src/fields/CommentField.js new file mode 100644 index 0000000000..a1d7ebf620 --- /dev/null +++ b/components/frontend/src/fields/CommentField.js @@ -0,0 +1,23 @@ +import { bool, func, string } from "prop-types" + +import { TextField } from "./TextField" + +export function CommentField({ disabled, id, onChange, value }) { + return ( + <TextField + disabled={disabled} + id={id} + label="Comment" + multiline + onChange={onChange} + placeholder="Enter comments here (HTML allowed; URL's are transformed into links)" + value={value} + /> + ) +} +CommentField.propTypes = { + disabled: bool, + id: string, + onChange: func, + value: string, +} diff --git a/components/frontend/src/fields/DateInput.css b/components/frontend/src/fields/DateInput.css deleted file mode 100644 index f9a00e2086..0000000000 --- a/components/frontend/src/fields/DateInput.css +++ /dev/null @@ -1,16 +0,0 @@ -.react-datepicker__input-container > input, -.react-datepicker-wrapper { - width: 100% !important; /* Unfortunately, the date picker does not support fluid, so use this as a work-around */ -} - -/* Make sure there are no rounded corners where the label with the calendar icon and the input field touch */ - -div.ui.left.labeled.input .react-datepicker__input-container > input { - border-top-left-radius: 0px !important; - border-bottom-left-radius: 0px !important; -} - -div.ui.left.labeled.input > div.ui.label { - border-top-right-radius: 0px !important; - border-bottom-right-radius: 0px !important; -} diff --git a/components/frontend/src/fields/DateInput.js b/components/frontend/src/fields/DateInput.js deleted file mode 100644 index a987f1a7db..0000000000 --- a/components/frontend/src/fields/DateInput.js +++ /dev/null @@ -1,60 +0,0 @@ -import "./DateInput.css" - -import { bool, func, string } from "prop-types" - -import { ReadOnlyOrEditable } from "../context/Permissions" -import { Form, Label } from "../semantic_ui_react_wrappers" -import { labelPropType, permissionsPropType } from "../sharedPropTypes" -import { toISODateStringInCurrentTZ } from "../utils" -import { DatePicker } from "../widgets/DatePicker" -import { CalendarIcon } from "../widgets/icons" -import { ReadOnlyInput } from "./ReadOnlyInput" - -function EditableDateInput({ ariaLabelledBy, label, placeholder, required, set_value, value }) { - value = value ? new Date(value) : null - return ( - <Form.Input error={required && !value} label={label} labelPosition="left" required={required}> - <Label style={{ padding: "8px" }}> - <CalendarIcon /> - </Label> - <DatePicker - ariaLabelledBy={ariaLabelledBy} - selected={value} - isClearable={!required} - onChange={(newDate) => { - let dateValue = null - if (newDate !== null) { - dateValue = toISODateStringInCurrentTZ(newDate) - } - set_value(dateValue) - }} - placeholderText={placeholder} - /> - </Form.Input> - ) -} -EditableDateInput.propTypes = { - ariaLabelledBy: string, - label: labelPropType, - placeholder: string, - required: bool, - set_value: func, - value: string, -} - -export function DateInput(props) { - return ( - <Form> - <ReadOnlyOrEditable - requiredPermissions={props.requiredPermissions} - readOnlyComponent={<ReadOnlyInput {...props} />} - editableComponent={<EditableDateInput {...props} label={props.editableLabel || props.label} />} - /> - </Form> - ) -} -DateInput.propTypes = { - editableLabel: labelPropType, - label: labelPropType, - requiredPermissions: permissionsPropType, -} diff --git a/components/frontend/src/fields/DateInput.test.js b/components/frontend/src/fields/DateInput.test.js deleted file mode 100644 index dfdd9a3c50..0000000000 --- a/components/frontend/src/fields/DateInput.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" - -import { Permissions } from "../context/Permissions" -import { DateInput } from "./DateInput" - -function renderDateInput(props) { - return render( - <Permissions.Provider value={false}> - <DateInput {...props} /> - </Permissions.Provider>, - ) -} - -it("renders the value", () => { - renderDateInput({ value: "2019-09-30" }) - expect(screen.getByDisplayValue("2019-09-30")).not.toBe(null) -}) - -it("renders the read only value", () => { - renderDateInput({ value: "2019-09-30", requiredPermissions: ["test"] }) - expect(screen.getByDisplayValue("2019-09-30")).not.toBe(null) -}) - -it("clears the value", () => { - let set_value = jest.fn() - renderDateInput({ value: "2019-09-30", set_value: set_value, required: false }) - fireEvent.click(screen.getByRole("button")) - expect(set_value).toHaveBeenCalledWith(null) -}) - -it("renders in error state if a value is missing and required", () => { - renderDateInput({ value: "", required: true }) - expect(screen.getByDisplayValue("").parentElement.parentElement.parentElement.parentElement).toHaveClass("error") -}) - -it("submits the value when changed", async () => { - let set_value = jest.fn() - renderDateInput({ value: "2022-02-10", set_value: set_value }) - await userEvent.type(screen.getByDisplayValue("2022-02-10"), "2023-03-11", { - initialSelectionStart: 0, - initialSelectionEnd: 10, - }) - expect(screen.getByDisplayValue("2023-03-11")).not.toBe(null) - expect(set_value).toHaveBeenCalledWith("2023-03-11") -}) - -it("submits the value when the value is not changed", async () => { - let set_value = jest.fn() - const date = "2022-02-10" - renderDateInput({ value: date, set_value: set_value }) - await userEvent.type(screen.getByDisplayValue(date), `${date}{Tab}`, { - initialSelectionStart: 0, - initialSelectionEnd: 10, - }) - expect(screen.getByDisplayValue(date)).not.toBe(null) - expect(set_value).toHaveBeenCalledWith(date) -}) - -it("does not submit the value when the value is not valid", async () => { - let set_value = jest.fn() - const date = "2022-02-10" - renderDateInput({ value: date, set_value: set_value }) - await userEvent.type(screen.getByDisplayValue(date), "invalid", { - initialSelectionStart: 0, - initialSelectionEnd: 10, - }) - expect(set_value).not.toHaveBeenCalled() -}) diff --git a/components/frontend/src/fields/Input.js b/components/frontend/src/fields/Input.js deleted file mode 100644 index 0d4e9bd08c..0000000000 --- a/components/frontend/src/fields/Input.js +++ /dev/null @@ -1,54 +0,0 @@ -import { bool, func, string } from "prop-types" -import { useState } from "react" - -import { Form, Label } from "../semantic_ui_react_wrappers" -import { labelPropType } from "../sharedPropTypes" - -export function Input(props) { - let { editableLabel, label, error, prefix, required, set_value, warning, ...otherProps } = props - const initialValue = props.value || "" - const [value, setValue] = useState(initialValue) - - function submit_if_changed() { - if (value !== initialValue) { - set_value(value) - } - } - function onKeyDown(event) { - if (event.key === "Escape") { - setValue(initialValue) - } - if (event.key === "Enter") { - submit_if_changed() - } - } - return ( - <Form.Input - {...otherProps} - error={error || warning || (required && value === "")} - fluid - focus - label={editableLabel || label} - labelPosition="left" - onBlur={() => { - submit_if_changed() - }} - onChange={(event) => setValue(event.target.value)} - onKeyDown={onKeyDown} - value={value} - > - {prefix ? <Label>{prefix}</Label> : null} - <input /> - </Form.Input> - ) -} -Input.propTypes = { - editableLabel: labelPropType, - label: labelPropType, - error: bool, - prefix: string, - required: bool, - set_value: func, - warning: bool, - value: string, -} diff --git a/components/frontend/src/fields/Input.test.js b/components/frontend/src/fields/Input.test.js deleted file mode 100644 index f2a4244303..0000000000 --- a/components/frontend/src/fields/Input.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import { render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" - -import { Input } from "./Input" - -it("changes the value", async () => { - const mockCallback = jest.fn() - render(<Input value="Hello" set_value={mockCallback} />) - await userEvent.type(screen.getByDisplayValue(/Hello/), "Bye{Enter}", { - initialSelectionStart: 0, - initialSelectionEnd: 5, - }) - expect(screen.getByDisplayValue(/Bye/)).not.toBe(null) - expect(mockCallback).toHaveBeenCalledWith("Bye") -}) - -it("changes the value when blurred", async () => { - const mockCallback = jest.fn() - render( - <> - <Input value="Hello" set_value={mockCallback} /> - <Input value="Bye" /> - </>, - ) - await userEvent.type(screen.getByDisplayValue(/Hello/), "Ciao", { - initialSelectionStart: 0, - initialSelectionEnd: 5, - }) - screen.getByDisplayValue(/Bye/).focus() // blur - expect(mockCallback).toHaveBeenCalledWith("Ciao") -}) - -it("does not submit the value when it is unchanged", async () => { - const mockCallback = jest.fn() - render(<Input value="Hello" set_value={mockCallback} />) - await userEvent.type(screen.getByDisplayValue(/Hello/), "Hello{Enter}", { - initialSelectionStart: 0, - initialSelectionEnd: 5, - }) - expect(screen.getByDisplayValue(/Hello/)).not.toBe(null) - expect(mockCallback).not.toHaveBeenCalled() -}) - -it("renders the initial value on escape and does not submit", async () => { - const mockCallback = jest.fn() - render(<Input value="Hello" set_value={mockCallback} />) - await userEvent.type(screen.getByDisplayValue(/Hello/), "Bye{Escape}") - expect(screen.getByDisplayValue(/Hello/)).not.toBe(null) - expect(mockCallback).not.toHaveBeenCalled() -}) - -it("shows an error for required empty fields", () => { - const { container } = render(<Input value="" required />) - expect(container.getElementsByTagName("input")[0]).toBeInvalid() -}) - -it("does not show an error for required non-empty fields", () => { - const { container } = render(<Input value="Hello" required />) - expect(container.getElementsByTagName("input")[0]).toBeValid() -}) - -it("does not show an error for non-required empty fields", () => { - const { container } = render(<Input value="" />) - expect(container.getElementsByTagName("input")[0]).toBeValid() -}) - -it("renders in error state if the warning props is true", () => { - const { container } = render(<Input value="" warning />) - expect(container.getElementsByTagName("input")[0]).toBeInvalid() -}) diff --git a/components/frontend/src/fields/IntegerInput.js b/components/frontend/src/fields/IntegerInput.js deleted file mode 100644 index 4278a6c4b7..0000000000 --- a/components/frontend/src/fields/IntegerInput.js +++ /dev/null @@ -1,102 +0,0 @@ -import { bool, func, number, oneOfType, string } from "prop-types" -import { useState } from "react" - -import { ReadOnlyOrEditable } from "../context/Permissions" -import { Form, Label } from "../semantic_ui_react_wrappers" -import { labelPropType, permissionsPropType } from "../sharedPropTypes" -import { ReadOnlyInput } from "./ReadOnlyInput" - -function EditableIntegerInput(props) { - let { allowEmpty, editableLabel, label, min, prefix, set_value, unit, ...otherProps } = props - const initialValue = props.value || (allowEmpty ? "" : 0) - const [value, setValue] = useState(initialValue) - const minValue = min || 0 - - function isValid(aValue) { - if (aValue === "") { - return allowEmpty - } - if (Number.isNaN(parseInt(aValue))) { - return false - } - if (Number(aValue) < Number(minValue)) { - return false - } - if (props.max !== null && Number(aValue) > Number(props.max)) { - return false - } - return true - } - - function submitIfChangedAndValid() { - if (value !== initialValue && isValid(value)) { - set_value(value) - } - } - - return ( - <Form> - <Form.Input - {...otherProps} - error={!isValid(value)} - fluid - focus - label={editableLabel || label} - labelPosition={unit ? "right" : "left"} - min={minValue} - onBlur={() => { - submitIfChangedAndValid() - }} - onChange={(event) => { - if (isValid(event.target.value)) { - setValue(event.target.value) - } - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - submitIfChangedAndValid() - } - if (event.key === "Escape") { - setValue(initialValue) - } - }} - type="number" - value={value} - width={16} - > - {prefix ? <Label>{prefix}</Label> : null} - <input /> - {unit ? <Label>{unit}</Label> : null} - </Form.Input> - </Form> - ) -} -EditableIntegerInput.propTypes = { - allowEmpty: bool, - editableLabel: labelPropType, - label: labelPropType, - max: oneOfType([number, string]), - min: oneOfType([number, string]), - prefix: string, - set_value: func, - unit: string, - value: oneOfType([number, string]), -} - -export function IntegerInput(props) { - let { requiredPermissions, ...otherProps } = props - return ( - <ReadOnlyOrEditable - requiredPermissions={requiredPermissions} - readOnlyComponent={ - <Form> - <ReadOnlyInput {...otherProps} /> - </Form> - } - editableComponent={<EditableIntegerInput {...otherProps} />} - /> - ) -} -IntegerInput.propTypes = { - requiredPermissions: permissionsPropType, -} diff --git a/components/frontend/src/fields/IntegerInput.test.js b/components/frontend/src/fields/IntegerInput.test.js deleted file mode 100644 index ba69485bc1..0000000000 --- a/components/frontend/src/fields/IntegerInput.test.js +++ /dev/null @@ -1,121 +0,0 @@ -import { render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" - -import { IntegerInput } from "./IntegerInput" - -it("renders the value read only", () => { - render(<IntegerInput requiredPermissions={["testPermission"]} value="42" />) - expect(screen.queryAllByDisplayValue(/42/).length).toBe(1) -}) - -it("renders and edits the value", async () => { - let setValue = jest.fn() - render(<IntegerInput value="42" set_value={setValue} />) - await userEvent.type(screen.getByDisplayValue(/42/), "123{Enter}", { - initialSelectionStart: 0, - initialSelectionEnd: 2, - }) - expect(screen.queryAllByDisplayValue(/123/).length).toBe(1) - expect(setValue).toHaveBeenCalledWith("123") -}) - -it("submits the changed value on blur", async () => { - let setValue = jest.fn() - render( - <> - <IntegerInput value="42" set_value={setValue} /> - <IntegerInput value="222" /> - </>, - ) - await userEvent.type(screen.getByDisplayValue(/42/), "123", { - initialSelectionStart: 0, - initialSelectionEnd: 2, - }) - screen.getByDisplayValue(/222/).focus() // blur - expect(screen.queryAllByDisplayValue(/123/).length).toBe(1) - expect(setValue).toHaveBeenCalledWith("123") -}) - -it("does not submit an unchanged value", async () => { - let setValue = jest.fn() - render(<IntegerInput value="42" set_value={setValue} />) - await userEvent.type(screen.getByDisplayValue(/42/), "{Enter}") - expect(screen.queryAllByDisplayValue(/42/).length).toBe(1) - expect(setValue).not.toHaveBeenCalled() -}) - -it("does not submit a value that is too small", async () => { - let setValue = jest.fn() - render(<IntegerInput value="5" min={10} set_value={setValue} />) - await userEvent.type(screen.getByDisplayValue(/5/), "{Enter}") - expect(screen.queryAllByDisplayValue(/5/).length).toBe(1) - expect(setValue).not.toHaveBeenCalled() -}) - -it("has a default minimum of zero", async () => { - let setValue = jest.fn() - render(<IntegerInput value="-1" set_value={setValue} />) - await userEvent.type(screen.getByDisplayValue(/-1/), "{Enter}") - expect(screen.queryAllByDisplayValue(/-1/).length).toBe(1) - expect(setValue).not.toHaveBeenCalled() -}) - -it("does not accept an invalid value", async () => { - let setValue = jest.fn() - render(<IntegerInput value="42" set_value={setValue} />) - await userEvent.type(screen.getByDisplayValue(/42/), "abc{Enter}") - expect(setValue).not.toHaveBeenCalled() -}) - -it("does not accept an empty value", async () => { - let setValue = jest.fn() - render(<IntegerInput value="42" set_value={setValue} />) - await userEvent.type(screen.getByDisplayValue(/42/), "{selectall}{backspace}{backspace}{enter}") - // The second backspace does not delete the 4 because input cannot be empty - expect(setValue).toHaveBeenCalledWith("4") -}) - -it("accepts an empty value if an empty value is allowed", async () => { - let setValue = jest.fn() - render(<IntegerInput allowEmpty value="42" set_value={setValue} />) - await userEvent.type(screen.getByDisplayValue(/42/), "{selectall}{backspace}{backspace}{enter}") - expect(setValue).toHaveBeenCalledWith("") -}) - -it("undoes the change on escape", async () => { - let setValue = jest.fn() - render(<IntegerInput value="42" set_value={setValue} />) - await userEvent.type(screen.getByDisplayValue(/42/), "24{escape}") - expect(screen.queryAllByDisplayValue(/42/).length).toBe(1) - expect(setValue).not.toHaveBeenCalled() -}) - -it("renders values less than the minimum as invalid", () => { - render(<IntegerInput value="12" min="42" />) - expect(screen.getByDisplayValue(/12/)).toBeInvalid() -}) - -it("renders values more than the minimum as valid", () => { - render(<IntegerInput value="42" min="0" />) - expect(screen.getByDisplayValue(/42/)).toBeValid() -}) - -it("renders values more than the maximum as invalid", () => { - render(<IntegerInput value="42" max="10" />) - expect(screen.getByDisplayValue(/42/)).toBeInvalid() -}) - -it("renders values less than the maximum as valid", () => { - render(<IntegerInput value="42" max="100" />) - expect(screen.getByDisplayValue(/42/)).toBeValid() -}) - -it("renders missing value as 0", () => { - render(<IntegerInput />) - expect(screen.queryAllByDisplayValue(/0/).length).toBe(1) -}) - -it("renders missing value as empty if empty allowed", () => { - render(<IntegerInput allowEmpty />) - expect(screen.queryAllByDisplayValue(/0/).length).toBe(0) -}) diff --git a/components/frontend/src/fields/MultipleChoiceField.js b/components/frontend/src/fields/MultipleChoiceField.js new file mode 100644 index 0000000000..b464cf0b6f --- /dev/null +++ b/components/frontend/src/fields/MultipleChoiceField.js @@ -0,0 +1,64 @@ +import { Autocomplete, TextField } from "@mui/material" +import { arrayOf, bool, element, func, object, oneOfType, string } from "prop-types" + +import { stringsPropType } from "../sharedPropTypes" + +export function MultipleChoiceField({ + disabled, + freeSolo, + helperText, + label, + onChange, + onInputChange, + options, + placeholder, + startAdornment, + value, +}) { + return ( + <Autocomplete + value={value} + disabled={disabled} + filterOptions={(x) => x} // Disable built-in filtering + freeSolo={freeSolo} // Allow additional options + fullWidth + multiple + options={options} + onChange={(_event, value) => onChange(value.map((value) => value?.id ?? value))} + onInputChange={onInputChange} + renderInput={(params) => { + return ( + <TextField + {...params} + helperText={helperText} + label={label} + placeholder={value.length === 0 ? placeholder : ""} + slotProps={{ + input: { + ...params.InputProps, + startAdornment: ( + <> + {startAdornment} + {params.InputProps.startAdornment} + </> + ), + }, + }} + /> + ) + }} + /> + ) +} +MultipleChoiceField.propTypes = { + disabled: bool, + freeSolo: bool, + helperText: string, + label: string, + onChange: func, + onInputChange: func, + options: oneOfType([stringsPropType, arrayOf(object)]), + placeholder: string, + startAdornment: element, + value: stringsPropType, +} diff --git a/components/frontend/src/fields/MultipleChoiceInput.js b/components/frontend/src/fields/MultipleChoiceInput.js deleted file mode 100644 index afe0604bb5..0000000000 --- a/components/frontend/src/fields/MultipleChoiceInput.js +++ /dev/null @@ -1,84 +0,0 @@ -import { array, bool, func } from "prop-types" -import { useState } from "react" - -import { ReadOnlyOrEditable } from "../context/Permissions" -import { Form } from "../semantic_ui_react_wrappers" -import { labelPropType, permissionsPropType, stringsPropType } from "../sharedPropTypes" -import { ReadOnlyInput } from "./ReadOnlyInput" - -function assembleOptions(optionList, values) { - // Create a sorted list of unique options. Also include the current values, or they won't be displayed for some reason - let options = new Set() - optionList.forEach((option) => { - options.add(option) - }) - values.forEach((value) => { - options.add({ key: value, text: value, value: value }) - }) - options = Array.from(options) - options.sort((a, b) => a.text.localeCompare(b.text)) - return options -} - -export function MultipleChoiceInput(props) { - let { allowAdditions, editableLabel, onSearchChange, required, set_value, requiredPermissions, ...otherProps } = - props - const [values, setValues] = useState(props.value || []) - const [searchQuery, setSearchQuery] = useState("") - return ( - <Form> - <ReadOnlyOrEditable - requiredPermissions={requiredPermissions} - readOnlyComponent={<ReadOnlyInput {...otherProps} value={values.join(", ")} />} - editableComponent={ - <Form.Dropdown - {...otherProps} - allowAdditions={allowAdditions} - error={required && values.length === 0} - fluid - label={editableLabel || props.label} - multiple - onAddItem={() => setSearchQuery("")} - onBlur={() => { - if (searchQuery && !values.includes(searchQuery)) { - // Save the data on loss of focus like we do with other input types - let newValues = values.concat(searchQuery) - setValues(newValues) - set_value(newValues) - } - setSearchQuery("") - }} - onChange={(_event, data) => { - setValues(data.value) - set_value(data.value) - setSearchQuery("") - }} - onSearchChange={(event, data) => { - event.preventDefault() - setSearchQuery(data.searchQuery) - if (onSearchChange) { - onSearchChange(data.searchQuery) - } - }} - options={assembleOptions(props.options || [], values)} - search - searchQuery={searchQuery} - selection - value={values} - /> - } - /> - </Form> - ) -} -MultipleChoiceInput.propTypes = { - allowAdditions: bool, - editableLabel: labelPropType, - label: labelPropType, - onSearchChange: func, - options: array, - required: bool, - requiredPermissions: permissionsPropType, - set_value: func, - value: stringsPropType, -} diff --git a/components/frontend/src/fields/MultipleChoiceInput.test.js b/components/frontend/src/fields/MultipleChoiceInput.test.js deleted file mode 100644 index 8dbeac1519..0000000000 --- a/components/frontend/src/fields/MultipleChoiceInput.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" - -import { Permissions } from "../context/Permissions" -import { dropdownOptions } from "../utils" -import { MultipleChoiceInput } from "./MultipleChoiceInput" - -const defaultOptions = dropdownOptions(["hello", "again"]) - -it("renders the value read only", () => { - render( - <MultipleChoiceInput - requiredPermissions={["testPermission"]} - value={["hello", "world"]} - options={defaultOptions} - />, - ) - expect(screen.getByDisplayValue(/hello, world/)).not.toBe(null) -}) - -it("renders an empty read only value", () => { - render(<MultipleChoiceInput requiredPermissions={["testPermission"]} value={[]} options={defaultOptions} />) - expect(screen.queryByDisplayValue(/hello/)).toBe(null) -}) - -it("renders in error state if a required value is missing", () => { - render(<MultipleChoiceInput value={[]} required />) - expect(screen.getByRole("combobox")).toBeInvalid() -}) - -it("does not render in error state if a required value is present", () => { - render(<MultipleChoiceInput value={["check"]} options={[]} required />) - expect(screen.getByRole("combobox")).toBeValid() -}) - -function renderMultipleChoiceInput(options = [], value = ["hello"]) { - let mockSetValue = jest.fn() - render( - <Permissions.Provider value={false}> - <MultipleChoiceInput value={value} options={options} set_value={mockSetValue} allowAdditions={true} /> - </Permissions.Provider>, - ) - return mockSetValue -} - -it("renders an editable value", () => { - renderMultipleChoiceInput(defaultOptions) - expect(screen.getByText(/hello/)).not.toBe(null) -}) - -it("renders a missing editable value", () => { - renderMultipleChoiceInput(defaultOptions, []) - expect(screen.queryByDisplayValue(/hello/)).toBe(null) -}) - -it("invokes the callback", () => { - let mockSetValue = renderMultipleChoiceInput(defaultOptions) - fireEvent.click(screen.getByText(/again/)) - expect(mockSetValue).toHaveBeenCalledWith(["hello", "again"]) -}) - -it("saves an uncommitted value on blur", async () => { - let mockSetValue = renderMultipleChoiceInput() - await userEvent.type(screen.getByDisplayValue(""), "new") - await userEvent.tab() - expect(mockSetValue).toHaveBeenCalledWith(["hello", "new"]) -}) - -it("does not save an uncommitted value on blur that is already in the list", async () => { - let mockSetValue = renderMultipleChoiceInput() - await userEvent.type(screen.getByDisplayValue(""), "hello") - await userEvent.tab() - expect(mockSetValue).not.toHaveBeenCalled() -}) - -it("does not save an uncommitted value on blur if there is none", async () => { - let mockSetValue = renderMultipleChoiceInput() - await userEvent.type(screen.getByDisplayValue(""), "x{Backspace}") - await userEvent.tab() - expect(mockSetValue).not.toHaveBeenCalled() -}) diff --git a/components/frontend/src/fields/PasswordInput.js b/components/frontend/src/fields/PasswordInput.js deleted file mode 100644 index e351014771..0000000000 --- a/components/frontend/src/fields/PasswordInput.js +++ /dev/null @@ -1,26 +0,0 @@ -import { string } from "prop-types" - -import { ReadOnlyOrEditable } from "../context/Permissions" -import { Form } from "../semantic_ui_react_wrappers" -import { permissionsPropType } from "../sharedPropTypes" -import { Input } from "./Input" -import { ReadOnlyInput } from "./ReadOnlyInput" - -export function PasswordInput(props) { - // We shouldn't have received a real password from the backend, but ignore the password value anyway to be sure - const { requiredPermissions, value, ...otherProps } = props - otherProps["value"] = value ? "*".repeat(value.length) : "" - return ( - <Form> - <ReadOnlyOrEditable - requiredPermissions={requiredPermissions} - readOnlyComponent={<ReadOnlyInput {...otherProps} type="password" />} - editableComponent={<Input {...otherProps} autoComplete="new-password" type="password" />} - /> - </Form> - ) -} -PasswordInput.propTypes = { - requiredPermissions: permissionsPropType, - value: string, -} diff --git a/components/frontend/src/fields/PasswordInput.test.js b/components/frontend/src/fields/PasswordInput.test.js deleted file mode 100644 index f0b99f0eb2..0000000000 --- a/components/frontend/src/fields/PasswordInput.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from "@testing-library/react" - -import { PasswordInput } from "./PasswordInput" - -function renderPasswordInput({ placeholder = "", value = "" } = {}) { - return render(<PasswordInput placeholder={placeholder} value={value} />) -} - -it("hides the password", () => { - renderPasswordInput({ value: "secret" }) - expect(screen.queryByDisplayValue(/secret/)).toBe(null) -}) - -it("shows the placeholder", () => { - renderPasswordInput({ placeholder: "Enter password" }) - expect(screen.queryByPlaceholderText(/Enter password/)).not.toBe(null) -}) diff --git a/components/frontend/src/fields/ReadOnlyInput.js b/components/frontend/src/fields/ReadOnlyInput.js deleted file mode 100644 index a5d0fb4c7c..0000000000 --- a/components/frontend/src/fields/ReadOnlyInput.js +++ /dev/null @@ -1,34 +0,0 @@ -import { bool, number, oneOfType, string } from "prop-types" - -import { Form, Label } from "../semantic_ui_react_wrappers" -import { labelPropType } from "../sharedPropTypes" - -export function ReadOnlyInput({ error, label, placeholder, prefix, required, value, type, unit }) { - return ( - <Form.Input - error={error || (required && value === "")} - fluid - label={label} - labelPosition={unit ? "right" : "left"} - placeholder={placeholder} - readOnly - tabIndex={-1} - type={type} - value={value || ""} - > - {prefix ? <Label>{prefix}</Label> : null} - <input /> - {unit ? <Label>{unit}</Label> : null} - </Form.Input> - ) -} -ReadOnlyInput.propTypes = { - error: bool, - label: labelPropType, - placeholder: string, - prefix: string, - required: bool, - value: oneOfType([bool, number, string]), - type: string, - unit: string, -} diff --git a/components/frontend/src/fields/ReadOnlyInput.test.js b/components/frontend/src/fields/ReadOnlyInput.test.js deleted file mode 100644 index 9cce1ec24a..0000000000 --- a/components/frontend/src/fields/ReadOnlyInput.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { render, screen } from "@testing-library/react" - -import { ReadOnlyInput } from "./ReadOnlyInput" - -function renderReadOnlyInput({ value = "value", prefix = "", error = false, required = false, unit = "" } = {}) { - return render( - <ReadOnlyInput label={"Label"} value={value} prefix={prefix} required={required} error={error} unit={unit} />, - ) -} - -it("displays the value", () => { - renderReadOnlyInput() - expect(screen.queryByDisplayValue(/value/)).not.toBe(null) -}) - -it("displays the prefix", () => { - renderReadOnlyInput({ prefix: "prefix" }) - expect(screen.queryByText(/prefix/)).not.toBe(null) -}) - -it("displays the postfix", () => { - renderReadOnlyInput({ unit: "postfix" }) - expect(screen.queryByText(/postfix/)).not.toBe(null) -}) - -it("renders invalid on error", () => { - renderReadOnlyInput({ error: true }) - expect(screen.queryByDisplayValue(/value/)).toBeInvalid() -}) - -it("renders invalid on required and empty", () => { - renderReadOnlyInput({ required: true, value: "" }) - expect(screen.queryByDisplayValue("")).toBeInvalid() -}) diff --git a/components/frontend/src/fields/SingleChoiceInput.js b/components/frontend/src/fields/SingleChoiceInput.js deleted file mode 100644 index 54e39e3e37..0000000000 --- a/components/frontend/src/fields/SingleChoiceInput.js +++ /dev/null @@ -1,70 +0,0 @@ -import { array, bool, func, number, oneOfType, string } from "prop-types" - -import { ReadOnlyOrEditable } from "../context/Permissions" -import { Form } from "../semantic_ui_react_wrappers" -import { labelPropType, permissionsPropType } from "../sharedPropTypes" -import { ReadOnlyInput } from "./ReadOnlyInput" - -function SingleChoiceDropdown(props) { - let { editableLabel, options, setValue, ...otherProps } = props - return ( - <Form.Dropdown - {...otherProps} - fluid - label={editableLabel || props.label} - onChange={(_event, { value }) => { - setValue(value) - }} - options={options} - search - selection - selectOnNavigation={false} - tabIndex="0" - value={props.value} - /> - ) -} -SingleChoiceDropdown.propTypes = { - editableLabel: labelPropType, - label: labelPropType, - options: array, - setValue: func, - value: oneOfType([bool, number, string]), -} - -export function SingleChoiceInput(props) { - const option_value = props.options.filter(({ value }) => value === props.value)[0] - const value_text = option_value ? option_value.text : "" - let { editableLabel, set_value, options, sort, requiredPermissions, ...otherProps } = props - - // default should be sorted - if (sort || sort === undefined) { - options.sort((a, b) => a.text.localeCompare(b.text)) - } - - return ( - <Form> - <ReadOnlyOrEditable - requiredPermissions={requiredPermissions} - readOnlyComponent={<ReadOnlyInput {...otherProps} value={value_text} />} - editableComponent={ - <SingleChoiceDropdown - editableLabel={editableLabel} - options={options} - setValue={set_value} - {...otherProps} - /> - } - /> - </Form> - ) -} -SingleChoiceInput.propTypes = { - editableLabel: labelPropType, - label: labelPropType, - options: array, - requiredPermissions: permissionsPropType, - set_value: func, - sort: bool, - value: oneOfType([bool, number, string]), -} diff --git a/components/frontend/src/fields/SingleChoiceInput.test.js b/components/frontend/src/fields/SingleChoiceInput.test.js deleted file mode 100644 index 57cde00701..0000000000 --- a/components/frontend/src/fields/SingleChoiceInput.test.js +++ /dev/null @@ -1,107 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react" - -import { Permissions } from "../context/Permissions" -import { SingleChoiceInput } from "./SingleChoiceInput" - -it("renders the value read only", () => { - render( - <SingleChoiceInput - requiredPermissions={["testPermission"]} - value="hello" - options={[{ text: "hello", value: "hello" }]} - />, - ) - expect(screen.getByDisplayValue(/hello/)).not.toBe(null) -}) - -it("renders the editable value", () => { - render( - <Permissions.Provider value={false}> - <SingleChoiceInput - requiredPermissions={["testPermission"]} - value="hello" - options={[{ text: "hello", value: "hello" }]} - /> - </Permissions.Provider>, - ) - expect(screen.getByDisplayValue(/hello/)).not.toBe(null) -}) - -it("invokes the callback on a change", () => { - let mockSetValue = jest.fn() - render( - <SingleChoiceInput - value="hello" - options={[ - { text: "hello", value: "hello" }, - { text: "hi", value: "hi" }, - ]} - set_value={mockSetValue} - />, - ) - fireEvent.click(screen.getByText(/hi/)) - expect(mockSetValue).toHaveBeenCalledWith("hi") -}) - -it("does not invoke the callback when the value is not changed", () => { - let mockSetValue = jest.fn() - render( - <SingleChoiceInput - value="hello" - options={[ - { text: "hello", value: "hello" }, - { text: "hi", value: "hi" }, - ]} - set_value={mockSetValue} - />, - ) - fireEvent.click(screen.getAllByText(/hello/)[1]) - expect(mockSetValue).not.toHaveBeenCalled() -}) - -it("does sort by default", () => { - render( - <SingleChoiceInput - value="b" - options={[ - { text: "option-b", value: "b" }, - { text: "option-a", value: "a" }, - ]} - />, - ) - const options = screen.getAllByRole("option") - expect(options[0]).toHaveTextContent("option-a") - expect(options[1]).toHaveTextContent("option-b") -}) - -it("does not sort when told not to", () => { - render( - <SingleChoiceInput - value="b" - options={[ - { text: "option-b", value: "b" }, - { text: "option-a", value: "a" }, - ]} - sort={false} - />, - ) - const options = screen.getAllByRole("option") - expect(options[0]).toHaveTextContent("option-b") - expect(options[1]).toHaveTextContent("option-a") -}) - -it("does sort when told to", () => { - render( - <SingleChoiceInput - value="b" - options={[ - { text: "option-b", value: "b" }, - { text: "option-a", value: "a" }, - ]} - sort={true} - />, - ) - const options = screen.getAllByRole("option") - expect(options[0]).toHaveTextContent("option-a") - expect(options[1]).toHaveTextContent("option-b") -}) diff --git a/components/frontend/src/fields/StringInput.js b/components/frontend/src/fields/StringInput.js deleted file mode 100644 index 8e03815325..0000000000 --- a/components/frontend/src/fields/StringInput.js +++ /dev/null @@ -1,78 +0,0 @@ -import { array, bool, func, string } from "prop-types" -import { useState } from "react" - -import { ReadOnlyOrEditable } from "../context/Permissions" -import { Form } from "../semantic_ui_react_wrappers" -import { labelPropType, permissionsPropType, stringsPropType } from "../sharedPropTypes" -import { sortWithLocaleCompare } from "../utils" -import { Input } from "./Input" -import { ReadOnlyInput } from "./ReadOnlyInput" - -function StringInputWithSuggestions(props) { - let { editableLabel, label, error, options, placeholder, required, set_value, warning, ...otherProps } = props - placeholder = placeholder || "none" - const initialValue = props.value || "" - const [stringOptions, setStringOptions] = useState([ - ...options, - { text: <font color="lightgrey">{placeholder}</font>, value: "", key: "" }, - ]) - const [searchQuery, setSearchQuery] = useState(initialValue) - return ( - <Form.Dropdown - {...otherProps} - allowAdditions - clearable - error={error || warning || (required && initialValue === "")} - fluid - label={editableLabel || label} - onAddItem={(_event, { value }) => { - setStringOptions((prev_options) => [{ text: value, value: value, key: value }, ...prev_options]) - }} - onChange={(_event, { value }) => { - setSearchQuery(value) - set_value(value) - }} - onSearchChange={(_event, data) => { - setSearchQuery(data.searchQuery) - }} - options={stringOptions} - placeholder={placeholder} - search - searchQuery={searchQuery} - selection - /> - ) -} -StringInputWithSuggestions.propTypes = { - editableLabel: labelPropType, - label: labelPropType, - error: bool, - options: array, - placeholder: string, - required: bool, - set_value: func, - value: string, - warning: bool, -} - -export function StringInput(props) { - const { requiredPermissions, options, ...otherProps } = props - const optionsArray = [...(options || [])] - sortWithLocaleCompare(optionsArray) - const optionMap = optionsArray.map((value) => ({ key: value, value: value, text: value })) - const input = <Input {...otherProps} /> - const inputWithSuggestions = <StringInputWithSuggestions options={optionMap} {...otherProps} /> - return ( - <Form> - <ReadOnlyOrEditable - requiredPermissions={requiredPermissions} - readOnlyComponent={<ReadOnlyInput {...otherProps} />} - editableComponent={optionMap.length === 0 ? input : inputWithSuggestions} - /> - </Form> - ) -} -StringInput.propTypes = { - requiredPermissions: permissionsPropType, - options: stringsPropType, -} diff --git a/components/frontend/src/fields/StringInput.test.js b/components/frontend/src/fields/StringInput.test.js deleted file mode 100644 index 3971241dd9..0000000000 --- a/components/frontend/src/fields/StringInput.test.js +++ /dev/null @@ -1,95 +0,0 @@ -import { render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" - -import { Permissions } from "../context/Permissions" -import { StringInput } from "./StringInput" - -function renderStringInput(set_value) { - return render( - <Permissions.Provider value={false}> - <StringInput options={["Option 1", "Option 2"]} set_value={set_value} value="Option 1" /> - </Permissions.Provider>, - ) -} - -it("renders the value of the input", () => { - renderStringInput() - expect(screen.getByDisplayValue(/Option 1/)).not.toBe(null) -}) - -it("renders a missing value", () => { - render(<StringInput options={["Option 1", "Option 2"]} />) - expect(screen.queryByDisplayValue(/Option/)).toBe(null) -}) - -it("invokes the callback on change", async () => { - const mockCallback = jest.fn() - renderStringInput(mockCallback) - await userEvent.type(screen.getByDisplayValue(/Option 1/), "Option 2{Enter}", { - initialSelectionStart: 0, - initialSelectionEnd: 8, - }) - expect(screen.getByDisplayValue(/Option 2/)).not.toBe(null) - expect(mockCallback).toHaveBeenCalledWith("Option 2") -}) - -it("invokes the callback on add", async () => { - const mockCallback = jest.fn() - renderStringInput(mockCallback) - await userEvent.type(screen.getByDisplayValue(/Option 1/), "Option 3{Enter}", { - initialSelectionStart: 0, - initialSelectionEnd: 8, - }) - expect(screen.getByDisplayValue(/Option 3/)).not.toBe(null) - expect(mockCallback).toHaveBeenCalledWith("Option 3") -}) - -it("does not invoke the callback when the new value equals the old value", async () => { - const mockCallback = jest.fn() - renderStringInput(mockCallback) - await userEvent.type(screen.getByDisplayValue(/Option 1/), "Option 1{Enter}", { - initialSelectionStart: 0, - initialSelectionEnd: 8, - }) - expect(screen.getByDisplayValue(/Option 1/)).not.toBe(null) - expect(mockCallback).not.toHaveBeenCalled() -}) - -it("works without options", async () => { - const mockCallback = jest.fn() - renderStringInput(mockCallback) - render(<StringInput set_value={mockCallback} />) - await userEvent.type(screen.getByDisplayValue(""), "New value{Enter}") - expect(screen.getByDisplayValue(/New value/)).not.toBe(null) - expect(mockCallback).toHaveBeenCalledWith("New value") -}) - -it("shows an error for required empty fields", () => { - render(<StringInput options={["Option 1", "Option 2"]} required />) - expect(screen.getByRole("combobox")).toBeInvalid() -}) - -it("does not show an error for required non-empty fields", () => { - render(<StringInput options={["Option 1", "Option 2"]} value="Hello" required />) - expect(screen.getByRole("combobox")).toBeValid() -}) - -it("does not show an error for non-required empty fields", () => { - render(<StringInput options={["Option 1", "Option 2"]} value="" />) - expect(screen.getByRole("combobox")).toBeValid() -}) - -it("does not show an error for non-required non-empty fields", () => { - render(<StringInput options={["Option 1", "Option 2"]} value="Hello" />) - expect(screen.getByRole("combobox")).toBeValid() -}) - -it("shows an error", () => { - render(<StringInput options={["Option 1", "Option 2"]} value="Hello" error />) - expect(screen.getByRole("combobox")).toBeInvalid() -}) - -it("shows a warning", () => { - render(<StringInput options={["Option 1", "Option 2"]} value="Hello" warning />) - expect(screen.getByRole("combobox")).toBeInvalid() -}) diff --git a/components/frontend/src/fields/TextField.js b/components/frontend/src/fields/TextField.js new file mode 100644 index 0000000000..b253c707cf --- /dev/null +++ b/components/frontend/src/fields/TextField.js @@ -0,0 +1,99 @@ +import { InputAdornment, TextField as MUITextField } from "@mui/material" +import { bool, element, func, number, oneOfType, string } from "prop-types" +import { useState } from "react" + +import { childrenPropType } from "../sharedPropTypes" + +export function TextField({ + children, + disabled, + endAdornment, + error, + helperText, + id, + label, + max, + multiline, + onChange, + placeholder, + required, + select, + startAdornment, + type, + value, +}) { + const [textValue, setTextValue] = useState(value) + + function submitIfChanged() { + if (textValue !== value) { + onChange(textValue) + } + } + + function onKeyDown(event) { + if (event.key === "Escape") { + setTextValue(value) + } + if (event.key === "Enter") { + submitIfChanged() + } + } + + const startInputAdornment = startAdornment ? ( + <InputAdornment position="start">{startAdornment}</InputAdornment> + ) : null + const endInputAdornment = endAdornment ? <InputAdornment position="end">{endAdornment}</InputAdornment> : null + return ( + <MUITextField + defaultValue={textValue ?? ""} + disabled={disabled || (select && children.length === 0)} + error={error} + fullWidth + helperText={helperText} + id={id} + label={label} + maxRows={8} + minRows={3} + multiline={multiline} + onBlur={() => submitIfChanged()} + onChange={select ? (event) => onChange(event.target.value) : (event) => setTextValue(event.target.value)} + onKeyDown={onKeyDown} + onWheel={(event) => event.target.blur()} // Prevent scrolling from changing the number value + placeholder={placeholder} + required={required} + select={select && children.length > 0} + slotProps={{ + input: { + endAdornment: endInputAdornment, + inputProps: { + max: max, + min: 0, + }, + startAdornment: startInputAdornment, + }, + }} + type={type} + variant="outlined" + > + {children} + </MUITextField> + ) +} +TextField.propTypes = { + children: childrenPropType, + disabled: bool, + endAdornment: oneOfType([element, string]), + error: bool, + helperText: oneOfType([element, string]), + id: string, + label: oneOfType([element, string]), + max: number, + multiline: bool, + onChange: func, + placeholder: string, + required: bool, + select: bool, + startAdornment: oneOfType([element, string]), + type: string, + value: oneOfType([bool, string]), +} diff --git a/components/frontend/src/fields/TextInput.js b/components/frontend/src/fields/TextInput.js deleted file mode 100644 index 9f3081ec41..0000000000 --- a/components/frontend/src/fields/TextInput.js +++ /dev/null @@ -1,76 +0,0 @@ -import { bool, func, string } from "prop-types" -import { useState } from "react" - -import { ReadOnlyOrEditable } from "../context/Permissions" -import { Form } from "../semantic_ui_react_wrappers" -import { labelPropType, permissionsPropType } from "../sharedPropTypes" - -function ReadOnlyTextInput({ label, required, value }) { - return ( - <Form> - <Form.TextArea error={required && value === ""} label={label} readOnly tabIndex={-1} value={value} /> - </Form> - ) -} -ReadOnlyTextInput.propTypes = { - label: labelPropType, - required: bool, - value: string, -} - -function EditableTextInput(props) { - let { label, required, set_value, ...otherProps } = props - const initialValue = props.value || "" - const [text, setText] = useState(initialValue) - - function onKeyDown(event) { - if (event.key === "Escape") { - setText(initialValue) - } - } - function onKeyPress(event) { - if (event.key === "Enter" && event.shiftKey) { - event.preventDefault() - submit() - } - } - function submit() { - if (text !== initialValue) { - set_value(text) - } - } - return ( - <Form onSubmit={submit}> - <Form.TextArea - {...otherProps} - error={required && text === ""} - label={label} - onBlur={submit} - onChange={(event) => setText(event.target.value)} - onKeyDown={onKeyDown} - onKeyPress={onKeyPress} - value={text} - /> - </Form> - ) -} -EditableTextInput.propTypes = { - label: labelPropType, - required: bool, - set_value: func, - value: string, -} - -export function TextInput(props) { - let { requiredPermissions, ...otherProps } = props - return ( - <ReadOnlyOrEditable - requiredPermissions={requiredPermissions} - readOnlyComponent={<ReadOnlyTextInput {...otherProps} />} - editableComponent={<EditableTextInput {...otherProps} />} - /> - ) -} -TextInput.propTypes = { - requiredPermissions: permissionsPropType, -} diff --git a/components/frontend/src/fields/TextInput.test.js b/components/frontend/src/fields/TextInput.test.js deleted file mode 100644 index 181332c2d7..0000000000 --- a/components/frontend/src/fields/TextInput.test.js +++ /dev/null @@ -1,76 +0,0 @@ -import { render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" - -import { TextInput } from "./TextInput" - -it("renders the value read only", () => { - render(<TextInput requiredPermissions={["test"]} value="Hello" />) - expect(screen.queryByText("Hello")).not.toBe(null) -}) - -it("changes the value", async () => { - const mockCallback = jest.fn() - render(<TextInput value="Hello" set_value={mockCallback} />) - await userEvent.type(screen.getByText(/Hello/), "Bye{Shift>}{Enter}") - expect(screen.getByText(/Bye/)).not.toBe(null) - expect(mockCallback).toHaveBeenCalledWith("HelloBye") -}) - -it("does not invoke the callback on enter", async () => { - const mockCallback = jest.fn() - render(<TextInput value="Hello" set_value={mockCallback} />) - await userEvent.type(screen.getByText(/Hello/), "Bye{Enter}") - expect(screen.getByText(/Bye/)).not.toBe(null) - expect(mockCallback).not.toHaveBeenCalled() -}) - -it("does not invoke the callback if the value is unchanged", async () => { - const mockCallback = jest.fn() - render(<TextInput value="Hello" set_value={mockCallback} />) - await userEvent.type(screen.getByText(/Hello/), "{Shift>}{Enter}") - expect(screen.getByText(/Hello/)).not.toBe(null) - expect(mockCallback).not.toHaveBeenCalled() -}) - -it("resets the value on escape", async () => { - const mockCallback = jest.fn() - render(<TextInput value="Hello" set_value={mockCallback} />) - await userEvent.type(screen.getByText(/Hello/), "Revert{Escape}") - expect(screen.getByText(/Hello/)).not.toBe(null) - expect(mockCallback).not.toHaveBeenCalled() -}) - -it("shows an error for required empty fields, when read only", () => { - const { container } = render(<TextInput requiredPermissions={["test"]} value="" required />) - expect(container.getElementsByTagName("textarea")[0]).toBeInvalid() -}) - -it("does not show an error for required non-empty fields, when read only", () => { - const { container } = render(<TextInput requiredPermissions={["test"]} value="Hello" required />) - expect(container.getElementsByTagName("textarea")[0]).toBeValid() -}) - -it("does not show an error for non-required empty fields, when read only", () => { - const { container } = render(<TextInput requiredPermissions={["test"]} value="" />) - expect(container.getElementsByTagName("textarea")[0]).toBeValid() -}) - -it("shows an error for required empty fields, when editable", () => { - const { container } = render(<TextInput value="" required />) - expect(container.getElementsByTagName("textarea")[0]).toBeInvalid() -}) - -it("does not show an error for required non-empty fields, when editable", () => { - const { container } = render(<TextInput value="Hello" required />) - expect(container.getElementsByTagName("textarea")[0]).toBeValid() -}) - -it("does not show an error for non-required empty fields, when editable", () => { - const { container } = render(<TextInput value="" />) - expect(container.getElementsByTagName("textarea")[0]).toBeValid() -}) - -it("shows the label", () => { - render(<TextInput label="Label" />) - expect(screen.queryByText("Label")).not.toBe(null) -}) diff --git a/components/frontend/src/header_footer/Footer.css b/components/frontend/src/header_footer/Footer.css deleted file mode 100644 index 1d6848b613..0000000000 --- a/components/frontend/src/header_footer/Footer.css +++ /dev/null @@ -1,11 +0,0 @@ -.MuiDivider-root { - padding: 20px; -} - -.MuiDivider-root:before { - border-top: thin solid rgba(255, 255, 255, 0.3) !important; -} - -.MuiDivider-root:after { - border-top: thin solid rgba(255, 255, 255, 0.3) !important; -} diff --git a/components/frontend/src/header_footer/Footer.js b/components/frontend/src/header_footer/Footer.js index 1cce21d25c..325b2a6592 100644 --- a/components/frontend/src/header_footer/Footer.js +++ b/components/frontend/src/header_footer/Footer.js @@ -1,5 +1,3 @@ -import "./Footer.css" - import BugReportIcon from "@mui/icons-material/BugReport" import CopyrightIcon from "@mui/icons-material/Copyright" import FeedbackIcon from "@mui/icons-material/Feedback" @@ -9,6 +7,7 @@ import MenuBookIcon from "@mui/icons-material/MenuBook" import PersonIcon from "@mui/icons-material/Person" import ScienceIcon from "@mui/icons-material/Science" import { + AppBar, Container, Divider, List, @@ -145,8 +144,8 @@ function QuoteColumn() { export function Footer({ lastUpdate, report }) { return ( - <Container maxWidth="100%" sx={{ bgcolor: "black", displayPrint: "none", marginTop: "20px", padding: "60px" }}> - <Container maxWidth="lg"> + <AppBar position="relative" sx={{ displayPrint: "none" }}> + <Container maxWidth="lg" sx={{ padding: "60px" }}> <Stack direction="row" sx={{ @@ -162,7 +161,7 @@ export function Footer({ lastUpdate, report }) { <img alt="" src="./favicon.ico" width="30px" /> </Divider> </Container> - </Container> + </AppBar> ) } Footer.propTypes = { diff --git a/components/frontend/src/header_footer/buttons/HomeButton.js b/components/frontend/src/header_footer/buttons/HomeButton.js index d7de1fa8a3..f6fc711a33 100644 --- a/components/frontend/src/header_footer/buttons/HomeButton.js +++ b/components/frontend/src/header_footer/buttons/HomeButton.js @@ -16,7 +16,7 @@ export function HomeButton({ atReportsOverview, openReportsOverview, setSettings startIcon={<img height="28px" width="28px" src="/favicon.ico" alt={label} />} sx={{ textTransform: "none" }} > - <Typography variant="h4">Quality-time</Typography> + <Typography variant="h3">Quality-time</Typography> </Button> </span> </Tooltip> diff --git a/components/frontend/src/index.js b/components/frontend/src/index.js index 40c9fdcee2..2691aba494 100644 --- a/components/frontend/src/index.js +++ b/components/frontend/src/index.js @@ -1,4 +1,3 @@ -import "fomantic-ui-css/semantic.min.css" import "react-grid-layout/css/styles.css" import { createRoot } from "react-dom/client" diff --git a/components/frontend/src/issue/IssueStatus.js b/components/frontend/src/issue/IssueStatus.js index a31ec09b40..c658ef62b5 100644 --- a/components/frontend/src/issue/IssueStatus.js +++ b/components/frontend/src/issue/IssueStatus.js @@ -1,21 +1,36 @@ +import { Card, CardActionArea, CardContent, List, ListItem, Tooltip, Typography } from "@mui/material" import { bool, string } from "prop-types" import TimeAgo from "react-timeago" -import { Label, Popup } from "../semantic_ui_react_wrappers" import { issueStatusPropType, metricPropType, settingsPropType, stringsPropType } from "../sharedPropTypes" -import { getMetricIssueIds, ISSUE_STATUS_COLORS } from "../utils" -import { HyperLink } from "../widgets/HyperLink" +import { getMetricIssueIds } from "../utils" import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate" function IssueWithoutTracker({ issueId }) { return ( - <Popup - content={ - "Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker' tab, and configuring an issue tracker." + <Tooltip + title={ + <> + <h4>No issue tracker configured</h4> + <p> + Please configure an issue tracker by expanding the report title, selecting the ‘Issue + tracker’ tab, and configuring an issue tracker. + </p> + </> } - header={"No issue tracker configured"} - trigger={<Label color="red">{issueId}</Label>} - /> + > + <span> + <Card elevation={1} sx={{ display: "inline-flex", margin: "1px" }}> + <CardActionArea disableRipple> + <CardContent sx={{ padding: "8px" }}> + <Typography color="error" noWrap> + {issueId} - ? + </Typography> + </CardContent> + </CardActionArea> + </Card> + </span> + </Tooltip> ) } IssueWithoutTracker.propTypes = { @@ -35,41 +50,47 @@ IssuesWithoutTracker.propTypes = { issueIds: stringsPropType, } -function labelDetails(issueStatus, settings) { - let details = [<Label.Detail key="name">{issueStatus.name || "?"}</Label.Detail>] +function cardDetails(issueStatus, settings) { + let details = [] if (issueStatus.summary && settings.showIssueSummary.value) { - details.push(<Label.Detail key="summary">{issueStatus.summary}</Label.Detail>) + details.push(<ListItem key="summary">{issueStatus.summary}</ListItem>) } if (issueStatus.created && settings.showIssueCreationDate.value) { details.push( - <Label.Detail key="created"> - Created <TimeAgo date={issueStatus.created} /> - </Label.Detail>, + <ListItem key="created"> + <Typography noWrap variant="inherit"> + Created <TimeAgo date={issueStatus.created} /> + </Typography> + </ListItem>, ) } if (issueStatus.updated && settings.showIssueUpdateDate.value) { details.push( - <Label.Detail key="updated"> - Updated <TimeAgo date={issueStatus.updated} /> - </Label.Detail>, + <ListItem key="updated"> + <Typography noWrap variant="inherit"> + Updated <TimeAgo date={issueStatus.updated} /> + </Typography> + </ListItem>, ) } if (issueStatus.duedate && settings.showIssueDueDate.value) { details.push( - <Label.Detail key="duedate"> - Due <TimeAgo date={issueStatus.duedate} /> - </Label.Detail>, + <ListItem key="duedate"> + <Typography noWrap variant="inherit"> + Due <TimeAgo date={issueStatus.duedate} /> + </Typography> + </ListItem>, ) } if (issueStatus.release_name && settings.showIssueRelease.value) { - details.push(releaseLabel(issueStatus)) + details.push(release(issueStatus)) } if (issueStatus.sprint_name && settings.showIssueSprint.value) { - details.push(sprintLabel(issueStatus)) + details.push(sprint(issueStatus)) } - return details + return details.length > 0 ? <List dense>{details}</List> : null } -labelDetails.propTypes = { +cardDetails.propTypes = { issueStatus: issueStatusPropType, settings: settingsPropType, } @@ -81,31 +102,31 @@ releaseStatus.propTypes = { issueStatus: issueStatusPropType, } -function releaseLabel(issueStatus) { +function release(issueStatus) { const date = issueStatus.release_date ? <TimeAgo date={issueStatus.release_date} /> : null return ( - <Label.Detail key="release"> + <ListItem key="release"> {prefixName(issueStatus.release_name, "Release")} {releaseStatus(issueStatus)} {date} - </Label.Detail> + </ListItem> ) } -releaseLabel.propTypes = { +release.propTypes = { issueStatus: issueStatusPropType, } -function sprintLabel(issueStatus) { +function sprint(issueStatus) { const sprintEnd = issueStatus.sprint_enddate ? ( <> ends <TimeAgo date={issueStatus.sprint_enddate} /> </> ) : null return ( - <Label.Detail key="sprint"> + <ListItem key="sprint"> {prefixName(issueStatus.sprint_name, "Sprint")} ({issueStatus.sprint_state}) {sprintEnd} - </Label.Detail> + </ListItem> ) } -sprintLabel.propTypes = { +sprint.propTypes = { issueStatus: issueStatusPropType, } @@ -118,26 +139,29 @@ prefixName.propType = { prefix: string, } -function issueLabel(issueStatus, settings, error) { +function IssueCard({ issueStatus, settings, error }) { // The issue status can be unknown when the issue was added recently and the status hasn't been collected yet - const color = error ? "red" : ISSUE_STATUS_COLORS[issueStatus.status_category ?? "unknown"] - const label = ( - <Label basic={!error} color={color}> - {issueStatus.issue_id} - {labelDetails(issueStatus, settings)} - </Label> + const color = error ? "error" : (issueStatus.status_category ?? "unknown") + const onClick = issueStatus.landing_url ? () => window.open(issueStatus.landing_url) : null + return ( + <Card onClick={onClick} elevation={1} sx={{ display: "inline-flex", margin: "1px" }}> + <CardActionArea disableRipple={!issueStatus.landing_url}> + <CardContent sx={{ padding: "8px" }}> + <Typography color={color} noWrap> + {issueStatus.issue_id} - {issueStatus.name || "?"} + </Typography> + <Typography + component="span" // Default component is p. Use span to prevent "Warning: validateDOMNesting(...): <ul> cannot appear as a descendant of <p>." + variant="body2" + > + {cardDetails(issueStatus, settings)} + </Typography> + </CardContent> + </CardActionArea> + </Card> ) - if (issueStatus.landing_url) { - // Without the span, the popup doesn't work - return ( - <span> - <HyperLink url={issueStatus.landing_url}>{label}</HyperLink> - </span> - ) - } - return label } -issueLabel.propTypes = { +IssueCard.propTypes = { issueStatus: issueStatusPropType, settings: settingsPropType, error: string, @@ -154,15 +178,26 @@ function IssueWithTracker({ issueStatus, settings }) { popupHeader = "Parse error" popupContent = "Quality-time could not parse the data received from the issue tracker." } - let label = issueLabel(issueStatus, settings, popupHeader) + let card = <IssueCard error={popupHeader} issueStatus={issueStatus} settings={settings} /> if (!popupContent && issueStatus.created) { popupHeader = issueStatus.summary popupContent = issuePopupContent(issueStatus) } if (popupContent) { - label = <Popup header={popupHeader} content={popupContent} flowing hoverable trigger={label} /> + card = ( + <Tooltip + title={ + <> + <h4>{popupHeader}</h4> + {popupContent} + </> + } + > + <span>{card}</span> + </Tooltip> + ) } - return label + return card } IssueWithTracker.propTypes = { issueStatus: issueStatusPropType, diff --git a/components/frontend/src/issue/IssueStatus.test.js b/components/frontend/src/issue/IssueStatus.test.js index 4bfdca6da4..fd8515d1bf 100644 --- a/components/frontend/src/issue/IssueStatus.test.js +++ b/components/frontend/src/issue/IssueStatus.test.js @@ -1,9 +1,8 @@ -import { render, screen, waitFor } from "@testing-library/react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import history from "history/browser" import { createTestableSettings } from "../__fixtures__/fixtures" -import { ISSUE_STATUS_COLORS } from "../utils" import { IssueStatus } from "./IssueStatus" function renderIssueStatus({ @@ -62,47 +61,22 @@ beforeEach(() => { }) it("displays the issue id", () => { - const { queryByText } = renderIssueStatus() - expect(queryByText(/123/)).not.toBe(null) -}) - -it("displays the status", () => { - const { queryByText } = renderIssueStatus() - expect(queryByText(/in progress/)).not.toBe(null) -}) - -it("displays the status category doing", () => { - renderIssueStatus({ statusCategory: "doing" }) - expect(screen.getByText(/123/).className).toContain("blue") -}) - -it("displays the status category todo", () => { - renderIssueStatus({ statusCategory: "todo" }) - expect(screen.getByText(/123/).className).toContain("grey") -}) - -it("displays the status category done", () => { - renderIssueStatus({ statusCategory: "done" }) - expect(screen.getByText(/123/).className).toContain("green") -}) - -it("displays a missing status category as unknown", () => { renderIssueStatus() - Object.values(ISSUE_STATUS_COLORS) - .filter((color) => color !== null) - .forEach((color) => { - expect(screen.getByText(/123/).className).not.toContain(color) - }) + expect(screen.queryByText(/123/)).not.toBe(null) }) -it("displays the issue landing url", async () => { +it("opens the issue landing url", async () => { + window.open = jest.fn() const { queryByText } = renderIssueStatus() - expect(queryByText(/123/).closest("a").href).toBe("https://issue/") + fireEvent.click(queryByText(/123/)) + expect(window.open).toHaveBeenCalledWith("https://issue") }) -it("does not display an url if the issue has no landing url", async () => { - const { queryByText } = renderIssueStatus({ landingUrl: null }) - expect(queryByText(/123/).closest("a")).toBe(null) +it("does not open an url if the issue has no landing url", async () => { + window.open = jest.fn() + const { queryByText } = renderIssueStatus({ landingUrl: "" }) + fireEvent.click(queryByText(/123/)) + expect(window.open).not.toHaveBeenCalled() }) it("displays a question mark as status if the issue has no status", () => { diff --git a/components/frontend/src/issue/IssuesRows.js b/components/frontend/src/issue/IssuesRows.js index 6f2960689d..cfd9dbf546 100644 --- a/components/frontend/src/issue/IssuesRows.js +++ b/components/frontend/src/issue/IssuesRows.js @@ -1,24 +1,25 @@ +import Grid from "@mui/material/Grid2" import { bool, func, node, string } from "prop-types" -import { useState } from "react" -import { Grid } from "semantic-ui-react" +import { useContext, useState } from "react" import { add_metric_issue, set_metric_attribute } from "../api/metric" import { get_report_issue_tracker_suggestions } from "../api/report" -import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions" -import { ErrorMessage } from "../errorMessage" -import { MultipleChoiceInput } from "../fields/MultipleChoiceInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { MultipleChoiceField } from "../fields/MultipleChoiceField" import { metricPropType, reportPropType } from "../sharedPropTypes" import { getMetricIssueIds } from "../utils" import { ActionButton } from "../widgets/buttons/ActionButton" +import { ErrorMessage } from "../widgets/ErrorMessage" import { AddItemIcon } from "../widgets/icons" -import { LabelWithHelp } from "../widgets/LabelWithHelp" import { showMessage } from "../widgets/toast" function CreateIssueButton({ issueTrackerConfigured, issueTrackerInstruction, metric_uuid, target, reload }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) return ( <ActionButton action="Create new" - disabled={!issueTrackerConfigured} + disabled={disabled || !issueTrackerConfigured} icon={<AddItemIcon />} itemType="issue" onClick={() => add_metric_issue(metric_uuid, reload)} @@ -40,33 +41,29 @@ CreateIssueButton.propTypes = { } function IssueIdentifiers({ entityKey, issueTrackerInstruction, metric, metric_uuid, report_uuid, target, reload }) { - const issueStatusHelp = ( - <> - <p> - Identifiers of issues in the configured issue tracker that track the progress of fixing this {target}. - </p> - <p> + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) + const issueStatusHelp = `Identifiers of issues in the configured issue tracker that track the progress of fixing this ${target}. When the issues have all been resolved, or the technical debt end date has passed, whichever happens - first, the technical debt should be resolved and the technical debt target is no longer evaluated. - </p> - {issueTrackerInstruction} - </> - ) + first, the technical debt should be resolved and the technical debt target is no longer evaluated.${issueTrackerInstruction ?? ""}` const [suggestions, setSuggestions] = useState([]) - const labelId = `issue-identifiers-label-${metric_uuid}` - const issue_ids = getMetricIssueIds(metric, entityKey) + const issueIds = getMetricIssueIds(metric, entityKey) return ( - <MultipleChoiceInput - aria-labelledby={labelId} + <MultipleChoiceField allowAdditions - onSearchChange={(query) => { + disabled={disabled} + freeSolo + helperText={issueStatusHelp} + key={issueIds} // Make sure the multiple choice input is rerendered when the issue ids change + label="Issue identifiers" + onChange={(value) => set_metric_attribute(metric_uuid, "issue_ids", value, reload)} + onInputChange={(_event, query) => { if (query) { get_report_issue_tracker_suggestions(report_uuid, query) .then((suggestionsResponse) => { const suggestionOptions = suggestionsResponse.suggestions.map((s) => ({ - key: s.key, - text: `${s.key}: ${s.text}`, - value: s.key, + id: s.key, + label: `${s.key}: ${s.text}`, })) setSuggestions(suggestionOptions) return null @@ -76,12 +73,8 @@ function IssueIdentifiers({ entityKey, issueTrackerInstruction, metric, metric_u setSuggestions([]) } }} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={<LabelWithHelp labelId={labelId} label="Issue identifiers" help={issueStatusHelp} />} options={suggestions} - set_value={(value) => set_metric_attribute(metric_uuid, "issue_ids", value, reload)} - value={issue_ids} - key={issue_ids} // Make sure the multiple choice input is rerendered when the issue ids change + value={issueIds} /> ) } @@ -116,64 +109,44 @@ export function IssuesRows({ metric, metric_uuid, reload, report, target }) { } return ( <> - <Grid.Row> - <ReadOnlyOrEditable - requiredPermissions={[EDIT_REPORT_PERMISSION]} - readOnlyComponent={ - <Grid.Column width={16}> - <IssueIdentifiers {...issueIdentifiersProps} /> - </Grid.Column> - } - editableComponent={ - <> - <Grid.Column width={2} verticalAlign="bottom"> - <CreateIssueButton - issueTrackerConfigured={issueTrackerConfigured} - issueTrackerInstruction={issueTrackerInstruction} - metric_uuid={metric_uuid} - target={target ?? "metric"} - reload={reload} - /> - </Grid.Column> - <Grid.Column width={14}> - <IssueIdentifiers {...issueIdentifiersProps} /> - </Grid.Column> - </> - } + <Grid size={{ xs: 1, sm: 1, md: "auto" }}> + <CreateIssueButton + issueTrackerConfigured={issueTrackerConfigured} + issueTrackerInstruction={issueTrackerInstruction} + metric_uuid={metric_uuid} + target={target ?? "metric"} + reload={reload} /> - </Grid.Row> + </Grid> + <Grid size={{ xs: 1, sm: 2, md: "grow" }}> + <IssueIdentifiers {...issueIdentifiersProps} /> + </Grid> {getMetricIssueIds(metric).length > 0 && !issueTrackerConfigured && ( - <Grid.Row> - <Grid.Column width={16}> - <ErrorMessage title="No issue tracker configured" message={issueTrackerInstruction} /> - </Grid.Column> - </Grid.Row> + <Grid size={{ xs: 1, sm: 3, md: 6 }}> + <ErrorMessage title="No issue tracker configured" message={issueTrackerInstruction} /> + </Grid> )} {(metric.issue_status ?? []) .filter((issue_status) => issue_status.connection_error) .map((issue_status) => ( - <Grid.Row key={issue_status.issue_id}> - <Grid.Column width={16}> - <ErrorMessage - key={issue_status.issue_id} - title={"Connection error while retrieving " + issue_status.issue_id} - message={issue_status.connection_error} - /> - </Grid.Column> - </Grid.Row> + <Grid key={issue_status.issue_id} size={{ xs: 1, sm: 3, md: 6 }}> + <ErrorMessage + key={issue_status.issue_id} + title={"Connection error while retrieving " + issue_status.issue_id} + message={issue_status.connection_error} + /> + </Grid> ))} {(metric.issue_status ?? []) .filter((issue_status) => issue_status.parse_error) .map((issue_status) => ( - <Grid.Row key={issue_status.issue_id}> - <Grid.Column width={16}> - <ErrorMessage - key={issue_status.issue_id} - title={"Parse error while processing " + issue_status.issue_id} - message={issue_status.parse_error} - /> - </Grid.Column> - </Grid.Row> + <Grid key={issue_status.issue_id} size={{ xs: 1, sm: 3, md: 6 }}> + <ErrorMessage + key={issue_status.issue_id} + title={"Parse error while processing " + issue_status.issue_id} + message={issue_status.parse_error} + /> + </Grid> ))} </> ) diff --git a/components/frontend/src/issue/IssuesRows.test.js b/components/frontend/src/issue/IssuesRows.test.js index 776eca0447..dfbe696be6 100644 --- a/components/frontend/src/issue/IssuesRows.test.js +++ b/components/frontend/src/issue/IssuesRows.test.js @@ -97,9 +97,9 @@ it("tries to create an issue", () => { }) }) -it("does not show the create issue button if the user has no permissions", () => { +it("disables the create issue button if the user has no permissions", () => { renderIssuesRow({ report: reportWithIssueTracker, permissions: [] }) - expect(screen.queryAllByText(/Create new issue/).length).toBe(0) + expect(screen.getByText(/Create new issue/)).toBeDisabled() }) it("adds an issue id", async () => { @@ -133,8 +133,8 @@ it("shows no issue id suggestions without a query", async () => { renderIssuesRow({ report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira" } } }, }) - await userEvent.type(screen.getByLabelText(/Issue identifiers/), "s") + await userEvent.type(screen.getByRole("combobox"), "s") expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(1) - await userEvent.clear(screen.getByLabelText(/Issue identifiers/).firstChild) + await userEvent.clear(screen.getByRole("combobox")) expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(0) }) diff --git a/components/frontend/src/measurement/MeasurementSources.js b/components/frontend/src/measurement/MeasurementSources.js index 049ff10b0c..692554ff76 100644 --- a/components/frontend/src/measurement/MeasurementSources.js +++ b/components/frontend/src/measurement/MeasurementSources.js @@ -2,8 +2,7 @@ import { SourceStatus } from "./SourceStatus" export function MeasurementSources({ metric }) { const sources = metric.latest_measurement?.sources ?? [] - return sources.map((source, index) => [ - index > 0 && ", ", + return sources.map((source) => [ <SourceStatus key={source.source_uuid} metric={metric} measurement_source={source} />, ]) } diff --git a/components/frontend/src/measurement/MeasurementSources.test.js b/components/frontend/src/measurement/MeasurementSources.test.js index 8cebc97468..1f4b135565 100644 --- a/components/frontend/src/measurement/MeasurementSources.test.js +++ b/components/frontend/src/measurement/MeasurementSources.test.js @@ -37,5 +37,6 @@ it("renders multiple measurement sources", () => { /> </DataModel.Provider>, ) - expect(screen.getAllByText(/Source name 1, Source name 2/).length).toBe(1) + expect(screen.getAllByText(/Source name 1/).length).toBe(1) + expect(screen.getAllByText(/Source name 2/).length).toBe(1) }) diff --git a/components/frontend/src/measurement/MeasurementTarget.js b/components/frontend/src/measurement/MeasurementTarget.js index 134e21d7dc..17bd0629e2 100644 --- a/components/frontend/src/measurement/MeasurementTarget.js +++ b/components/frontend/src/measurement/MeasurementTarget.js @@ -1,7 +1,7 @@ +import { Tooltip } from "@mui/material" import { useContext } from "react" import { DataModel } from "../context/DataModel" -import { Label, Popup } from "../semantic_ui_react_wrappers" import { metricPropType } from "../sharedPropTypes" import { formatMetricDirection, @@ -12,6 +12,7 @@ import { getMetricTarget, isValidDate_YYYYMMDD, } from "../utils" +import { Label } from "../widgets/Label" function popupText(metric, debtEndDateInThePast, allIssuesDone, dataModel) { const unit = formatMetricScaleAndUnit(metric, dataModel) @@ -59,11 +60,11 @@ export function MeasurementTarget({ metric }) { const today = new Date() debtEndDateInThePast = endDate.toISOString().split("T")[0] < today.toISOString().split("T")[0] } - const label = allIssuesDone || debtEndDateInThePast ? <Label color="grey">{target}</Label> : <span>{target}</span> + const label = allIssuesDone || debtEndDateInThePast ? <Label color="debt_target_met">{target}</Label> : target return ( - <Popup hoverable on={["hover", "focus"]} trigger={label}> - {popupText(metric, debtEndDateInThePast, allIssuesDone, dataModel)} - </Popup> + <Tooltip title={<span>{popupText(metric, debtEndDateInThePast, allIssuesDone, dataModel)}</span>}> + <span>{label}</span> + </Tooltip> ) } MeasurementTarget.propTypes = { diff --git a/components/frontend/src/measurement/MeasurementValue.js b/components/frontend/src/measurement/MeasurementValue.js index f9d7e58c63..73348294db 100644 --- a/components/frontend/src/measurement/MeasurementValue.js +++ b/components/frontend/src/measurement/MeasurementValue.js @@ -1,10 +1,10 @@ import "./MeasurementValue.css" +import { Alert, Tooltip, Typography } from "@mui/material" import { bool, string } from "prop-types" import { useContext } from "react" import { DataModel } from "../context/DataModel" -import { Label, Message, Popup } from "../semantic_ui_react_wrappers" import { datePropType, measurementPropType, metricPropType } from "../sharedPropTypes" import { IGNORABLE_SOURCE_ENTITY_STATUSES, SOURCE_ENTITY_STATUS_NAME } from "../source/source_entity_status" import { @@ -18,6 +18,7 @@ import { sum, } from "../utils" import { IgnoreIcon, LoadingIcon } from "../widgets/icons" +import { Label } from "../widgets/Label" import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate" import { WarningMessage } from "../widgets/WarningMessage" @@ -30,14 +31,20 @@ function measurementValueLabel(hasIgnoredEntities, stale, updating, value) { value ) if (stale) { - return <Label color="red">{measurementValue}</Label> + return ( + <span> + <Label color="error">{measurementValue}</Label> + </span> + ) } if (updating) { return ( - <Label color="yellow"> - <LoadingIcon /> - {measurementValue} - </Label> + <span> + <Label color="warning"> + <LoadingIcon /> + {measurementValue} + </Label> + </span> ) } return <span>{measurementValue}</span> @@ -101,49 +108,43 @@ export function MeasurementValue({ metric, reportDate }) { const requested = isMeasurementRequested(metric) const hasIgnoredEntities = sum(ignoredEntitiesCount(metric.latest_measurement)) > 0 return ( - <Popup - trigger={measurementValueLabel(hasIgnoredEntities, stale, outdated || requested, value)} - flowing - hoverable + <Tooltip + slotProps={{ tooltip: { sx: { maxWidth: "32em" } } }} + title={ + <div> + <WarningMessage showIf={stale} title="This metric was not recently measured"> + This may indicate a problem with Quality-time itself. Please contact a system administrator. + </WarningMessage> + <WarningMessage showIf={outdated} title="Latest measurement out of date"> + The source configuration of this metric was changed after the latest measurement. + </WarningMessage> + <WarningMessage showIf={requested} title="Measurement requested"> + An update of the latest measurement was requested by a user. + </WarningMessage> + {hasIgnoredEntities && ( + <Alert severity="info"> + <Typography> + <IgnoreIcon /> {`Ignored ${unit}`} + </Typography> + {ignoredEntitiesMessage(metric.latest_measurement, unit)} + </Alert> + )} + {metric.latest_measurement && ( + <> + <TimeAgoWithDate date={metric.latest_measurement.end}> + {metric.status ? "The metric was last measured" : "Last measurement attempt"} + </TimeAgoWithDate> + <br /> + <TimeAgoWithDate date={metric.latest_measurement.start}> + {metric.status ? "The current value was first measured" : "The value is unknown since"} + </TimeAgoWithDate> + </> + )} + </div> + } > - <WarningMessage - showIf={stale} - header="This metric was not recently measured" - content="This may indicate a problem with Quality-time itself. Please contact a system administrator." - /> - <WarningMessage - showIf={outdated} - header="Latest measurement out of date" - content="The source configuration of this metric was changed after the latest measurement." - /> - <WarningMessage - showIf={requested} - header="Measurement requested" - content="An update of the latest measurement was requested by a user." - /> - {hasIgnoredEntities && ( - <Message - info - header={ - <span> - <IgnoreIcon /> {`Ignored ${unit}`} - </span> - } - content={ignoredEntitiesMessage(metric.latest_measurement, unit)} - /> - )} - {metric.latest_measurement && ( - <> - <TimeAgoWithDate date={metric.latest_measurement.end}> - {metric.status ? "The metric was last measured" : "Last measurement attempt"} - </TimeAgoWithDate> - <br /> - <TimeAgoWithDate date={metric.latest_measurement.start}> - {metric.status ? "The current value was first measured" : "The value is unknown since"} - </TimeAgoWithDate> - </> - )} - </Popup> + {measurementValueLabel(hasIgnoredEntities, stale, outdated || requested, value)} + </Tooltip> ) } MeasurementValue.propTypes = { diff --git a/components/frontend/src/measurement/MeasurementValue.test.js b/components/frontend/src/measurement/MeasurementValue.test.js index 1af6c4721e..62f5e67c9d 100644 --- a/components/frontend/src/measurement/MeasurementValue.test.js +++ b/components/frontend/src/measurement/MeasurementValue.test.js @@ -55,7 +55,6 @@ it("renders an outdated value", async () => { }, }) const measurementValue = screen.getByText(/1/) - expect(measurementValue.className).toContain("yellow") expect(screen.getAllByTestId("LoopIcon").length).toBe(1) await userEvent.hover(measurementValue) await waitFor(() => { @@ -73,7 +72,6 @@ it("renders a value for which a measurement was requested", async () => { measurement_requested: now, }) const measurementValue = screen.getByText(/1/) - expect(measurementValue.className).toContain("yellow") expect(screen.getAllByTestId("LoopIcon").length).toBe(1) await userEvent.hover(measurementValue) await waitFor(() => { @@ -89,7 +87,6 @@ it("renders a value for which a measurement was requested, but which is now up t measurement_requested: "2024-01-01T00:00:00", }) const measurementValue = screen.getByText(/1/) - expect(measurementValue.className).not.toContain("yellow") expect(screen.queryAllByTestId("LoopIcon").length).toBe(0) await userEvent.hover(measurementValue) await waitFor(() => { diff --git a/components/frontend/src/measurement/Overrun.js b/components/frontend/src/measurement/Overrun.js index 4f29c6016d..a7d46fe9d7 100644 --- a/components/frontend/src/measurement/Overrun.js +++ b/components/frontend/src/measurement/Overrun.js @@ -1,8 +1,18 @@ +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material" import { string } from "prop-types" import { useContext } from "react" import { DataModel } from "../context/DataModel" -import { Header, Popup, Table } from "../semantic_ui_react_wrappers" import { datesPropType, measurementsPropType, metricPropType, reportPropType } from "../sharedPropTypes" import { getMetricResponseOverrun, pluralize } from "../utils" import { StatusIcon } from "./StatusIcon" @@ -23,59 +33,60 @@ export function Overrun({ metric_uuid, metric, report, measurements, dates }) { const period = `${sortedDates.at(0).toLocaleDateString()} - ${sortedDates.at(-1).toLocaleDateString()}` const content = ( <> - <Header> - <Header.Content> - Metric reaction time overruns - <Header.Subheader>In the period {period}</Header.Subheader> - </Header.Content> - </Header> - <Table compact size="small"> - <Table.Header> - <Table.Row> - <Table.HeaderCell textAlign="center" colSpan="3"> - When did the metric need action? - </Table.HeaderCell> - <Table.HeaderCell textAlign="center" colSpan="3"> - How long did it take to react? - </Table.HeaderCell> - </Table.Row> - <Table.Row> - <Table.HeaderCell textAlign="center">Status</Table.HeaderCell> - <Table.HeaderCell textAlign="center">Start</Table.HeaderCell> - <Table.HeaderCell textAlign="center">End</Table.HeaderCell> - <Table.HeaderCell textAlign="right">Actual</Table.HeaderCell> - <Table.HeaderCell textAlign="right">Desired</Table.HeaderCell> - <Table.HeaderCell textAlign="right">Overrun</Table.HeaderCell> - </Table.Row> - </Table.Header> - <Table.Body> - {overruns.map((overrun) => ( - <Table.Row key={overrun.start}> - <Table.Cell textAlign="center"> - <StatusIcon size="small" status={overrun.status} /> - </Table.Cell> - <Table.Cell>{overrun.start.split("T")[0]}</Table.Cell> - <Table.Cell>{overrun.end.split("T")[0]}</Table.Cell> - <Table.Cell textAlign="right">{formatDays(overrun.actual_response_time)}</Table.Cell> - <Table.Cell textAlign="right">{formatDays(overrun.desired_response_time)}</Table.Cell> - <Table.Cell textAlign="right">{formatDays(overrun.overrun)}</Table.Cell> - </Table.Row> - ))} - </Table.Body> - <Table.Footer> - <Table.Row> - <Table.HeaderCell colSpan="5"> - <b>Total</b> - </Table.HeaderCell> - <Table.HeaderCell textAlign="right"> - <b>{triggerText}</b> - </Table.HeaderCell> - </Table.Row> - </Table.Footer> - </Table> + <Typography>Metric reaction time overruns in the period {period}</Typography> + <TableContainer component={Paper}> + <Table> + <TableHead> + <TableRow> + <TableCell align="center" colSpan="3"> + When did the metric need action? + </TableCell> + <TableCell align="center" colSpan="3"> + How long did it take to react? + </TableCell> + </TableRow> + <TableRow> + <TableCell align="left">Status</TableCell> + <TableCell align="left">Start</TableCell> + <TableCell align="left">End</TableCell> + <TableCell align="right">Actual</TableCell> + <TableCell align="right">Desired</TableCell> + <TableCell align="right">Overrun</TableCell> + </TableRow> + </TableHead> + <TableBody> + {overruns.map((overrun) => ( + <TableRow key={overrun.start}> + <TableCell align="left"> + <StatusIcon size="small" status={overrun.status} /> + </TableCell> + <TableCell align="left">{overrun.start.split("T")[0]}</TableCell> + <TableCell align="left">{overrun.end.split("T")[0]}</TableCell> + <TableCell align="right">{formatDays(overrun.actual_response_time)}</TableCell> + <TableCell align="right">{formatDays(overrun.desired_response_time)}</TableCell> + <TableCell align="right">{formatDays(overrun.overrun)}</TableCell> + </TableRow> + ))} + </TableBody> + <TableHead> + <TableRow> + <TableCell colSpan="5"> + <b>Total</b> + </TableCell> + <TableCell align="right"> + <b>{triggerText}</b> + </TableCell> + </TableRow> + </TableHead> + </Table> + </TableContainer> </> ) - return <Popup content={content} flowing hoverable trigger={trigger} /> + return ( + <Tooltip slotProps={{ tooltip: { sx: { maxWidth: "40em" } } }} title={content}> + {trigger} + </Tooltip> + ) } Overrun.propTypes = { dates: datesPropType, diff --git a/components/frontend/src/measurement/SourceStatus.js b/components/frontend/src/measurement/SourceStatus.js index 48ac30f5a4..16007472c5 100644 --- a/components/frontend/src/measurement/SourceStatus.js +++ b/components/frontend/src/measurement/SourceStatus.js @@ -1,10 +1,11 @@ +import { Tooltip, Typography } from "@mui/material" import { useContext } from "react" import { DataModel } from "../context/DataModel" -import { Label, Popup } from "../semantic_ui_react_wrappers" import { measurementSourcePropType, metricPropType } from "../sharedPropTypes" import { getMetricName, getSourceName } from "../utils" import { HyperLink } from "../widgets/HyperLink" +import { Label } from "../widgets/Label" export function SourceStatus({ metric, measurement_source }) { const dataModel = useContext(DataModel) @@ -36,13 +37,18 @@ export function SourceStatus({ metric, measurement_source }) { header = "Parse error" } return ( - <Popup - content={content} - flowing - header={header} - hoverable - trigger={<Label color="red">{source_label()}</Label>} - /> + <Tooltip + title={ + <> + <Typography>{header}</Typography> + {content} + </> + } + > + <span> + <Label color="error">{source_label()}</Label> + </span> + </Tooltip> ) } else { return source_label() diff --git a/components/frontend/src/measurement/StatusIcon.js b/components/frontend/src/measurement/StatusIcon.js index fd73ed86b3..5a44aa0003 100644 --- a/components/frontend/src/measurement/StatusIcon.js +++ b/components/frontend/src/measurement/StatusIcon.js @@ -1,7 +1,7 @@ import { Avatar, Tooltip } from "@mui/material" import { instanceOf, oneOfType, string } from "prop-types" -import { STATUS_COLORS_MUI, STATUS_ICONS, STATUS_SHORT_NAME, statusPropType } from "../metric/status" +import { STATUS_ICONS, STATUS_SHORT_NAME, statusPropType } from "../metric/status" import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate" export function StatusIcon({ status, statusStart, size }) { @@ -9,7 +9,7 @@ export function StatusIcon({ status, statusStart, size }) { const sizes = { small: 20, undefined: 32 } const statusName = STATUS_SHORT_NAME[status] // Use Avatar to create a round inverted icon: - const iconStyle = { width: sizes[size], height: sizes[size], bgcolor: STATUS_COLORS_MUI[status] } + const iconStyle = { width: sizes[size], height: sizes[size], bgcolor: `${status}.main` } const icon = ( <Avatar aria-label={statusName} sx={iconStyle}> {STATUS_ICONS[status]} diff --git a/components/frontend/src/measurement/TimeLeft.js b/components/frontend/src/measurement/TimeLeft.js index d89e8ae5d4..31e5c448f3 100644 --- a/components/frontend/src/measurement/TimeLeft.js +++ b/components/frontend/src/measurement/TimeLeft.js @@ -1,6 +1,8 @@ -import { Label, Popup } from "../semantic_ui_react_wrappers" +import { Tooltip } from "@mui/material" + import { metricPropType, reportPropType } from "../sharedPropTypes" import { days, getMetricResponseDeadline, getMetricResponseTimeLeft, pluralize } from "../utils" +import { Label } from "../widgets/Label" import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate" export function TimeLeft({ metric, report }) { @@ -12,15 +14,15 @@ export function TimeLeft({ metric, report }) { const daysLeft = days(Math.max(0, timeLeft)) const triggerText = `${daysLeft} ${pluralize("day", daysLeft)}` let deadlineLabel = "Deadline to address this metric was" - let trigger = <Label color="red">{triggerText}</Label> + let trigger = <Label color="error">{triggerText}</Label> if (timeLeft >= 0) { deadlineLabel = "Time left to address this metric is" - trigger = <span>{triggerText}</span> + trigger = triggerText } return ( - <Popup flowing hoverable trigger={trigger}> - <TimeAgoWithDate date={deadline}>{deadlineLabel}</TimeAgoWithDate>. - </Popup> + <Tooltip title={<TimeAgoWithDate date={deadline}>{deadlineLabel}</TimeAgoWithDate>}> + <span>{trigger}</span> + </Tooltip> ) } TimeLeft.propTypes = { diff --git a/components/frontend/src/metric/MetricConfigurationParameters.js b/components/frontend/src/metric/MetricConfigurationParameters.js index abbdb6ac36..e8da8b9a16 100644 --- a/components/frontend/src/metric/MetricConfigurationParameters.js +++ b/components/frontend/src/metric/MetricConfigurationParameters.js @@ -1,25 +1,19 @@ +import { MenuItem, Stack, Typography } from "@mui/material" +import Grid from "@mui/material/Grid2" import { func, string } from "prop-types" import { useContext } from "react" -import { Grid, Header } from "semantic-ui-react" import { set_metric_attribute } from "../api/metric" import { DataModel } from "../context/DataModel" -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { MultipleChoiceInput } from "../fields/MultipleChoiceInput" -import { SingleChoiceInput } from "../fields/SingleChoiceInput" -import { StringInput } from "../fields/StringInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { MultipleChoiceField } from "../fields/MultipleChoiceField" +import { TextField } from "../fields/TextField" import { metricPropType, reportPropType, subjectPropType } from "../sharedPropTypes" -import { - dropdownOptions, - formatMetricScale, - getMetricDirection, - getMetricScale, - getMetricTags, - getReportTags, -} from "../utils" -import { LabelWithHelp } from "../widgets/LabelWithHelp" +import { formatMetricScale, getMetricDirection, getMetricScale, getMetricTags, getReportTags } from "../utils" +import { Header } from "../widgets/Header" import { MetricType } from "./MetricType" import { Target } from "./Target" +import { TargetVisualiser } from "./TargetVisualiser" function metric_scale_options(metric_scales, dataModel) { let scale_options = [] @@ -27,7 +21,7 @@ function metric_scale_options(metric_scales, dataModel) { let scale_name = dataModel.scales ? dataModel.scales[scale].name : "Count" let scale_description = dataModel.scales ? dataModel.scales[scale].description : "" scale_options.push({ - content: <Header as="h4" content={scale_name} subheader={scale_description} />, + content: <Header level="h4" header={scale_name} subheader={scale_description} />, key: scale, text: scale_name, value: scale, @@ -38,16 +32,16 @@ function metric_scale_options(metric_scales, dataModel) { function MetricName({ metric, metric_uuid, reload }) { const dataModel = useContext(DataModel) + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const metricType = dataModel.metrics[metric.type] - const labelId = `metric-name-${metric_uuid}` return ( - <StringInput - aria-labelledby={labelId} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={<label id={labelId}>Metric name</label>} + <TextField + disabled={disabled} + label="Metric name" placeholder={metricType.name} - set_value={(value) => set_metric_attribute(metric_uuid, "name", value, reload)} - value={metric.name ?? ""} + onChange={(value) => set_metric_attribute(metric_uuid, "name", value, reload)} + value={metric.name} /> ) } @@ -58,16 +52,16 @@ MetricName.propTypes = { } function Tags({ metric, metric_uuid, reload, report }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const tags = getReportTags(report) - const labelId = `tags-${metric_uuid}` return ( - <MultipleChoiceInput - allowAdditions - aria-labelledby={labelId} - label={<label id={labelId}>Tags</label>} - options={dropdownOptions(tags)} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => set_metric_attribute(metric_uuid, "tags", value, reload)} + <MultipleChoiceField + disabled={disabled} + freeSolo + label="Tags" + options={tags} + onChange={(value) => set_metric_attribute(metric_uuid, "tags", value, reload)} value={getMetricTags(metric)} /> ) @@ -81,20 +75,26 @@ Tags.propTypes = { function Scale({ metric, metric_uuid, reload }) { const dataModel = useContext(DataModel) + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const scale = getMetricScale(metric, dataModel) const metricType = dataModel.metrics[metric.type] const scale_options = metric_scale_options(metricType.scales || ["count"], dataModel) - const labelId = `scale-${metric_uuid}` return ( - <SingleChoiceInput - aria-labelledby={labelId} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={<label id={labelId}>Metric scale</label>} - options={scale_options} + <TextField + disabled={disabled} + label="Metric scale" + onChange={(value) => set_metric_attribute(metric_uuid, "scale", value, reload)} placeholder={metricType.default_scale || "Count"} - set_value={(value) => set_metric_attribute(metric_uuid, "scale", value, reload)} + select value={scale} - /> + > + {scale_options.map((option) => ( + <MenuItem key={option.key} value={option.value}> + {option.content} + </MenuItem> + ))} + </TextField> ) } Scale.propTypes = { @@ -105,6 +105,8 @@ Scale.propTypes = { function Direction({ metric, metric_uuid, reload }) { const dataModel = useContext(DataModel) + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const scale = getMetricScale(metric, dataModel) const metricType = dataModel.metrics[metric.type] const metricUnitWithoutPercentage = metric.unit || metricType.unit @@ -119,20 +121,21 @@ function Direction({ metric, metric_uuid, reload }) { percentage: `A higher percentage of ${metricUnitWithoutPercentage}`, version_number: "A higher version number", }[scale] - const metricDirection = getMetricDirection(metric, dataModel) ?? "<" - const labelId = `direction-${metric_uuid}` return ( - <SingleChoiceInput - aria-labelledby={labelId} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={<label id={labelId}>Metric direction</label>} - options={[ - { key: "0", text: `${fewer} is better`, value: "<" }, - { key: "1", text: `${more} is better`, value: ">" }, - ]} - set_value={(value) => set_metric_attribute(metric_uuid, "direction", value, reload)} - value={metricDirection} - /> + <TextField + disabled={disabled} + label="Metric direction" + onChange={(value) => set_metric_attribute(metric_uuid, "direction", value, reload)} + select + value={getMetricDirection(metric, dataModel) ?? "<"} + > + <MenuItem key="0" value="<"> + {`${fewer} is better`} + </MenuItem> + <MenuItem key="1" value=">"> + {`${more} is better`} + </MenuItem> + </TextField> ) } Direction.propTypes = { @@ -143,17 +146,17 @@ Direction.propTypes = { function Unit({ metric, metric_uuid, reload }) { const dataModel = useContext(DataModel) + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const metricType = dataModel.metrics[metric.type] - const labelId = `unit-${metric_uuid}` return ( - <StringInput - aria-labelledby={labelId} - label={<label id={labelId}>Metric unit</label>} + <TextField + disabled={disabled} + label="Metric unit" placeholder={metricType.unit} - prefix={formatMetricScale(metric, dataModel)} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => set_metric_attribute(metric_uuid, "unit", value, reload)} - value={metric.unit ?? ""} + startAdornment={formatMetricScale(metric, dataModel)} + onChange={(value) => set_metric_attribute(metric_uuid, "unit", value, reload)} + value={metric.unit} /> ) } @@ -164,21 +167,26 @@ Unit.propTypes = { } function EvaluateTargets({ metric, metric_uuid, reload }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const help = "Turning off evaluation of the metric targets makes this an informative metric. Informative metrics do not turn red, green, or yellow, and can't have accepted technical debt." - const labelId = `evaluate-targets-label-${metric_uuid}` return ( - <SingleChoiceInput - aria-labelledby={labelId} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={<LabelWithHelp labelId={labelId} label="Evaluate metric targets?" help={help} />} + <TextField + disabled={disabled} + helperText={help} + label="Evaluate metric targets?" + onChange={(value) => set_metric_attribute(metric_uuid, "evaluate_targets", value, reload)} + select value={metric.evaluate_targets ?? true} - options={[ - { key: true, text: "Yes", value: true }, - { key: false, text: "No", value: false }, - ]} - set_value={(value) => set_metric_attribute(metric_uuid, "evaluate_targets", value, reload)} - /> + > + <MenuItem key={true} value={true}> + Yes + </MenuItem> + <MenuItem key={false} value={false}> + No + </MenuItem> + </TextField> ) } EvaluateTargets.propTypes = { @@ -191,61 +199,45 @@ export function MetricConfigurationParameters({ metric, metric_uuid, reload, rep const dataModel = useContext(DataModel) const metricScale = getMetricScale(metric, dataModel) return ( - <Grid stackable columns={3}> - <Grid.Row> - <Grid.Column> - <MetricType - subjectType={subject.type} - metricType={metric.type} - metric_uuid={metric_uuid} - reload={reload} - /> - </Grid.Column> - <Grid.Column> - <MetricName metric={metric} metric_uuid={metric_uuid} reload={reload} /> - </Grid.Column> - <Grid.Column> - <Tags metric={metric} metric_uuid={metric_uuid} reload={reload} report={report} /> - </Grid.Column> - </Grid.Row> - <Grid.Row> - <Grid.Column> - <Scale metric={metric} metric_uuid={metric_uuid} reload={reload} /> - </Grid.Column> - <Grid.Column> - <Direction metric={metric} metric_uuid={metric_uuid} reload={reload} /> - </Grid.Column> - {metricScale !== "version_number" && ( - <Grid.Column> - <Unit metric={metric} metric_uuid={metric_uuid} reload={reload} /> - </Grid.Column> - )} - </Grid.Row> - <Grid.Row> - <Grid.Column> - <EvaluateTargets metric={metric} metric_uuid={metric_uuid} reload={reload} /> - </Grid.Column> - <Grid.Column> - <Target - label="Metric target" - labelPosition="top center" - target_type="target" - metric={metric} - metric_uuid={metric_uuid} - reload={reload} - /> - </Grid.Column> - <Grid.Column> - <Target - label="Metric near target" - labelPosition="top right" - target_type="near_target" - metric={metric} - metric_uuid={metric_uuid} - reload={reload} - /> - </Grid.Column> - </Grid.Row> + <Grid container spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 3, md: 3 }}> + <Grid size={1}> + <MetricType + subjectType={subject.type} + metricType={metric.type} + metric_uuid={metric_uuid} + reload={reload} + /> + </Grid> + <Grid size={1}> + <MetricName metric={metric} metric_uuid={metric_uuid} reload={reload} /> + </Grid> + <Grid size={1}> + <Tags metric={metric} metric_uuid={metric_uuid} reload={reload} report={report} /> + </Grid> + <Grid size={1}> + <Scale metric={metric} metric_uuid={metric_uuid} reload={reload} /> + </Grid> + <Grid size={1}> + <Direction metric={metric} metric_uuid={metric_uuid} reload={reload} /> + </Grid> + <Grid size={1}> + {metricScale !== "version_number" && <Unit metric={metric} metric_uuid={metric_uuid} reload={reload} />} + </Grid> + <Grid size={1}> + <EvaluateTargets metric={metric} metric_uuid={metric_uuid} reload={reload} /> + </Grid> + <Grid size={1}> + <Target target_type="target" metric={metric} metric_uuid={metric_uuid} reload={reload} /> + </Grid> + <Grid size={1}> + <Target target_type="near_target" metric={metric} metric_uuid={metric_uuid} reload={reload} /> + </Grid> + <Grid size={3}> + <Stack spacing={1}> + <Typography variant="h4">How targets are evaluated</Typography> + <TargetVisualiser metric={metric} /> + </Stack> + </Grid> </Grid> ) } diff --git a/components/frontend/src/metric/MetricConfigurationParameters.test.js b/components/frontend/src/metric/MetricConfigurationParameters.test.js index d81aea5d30..16544b74e7 100644 --- a/components/frontend/src/metric/MetricConfigurationParameters.test.js +++ b/components/frontend/src/metric/MetricConfigurationParameters.test.js @@ -94,7 +94,7 @@ it("changes the scale", async () => { await act(async () => { renderMetricParameters() }) - fireEvent.click(screen.getByText(/Metric scale/)) + fireEvent.mouseDown(screen.getByLabelText(/Metric scale/)) fireEvent.click(screen.getByText(/Percentage/)) expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/scale", { scale: "percentage", @@ -105,7 +105,7 @@ it("changes the direction", async () => { await act(async () => { renderMetricParameters() }) - fireEvent.click(screen.getByText(/direction/)) + fireEvent.mouseDown(screen.getByLabelText(/direction/)) fireEvent.click(screen.getByText(/More violations is better/)) expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith( "post", diff --git a/components/frontend/src/metric/MetricDebtParameters.js b/components/frontend/src/metric/MetricDebtParameters.js index a473eaabc7..260cf19fe0 100644 --- a/components/frontend/src/metric/MetricDebtParameters.js +++ b/components/frontend/src/metric/MetricDebtParameters.js @@ -1,46 +1,34 @@ +import { MenuItem } from "@mui/material" +import Grid from "@mui/material/Grid2" +import { DatePicker } from "@mui/x-date-pickers/DatePicker" +import dayjs from "dayjs" import { func, string } from "prop-types" -import { Grid } from "semantic-ui-react" +import { useContext } from "react" +import TimeAgo from "react-timeago" import { set_metric_attribute, set_metric_debt } from "../api/metric" -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { Comment } from "../fields/Comment" -import { DateInput } from "../fields/DateInput" -import { SingleChoiceInput } from "../fields/SingleChoiceInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { CommentField } from "../fields/CommentField" +import { TextField } from "../fields/TextField" import { IssuesRows } from "../issue/IssuesRows" import { metricPropType, reportPropType } from "../sharedPropTypes" -import { LabelWithDate } from "../widgets/LabelWithDate" -import { LabelWithHyperLink } from "../widgets/LabelWithHyperLink" +import { HyperLink } from "../widgets/HyperLink" import { Target } from "./Target" function AcceptTechnicalDebt({ metric, metric_uuid, reload }) { - const labelId = `accept-debt-label-${metric_uuid}` + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) return ( - <SingleChoiceInput - aria-labelledby={labelId} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={ - <LabelWithHyperLink - labelId={labelId} - label="Accept technical debt?" - url="https://en.wikipedia.org/wiki/Technical_debt" - /> + <TextField + disabled={disabled} + helperText={ + <> + Read more about{" "} + <HyperLink url="https://en.wikipedia.org/wiki/Technical_debt">technical debt</HyperLink> + </> } - value={metric.accept_debt ? "yes" : "no"} - options={[ - { key: "yes", text: "Yes", value: "yes" }, - { - key: "yes_completely", - text: "Yes, and also set technical debt target and end date", - value: "yes_completely", - }, - { key: "no", text: "No", value: "no" }, - { - key: "no_completely", - text: "No, and also clear technical debt target and end date", - value: "no_completely", - }, - ]} - set_value={(value) => { + label="Accept technical debt?" + onChange={(value) => { const acceptDebt = value.startsWith("yes") if (value.endsWith("completely")) { set_metric_debt(metric_uuid, acceptDebt, reload) @@ -48,7 +36,22 @@ function AcceptTechnicalDebt({ metric, metric_uuid, reload }) { set_metric_attribute(metric_uuid, "accept_debt", acceptDebt, reload) } }} - /> + select + value={metric.accept_debt ? "yes" : "no"} + > + <MenuItem key="yes" value="yes"> + Yes + </MenuItem> + <MenuItem key="yes_completely" value="yes_completely"> + Yes, and also set technical debt target and end date + </MenuItem> + <MenuItem key="no" value="no"> + No + </MenuItem> + <MenuItem key="no_completely" value="no_completely"> + No, and also clear technical debt target and end date + </MenuItem> + </TextField> ) } AcceptTechnicalDebt.propTypes = { @@ -58,31 +61,24 @@ AcceptTechnicalDebt.propTypes = { } function TechnicalDebtEndDate({ metric, metric_uuid, reload }) { - const labelId = `technical-debt-end-date-label-${metric_uuid}` - const help = ( - <> - <p>Accept technical debt until this date.</p> - <p> - After this date, or when the issues below have all been resolved, whichever happens first, the technical - debt should be resolved and the technical debt target is no longer evaluated. - </p> - </> + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) + const debtEndDateTime = metric.debt_end_date ? dayjs(metric.debt_end_date) : null + const helperText = metric.debt_end_date ? ( + <TimeAgo date={debtEndDateTime} /> + ) : ( + "Accept technical debt until this date. After this date, or when the issues below have all been resolved, whichever happens first, the technical debt should be resolved and the technical debt target is no longer evaluated." ) - let debtEndDateTime = null - if (metric.debt_end_date) { - debtEndDateTime = new Date(metric.debt_end_date) - debtEndDateTime.setHours(23, 59, 59) - } return ( - <DateInput - ariaLabelledBy={labelId} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={ - <LabelWithDate date={debtEndDateTime} labelId={labelId} help={help} label="Technical debt end date" /> - } - placeholder="YYYY-MM-DD" - set_value={(value) => set_metric_attribute(metric_uuid, "debt_end_date", value, reload)} - value={metric.debt_end_date ?? ""} + <DatePicker + defaultValue={debtEndDateTime} + disabled={disabled} + format="YYYY-MM-DD" + label="Technical debt end date" + onChange={(value) => set_metric_attribute(metric_uuid, "debt_end_date", value, reload)} + slotProps={{ field: { clearable: true }, textField: { helperText: helperText } }} + sx={{ width: "100%" }} + timezone="default" /> ) } @@ -94,35 +90,29 @@ TechnicalDebtEndDate.propTypes = { export function MetricDebtParameters({ metric, metric_uuid, reload, report }) { return ( - <Grid stackable columns={3}> - <Grid.Row> - <Grid.Column> - <AcceptTechnicalDebt metric={metric} metric_uuid={metric_uuid} reload={reload} /> - </Grid.Column> - <Grid.Column> - <Target - key={metric.debt_target} - label="Technical debt target" - labelPosition="top center" - target_type="debt_target" - metric={metric} - metric_uuid={metric_uuid} - reload={reload} - /> - </Grid.Column> - <Grid.Column> - <TechnicalDebtEndDate metric={metric} metric_uuid={metric_uuid} reload={reload} /> - </Grid.Column> - </Grid.Row> + <Grid alignItems="flex-start" container spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 3, md: 6 }}> + <Grid size={{ xs: 1, sm: 1, md: 2 }}> + <AcceptTechnicalDebt metric={metric} metric_uuid={metric_uuid} reload={reload} /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 2 }}> + <Target + key={metric.debt_target} + target_type="debt_target" + metric={metric} + metric_uuid={metric_uuid} + reload={reload} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 2 }}> + <TechnicalDebtEndDate metric={metric} metric_uuid={metric_uuid} reload={reload} /> + </Grid> <IssuesRows metric={metric} metric_uuid={metric_uuid} reload={reload} report={report} /> - <Grid.Row> - <Grid.Column width={16}> - <Comment - set_value={(value) => set_metric_attribute(metric_uuid, "comment", value, reload)} - value={metric.comment} - /> - </Grid.Column> - </Grid.Row> + <Grid size={{ xs: 1, sm: 3, md: 6 }}> + <CommentField + onChange={(value) => set_metric_attribute(metric_uuid, "comment", value, reload)} + value={metric.comment} + /> + </Grid> </Grid> ) } diff --git a/components/frontend/src/metric/MetricDebtParameters.test.js b/components/frontend/src/metric/MetricDebtParameters.test.js index c65732960c..ef244e6fef 100644 --- a/components/frontend/src/metric/MetricDebtParameters.test.js +++ b/components/frontend/src/metric/MetricDebtParameters.test.js @@ -1,5 +1,9 @@ +import { LocalizationProvider } from "@mui/x-date-pickers" +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs" import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" +import dayjs from "dayjs" +import { locale_en_gb } from "dayjs/locale/en-gb" import * as fetch_server_api from "../api/fetch_server_api" import { DataModel } from "../context/DataModel" @@ -34,27 +38,34 @@ const dataModel = { function renderMetricDebtParameters({ accept_debt = false, debt_end_date = null } = {}) { render( - <Permissions.Provider value={[EDIT_REPORT_PERMISSION]}> - <DataModel.Provider value={dataModel}> - <MetricDebtParameters - metric={{ - accept_debt: accept_debt, - debt_end_date: debt_end_date, - issue_ids: [], - issue_status: [], - scale: "count", - tags: [], - type: "violations", - }} - metric_uuid="metric_uuid" - reload={jest.fn()} - report={{ subjects: {} }} - /> - </DataModel.Provider> - </Permissions.Provider>, + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}> + <Permissions.Provider value={[EDIT_REPORT_PERMISSION]}> + <DataModel.Provider value={dataModel}> + <MetricDebtParameters + metric={{ + accept_debt: accept_debt, + debt_end_date: debt_end_date, + issue_ids: [], + issue_status: [], + scale: "count", + tags: [], + type: "violations", + }} + metric_uuid="metric_uuid" + reload={jest.fn()} + report={{ subjects: {} }} + /> + </DataModel.Provider> + </Permissions.Provider> + , + </LocalizationProvider>, ) } +beforeEach(() => { + jest.resetAllMocks() +}) + it("accepts technical debt", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) renderMetricDebtParameters() @@ -85,6 +96,7 @@ it("unaccepts technical debt and resets target and end date", async () => { }) it("adds a comment", async () => { + fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) renderMetricDebtParameters() await userEvent.type(screen.getByLabelText(/Comment/), "Keep cool{Tab}") expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/comment", { @@ -92,26 +104,25 @@ it("adds a comment", async () => { }) }) +it("undoes changes to a comment", async () => { + renderMetricDebtParameters() + await userEvent.type(screen.getByLabelText(/Comment/), "Keep cool{Escape}") + expect(fetch_server_api.fetch_server_api).not.toHaveBeenCalled() +}) + it("sets the technical debt end date", async () => { - // Suppress "Warning: An update to t inside a test was not wrapped in act(...)." caused by interacting with - // the date picker. - const consoleLog = console.log - console.error = jest.fn() fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) renderMetricDebtParameters() - await userEvent.type(screen.getByPlaceholderText(/YYYY-MM-DD/), "2022-12-31{Tab}", { - initialSelectionStart: 0, - initialSelectionEnd: 10, - }) + await userEvent.type(screen.getByPlaceholderText(/YYYY-MM-DD/), "20221231{Enter}") + const expectedDate = dayjs("2022-12-31") expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith( "post", "metric/metric_uuid/attribute/debt_end_date", - { debt_end_date: "2022-12-31" }, + { debt_end_date: expectedDate }, ) - console.log = consoleLog }) it("shows days ago for the technical debt end date", () => { renderMetricDebtParameters({ debt_end_date: "2000-01-01" }) - expect(screen.getAllByLabelText(/years ago/).length).toBe(1) + expect(screen.getAllByText(/years ago/).length).toBe(1) }) diff --git a/components/frontend/src/metric/MetricDetails.js b/components/frontend/src/metric/MetricDetails.js index 56cc768851..7bd5c3dedb 100644 --- a/components/frontend/src/metric/MetricDetails.js +++ b/components/frontend/src/metric/MetricDetails.js @@ -1,4 +1,6 @@ +import HistoryIcon from "@mui/icons-material/History" import MoneyIcon from "@mui/icons-material/Money" +import SettingsIcon from "@mui/icons-material/Settings" import ShowChartIcon from "@mui/icons-material/ShowChart" import StorageIcon from "@mui/icons-material/Storage" import { bool, func, string } from "prop-types" @@ -6,19 +8,10 @@ import { useContext, useEffect, useState } from "react" import { get_metric_measurements } from "../api/measurement" import { delete_metric, set_metric_attribute } from "../api/metric" -import { activeTabIndex, tabChangeHandler } from "../app_ui_settings" import { ChangeLog } from "../changelog/ChangeLog" import { DataModel } from "../context/DataModel" import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions" -import { Tab } from "../semantic_ui_react_wrappers" -import { - datePropType, - metricPropType, - reportPropType, - reportsPropType, - stringsPropType, - stringsURLSearchQueryPropType, -} from "../sharedPropTypes" +import { datePropType, metricPropType, reportPropType, reportsPropType, stringsPropType } from "../sharedPropTypes" import { Logo } from "../source/Logo" import { SourceEntities } from "../source/SourceEntities" import { Sources } from "../source/Sources" @@ -29,7 +22,7 @@ import { DeleteButton } from "../widgets/buttons/DeleteButton" import { PermLinkButton } from "../widgets/buttons/PermLinkButton" import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup" import { RefreshIcon } from "../widgets/icons" -import { changelogTabPane, configurationTabPane, tabPane } from "../widgets/TabPane" +import { Tabs } from "../widgets/Tabs" import { showMessage } from "../widgets/toast" import { MetricConfigurationParameters } from "./MetricConfigurationParameters" import { MetricDebtParameters } from "./MetricDebtParameters" @@ -128,7 +121,6 @@ export function MetricDetails({ report, stopFilteringAndSorting, subject_uuid, - expandedItems, }) { const dataModel = useContext(DataModel) const [measurements, setMeasurements] = useState([]) @@ -149,69 +141,59 @@ export function MetricDetails({ anyError || Object.values(metric.sources).some((source) => !dataModel.metrics[metric.type].sources.includes(source.type)) const metricUrl = `${window.location.href.split("#")[0]}#${metric_uuid}` - let panes = [] - panes.push( - configurationTabPane( - <MetricConfigurationParameters - subject={subject} + const panes = [ + <MetricConfigurationParameters + key="1" + metric={metric} + metric_uuid={metric_uuid} + reload={reload} + report={report} + subject={subject} + />, + <Sources + changed_fields={changed_fields} + key="2" + measurement={metric.latest_measurement} + metric={metric} + metric_uuid={metric_uuid} + reload={reload} + report={report} + reports={reports} + />, + <MetricDebtParameters key="3" metric={metric} metric_uuid={metric_uuid} report={report} reload={reload} />, + <ChangeLog key="4" timestamp={report.timestamp} metric_uuid={metric_uuid} />, + <TrendGraph key="5" metric={metric} measurements={measurements} loading={measurementsStatus} />, + ] + const tabs = [ + { label: "Configuration", icon: <SettingsIcon /> }, + { error: Boolean(anyError), label: "Sources", icon: <StorageIcon />, warning: Boolean(anyWarning) }, + { label: "Technical debt", icon: <MoneyIcon /> }, + { label: "Changelog", icon: <HistoryIcon /> }, + { label: "Trend graph", icon: <ShowChartIcon /> }, + ] + Object.entries(metric.sources).forEach(([source_uuid, source]) => { + const sourceName = getSourceName(source, dataModel) + tabs.push({ + image: <Logo logo={source.type} alt={sourceName} width="21px" height="21px" marginBottom="6px" />, + label: sourceName, + }) + panes.push( + <SourceEntities + key={metric_uuid} + loading={measurementsStatus} + measurements={measurements} metric={metric} metric_uuid={metric_uuid} + reload={measurementsReload} report={report} - reload={reload} - />, - ), - tabPane( - "Sources", - <Sources - reports={reports} - report={report} - metric={metric} - metric_uuid={metric_uuid} - measurement={metric.latest_measurement} - changed_fields={changed_fields} - reload={reload} + source_uuid={source_uuid} />, - { icon: <StorageIcon />, error: Boolean(anyError), warning: Boolean(anyWarning) }, - ), - tabPane( - "Technical debt", - <MetricDebtParameters metric={metric} metric_uuid={metric_uuid} report={report} reload={reload} />, - { icon: <MoneyIcon /> }, - ), - changelogTabPane(<ChangeLog timestamp={report.timestamp} metric_uuid={metric_uuid} />), - tabPane( - "Trend graph", - <TrendGraph metric={metric} measurements={measurements} loading={measurementsStatus} />, - { icon: <ShowChartIcon /> }, - ), - ) - Object.entries(metric.sources).forEach(([source_uuid, source]) => { - const sourceName = getSourceName(source, dataModel) - panes.push( - tabPane( - sourceName, - <SourceEntities - loading={measurementsStatus} - measurements={measurements} - metric={metric} - metric_uuid={metric_uuid} - reload={measurementsReload} - report={report} - source_uuid={source_uuid} - />, - { image: <Logo logo={source.type} alt={sourceName} /> }, - ), ) }) - return ( <> <MetricTypeHeader metricType={dataModel.metrics[metric.type]} /> - <Tab - defaultActiveIndex={activeTabIndex(expandedItems, metric_uuid)} - onTabChange={tabChangeHandler(expandedItems, metric_uuid)} - panes={panes} - /> + <Tabs tabs={tabs}>{panes}</Tabs> <MetricDetailsButtonRow metric={metric} metric_uuid={metric_uuid} @@ -235,5 +217,4 @@ MetricDetails.propTypes = { report: reportPropType, stopFilteringAndSorting: func, subject_uuid: string, - expandedItems: stringsURLSearchQueryPropType, } diff --git a/components/frontend/src/metric/MetricDetails.test.js b/components/frontend/src/metric/MetricDetails.test.js index 2e1de91a2c..5c655e60cf 100644 --- a/components/frontend/src/metric/MetricDetails.test.js +++ b/components/frontend/src/metric/MetricDetails.test.js @@ -1,7 +1,9 @@ +import { LocalizationProvider } from "@mui/x-date-pickers" +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs" import { act, fireEvent, render, screen } from "@testing-library/react" +import { locale_en_gb } from "dayjs/locale/en-gb" import history from "history/browser" -import { createTestableSettings } from "../__fixtures__/fixtures" import * as changelog_api from "../api/changelog" import * as fetch_server_api from "../api/fetch_server_api" import * as measurement_api from "../api/measurement" @@ -54,7 +56,14 @@ const dataModel = { entities: { violations: { name: "Attribute", attributes: [] } }, }, }, - metrics: { violations: { direction: "<", tags: [], sources: ["sonarqube"] } }, + metrics: { + violations: { + direction: "<", + tags: [], + sources: ["sonarqube"], + scales: ["count", "percentage", "version_number"], + }, + }, subjects: { subject_type: { metrics: ["violations"] } }, } @@ -88,22 +97,22 @@ async function renderMetricDetails(stopFilteringAndSorting, connection_error, fa (_metric_uuid, _source_uuid, _entity_key, _attribute, _value, reload) => reload(), ) changelog_api.get_changelog.mockImplementation(() => Promise.resolve({ changelog: [] })) - const settings = createTestableSettings() await act(async () => render( - <Permissions.Provider value={[EDIT_ENTITY_PERMISSION, EDIT_REPORT_PERMISSION]}> - <DataModel.Provider value={dataModel}> - <MetricDetails - metric_uuid="metric_uuid" - reload={jest.fn()} - report={report} - reports={[report]} - stopFilteringAndSorting={stopFilteringAndSorting} - subject_uuid="subject_uuid" - expandedItems={settings.expandedItems} - /> - </DataModel.Provider> - </Permissions.Provider>, + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}> + <Permissions.Provider value={[EDIT_ENTITY_PERMISSION, EDIT_REPORT_PERMISSION]}> + <DataModel.Provider value={dataModel}> + <MetricDetails + metric_uuid="metric_uuid" + reload={jest.fn()} + report={report} + reports={[report]} + stopFilteringAndSorting={stopFilteringAndSorting} + subject_uuid="subject_uuid" + /> + </DataModel.Provider> + </Permissions.Provider> + </LocalizationProvider>, ), ) } @@ -116,28 +125,28 @@ beforeEach(() => { it("switches tabs", async () => { await renderMetricDetails() - expect(screen.getAllByText(/Metric name/).length).toBe(1) + expect(screen.getAllByLabelText(/Metric name/).length).toBe(1) fireEvent.click(screen.getByText(/Sources/)) - expect(screen.getAllByText(/Source name/).length).toBe(1) + expect(screen.getAllByLabelText(/Source name/).length).toBe(1) }) it("switches tabs to technical debt", async () => { await renderMetricDetails() - expect(screen.getAllByText(/Metric name/).length).toBe(1) + expect(screen.getAllByLabelText(/Metric name/).length).toBe(1) fireEvent.click(screen.getByText(/Technical debt/)) - expect(screen.getAllByText(/Technical debt target/).length).toBe(1) + expect(screen.getAllByLabelText(/Metric technical debt target/).length).toBe(1) }) it("switches tabs to measurement entities", async () => { await renderMetricDetails() - expect(screen.getAllByText(/Metric name/).length).toBe(1) + expect(screen.getAllByLabelText(/Metric name/).length).toBe(1) fireEvent.click(screen.getByText(/The source/)) expect(screen.getAllByText(/Attribute status/).length).toBe(1) }) it("switches tabs to the trend graph", async () => { await renderMetricDetails() - expect(screen.getAllByText(/Metric name/).length).toBe(1) + expect(screen.getAllByLabelText(/Metric name/).length).toBe(1) fireEvent.click(screen.getByText(/Trend graph/)) expect(screen.getAllByText(/Time/).length).toBe(1) }) @@ -164,12 +173,12 @@ it("removes the existing hashtag from the URL to share", async () => { it("displays whether sources have errors", async () => { await renderMetricDetails(null, "Connection error") - expect(screen.getByText(/Sources/)).toHaveClass("red label") + expect(screen.getByText(/Sources/)).toHaveClass("error") }) it("displays whether sources have warnings", async () => { await renderMetricDetails() - expect(screen.getByText(/Sources/)).toHaveClass("yellow label") + expect(screen.getByText(/Sources/)).toHaveClass("warning") }) it("moves the metric", async () => { @@ -213,7 +222,7 @@ it("reloads the measurements after editing a measurement entity", async () => { expect(measurement_api.get_metric_measurements).toHaveBeenCalledTimes(1) fireEvent.click(screen.getByText(/The source/)) fireEvent.click(screen.getByRole("button", { name: "Expand/collapse" })) - fireEvent.click(screen.getAllByText("Unconfirmed")[1]) + fireEvent.mouseDown(screen.getByText("Unconfirm")) await act(async () => fireEvent.click(screen.getByText("Confirm"))) expect(measurement_api.get_metric_measurements).toHaveBeenCalledTimes(2) }) diff --git a/components/frontend/src/metric/MetricType.js b/components/frontend/src/metric/MetricType.js index f0a8d1543d..90e3f32109 100644 --- a/components/frontend/src/metric/MetricType.js +++ b/components/frontend/src/metric/MetricType.js @@ -1,11 +1,11 @@ -import { Stack, Typography } from "@mui/material" +import { MenuItem, Stack, Typography } from "@mui/material" import { func, string } from "prop-types" import { useContext } from "react" import { set_metric_attribute } from "../api/metric" import { DataModel } from "../context/DataModel" -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { SingleChoiceInput } from "../fields/SingleChoiceInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { TextField } from "../fields/TextField" import { getSubjectTypeMetrics } from "../utils" export function metricTypeOption(key, metricType) { @@ -41,19 +41,27 @@ export function usedMetricTypes(subject) { export function MetricType({ subjectType, metricType, metric_uuid, reload }) { const dataModel = useContext(DataModel) + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const options = metricTypeOptions(dataModel, subjectType) const metricTypes = options.map((option) => option.key) if (!metricTypes.includes(metricType)) { options.push(metricTypeOption(metricType, dataModel.metrics[metricType])) } return ( - <SingleChoiceInput - requiredPermissions={[EDIT_REPORT_PERMISSION]} + <TextField + disabled={disabled} label="Metric type" - options={options} - set_value={(value) => set_metric_attribute(metric_uuid, "type", value, reload)} + onChange={(value) => set_metric_attribute(metric_uuid, "type", value, reload)} + select value={metricType} - /> + > + {options.map((option) => ( + <MenuItem key={option.key} value={option.value}> + {option.content} + </MenuItem> + ))} + </TextField> ) } MetricType.propTypes = { diff --git a/components/frontend/src/metric/MetricType.test.js b/components/frontend/src/metric/MetricType.test.js index 93599c78f8..6a0e87faba 100644 --- a/components/frontend/src/metric/MetricType.test.js +++ b/components/frontend/src/metric/MetricType.test.js @@ -1,4 +1,4 @@ -import { act, render, screen } from "@testing-library/react" +import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import * as fetch_server_api from "../api/fetch_server_api" @@ -58,9 +58,7 @@ function renderMetricType(metricType) { it("sets the metric type", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) - await act(async () => { - renderMetricType("violations") - }) + renderMetricType("violations") await userEvent.type(screen.getByRole("combobox"), "Source version{Enter}") expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/type", { type: "source_version", @@ -68,8 +66,6 @@ it("sets the metric type", async () => { }) it("shows the metric type even when not supported by the subject type", async () => { - await act(async () => { - renderMetricType("unsupported") - }) - expect(screen.queryAllByText(/Unsupported/).length).toBe(2) + renderMetricType("unsupported") + expect(screen.queryAllByText(/Unsupported/).length).toBe(1) }) diff --git a/components/frontend/src/metric/MetricTypeHeader.js b/components/frontend/src/metric/MetricTypeHeader.js index a03bc13273..df2d281f95 100644 --- a/components/frontend/src/metric/MetricTypeHeader.js +++ b/components/frontend/src/metric/MetricTypeHeader.js @@ -1,6 +1,6 @@ -import { Header } from "../semantic_ui_react_wrappers" import { metricTypePropType } from "../sharedPropTypes" import { referenceDocumentationURL } from "../utils" +import { Header } from "../widgets/Header" import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink" export function MetricTypeHeader({ metricType }) { @@ -8,15 +8,16 @@ export function MetricTypeHeader({ metricType }) { ? " for specific information on how to configure this metric type." : "" return ( - <Header> - <Header.Content> - {metricType.name} - <Header.Subheader> + <Header + header={metricType.name} + level="h4" + subheader={ + <> {metricType.description} <ReadTheDocsLink url={referenceDocumentationURL(metricType.name)} /> {howToConfigure} - </Header.Subheader> - </Header.Content> - </Header> + </> + } + /> ) } MetricTypeHeader.propTypes = { diff --git a/components/frontend/src/metric/Target.js b/components/frontend/src/metric/Target.js index fdaf4b49a7..c8408b175b 100644 --- a/components/frontend/src/metric/Target.js +++ b/components/frontend/src/metric/Target.js @@ -1,306 +1,64 @@ -import HelpIcon from "@mui/icons-material/Help" -import { Box, Stack, Typography } from "@mui/material" -import { bool, func, oneOf, string } from "prop-types" +import { func, string } from "prop-types" import { useContext } from "react" import { set_metric_attribute } from "../api/metric" import { DataModel } from "../context/DataModel" -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { IntegerInput } from "../fields/IntegerInput" -import { StringInput } from "../fields/StringInput" -import { StatusIcon } from "../measurement/StatusIcon" -import { Popup } from "../semantic_ui_react_wrappers" -import { childrenPropType, labelPropType, metricPropType, scalePropType } from "../sharedPropTypes" -import { - capitalize, - formatMetricDirection, - formatMetricScaleAndUnit, - formatMetricValue, - getMetricScale, -} from "../utils" -import { STATUS_COLORS_MUI, STATUS_SHORT_NAME, statusPropType } from "./status" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { TextField } from "../fields/TextField" +import { metricPropType, targetType } from "../sharedPropTypes" +import { formatMetricDirection, formatMetricScaleAndUnit, formatMetricValue, getMetricScale } from "../utils" -function smallerThan(target1, target2) { - const t1 = target1 ?? `${Number.POSITIVE_INFINITY}` - const t2 = target2 ?? "0" - return t1.localeCompare(t2, undefined, { numeric: true }) < 0 -} - -function maxTarget(...targets) { - targets.sort((target1, target2) => target1.localeCompare(target2, undefined, { numeric: true })) - return targets.at(-1) -} - -function minTarget(...targets) { - targets.sort((target1, target2) => target1.localeCompare(target2, undefined, { numeric: true })) - return targets.at(0) -} - -function debtTargetActive(metric, direction) { - const endDate = metric.debt_end_date ? new Date(metric.debt_end_date) : null - const active = !!metric.accept_debt && ((endDate && endDate >= new Date()) || !endDate) - return ( - active && - (direction === "≦" - ? smallerThan(metric.target, metric.debt_target) - : smallerThan(metric.debt_target, metric.target)) - ) -} - -function ColoredSegment({ children, color, show, status }) { - if (show === false) { - return null - } - return ( - <Box sx={{ padding: "10px", border: `12px solid ${STATUS_COLORS_MUI[status]}` }}> - <Typography variant="h6"> - <Stack direction="row"> - {STATUS_SHORT_NAME[status]} - <StatusIcon status={status} size="small" /> - </Stack> - </Typography> - <Typography variant="subtitle2">{capitalize(color)}</Typography> - <b>{children}</b> - </Box> - ) -} -ColoredSegment.propTypes = { - children: childrenPropType, - color: string, - show: bool, - status: statusPropType, -} - -function BlueSegment({ unit }) { - return <ColoredSegment color="blue" status="informative">{`${unit} are not evaluated`}</ColoredSegment> -} -BlueSegment.propTypes = { - unit: string, -} - -function GreenSegment({ direction, scale, target, show, unit }) { - return ( - <ColoredSegment - color="green" - show={show} - status="target_met" - >{`${direction} ${formatMetricValue(scale, target)}${unit}`}</ColoredSegment> - ) -} -GreenSegment.propTypes = { - direction: oneOf(["≦", "≧"]), - scale: scalePropType, - target: string, - show: bool, - unit: string, -} - -function RedSegment({ direction, scale, target, show, unit }) { - if (direction === "<" && target === "0") { - return null - } - return ( - <ColoredSegment - color="red" - show={show} - status="target_not_met" - >{`${direction} ${formatMetricValue(scale, target)}${unit}`}</ColoredSegment> - ) -} -RedSegment.propTypes = { - direction: oneOf(["<", ">"]), - scale: scalePropType, - target: string, - show: bool, - unit: string, -} - -function GreySegment({ lowTarget, highTarget, scale, show, unit }) { - return ( - <ColoredSegment - color="grey" - show={show} - status="debt_target_met" - >{`${formatMetricValue(scale, lowTarget)} - ${formatMetricValue(scale, highTarget)}${unit}`}</ColoredSegment> - ) -} -GreySegment.propTypes = { - lowTarget: string, - highTarget: string, - scale: scalePropType, - show: bool, - unit: string, -} - -function YellowSegment({ lowTarget, highTarget, scale, show, unit }) { - if (!smallerThan(lowTarget, highTarget)) { - return null - } - return ( - <ColoredSegment - color="yellow" - show={show} - status="near_target_met" - >{`${formatMetricValue(scale, lowTarget)} - ${formatMetricValue(scale, highTarget)}${unit}`}</ColoredSegment> - ) -} -YellowSegment.propTypes = { - lowTarget: string, - highTarget: string, - scale: scalePropType, - show: bool, - unit: string, -} - -function ColoredSegments({ children }) { - return <Stack direction="row">{children}</Stack> -} -ColoredSegments.propTypes = { - children: childrenPropType, -} - -function TargetVisualiser({ metric }) { +export function Target({ metric, metric_uuid, reload, target_type }) { const dataModel = useContext(DataModel) + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) + const metricScale = getMetricScale(metric, dataModel) + const metricDirection = formatMetricDirection(metric, dataModel) + const targetValue = metric[target_type] const unit = formatMetricScaleAndUnit(metric, dataModel) - if (metric.evaluate_targets === false) { - return ( - <ColoredSegments> - <BlueSegment unit={unit} /> - </ColoredSegments> - ) - } - const direction = formatMetricDirection(metric, dataModel) const scale = getMetricScale(metric, dataModel) - const oppositeDirection = { "≦": ">", "≧": "<" }[direction] - const target = metric.target - const nearTarget = metric.near_target - const debtTarget = metric.debt_target - const debtTargetApplies = debtTargetActive(metric, direction) - if (direction === "≦") { - return ( - <ColoredSegments> - <GreenSegment direction={direction} scale={scale} target={target} unit={unit} /> - <GreySegment - lowTarget={target} - highTarget={debtTarget} - scale={scale} - unit={unit} - show={debtTargetApplies} - /> - <YellowSegment - lowTarget={debtTargetApplies ? maxTarget(debtTarget, target) : target} - highTarget={nearTarget} - scale={scale} - unit={unit} - /> - <RedSegment - direction={oppositeDirection} - target={debtTargetApplies ? maxTarget(nearTarget, debtTarget) : maxTarget(nearTarget, target)} - scale={scale} - unit={unit} - /> - </ColoredSegments> - ) - } else { - return ( - <ColoredSegments> - <RedSegment - direction={oppositeDirection} - target={debtTargetApplies ? minTarget(debtTarget, nearTarget) : minTarget(nearTarget, target)} - scale={scale} - unit={unit} - /> - <YellowSegment - lowTarget={nearTarget} - highTarget={debtTargetApplies ? debtTarget : target} - scale={scale} - unit={unit} - /> - <GreySegment - lowTarget={debtTarget} - highTarget={target} - scale={scale} - unit={unit} - show={debtTargetApplies} - /> - <GreenSegment direction={direction} scale={scale} target={target} unit={unit} /> - </ColoredSegments> - ) - } -} -TargetVisualiser.propTypes = { - metric: metricPropType, -} - -function TargetLabel({ label, metric, position, targetType }) { - const dataModel = useContext(DataModel) const metricType = dataModel.metrics[metric.type] - const defaultTarget = metricType[targetType] - const scale = getMetricScale(metric, dataModel) - const unit = formatMetricScaleAndUnit(metric, dataModel) - const defaultTargetLabel = - defaultTarget === metric[targetType] || defaultTarget === undefined + const defaultTarget = metricType[target_type] + const targetType = { debt_target: "technical debt target", near_target: "near target", target: "target" }[ + target_type + ] + let helperText = + defaultTarget === metric[target_type] || defaultTarget === undefined ? "" - : ` (default: ${formatMetricValue(scale, defaultTarget)} ${unit})` - return ( - <label> - {label + defaultTargetLabel}{" "} - <Popup - content={<TargetVisualiser metric={metric} />} - flowing - header="How measurement values are evaluated" - hoverable - on={["hover", "focus"]} - position={position} - trigger={<HelpIcon fontSize="inherit" tabIndex={0} />} - /> - </label> - ) -} -TargetLabel.propTypes = { - label: labelPropType, - metric: metricPropType, - position: string, - targetType: string, -} - -export function Target({ label, labelPosition, metric, metric_uuid, reload, target_type }) { - const dataModel = useContext(DataModel) - const metricScale = getMetricScale(metric, dataModel) - const metricDirectionPrefix = formatMetricDirection(metric, dataModel) - const targetValue = metric[target_type] - const unit = formatMetricScaleAndUnit(metric, dataModel) - const targetLabel = <TargetLabel label={label} metric={metric} position={labelPosition} targetType={target_type} /> + : `Default ${targetType}: ${formatMetricValue(scale, defaultTarget)} ${unit}` + if (target_type === "debt_target") { + helperText = "Accept technical debt if the metric value is equal to or better than the technical debt target." + } if (metricScale === "version_number") { return ( - <StringInput - label={targetLabel} - prefix={metricDirectionPrefix} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => set_metric_attribute(metric_uuid, target_type, value, reload)} + <TextField + disabled={disabled} + label={`Metric ${targetType}`} + startAdornment={metricDirection} + onChange={(value) => set_metric_attribute(metric_uuid, target_type, value, reload)} value={targetValue} /> ) } else { - const max = metricScale === "percentage" ? "100" : null + const max = metricScale === "percentage" ? 100 : null return ( - <IntegerInput - label={targetLabel} + <TextField + disabled={disabled} + endAdornment={unit} + helperText={helperText} + label={`Metric ${targetType}`} max={max} - prefix={metricDirectionPrefix} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => set_metric_attribute(metric_uuid, target_type, value, reload)} - unit={unit} + onChange={(value) => set_metric_attribute(metric_uuid, target_type, value, reload)} + startAdornment={metricDirection} + type="number" value={targetValue} /> ) } } Target.propTypes = { - label: labelPropType, - labelPosition: string, metric: metricPropType, metric_uuid: string, reload: func, - target_type: string, + target_type: targetType, } diff --git a/components/frontend/src/metric/Target.test.js b/components/frontend/src/metric/Target.test.js index 2c03f16205..73a1c40270 100644 --- a/components/frontend/src/metric/Target.test.js +++ b/components/frontend/src/metric/Target.test.js @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react" +import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import * as fetch_server_api from "../api/fetch_server_api" @@ -43,7 +43,6 @@ function renderMetricTarget(metric) { metric={metric} metric_uuid="metric_uuid" target_type="target" - label="Target" reload={() => { /* Dummy implementation */ }} @@ -79,266 +78,5 @@ it("sets the metric version target", async () => { it("displays the default target if changed", () => { renderMetricTarget({ type: "violations_with_default_target" }) - expect(screen.queryAllByText(/default:/).length).toBe(1) -}) - -it("shows help", async () => { - renderMetricTarget({ type: "violations", target: "10", near_target: "15" }) - await userEvent.tab() - await waitFor(() => { - expect(screen.queryAllByText(/How measurement values are evaluated/).length).toBe(1) - }) -}) - -function expectVisible(...matchers) { - matchers.forEach((matcher) => expect(screen.queryAllByText(matcher).length).toBe(1)) -} - -function expectNotVisible(...matchers) { - matchers.forEach((matcher) => expect(screen.queryAllByText(matcher).length).toBe(0)) -} - -it("shows help for evaluated metric without tech debt", async () => { - renderMetricTarget({ type: "violations", target: "10", near_target: "15" }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target met/, - /≦ 10 violations/, - /Near target met/, - /10 - 15 violations/, - /Target not met/, - /> 15 violations/, - ) - expectNotVisible(/Debt target met/) - }) -}) - -it("shows help for evaluated metric with tech debt", async () => { - renderMetricTarget({ - type: "violations", - target: "10", - debt_target: "15", - near_target: "20", - accept_debt: true, - }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target met/, - /≦ 10 violations/, - /Debt target met/, - /10 - 15 violations/, - /Near target met/, - /15 - 20 violations/, - /Target not met/, - /> 20 violations/, - ) - }) -}) - -it("shows help for evaluated metric with tech debt if debt target is missing", async () => { - renderMetricTarget({ type: "violations", target: "10", near_target: "20", accept_debt: true }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target met/, - /≦ 10 violations/, - /Near target met/, - /10 - 20 violations/, - /Target not met/, - /> 20 violations/, - ) - expectNotVisible(/Debt target met/) - }) -}) - -it("shows help for evaluated metric with tech debt with end date", async () => { - renderMetricTarget({ - type: "violations", - target: "10", - debt_target: "15", - near_target: "20", - accept_debt: true, - debt_end_date: "3000-01-01", - }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target met/, - /≦ 10 violations/, - /Debt target met/, - /10 - 15 violations/, - /Near target met/, - /15 - 20 violations/, - /Target not met/, - /> 20 violations/, - ) - }) -}) - -it("shows help for evaluated metric with tech debt with end date in the past", async () => { - renderMetricTarget({ - type: "violations", - target: "10", - debt_target: "15", - near_target: "20", - accept_debt: true, - debt_end_date: "2000-01-01", - }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target met/, - /≦ 10 violations/, - /Near target met/, - /10 - 20 violations/, - /Target not met/, - /> 20 violations/, - ) - expectNotVisible(/Debt target met/) - }) -}) - -it("shows help for evaluated metric with tech debt completely overlapping near target", async () => { - renderMetricTarget({ - type: "violations", - target: "10", - debt_target: "20", - near_target: "20", - accept_debt: true, - }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target met/, - /≦ 10 violations/, - /Debt target met/, - /10 - 20 violations/, - /Target not met/, - /> 20 violations/, - ) - expectNotVisible(/Near target met/) - }) -}) - -it("shows help for evaluated metric without tech debt and target completely overlapping near target", async () => { - renderMetricTarget({ type: "violations", target: "10", near_target: "10" }) - await userEvent.tab() - await waitFor(() => { - expectVisible(/Target met/, /≦ 10 violations/, /Target not met/, /> 10 violations/) - expectNotVisible(/Debt target met/, /Near target met/) - }) -}) - -it("shows help for evaluated more-is-better metric without tech debt", async () => { - renderMetricTarget({ type: "violations", target: "15", near_target: "10", direction: ">" }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target not met/, - /< 10 violations/, - /Near target met/, - /10 - 15 violations/, - /Target met/, - /≧ 15 violations/, - ) - expectNotVisible(/Debt target met/) - }) -}) - -it("shows help for evaluated more-is-better metric with tech debt", async () => { - renderMetricTarget({ - type: "violations", - target: "15", - near_target: "5", - debt_target: "10", - accept_debt: true, - direction: ">", - }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target not met/, - /< 5 violations/, - /Near target met/, - /5 - 10 violations/, - /Debt target met/, - /10 - 15 violations/, - /Target met/, - /≧ 15 violations/, - ) - }) -}) - -it("shows help for evaluated more-is-better metric with tech debt and missing debt target", async () => { - renderMetricTarget({ - type: "violations", - target: "15", - near_target: "5", - accept_debt: true, - direction: ">", - }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target not met/, - /< 5 violations/, - /Near target met/, - /5 - 15 violations/, - /Target met/, - /≧ 15 violations/, - ) - expectNotVisible(/Debt target met/) - }) -}) - -it("shows help for evaluated more-is-better metric with tech debt completely overlapping near target", async () => { - renderMetricTarget({ - type: "violations", - target: "15", - near_target: "5", - debt_target: "5", - accept_debt: true, - direction: ">", - }) - await userEvent.tab() - await waitFor(() => { - expectVisible( - /Target not met/, - /< 5 violations/, - /Debt target met/, - /5 - 15 violations/, - /Target met/, - /≧ 15 violations/, - ) - expectNotVisible(/Near target met/) - }) -}) - -it("shows help for evaluated more-is-better metric without tech debt and target completely overlapping near target", async () => { - renderMetricTarget({ type: "violations", target: "15", near_target: "15", direction: ">" }) - await userEvent.tab() - await waitFor(() => { - expectVisible(/Target not met/, /< 15 violations/, /Target met/, /≧ 15 violations/) - expectNotVisible(/Near target met/, /Debt target met/) - }) -}) - -it("shows help for evaluated metric without tech debt and zero target completely overlapping near target", async () => { - renderMetricTarget({ type: "violations", target: "0", near_target: "0", direction: ">" }) - await userEvent.tab() - await waitFor(() => { - expectVisible(/Target met/, /≧ 0 violations/) - expectNotVisible(/Debt target met/, /Near target met/, /Target not met/) - }) -}) - -it("shows help for informative metric", async () => { - renderMetricTarget({ type: "violations", evaluate_targets: false }) - await userEvent.tab() - await waitFor(() => { - expectVisible(/Informative/, /violations are not evaluated/) - expectNotVisible(/Target met/, /Debt target met/, /Near target met/, /Target not met/) - }) + expect(screen.queryAllByText(/Default/).length).toBe(1) }) diff --git a/components/frontend/src/metric/TargetVisualiser.js b/components/frontend/src/metric/TargetVisualiser.js new file mode 100644 index 0000000000..14009d54aa --- /dev/null +++ b/components/frontend/src/metric/TargetVisualiser.js @@ -0,0 +1,225 @@ +import { Box, Stack } from "@mui/material" +import { bool, oneOf, string } from "prop-types" +import { useContext } from "react" + +import { DataModel } from "../context/DataModel" +import { StatusIcon } from "../measurement/StatusIcon" +import { childrenPropType, metricPropType, scalePropType } from "../sharedPropTypes" +import { + capitalize, + formatMetricDirection, + formatMetricScaleAndUnit, + formatMetricValue, + getMetricScale, +} from "../utils" +import { STATUS_SHORT_NAME, statusPropType } from "./status" + +function smallerThan(target1, target2) { + const t1 = target1 ?? `${Number.POSITIVE_INFINITY}` + const t2 = target2 ?? "0" + return t1.localeCompare(t2, undefined, { numeric: true }) < 0 +} + +function maxTarget(...targets) { + targets.sort((target1, target2) => target1.localeCompare(target2, undefined, { numeric: true })) + return targets.at(-1) +} + +function minTarget(...targets) { + targets.sort((target1, target2) => target1.localeCompare(target2, undefined, { numeric: true })) + return targets.at(0) +} + +function debtTargetActive(metric, direction) { + const endDate = metric.debt_end_date ? new Date(metric.debt_end_date) : null + const active = !!metric.accept_debt && ((endDate && endDate >= new Date()) || !endDate) + return ( + active && + (direction === "≦" + ? smallerThan(metric.target, metric.debt_target) + : smallerThan(metric.debt_target, metric.target)) + ) +} + +function ColoredSegment({ children, color, show, status }) { + if (show === false) { + return null + } + return ( + <Box sx={{ padding: "10px", border: "12px solid", borderColor: `${status}.main`, width: "100%" }}> + <Stack alignItems="center" direction="row" justifyContent="space-around" spacing={2}> + {STATUS_SHORT_NAME[status]} + <StatusIcon status={status} size="small" /> + {capitalize(color)} + <b>{children}</b> + </Stack> + </Box> + ) +} +ColoredSegment.propTypes = { + children: childrenPropType, + color: string, + show: bool, + status: statusPropType, +} + +function BlueSegment({ unit }) { + return <ColoredSegment color="blue" status="informative">{`${unit} are not evaluated`}</ColoredSegment> +} +BlueSegment.propTypes = { + unit: string, +} + +function GreenSegment({ direction, scale, target, show, unit }) { + return ( + <ColoredSegment + color="green" + show={show} + status="target_met" + >{`${direction} ${formatMetricValue(scale, target)}${unit}`}</ColoredSegment> + ) +} +GreenSegment.propTypes = { + direction: oneOf(["≦", "≧"]), + scale: scalePropType, + target: string, + show: bool, + unit: string, +} + +function RedSegment({ direction, scale, target, show, unit }) { + if (direction === "<" && target === "0") { + return null + } + return ( + <ColoredSegment + color="red" + show={show} + status="target_not_met" + >{`${direction} ${formatMetricValue(scale, target)}${unit}`}</ColoredSegment> + ) +} +RedSegment.propTypes = { + direction: oneOf(["<", ">"]), + scale: scalePropType, + target: string, + show: bool, + unit: string, +} + +function GreySegment({ lowTarget, highTarget, scale, show, unit }) { + return ( + <ColoredSegment + color="grey" + show={show} + status="debt_target_met" + >{`${formatMetricValue(scale, lowTarget)} - ${formatMetricValue(scale, highTarget)}${unit}`}</ColoredSegment> + ) +} +GreySegment.propTypes = { + lowTarget: string, + highTarget: string, + scale: scalePropType, + show: bool, + unit: string, +} + +function YellowSegment({ lowTarget, highTarget, scale, show, unit }) { + if (!smallerThan(lowTarget, highTarget)) { + return null + } + return ( + <ColoredSegment + color="yellow" + show={show} + status="near_target_met" + >{`${formatMetricValue(scale, lowTarget)} - ${formatMetricValue(scale, highTarget)}${unit}`}</ColoredSegment> + ) +} +YellowSegment.propTypes = { + lowTarget: string, + highTarget: string, + scale: scalePropType, + show: bool, + unit: string, +} + +function ColoredSegments({ children }) { + return <Stack direction="row">{children}</Stack> +} +ColoredSegments.propTypes = { + children: childrenPropType, +} + +export function TargetVisualiser({ metric }) { + const dataModel = useContext(DataModel) + const unit = formatMetricScaleAndUnit(metric, dataModel) + if (metric.evaluate_targets === false) { + return ( + <ColoredSegments> + <BlueSegment unit={unit} /> + </ColoredSegments> + ) + } + const direction = formatMetricDirection(metric, dataModel) + const scale = getMetricScale(metric, dataModel) + const oppositeDirection = { "≦": ">", "≧": "<" }[direction] + const target = metric.target + const nearTarget = metric.near_target + const debtTarget = metric.debt_target + const debtTargetApplies = debtTargetActive(metric, direction) + if (direction === "≦") { + return ( + <ColoredSegments> + <GreenSegment direction={direction} scale={scale} target={target} unit={unit} /> + <GreySegment + lowTarget={target} + highTarget={debtTarget} + scale={scale} + unit={unit} + show={debtTargetApplies} + /> + <YellowSegment + lowTarget={debtTargetApplies ? maxTarget(debtTarget, target) : target} + highTarget={nearTarget} + scale={scale} + unit={unit} + /> + <RedSegment + direction={oppositeDirection} + target={debtTargetApplies ? maxTarget(nearTarget, debtTarget) : maxTarget(nearTarget, target)} + scale={scale} + unit={unit} + /> + </ColoredSegments> + ) + } else { + return ( + <ColoredSegments> + <RedSegment + direction={oppositeDirection} + target={debtTargetApplies ? minTarget(debtTarget, nearTarget) : minTarget(nearTarget, target)} + scale={scale} + unit={unit} + /> + <YellowSegment + lowTarget={nearTarget} + highTarget={debtTargetApplies ? debtTarget : target} + scale={scale} + unit={unit} + /> + <GreySegment + lowTarget={debtTarget} + highTarget={target} + scale={scale} + unit={unit} + show={debtTargetApplies} + /> + <GreenSegment direction={direction} scale={scale} target={target} unit={unit} /> + </ColoredSegments> + ) + } +} +TargetVisualiser.propTypes = { + metric: metricPropType, +} diff --git a/components/frontend/src/metric/TargetVisualiser.test.js b/components/frontend/src/metric/TargetVisualiser.test.js new file mode 100644 index 0000000000..10005bb150 --- /dev/null +++ b/components/frontend/src/metric/TargetVisualiser.test.js @@ -0,0 +1,250 @@ +import { render, screen } from "@testing-library/react" + +import { DataModel } from "../context/DataModel" +import { TargetVisualiser } from "./TargetVisualiser" + +const dataModel = { + metrics: { + violations: { + unit: "violations", + direction: "<", + name: "Violations", + default_scale: "count", + scales: ["count", "percentage"], + }, + violations_with_default_target: { + target: "100", + unit: "violations", + direction: "<", + name: "Violations", + default_scale: "count", + scales: ["count", "percentage"], + }, + source_version: { + unit: "", + direction: "<", + name: "Source version", + default_scale: "version_number", + scales: ["version_number"], + }, + }, +} + +function renderVisualiser(metric) { + render( + <DataModel.Provider value={dataModel}> + <TargetVisualiser metric={metric} /> + </DataModel.Provider>, + ) +} + +function expectVisible(...matchers) { + matchers.forEach((matcher) => expect(screen.queryAllByText(matcher).length).toBe(1)) +} + +function expectNotVisible(...matchers) { + matchers.forEach((matcher) => expect(screen.queryAllByText(matcher).length).toBe(0)) +} + +it("shows help for evaluated metric without tech debt", async () => { + renderVisualiser({ type: "violations", target: "10", near_target: "15" }) + expectVisible( + /Target met/, + /≦ 10 violations/, + /Near target met/, + /10 - 15 violations/, + /Target not met/, + /> 15 violations/, + ) + expectNotVisible(/Debt target met/) +}) + +it("shows help for evaluated metric with tech debt", async () => { + renderVisualiser({ + type: "violations", + target: "10", + debt_target: "15", + near_target: "20", + accept_debt: true, + }) + expectVisible( + /Target met/, + /≦ 10 violations/, + /Debt target met/, + /10 - 15 violations/, + /Near target met/, + /15 - 20 violations/, + /Target not met/, + /> 20 violations/, + ) +}) + +it("shows help for evaluated metric with tech debt if debt target is missing", async () => { + renderVisualiser({ type: "violations", target: "10", near_target: "20", accept_debt: true }) + expectVisible( + /Target met/, + /≦ 10 violations/, + /Near target met/, + /10 - 20 violations/, + /Target not met/, + /> 20 violations/, + ) + expectNotVisible(/Debt target met/) +}) + +it("shows help for evaluated metric with tech debt with end date", async () => { + renderVisualiser({ + type: "violations", + target: "10", + debt_target: "15", + near_target: "20", + accept_debt: true, + debt_end_date: "3000-01-01", + }) + expectVisible( + /Target met/, + /≦ 10 violations/, + /Debt target met/, + /10 - 15 violations/, + /Near target met/, + /15 - 20 violations/, + /Target not met/, + /> 20 violations/, + ) +}) + +it("shows help for evaluated metric with tech debt with end date in the past", async () => { + renderVisualiser({ + type: "violations", + target: "10", + debt_target: "15", + near_target: "20", + accept_debt: true, + debt_end_date: "2000-01-01", + }) + expectVisible( + /Target met/, + /≦ 10 violations/, + /Near target met/, + /10 - 20 violations/, + /Target not met/, + /> 20 violations/, + ) + expectNotVisible(/Debt target met/) +}) + +it("shows help for evaluated metric with tech debt completely overlapping near target", async () => { + renderVisualiser({ + type: "violations", + target: "10", + debt_target: "20", + near_target: "20", + accept_debt: true, + }) + expectVisible( + /Target met/, + /≦ 10 violations/, + /Debt target met/, + /10 - 20 violations/, + /Target not met/, + /> 20 violations/, + ) + expectNotVisible(/Near target met/) +}) + +it("shows help for evaluated metric without tech debt and target completely overlapping near target", async () => { + renderVisualiser({ type: "violations", target: "10", near_target: "10" }) + expectVisible(/Target met/, /≦ 10 violations/, /Target not met/, /> 10 violations/) + expectNotVisible(/Debt target met/, /Near target met/) +}) + +it("shows help for evaluated more-is-better metric without tech debt", async () => { + renderVisualiser({ type: "violations", target: "15", near_target: "10", direction: ">" }) + expectVisible( + /Target not met/, + /< 10 violations/, + /Near target met/, + /10 - 15 violations/, + /Target met/, + /≧ 15 violations/, + ) + expectNotVisible(/Debt target met/) +}) + +it("shows help for evaluated more-is-better metric with tech debt", async () => { + renderVisualiser({ + type: "violations", + target: "15", + near_target: "5", + debt_target: "10", + accept_debt: true, + direction: ">", + }) + expectVisible( + /Target not met/, + /< 5 violations/, + /Near target met/, + /5 - 10 violations/, + /Debt target met/, + /10 - 15 violations/, + /Target met/, + /≧ 15 violations/, + ) +}) + +it("shows help for evaluated more-is-better metric with tech debt and missing debt target", async () => { + renderVisualiser({ + type: "violations", + target: "15", + near_target: "5", + accept_debt: true, + direction: ">", + }) + expectVisible( + /Target not met/, + /< 5 violations/, + /Near target met/, + /5 - 15 violations/, + /Target met/, + /≧ 15 violations/, + ) + expectNotVisible(/Debt target met/) +}) + +it("shows help for evaluated more-is-better metric with tech debt completely overlapping near target", async () => { + renderVisualiser({ + type: "violations", + target: "15", + near_target: "5", + debt_target: "5", + accept_debt: true, + direction: ">", + }) + expectVisible( + /Target not met/, + /< 5 violations/, + /Debt target met/, + /5 - 15 violations/, + /Target met/, + /≧ 15 violations/, + ) + expectNotVisible(/Near target met/) +}) + +it("shows help for evaluated more-is-better metric without tech debt and target completely overlapping near target", async () => { + renderVisualiser({ type: "violations", target: "15", near_target: "15", direction: ">" }) + expectVisible(/Target not met/, /< 15 violations/, /Target met/, /≧ 15 violations/) + expectNotVisible(/Near target met/, /Debt target met/) +}) + +it("shows help for evaluated metric without tech debt and zero target completely overlapping near target", async () => { + renderVisualiser({ type: "violations", target: "0", near_target: "0", direction: ">" }) + expectVisible(/Target met/, /≧ 0 violations/) + expectNotVisible(/Debt target met/, /Near target met/, /Target not met/) +}) + +it("shows help for informative metric", async () => { + renderVisualiser({ type: "violations", evaluate_targets: false }) + expectVisible(/Informative/, /violations are not evaluated/) + expectNotVisible(/Target met/, /Debt target met/, /Near target met/, /Target not met/) +}) diff --git a/components/frontend/src/metric/TrendGraph.js b/components/frontend/src/metric/TrendGraph.js index ca509a5201..6b804d0966 100644 --- a/components/frontend/src/metric/TrendGraph.js +++ b/components/frontend/src/metric/TrendGraph.js @@ -1,5 +1,4 @@ import { useContext } from "react" -import { Message } from "semantic-ui-react" import { VictoryAxis, VictoryChart, VictoryLabel, VictoryLine, VictoryTheme } from "victory" import { DarkMode } from "../context/DarkMode" @@ -7,7 +6,7 @@ import { DataModel } from "../context/DataModel" import { loadingPropType, measurementsPropType, metricPropType } from "../sharedPropTypes" import { capitalize, formatMetricScaleAndUnit, getMetricName, getMetricScale, niceNumber, scaledNumber } from "../utils" import { LoadingPlaceHolder } from "../widgets/Placeholder" -import { FailedToLoadMeasurementsWarningMessage, WarningMessage } from "../widgets/WarningMessage" +import { FailedToLoadMeasurementsWarningMessage, InfoMessage, WarningMessage } from "../widgets/WarningMessage" function measurementAttributeAsNumber(metric, measurement, field, dataModel) { const scale = getMetricScale(metric, dataModel) @@ -22,10 +21,9 @@ export function TrendGraph({ metric, measurements, loading }) { const estimatedTotalChartHeight = chartHeight + 200 // Estimate of the height including title and axis if (getMetricScale(metric, dataModel) === "version_number") { return ( - <Message - content="Trend graphs are not supported for metrics with a version number scale." - header="Trend graph not supported for version numbers" - /> + <InfoMessage title="Trend graph not supported for version numbers"> + Trend graphs are not supported for metrics with a version number scale. + </InfoMessage> ) } if (loading === "failed") { @@ -36,10 +34,9 @@ export function TrendGraph({ metric, measurements, loading }) { } if (measurements.length === 0) { return ( - <WarningMessage - content="A trend graph can not be displayed until this metric has measurements." - header="No measurements" - /> + <WarningMessage title="No measurements"> + A trend graph can not be displayed until this metric has measurements. + </WarningMessage> ) } const metricName = getMetricName(metric, dataModel) diff --git a/components/frontend/src/metric/status.js b/components/frontend/src/metric/status.js index 3ef465e9f7..f6b5990e8d 100644 --- a/components/frontend/src/metric/status.js +++ b/components/frontend/src/metric/status.js @@ -1,7 +1,6 @@ // Metric status constants import { Bolt, Check, Money, QuestionMark, Warning } from "@mui/icons-material" -import { blue, green, grey, orange, red } from "@mui/material/colors" import { oneOf } from "prop-types" import { HyperLink } from "../widgets/HyperLink" @@ -25,14 +24,6 @@ export const STATUS_COLORS_RGB = { informative: "rgb(0,165,255)", unknown: "rgb(245,245,245)", } -export const STATUS_COLORS_MUI = { - target_not_met: red[700], - target_met: green[600], - near_target_met: orange[300], - debt_target_met: grey[500], - informative: blue[500], - unknown: grey[300], -} export const STATUS_ICONS = { target_met: <Check />, near_target_met: <Warning />, diff --git a/components/frontend/src/notification/NotificationDestinations.js b/components/frontend/src/notification/NotificationDestinations.js index 28ce09fc8e..92f3fd195d 100644 --- a/components/frontend/src/notification/NotificationDestinations.js +++ b/components/frontend/src/notification/NotificationDestinations.js @@ -1,69 +1,64 @@ import { Stack } from "@mui/material" +import Grid from "@mui/material/Grid2" import { func, objectOf, string } from "prop-types" -import { Grid } from "semantic-ui-react" +import { useContext } from "react" import { add_notification_destination, delete_notification_destination, set_notification_destination_attributes, } from "../api/notification" -import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions" -import { StringInput } from "../fields/StringInput" -import { Message } from "../semantic_ui_react_wrappers" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions, ReadOnlyOrEditable } from "../context/Permissions" +import { TextField } from "../fields/TextField" import { destinationPropType } from "../sharedPropTypes" import { ButtonRow } from "../widgets/ButtonRow" import { AddButton } from "../widgets/buttons/AddButton" import { DeleteButton } from "../widgets/buttons/DeleteButton" import { HyperLink } from "../widgets/HyperLink" -import { LabelWithHelp } from "../widgets/LabelWithHelp" +import { InfoMessage } from "../widgets/WarningMessage" function NotificationDestination({ destination, destination_uuid, reload, report_uuid }) { - const help_url = + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) + const helpUrl = "https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook" - const teams_hyperlink = <HyperLink url={help_url}>Microsoft Teams</HyperLink> + const teamsHyperlink = <HyperLink url={helpUrl}>Microsoft Teams webhook URL</HyperLink> return ( <Stack key={destination_uuid} direction="column" spacing={2}> - <Grid stackable> - <Grid.Row columns={2}> - <Grid.Column width={6}> - <StringInput - requiredPermissions={[EDIT_REPORT_PERMISSION]} - id={destination_uuid} - label="Name" - set_value={(value) => { - set_notification_destination_attributes( - report_uuid, - destination_uuid, - { name: value }, - reload, - ) - }} - value={destination.name} - /> - </Grid.Column> - <Grid.Column width={10}> - <StringInput - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={ - <LabelWithHelp - label="Webhook" - help={<>Paste a {teams_hyperlink} webhook URL here.</>} - hoverable - /> - } - placeholder="https://example.webhook.office.com/webhook..." - set_value={(value) => { - set_notification_destination_attributes( - report_uuid, - destination_uuid, - { webhook: value, url: window.location.href }, - reload, - ) - }} - value={destination.webhook} - /> - </Grid.Column> - </Grid.Row> + <Grid container spacing={2}> + <Grid size={4}> + <TextField + disabled={disabled} + id={destination_uuid} + label="Webhook name" + onChange={(value) => { + set_notification_destination_attributes( + report_uuid, + destination_uuid, + { name: value }, + reload, + ) + }} + value={destination.name} + /> + </Grid> + <Grid size={8}> + <TextField + disabled={disabled} + helperText={<>Paste a {teamsHyperlink} here.</>} + label="Webhook" + onChange={(value) => { + set_notification_destination_attributes( + report_uuid, + destination_uuid, + { webhook: value, url: window.location.href }, + reload, + ) + }} + placeholder="https://example.webhook.office.com/webhook..." + value={destination.webhook} + /> + </Grid> </Grid> <ReadOnlyOrEditable requiredPermissions={[EDIT_REPORT_PERMISSION]} @@ -102,12 +97,11 @@ export function NotificationDestinations({ destinations, reload, report_uuid }) ) }) return ( - <> + <Stack direction="column" spacing={1}> {notification_destinations.length === 0 ? ( - <Message> - <Message.Header>No notification destinations</Message.Header> - <p>No notification destinations have been configured yet.</p> - </Message> + <InfoMessage title="No notification destinations"> + No notification destinations have been configured yet. + </InfoMessage> ) : ( notification_destinations )} @@ -121,7 +115,7 @@ export function NotificationDestinations({ destinations, reload, report_uuid }) /> } /> - </> + </Stack> ) } NotificationDestinations.propTypes = { diff --git a/components/frontend/src/notification/NotificationDestinations.test.js b/components/frontend/src/notification/NotificationDestinations.test.js index 23cdcd5c37..d1f421503f 100644 --- a/components/frontend/src/notification/NotificationDestinations.test.js +++ b/components/frontend/src/notification/NotificationDestinations.test.js @@ -53,7 +53,7 @@ it("creates a new notification destination when the add notification destination it("edits notification destination name attribute when it is changed in the input field", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) renderNotificationDestinations(notification_destinations) - await userEvent.type(screen.getByLabelText(/Name/), " changed{Enter}") + await userEvent.type(screen.getByLabelText(/Webhook name/), " changed{Enter}") expect(fetch_server_api.fetch_server_api).toHaveBeenCalledWith( "post", "report/report_uuid/notification_destination/destination_uuid1/attributes", diff --git a/components/frontend/src/report/IssueTracker.js b/components/frontend/src/report/IssueTracker.js index 6c8a8c9169..b24e0e8bcd 100644 --- a/components/frontend/src/report/IssueTracker.js +++ b/components/frontend/src/report/IssueTracker.js @@ -1,30 +1,25 @@ +import { MenuItem, Stack } from "@mui/material" +import Grid from "@mui/material/Grid2" import { func } from "prop-types" import { useContext, useEffect, useState } from "react" -import { Grid, Header } from "semantic-ui-react" import { get_report_issue_tracker_options, set_report_issue_tracker_attribute } from "../api/report" import { DataModel } from "../context/DataModel" -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { MultipleChoiceInput } from "../fields/MultipleChoiceInput" -import { PasswordInput } from "../fields/PasswordInput" -import { SingleChoiceInput } from "../fields/SingleChoiceInput" -import { StringInput } from "../fields/StringInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { MultipleChoiceField } from "../fields/MultipleChoiceField" +import { TextField } from "../fields/TextField" import { reportPropType } from "../sharedPropTypes" import { Logo } from "../source/Logo" -import { LabelWithHelp } from "../widgets/LabelWithHelp" -import { LabelWithHyperLink } from "../widgets/LabelWithHyperLink" +import { Header } from "../widgets/Header" +import { HyperLink } from "../widgets/HyperLink" import { showMessage } from "../widgets/toast" import { WarningMessage } from "../widgets/WarningMessage" const NONE_OPTION = { - key: null, + key: "None", text: "None", - value: null, - content: ( - <Header as="h4"> - <Header.Content>None</Header.Content> - </Header> - ), + value: "None", + content: <Header header="None" level="h4" />, } export function IssueTracker({ report, reload }) { @@ -36,6 +31,8 @@ export function IssueTracker({ report, reload }) { const [labelFieldSupported, setLabelFieldSupported] = useState(false) // Does the current issue type support labels? const [issueEpicOptions, setIssueEpicOptions] = useState([]) // Possible epic links for new issues in the current project const [issueEpicFieldSupported, setIssueEpicFieldSupported] = useState(false) // Does the current project and issue type support epic links? + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) useEffect(() => { let didCancel = false get_report_issue_tracker_options(report.report_uuid) @@ -80,181 +77,188 @@ export function IssueTracker({ report, reload }) { text: source_type.name, value: source_name, content: ( - <Header as="h4"> - <Header.Content> - <Logo logo={source_name} alt={source_type.name} /> - {source_type.name} - <Header.Subheader>{source_type.description}</Header.Subheader> - </Header.Content> - </Header> + <Header + header={ + <Stack + direction="row" + spacing={1} + sx={{ + alignItems: "center", + }} + > + <Logo logo={source_name} alt={source_type.name} width="21px" height="21px" /> + <span>{source_type.name}</span> + </Stack> + } + level="h4" + subheader={source_type.description} + /> ), } }) trackerSources.push(NONE_OPTION) - let privateTokenLabel = "Private token" + let privateTokenHelp = "" if (report.issue_tracker) { - const help_url = dataModel.sources[report.issue_tracker?.type]?.parameters?.private_token?.help_url - if (help_url) { - privateTokenLabel = <LabelWithHyperLink label={privateTokenLabel} url={help_url} /> + const helpUrl = dataModel.sources[report.issue_tracker?.type]?.parameters?.private_token?.help_url + if (helpUrl) { + privateTokenHelp = <HyperLink url={helpUrl}>How to configure a private token</HyperLink> } } const report_uuid = report.report_uuid const project_key = report.issue_tracker?.parameters?.project_key const issue_type = report.issue_tracker?.parameters?.issue_type const epic_link = report.issue_tracker?.parameters?.epic_link - return ( - <Grid stackable> - <Grid.Row columns={2}> - <Grid.Column> - <SingleChoiceInput - id="tracker-type" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - placeholder="None" - label="Issue tracker type" - options={trackerSources} - set_value={(value) => set_report_issue_tracker_attribute(report_uuid, "type", value, reload)} - value={report.issue_tracker?.type} - /> - </Grid.Column> - <Grid.Column> - <StringInput - id="tracker-url" - required={!!report.issue_tracker?.type} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label="Issue tracker URL" - set_value={(value) => set_report_issue_tracker_attribute(report_uuid, "url", value, reload)} - value={report.issue_tracker?.parameters?.url} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row columns={2}> - <Grid.Column> - <StringInput - id="tracker-username" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label="Username for basic authentication" - set_value={(value) => - set_report_issue_tracker_attribute(report_uuid, "username", value, reload) - } - value={report.issue_tracker?.parameters?.username} - /> - </Grid.Column> - <Grid.Column> - <PasswordInput - id="tracker-password" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label="Password for basic authentication" - set_value={(value) => - set_report_issue_tracker_attribute(report_uuid, "password", value, reload) - } - value={report.issue_tracker?.parameters?.password} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row columns={2}> - <Grid.Column> - <PasswordInput - id="tracker-token" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={privateTokenLabel} - set_value={(value) => - set_report_issue_tracker_attribute(report_uuid, "private_token", value, reload) - } - value={report.issue_tracker?.parameters?.private_token} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row columns={2}> - <Grid.Column> - <SingleChoiceInput - id="tracker-project-key" - error={!!report.issue_tracker?.type && !projectValid} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - required={!!report.issue_tracker?.type} - label={ - <LabelWithHelp - label="Project for new issues" - help="The projects available for new issues are determined by the configured credentials" - /> - } - options={projectOptions} - placeholder="None" - set_value={(value) => - set_report_issue_tracker_attribute(report_uuid, "project_key", value, reload) - } - value={project_key} - /> - </Grid.Column> - <Grid.Column> - <SingleChoiceInput - id="tracker-issue-type" - error={!!report.issue_tracker?.type && !issueTypeValid} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - required={!!report.issue_tracker?.type} - label={ - <LabelWithHelp - label="Issue type for new issues" - help="The issue types available for new issues are determined by the selected project" - /> - } - options={issueTypeOptions} - placeholder="None" - set_value={(value) => - set_report_issue_tracker_attribute(report_uuid, "issue_type", value, reload) - } - value={issue_type} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row columns={2}> - <Grid.Column> - <SingleChoiceInput + <Grid alignItems="flex-start" container spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 2, md: 2 }}> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="tracker-type" + label="Issue tracker type" + onChange={(value) => set_report_issue_tracker_attribute(report_uuid, "type", value, reload)} + select + value={report.issue_tracker?.type ?? "None"} + > + {trackerSources.map((source) => ( + <MenuItem key={source.key} value={source.value}> + {source.content} + </MenuItem> + ))} + </TextField> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + id="tracker_url" + disabled={disabled} + label="Issue tracker URL" + onChange={(value) => set_report_issue_tracker_attribute(report_uuid, "url", value, reload)} + required={!!report.issue_tracker?.type} + value={report.issue_tracker?.parameters?.url} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="tracker-username" + label="Username for basic authentication" + onChange={(value) => set_report_issue_tracker_attribute(report_uuid, "username", value, reload)} + value={report.issue_tracker?.parameters?.username} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="tracker-password" + label="Password for basic authentication" + onChange={(value) => set_report_issue_tracker_attribute(report_uuid, "password", value, reload)} + type="password" + value={report.issue_tracker?.parameters?.password} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="tracker-token" + helperText={privateTokenHelp} + label="Private token" + onChange={(value) => + set_report_issue_tracker_attribute(report_uuid, "private_token", value, reload) + } + type="password" + value={report.issue_tracker?.parameters?.private_token} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }} /> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + error={!!report.issue_tracker?.type && project_key && !projectValid} + helperText="The projects available for new issues are determined by the configured credentials" + id="tracker-project-key" + label="Project for new issues" + required={!!report.issue_tracker?.type} + placeholder="None" + onChange={(value) => set_report_issue_tracker_attribute(report_uuid, "project_key", value, reload)} + select + value={project_key} + > + {projectOptions.map((option) => ( + <MenuItem key={option.key} value={option.value}> + {option.text} + </MenuItem> + ))} + </TextField> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + error={!!report.issue_tracker?.type && issue_type && !issueTypeValid} + helperText="The issue types available for new issues are determined by the selected project" + id="tracker-issue-type" + label="Issue type for new issues" + onChange={(value) => set_report_issue_tracker_attribute(report_uuid, "issue_type", value, reload)} + placeholder="None" + required={!!report.issue_tracker?.type} + select + value={issue_type} + > + {issueTypeOptions.map((option) => ( + <MenuItem key={option.key} value={option.value}> + {option.text} + </MenuItem> + ))} + </TextField> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <Stack spacing={2}> + <TextField + disabled={disabled} + helperText="The epics available for new issues are determined by the selected project" id="tracker-issue-epic-link" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={ - <LabelWithHelp - label="Epic link for new issues" - help="The epics available for new issues are determined by the selected project" - /> - } - placeholder="None" - options={issueEpicOptions} - set_value={(value) => + label="Epic link for new issues" + onChange={(value) => set_report_issue_tracker_attribute(report_uuid, "epic_link", value, reload) } + placeholder="None" + select value={epic_link} - /> + > + {issueEpicOptions.map((option) => ( + <MenuItem key={option.key} value={option.value}> + {option.text} + </MenuItem> + ))} + </TextField> <WarningMessage showIf={Boolean(project_key && issue_type && !issueEpicFieldSupported)} - header="Epic links not supported" - content={`The issue type '${issue_type}' in project '${project_key}' does not support adding epic links when creating issues, so no epic link will be added to new issues.`} - /> - </Grid.Column> - <Grid.Column> - <MultipleChoiceInput - allowAdditions + title="Epic links not supported" + > + {`The issue type '${issue_type}' in project '${project_key}' does not support adding epic links when creating issues, so no epic link will be added to new issues.`} + </WarningMessage> + </Stack> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <Stack spacing={2}> + <MultipleChoiceField + disabled={disabled} + freeSolo + helperText="Spaces in labels are allowed here, but they will be replaced by underscores in Jira" id="tracker-issue-labels" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={ - <LabelWithHelp - label="Labels for new issues" - help="Spaces in labels are allowed here, but they will be replaced by underscores in Jira" - /> - } - placeholder="Enter one or more labels here" - set_value={(value) => + label="Labels for new issues" + onChange={(value) => set_report_issue_tracker_attribute(report_uuid, "issue_labels", value, reload) } - value={report.issue_tracker?.parameters?.issue_labels} + options={[]} + value={report.issue_tracker?.parameters?.issue_labels ?? []} /> <WarningMessage showIf={Boolean(project_key && issue_type && !labelFieldSupported)} - header="Labels not supported" - content={`The issue type '${issue_type}' in project '${project_key}' does not support adding labels when creating issues, so no labels will be added to new issues.`} - /> - </Grid.Column> - </Grid.Row> + title="Labels not supported" + > + {`The issue type '${issue_type}' in project '${project_key}' does not support adding labels when creating issues, so no labels will be added to new issues.`} + </WarningMessage> + </Stack> + </Grid> </Grid> ) } diff --git a/components/frontend/src/report/IssueTracker.test.js b/components/frontend/src/report/IssueTracker.test.js index ccc4a6bfd4..fb81b0f9d0 100644 --- a/components/frontend/src/report/IssueTracker.test.js +++ b/components/frontend/src/report/IssueTracker.test.js @@ -41,11 +41,9 @@ function renderIssueTracker({ report = { report_uuid: "report_uuid", title: "Rep } it("sets the issue tracker type", async () => { - renderIssueTracker() - fireEvent.click(screen.getByText(/Issue tracker type/)) - await act(async () => { - fireEvent.click(screen.getByText(/Jira/)) - }) + await act(async () => renderIssueTracker()) + fireEvent.mouseDown(screen.getByLabelText(/Issue tracker type/)) + fireEvent.click(screen.getByText("Jira")) expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "type", @@ -56,7 +54,7 @@ it("sets the issue tracker type", async () => { it("sets the issue tracker url", async () => { renderIssueTracker() - await userEvent.type(screen.getByText(/URL/), "https://jira{Enter}") + await userEvent.type(screen.getByLabelText(/URL/), "https://jira{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "url", @@ -67,7 +65,7 @@ it("sets the issue tracker url", async () => { it("sets the issue tracker username", async () => { renderIssueTracker() - await userEvent.type(screen.getByText(/Username/), "janedoe{Enter}") + await userEvent.type(screen.getByLabelText(/Username/), "janedoe{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "username", @@ -78,7 +76,7 @@ it("sets the issue tracker username", async () => { it("sets the issue tracker password", async () => { renderIssueTracker() - await userEvent.type(screen.getByText(/Password/), "secret{Enter}") + await userEvent.type(screen.getByLabelText(/Password/), "secret{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "password", @@ -89,7 +87,7 @@ it("sets the issue tracker password", async () => { it("sets the issue tracker private token", async () => { renderIssueTracker() - await userEvent.type(screen.getByText(/Private token/), "secret{Enter}") + await userEvent.type(screen.getByLabelText(/Private token/), "secret{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "private_token", @@ -135,13 +133,9 @@ it("shows the issue tracker private token help url", async () => { }) it("sets the issue tracker project", async () => { - renderIssueTracker() - await act(async () => { - fireEvent.click(screen.getByText(/Project for new issues/)) - }) - await act(async () => { - fireEvent.click(screen.getByText(/Project name/)) - }) + await act(async () => renderIssueTracker()) + fireEvent.mouseDown(screen.getByLabelText(/Project for new issues/)) + fireEvent.click(screen.getByText(/Project name/)) expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "project_key", @@ -151,13 +145,9 @@ it("sets the issue tracker project", async () => { }) it("sets the issue tracker issue type", async () => { - renderIssueTracker() - await act(async () => { - fireEvent.click(screen.getByText(/Issue type/)) - }) - await act(async () => { - fireEvent.click(screen.getByText(/Bug/)) - }) + await act(async () => renderIssueTracker()) + fireEvent.mouseDown(screen.getByLabelText(/Issue type/)) + fireEvent.click(screen.getByText(/Bug/)) expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "issue_type", @@ -168,7 +158,7 @@ it("sets the issue tracker issue type", async () => { it("sets the issue tracker issue labels", async () => { renderIssueTracker() - await userEvent.type(screen.getByText(/Enter one or more labels here/), "Label{Enter}") + await userEvent.type(screen.getByLabelText(/Labels/), "Label{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "issue_labels", @@ -178,13 +168,9 @@ it("sets the issue tracker issue labels", async () => { }) it("sets the issue tracker epic link", async () => { - renderIssueTracker() - await act(async () => { - fireEvent.click(screen.getByText(/Epic link/)) - }) - await act(async () => { - fireEvent.click(screen.getByText(/FOO-420/)) - }) + await act(async () => renderIssueTracker()) + fireEvent.mouseDown(screen.getByLabelText(/Epic link/)) + fireEvent.click(screen.getByText(/FOO-420/)) expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "epic_link", diff --git a/components/frontend/src/report/Report.js b/components/frontend/src/report/Report.js index 05db2beb3d..fffc0ff9f6 100644 --- a/components/frontend/src/report/Report.js +++ b/components/frontend/src/report/Report.js @@ -1,6 +1,7 @@ +import { Divider, Paper } from "@mui/material" import { func } from "prop-types" -import { ExportCard } from "../dashboard/ExportCard" +import { PageHeader } from "../dashboard/PageHeader" import { datePropType, datesPropType, @@ -15,8 +16,8 @@ import { Subjects } from "../subject/Subjects" import { SubjectsButtonRow } from "../subject/SubjectsButtonRow" import { getReportTags } from "../utils" import { CommentSegment } from "../widgets/CommentSegment" +import { WarningMessage } from "../widgets/WarningMessage" import { ReportDashboard } from "./ReportDashboard" -import { ReportErrorMessage } from "./ReportErrorMessage" import { ReportTitle } from "./ReportTitle" export function Report({ @@ -42,13 +43,18 @@ export function Report({ } if (!report) { - return <ReportErrorMessage reportDate={report_date} /> + return ( + <WarningMessage title="Report not found"> + {report_date ? `Sorry, this report didn't exist at ${report_date}` : "Sorry, this report doesn't exist"} + </WarningMessage> + ) } // Sort measurements in reverse order so that if there multiple measurements on a day, we find the most recent one: const reversedMeasurements = measurements.slice().sort((m1, m2) => (m1.start < m2.start ? 1 : -1)) return ( <div id="dashboard"> - <div className="reportHeader"> + <PageHeader lastUpdate={lastUpdate} report={report} reportDate={report_date} /> + <Paper elevation={5} sx={{ marginTop: "20px" }}> <ReportTitle openReportsOverview={openReportsOverview} report={report} @@ -58,24 +64,24 @@ export function Report({ reports={reports} settings={settings} /> - <ExportCard lastUpdate={lastUpdate} report={report} reportDate={report_date} /> - </div> - <CommentSegment comment={report.comment} /> - <ReportDashboard - dates={dates} - measurements={reversedMeasurements} - onClick={(e, s) => navigate_to_subject(e, s)} - onClickTag={(tag) => { - // If there are hidden tags (hiddenTags.length > 0), show the hidden tags. - // Otherwise, hide all tags in this report except the one clicked on. - const tagsToToggle = - settings.hiddenTags.value.length > 0 ? settings.hiddenTags.value : getReportTags(report) - settings.hiddenTags.toggle(...tagsToToggle.filter((visibleTag) => visibleTag !== tag)) - }} - report={report} - reload={reload} - settings={settings} - /> + <CommentSegment comment={report.comment} /> + <Divider sx={{ padding: "0px" }} /> + <ReportDashboard + dates={dates} + measurements={reversedMeasurements} + onClick={(e, s) => navigate_to_subject(e, s)} + onClickTag={(tag) => { + // If there are hidden tags (hiddenTags.length > 0), show the hidden tags. + // Otherwise, hide all tags in this report except the one clicked on. + const tagsToToggle = + settings.hiddenTags.value.length > 0 ? settings.hiddenTags.value : getReportTags(report) + settings.hiddenTags.toggle(...tagsToToggle.filter((visibleTag) => visibleTag !== tag)) + }} + report={report} + reload={reload} + settings={settings} + /> + </Paper> <Subjects atReportsOverview={false} changed_fields={changed_fields} diff --git a/components/frontend/src/report/ReportErrorMessage.js b/components/frontend/src/report/ReportErrorMessage.js deleted file mode 100644 index fdc4373277..0000000000 --- a/components/frontend/src/report/ReportErrorMessage.js +++ /dev/null @@ -1,33 +0,0 @@ -import { string } from "prop-types" - -import { Message } from "../semantic_ui_react_wrappers" -import { datePropType, optionalDatePropType } from "../sharedPropTypes" - -function ErrorMessage({ children }) { - return ( - <Message warning size="huge"> - <Message.Header>{children}</Message.Header> - </Message> - ) -} -ErrorMessage.propTypes = { - children: string, -} - -export function ReportErrorMessage({ reportDate }) { - return ( - <ErrorMessage> - {reportDate ? `Sorry, this report didn't exist at ${reportDate}` : "Sorry, this report doesn't exist"} - </ErrorMessage> - ) -} -ReportErrorMessage.propTypes = { - reportDate: optionalDatePropType, -} - -export function ReportsOverviewErrorMessage({ reportDate }) { - return <ErrorMessage>{`Sorry, no reports existed at ${reportDate}`}</ErrorMessage> -} -ReportsOverviewErrorMessage.propTypes = { - reportDate: datePropType, -} diff --git a/components/frontend/src/report/ReportTitle.js b/components/frontend/src/report/ReportTitle.js index 8d4dfdbfa8..a11f85070c 100644 --- a/components/frontend/src/report/ReportTitle.js +++ b/components/frontend/src/report/ReportTitle.js @@ -1,16 +1,20 @@ -import { bool, func, oneOfType, string } from "prop-types" -import { Grid } from "semantic-ui-react" +import AssignmentIcon from "@mui/icons-material/Assignment" +import HistoryIcon from "@mui/icons-material/History" +import NotificationsIcon from "@mui/icons-material/Notifications" +import SettingsIcon from "@mui/icons-material/Settings" +import TimerIcon from "@mui/icons-material/Timer" +import { Typography } from "@mui/material" +import Grid from "@mui/material/Grid2" +import { func, oneOfType, string } from "prop-types" +import { useContext } from "react" import { delete_report, set_report_attribute } from "../api/report" -import { activeTabIndex, tabChangeHandler } from "../app_ui_settings" import { ChangeLog } from "../changelog/ChangeLog" -import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions" -import { Comment } from "../fields/Comment" -import { IntegerInput } from "../fields/IntegerInput" -import { StringInput } from "../fields/StringInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions, ReadOnlyOrEditable } from "../context/Permissions" +import { CommentField } from "../fields/CommentField" +import { TextField } from "../fields/TextField" import { STATUS_DESCRIPTION, STATUS_NAME, statusPropType } from "../metric/status" import { NotificationDestinations } from "../notification/NotificationDestinations" -import { Label, Segment, Tab } from "../semantic_ui_react_wrappers" import { entityStatusPropType, reportPropType, settingsPropType } from "../sharedPropTypes" import { SOURCE_ENTITY_STATUS_DESCRIPTION, SOURCE_ENTITY_STATUS_NAME } from "../source/source_entity_status" import { getDesiredResponseTime } from "../utils" @@ -18,43 +22,41 @@ import { ButtonRow } from "../widgets/ButtonRow" import { DeleteButton } from "../widgets/buttons/DeleteButton" import { PermLinkButton } from "../widgets/buttons/PermLinkButton" import { HeaderWithDetails } from "../widgets/HeaderWithDetails" -import { LabelWithHelp } from "../widgets/LabelWithHelp" -import { changelogTabPane, configurationTabPane, tabPane } from "../widgets/TabPane" +import { Tabs } from "../widgets/Tabs" import { setDocumentTitle } from "./document_title" import { IssueTracker } from "./IssueTracker" function ReportConfiguration({ reload, report }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) return ( - <Grid stackable> - <Grid.Row columns={2}> - <Grid.Column> - <StringInput - id="report-title" - label="Report title" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => set_report_attribute(report.report_uuid, "title", value, reload)} - value={report.title} - /> - </Grid.Column> - <Grid.Column> - <StringInput - id="report-subtitle" - label="Report subtitle" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => set_report_attribute(report.report_uuid, "subtitle", value, reload)} - value={report.subtitle} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row> - <Grid.Column> - <Comment - id="report-comment" - set_value={(value) => set_report_attribute(report.report_uuid, "comment", value, reload)} - value={report.comment} - /> - </Grid.Column> - </Grid.Row> + <Grid container alignItems="flex-end" spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 2, md: 2 }}> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="report_title" + label="Report title" + onChange={(value) => set_report_attribute(report.report_uuid, "title", value, reload)} + value={report.title} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="report-subtitle" + label="Report subtitle" + onChange={(value) => set_report_attribute(report.report_uuid, "subtitle", value, reload)} + value={report.subtitle} + /> + </Grid> + <Grid size={{ xs: 1, sm: 2, md: 2 }}> + <CommentField + disabled={disabled} + id="report-comment" + onChange={(value) => set_report_attribute(report.report_uuid, "comment", value, reload)} + value={report.comment} + /> + </Grid> </Grid> ) } @@ -63,28 +65,30 @@ ReportConfiguration.propTypes = { report: reportPropType, } -function DesiredResponseTimeInput({ hoverableLabel, reload, report, status }) { +function DesiredResponseTimeInput({ reload, report, status }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const desiredResponseTimes = report.desired_response_times ?? {} const inputId = `desired-response-time-${status}` const label = STATUS_NAME[status] || SOURCE_ENTITY_STATUS_NAME[status] const help = STATUS_DESCRIPTION[status] || SOURCE_ENTITY_STATUS_DESCRIPTION[status] return ( - <IntegerInput - allowEmpty + <TextField + disabled={disabled} + endAdornment="days" + helperText={help} id={inputId} - label={<LabelWithHelp hoverable={hoverableLabel} labelFor={inputId} label={label} help={help} />} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => { + label={label} + onChange={(value) => { desiredResponseTimes[status] = parseInt(value) set_report_attribute(report.report_uuid, "desired_response_times", desiredResponseTimes, reload) }} - unit="days" - value={getDesiredResponseTime(report, status)} + type="number" + value={getDesiredResponseTime(report, status)?.toString()} /> ) } DesiredResponseTimeInput.propTypes = { - hoverableLabel: bool, reload: func, report: reportPropType, status: oneOfType([statusPropType, entityStatusPropType]), @@ -92,50 +96,40 @@ DesiredResponseTimeInput.propTypes = { function ReactionTimes(props) { return ( - <> - <Segment> - <Label attached="top" size="large"> - Desired metric response times - </Label> - <Grid stackable> - <Grid.Row columns={4}> - <Grid.Column> - <DesiredResponseTimeInput status="unknown" {...props} /> - </Grid.Column> - <Grid.Column> - <DesiredResponseTimeInput status="target_not_met" {...props} /> - </Grid.Column> - <Grid.Column> - <DesiredResponseTimeInput status="near_target_met" {...props} /> - </Grid.Column> - <Grid.Column> - <DesiredResponseTimeInput hoverableLabel status="debt_target_met" {...props} /> - </Grid.Column> - </Grid.Row> - </Grid> - </Segment> - <Segment> - <Label attached="top" size="large"> + <Grid container alignItems="flex-start" spacing={{ xs: 1, sm: 2, md: 2 }} columns={{ xs: 1, sm: 2, md: 4 }}> + <Grid size={{ xs: 1, sm: 2, md: 4 }}> + <Typography variant="subtitle1">Desired metric response times</Typography> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <DesiredResponseTimeInput status="unknown" {...props} /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <DesiredResponseTimeInput status="target_not_met" {...props} /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <DesiredResponseTimeInput status="near_target_met" {...props} /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <DesiredResponseTimeInput hoverableLabel status="debt_target_met" {...props} /> + </Grid> + <Grid size={{ xs: 1, sm: 2, md: 4 }}> + <Typography variant="subtitle1"> Desired time after which to review measurement entities (violations, warnings, issues, etc.) - </Label> - <Grid stackable> - <Grid.Row columns={4}> - <Grid.Column> - <DesiredResponseTimeInput status="confirmed" {...props} /> - </Grid.Column> - <Grid.Column> - <DesiredResponseTimeInput status="fixed" {...props} /> - </Grid.Column> - <Grid.Column> - <DesiredResponseTimeInput status="false_positive" {...props} /> - </Grid.Column> - <Grid.Column> - <DesiredResponseTimeInput status="wont_fix" {...props} /> - </Grid.Column> - </Grid.Row> - </Grid> - </Segment> - </> + </Typography> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <DesiredResponseTimeInput status="confirmed" {...props} /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <DesiredResponseTimeInput status="fixed" {...props} /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <DesiredResponseTimeInput status="false_positive" {...props} /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <DesiredResponseTimeInput status="wont_fix" {...props} /> + </Grid> + </Grid> ) } ReactionTimes.propTypes = { @@ -166,38 +160,35 @@ ReportTitleButtonRow.propTypes = { export function ReportTitle({ report, openReportsOverview, reload, settings }) { const report_uuid = report.report_uuid - const tabIndex = activeTabIndex(settings.expandedItems, report_uuid) const reportUrl = `${window.location}` - const panes = [ - configurationTabPane(<ReportConfiguration report={report} reload={reload} />), - tabPane("Desired reaction times", <ReactionTimes report={report} reload={reload} />, { iconName: "time" }), - tabPane( - "Notifications", - <NotificationDestinations - destinations={report.notification_destinations || {}} - report_uuid={report_uuid} - reload={reload} - />, - { iconName: "feed" }, - ), - tabPane("Issue tracker", <IssueTracker report={report} reload={reload} />, { iconName: "tasks" }), - changelogTabPane(<ChangeLog report_uuid={report_uuid} timestamp={report.timestamp} />), - ] setDocumentTitle(report.title) - return ( <HeaderWithDetails header={report.title} - item_uuid={`${report.report_uuid}:${tabIndex}`} + item_uuid={report.report_uuid} level="h1" settings={settings} subheader={report.subtitle} > - <Tab - defaultActiveIndex={tabIndex} - onTabChange={tabChangeHandler(settings.expandedItems, report_uuid)} - panes={panes} - /> + <Tabs + tabs={[ + { label: "Configuration", icon: <SettingsIcon /> }, + { label: "Desired reaction times", icon: <TimerIcon /> }, + { label: "Notifications", icon: <NotificationsIcon /> }, + { label: "Issue tracker", icon: <AssignmentIcon /> }, + { label: "Changelog", icon: <HistoryIcon /> }, + ]} + > + <ReportConfiguration report={report} reload={reload} /> + <ReactionTimes report={report} reload={reload} /> + <NotificationDestinations + destinations={report.notification_destinations || {}} + report_uuid={report_uuid} + reload={reload} + /> + <IssueTracker report={report} reload={reload} /> + <ChangeLog report_uuid={report_uuid} timestamp={report.timestamp} /> + </Tabs> <ReportTitleButtonRow report_uuid={report_uuid} openReportsOverview={openReportsOverview} url={reportUrl} /> </HeaderWithDetails> ) diff --git a/components/frontend/src/report/ReportTitle.test.js b/components/frontend/src/report/ReportTitle.test.js index 64494f9548..d160ebd9c4 100644 --- a/components/frontend/src/report/ReportTitle.test.js +++ b/components/frontend/src/report/ReportTitle.test.js @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from "@testing-library/react" +import { act, fireEvent, render, screen, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import history from "history/browser" @@ -13,15 +13,14 @@ jest.mock("../api/changelog.js") jest.mock("../api/report.js") beforeEach(() => { - history.push("?expanded=report_uuid:0") + history.push("?expanded=report_uuid") + jest.resetAllMocks() }) report_api.get_report_issue_tracker_options.mockImplementation(() => Promise.resolve({ projects: [], issue_types: [], fields: [], epic_links: [] }), ) -changelog_api.get_changelog.mockImplementation(() => Promise.resolve({ changelog: [] })) - const reload = jest.fn function renderReportTitle() { @@ -41,7 +40,6 @@ function renderReportTitle() { it("deletes the report", async () => { report_api.delete_report = jest.fn().mockResolvedValue({ ok: true }) renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/Delete report/)) }) @@ -50,7 +48,6 @@ it("deletes the report", async () => { it("sets the title", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await userEvent.type(screen.getByLabelText(/Report title/), "New title{Enter}", { initialSelectionStart: 0, initialSelectionEnd: 12, @@ -60,7 +57,6 @@ it("sets the title", async () => { it("sets the subtitle", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await userEvent.type(screen.getByLabelText(/Report subtitle/), "New subtitle{Enter}", { initialSelectionStart: 0, initialSelectionEnd: 12, @@ -70,7 +66,6 @@ it("sets the subtitle", async () => { it("sets the comment", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await userEvent.type(screen.getByLabelText(/Comment/), "New comment{Shift>}{Enter}", { initialSelectionStart: 0, initialSelectionEnd: 8, @@ -80,11 +75,13 @@ it("sets the comment", async () => { it("sets the unknown status reaction time", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { - fireEvent.click(screen.getByText(/reaction times/)) + fireEvent.click(screen.getByRole("tab", { name: /reaction times/ })) + }) + await act(async () => { + fireEvent.click(screen.getByLabelText("Unknown")) }) - await userEvent.type(screen.getByLabelText(/Unknown/), "4{Enter}}", { + await userEvent.type(screen.getByLabelText("Unknown"), "4{Enter}}", { initialSelectionStart: 0, initialSelectionEnd: 1, }) @@ -98,11 +95,10 @@ it("sets the unknown status reaction time", async () => { it("sets the target not met status reaction time", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/reaction times/)) }) - await userEvent.type(screen.getByLabelText(/Target not met/), "5{Enter}}", { + await userEvent.type(screen.getByLabelText("Target not met"), "5{Enter}}", { initialSelectionStart: 0, initialSelectionEnd: 1, }) @@ -116,11 +112,10 @@ it("sets the target not met status reaction time", async () => { it("sets the near target met status reaction time", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/reaction times/)) }) - await userEvent.type(screen.getByLabelText(/Near target met/), "6{Enter}}", { + await userEvent.type(screen.getByLabelText("Near target met"), "6{Enter}}", { initialSelectionStart: 0, initialSelectionEnd: 2, }) @@ -134,7 +129,6 @@ it("sets the near target met status reaction time", async () => { it("sets the tech debt target status reaction time", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/reaction times/)) }) @@ -152,11 +146,10 @@ it("sets the tech debt target status reaction time", async () => { it("sets the confirmed measurement entity status reaction time", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/reaction times/)) }) - await userEvent.type(screen.getByLabelText(/Confirmed/), "60{Enter}}", { + await userEvent.type(screen.getByLabelText("Confirmed"), "60{Enter}}", { initialSelectionStart: 0, initialSelectionEnd: 3, }) @@ -170,11 +163,10 @@ it("sets the confirmed measurement entity status reaction time", async () => { it("sets the false positive measurement entity status reaction time", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/reaction times/)) }) - await userEvent.type(screen.getByLabelText(/False positive/), "70{Enter}}", { + await userEvent.type(screen.getByLabelText("False positive"), "70{Enter}}", { initialSelectionStart: 0, initialSelectionEnd: 3, }) @@ -188,11 +180,10 @@ it("sets the false positive measurement entity status reaction time", async () = it("sets the fixed measurement entity status reaction time", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/reaction times/)) }) - await userEvent.type(screen.getByLabelText(/Fixed/), "80{Enter}}", { + await userEvent.type(screen.getByLabelText("Fixed"), "80{Enter}}", { initialSelectionStart: 0, initialSelectionEnd: 3, }) @@ -206,11 +197,10 @@ it("sets the fixed measurement entity status reaction time", async () => { it("sets the won't fixed measurement entity status reaction time", async () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/reaction times/)) }) - await userEvent.type(screen.getByLabelText(/Won't fix/), "90{Enter}}", { + await userEvent.type(screen.getByLabelText("Won't fix"), "90{Enter}}", { initialSelectionStart: 0, initialSelectionEnd: 3, }) @@ -223,13 +213,14 @@ it("sets the won't fixed measurement entity status reaction time", async () => { }) it("sets the issue tracker type", async () => { + report_api.get_report_issue_tracker_options.mockImplementation(() => + Promise.resolve({ projects: [], issue_types: [], fields: [], epic_links: [] }), + ) renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) fireEvent.click(screen.getByText(/Issue tracker/)) - fireEvent.click(screen.getByText(/Issue tracker type/)) - await act(async () => { - fireEvent.click(screen.getByText(/Jira/)) - }) + fireEvent.mouseDown(screen.getByLabelText(/Issue tracker type/)) + const listbox = within(screen.getByRole("listbox")) + await act(async () => fireEvent.click(listbox.getByText(/Jira/))) expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "type", @@ -239,10 +230,12 @@ it("sets the issue tracker type", async () => { }) it("sets the issue tracker url", async () => { + report_api.get_report_issue_tracker_options.mockImplementation(() => + Promise.resolve({ projects: [], issue_types: [], fields: [], epic_links: [] }), + ) renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) fireEvent.click(screen.getByText(/Issue tracker/)) - await userEvent.type(screen.getByText(/URL/), "https://jira{Enter}") + await userEvent.type(screen.getByLabelText(/URL/), "https://jira{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "url", @@ -252,10 +245,12 @@ it("sets the issue tracker url", async () => { }) it("sets the issue tracker username", async () => { + report_api.get_report_issue_tracker_options.mockImplementation(() => + Promise.resolve({ projects: [], issue_types: [], fields: [], epic_links: [] }), + ) renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) fireEvent.click(screen.getByText(/Issue tracker/)) - await userEvent.type(screen.getByText(/Username/), "janedoe{Enter}") + await userEvent.type(screen.getByLabelText(/Username/), "janedoe{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "username", @@ -265,10 +260,12 @@ it("sets the issue tracker username", async () => { }) it("sets the issue tracker password", async () => { + report_api.get_report_issue_tracker_options.mockImplementation(() => + Promise.resolve({ projects: [], issue_types: [], fields: [], epic_links: [] }), + ) renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) fireEvent.click(screen.getByText(/Issue tracker/)) - await userEvent.type(screen.getByText(/Password/), "secret{Enter}") + await userEvent.type(screen.getByLabelText(/Password/), "secret{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "password", @@ -278,10 +275,12 @@ it("sets the issue tracker password", async () => { }) it("sets the issue tracker private token", async () => { + report_api.get_report_issue_tracker_options.mockImplementation(() => + Promise.resolve({ projects: [], issue_types: [], fields: [], epic_links: [] }), + ) renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) fireEvent.click(screen.getByText(/Issue tracker/)) - await userEvent.type(screen.getByText(/Private token/), "secret{Enter}") + await userEvent.type(screen.getByLabelText(/Private token/), "secret{Enter}") expect(report_api.set_report_issue_tracker_attribute).toHaveBeenLastCalledWith( "report_uuid", "private_token", @@ -291,8 +290,8 @@ it("sets the issue tracker private token", async () => { }) it("loads the changelog", async () => { + changelog_api.get_changelog.mockImplementation(() => Promise.resolve({ changelog: [] })) renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) await act(async () => { fireEvent.click(screen.getByText(/Changelog/)) }) @@ -301,7 +300,6 @@ it("loads the changelog", async () => { it("shows the notification destinations", () => { renderReportTitle() - fireEvent.click(screen.getByTitle(/expand/)) fireEvent.click(screen.getByText(/Notifications/)) expect(screen.getAllByText(/No notification destinations/).length).toBe(2) }) diff --git a/components/frontend/src/report/ReportsOverview.js b/components/frontend/src/report/ReportsOverview.js index ff25b1ef57..82aaf5642a 100644 --- a/components/frontend/src/report/ReportsOverview.js +++ b/components/frontend/src/report/ReportsOverview.js @@ -3,7 +3,7 @@ import { func } from "prop-types" import { add_report, copy_report } from "../api/report" import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions" -import { ExportCard } from "../dashboard/ExportCard" +import { PageHeader } from "../dashboard/PageHeader" import { datePropType, datesPropType, @@ -21,7 +21,7 @@ import { AddButton } from "../widgets/buttons/AddButton" import { CopyButton } from "../widgets/buttons/CopyButton" import { CommentSegment } from "../widgets/CommentSegment" import { report_options } from "../widgets/menu_options" -import { ReportsOverviewErrorMessage } from "./ReportErrorMessage" +import { WarningMessage } from "../widgets/WarningMessage" import { ReportsOverviewDashboard } from "./ReportsOverviewDashboard" import { ReportsOverviewTitle } from "./ReportsOverviewTitle" @@ -30,7 +30,7 @@ function ReportsOverviewButtonRow({ reload, reports }) { <ReadOnlyOrEditable requiredPermissions={[EDIT_REPORT_PERMISSION]} editableComponent={ - <Box sx={{ p: "10px" }}> + <Box sx={{ paddingTop: "50px" }}> <ButtonRow> <AddButton itemType={"report"} onClick={() => add_report(reload)} /> <CopyButton @@ -63,21 +63,14 @@ export function ReportsOverview({ settings, }) { if (reports.length === 0 && report_date !== null) { - return <ReportsOverviewErrorMessage reportDate={report_date} /> + return <WarningMessage title="No reports found">{`Sorry, no reports existed at ${report_date}`}</WarningMessage> } // Sort measurements in reverse order so that if there multiple measurements on a day, we find the most recent one: const reversedMeasurements = measurements.slice().sort((m1, m2) => (m1.start < m2.start ? 1 : -1)) return ( <div id="dashboard"> - <div className="reportHeader"> - <ReportsOverviewTitle reports_overview={reports_overview} reload={reload} settings={settings} /> - <ExportCard - isOverview={true} - lastUpdate={lastUpdate} - report={reports_overview} - reportDate={report_date} - /> - </div> + <PageHeader lastUpdate={lastUpdate} reportDate={report_date} /> + <ReportsOverviewTitle reports_overview={reports_overview} reload={reload} settings={settings} /> <CommentSegment comment={reports_overview.comment} /> <ReportsOverviewDashboard dates={dates} diff --git a/components/frontend/src/report/ReportsOverview.test.js b/components/frontend/src/report/ReportsOverview.test.js index bef3612336..0f04fbd1fa 100644 --- a/components/frontend/src/report/ReportsOverview.test.js +++ b/components/frontend/src/report/ReportsOverview.test.js @@ -50,7 +50,7 @@ it("shows the reports overview", async () => { const reports = [{ report_uuid: "report_uuid", subjects: {} }] const reportsOverview = { title: "Overview", permissions: {} } renderReportsOverview({ reports: reports, reportsOverview: reportsOverview }) - expect(screen.getAllByText(/Overview/).length).toBe(2) + expect(screen.getAllByText(/Overview/).length).toBe(1) }) it("shows the comment", async () => { diff --git a/components/frontend/src/report/ReportsOverviewTitle.js b/components/frontend/src/report/ReportsOverviewTitle.js index b5f383e1ee..adf7cdc0b3 100644 --- a/components/frontend/src/report/ReportsOverviewTitle.js +++ b/components/frontend/src/report/ReportsOverviewTitle.js @@ -1,52 +1,52 @@ +import HistoryIcon from "@mui/icons-material/History" +import LockIcon from "@mui/icons-material/Lock" +import SettingsIcon from "@mui/icons-material/Settings" +import Grid from "@mui/material/Grid2" import { func, shape } from "prop-types" -import { Grid } from "semantic-ui-react" +import { useContext } from "react" import { set_reports_attribute } from "../api/report" -import { activeTabIndex, tabChangeHandler } from "../app_ui_settings" import { ChangeLog } from "../changelog/ChangeLog" -import { EDIT_ENTITY_PERMISSION, EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { Comment } from "../fields/Comment" -import { MultipleChoiceInput } from "../fields/MultipleChoiceInput" -import { StringInput } from "../fields/StringInput" -import { Tab } from "../semantic_ui_react_wrappers" +import { accessGranted, EDIT_ENTITY_PERMISSION, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { CommentField } from "../fields/CommentField" +import { MultipleChoiceField } from "../fields/MultipleChoiceField" +import { TextField } from "../fields/TextField" import { permissionsPropType, reportsOverviewPropType, settingsPropType } from "../sharedPropTypes" -import { dropdownOptions } from "../utils" import { HeaderWithDetails } from "../widgets/HeaderWithDetails" -import { changelogTabPane, configurationTabPane, tabPane } from "../widgets/TabPane" +import { Tabs } from "../widgets/Tabs" import { setDocumentTitle } from "./document_title" function ReportsOverviewConfiguration({ reports_overview, reload }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) return ( - <Grid stackable> - <Grid.Row columns={2}> - <Grid.Column> - <StringInput - id="reports-overview-title" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label="Report overview title" - set_value={(value) => set_reports_attribute("title", value, reload)} - value={reports_overview.title} - /> - </Grid.Column> - <Grid.Column> - <StringInput - id="reports-overview-subtitle" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label="Report overview subtitle" - set_value={(value) => set_reports_attribute("subtitle", value, reload)} - value={reports_overview.subtitle} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row> - <Grid.Column> - <Comment - id="reports-overview-comment" - set_value={(value) => set_reports_attribute("comment", value, reload)} - value={reports_overview.comment} - /> - </Grid.Column> - </Grid.Row> + <Grid container alignItems="flex-end" spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 2, md: 2 }}> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="reports-overview-title" + label="Report overview title" + onChange={(value) => set_reports_attribute("title", value, reload)} + value={reports_overview.title} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="reports-overview-subtitle" + label="Report overview subtitle" + onChange={(value) => set_reports_attribute("subtitle", value, reload)} + value={reports_overview.subtitle} + /> + </Grid> + <Grid size={{ xs: 1, sm: 2, md: 2 }}> + <CommentField + disabled={disabled} + id="reports-overview-comment" + onChange={(value) => set_reports_attribute("comment", value, reload)} + value={reports_overview.comment} + /> + </Grid> </Grid> ) } @@ -60,41 +60,39 @@ function setPermissions(permissions, permission, value, reload) { set_reports_attribute("permissions", permissions, reload) } -function Permissions({ permissions, reload }) { +function PermissionsConfiguration({ permissions, reload }) { + const currentPermissions = useContext(Permissions) + const disabled = !accessGranted(currentPermissions, [EDIT_REPORT_PERMISSION]) return ( - <Grid stackable> - <Grid.Row columns={1}> - <Grid.Column> - <MultipleChoiceInput - allowAdditions - id="report_overview_edit_report_permission" - label="Users allowed to edit reports (user name or email address)" - options={dropdownOptions(permissions[EDIT_REPORT_PERMISSION] || [])} - placeholder="All authenticated users" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => setPermissions(permissions, EDIT_REPORT_PERMISSION, value, reload)} - value={permissions[EDIT_REPORT_PERMISSION]} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row columns={1}> - <Grid.Column> - <MultipleChoiceInput - allowAdditions - id="report_overview_edit_entity_permission" - label="Users allowed to edit measured entities (user name or email address)" - options={dropdownOptions(permissions[EDIT_ENTITY_PERMISSION] || [])} - placeholder="All authenticated users" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => setPermissions(permissions, EDIT_ENTITY_PERMISSION, value, reload)} - value={permissions[EDIT_ENTITY_PERMISSION]} - /> - </Grid.Column> - </Grid.Row> + <Grid container alignItems="flex-end" spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 1, md: 2 }}> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <MultipleChoiceField + disabled={disabled} + freeSolo + id="report_overview_edit_report_permission" + label="Users allowed to edit reports (user name or email address)" + onChange={(value) => setPermissions(permissions, EDIT_REPORT_PERMISSION, value, reload)} + options={permissions[EDIT_REPORT_PERMISSION] || []} + placeholder="All authenticated users" + value={permissions[EDIT_REPORT_PERMISSION] || []} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <MultipleChoiceField + disabled={disabled} + freeSolo + id="report_overview_edit_entity_permission" + label="Users allowed to edit measured entities (user name or email address)" + onChange={(value) => setPermissions(permissions, EDIT_ENTITY_PERMISSION, value, reload)} + options={permissions[EDIT_ENTITY_PERMISSION] || []} + placeholder="All authenticated users" + value={permissions[EDIT_ENTITY_PERMISSION] || []} + /> + </Grid> </Grid> ) } -Permissions.propTypes = { +PermissionsConfiguration.propTypes = { permissions: shape({ EDIT_REPORT_PERMISSION: permissionsPropType, EDIT_ENTITY_PERMISSION: permissionsPropType, @@ -104,29 +102,27 @@ Permissions.propTypes = { export function ReportsOverviewTitle({ reports_overview, reload, settings }) { const uuid = "reports_overview" - const tabIndex = activeTabIndex(settings.expandedItems, uuid) - const panes = [ - configurationTabPane(<ReportsOverviewConfiguration reports_overview={reports_overview} reload={reload} />), - tabPane("Permissions", <Permissions permissions={reports_overview.permissions ?? {}} reload={reload} />, { - iconName: "lock", - }), - changelogTabPane(<ChangeLog />), - ] setDocumentTitle(reports_overview.title) return ( <HeaderWithDetails header={reports_overview.title} - item_uuid={`${uuid}:${tabIndex}`} + item_uuid={uuid} level="h1" settings={settings} subheader={reports_overview.subtitle} > - <Tab - defaultActiveIndex={tabIndex} - onTabChange={tabChangeHandler(settings.expandedItems, uuid)} - panes={panes} - /> + <Tabs + tabs={[ + { label: "Configuration", icon: <SettingsIcon /> }, + { label: "Permissions", icon: <LockIcon /> }, + { label: "Changelog", icon: <HistoryIcon /> }, + ]} + > + <ReportsOverviewConfiguration reports_overview={reports_overview} reload={reload} /> + <PermissionsConfiguration permissions={reports_overview.permissions ?? {}} reload={reload} /> + <ChangeLog /> + </Tabs> </HeaderWithDetails> ) } diff --git a/components/frontend/src/report/ReportsOverviewTitle.test.js b/components/frontend/src/report/ReportsOverviewTitle.test.js index e2b2256ae1..91f0c80c03 100644 --- a/components/frontend/src/report/ReportsOverviewTitle.test.js +++ b/components/frontend/src/report/ReportsOverviewTitle.test.js @@ -10,7 +10,7 @@ import { ReportsOverviewTitle } from "./ReportsOverviewTitle" jest.mock("../api/fetch_server_api.js") beforeEach(() => { - history.push("?expanded=reports_overview:0") + history.push("?expanded=reports_overview") }) function renderReportsOverviewTitle() { @@ -52,7 +52,7 @@ it("sets the edit report permission", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) renderReportsOverviewTitle() fireEvent.click(screen.getByText(/Permissions/)) - await userEvent.type(screen.getAllByText(/All authenticated users/)[0], "jadoe{Enter}") + await userEvent.type(screen.getByLabelText(/Users allowed to edit reports/), "jadoe{Enter}") expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith( "post", "reports_overview/attribute/permissions", @@ -64,7 +64,7 @@ it("sets the edit entities permission", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) renderReportsOverviewTitle() fireEvent.click(screen.getByText(/Permissions/)) - await userEvent.type(screen.getAllByText(/All authenticated users/)[1], "jodoe{Enter}") + await userEvent.type(screen.getByLabelText(/Users allowed to edit measured entities/), "jodoe{Enter}") expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith( "post", "reports_overview/attribute/permissions", diff --git a/components/frontend/src/semantic_ui_react_wrappers.js b/components/frontend/src/semantic_ui_react_wrappers.js deleted file mode 100644 index 53c7959114..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers.js +++ /dev/null @@ -1,10 +0,0 @@ -export { Card } from "./semantic_ui_react_wrappers/Card" -export { Dropdown } from "./semantic_ui_react_wrappers/Dropdown" -export { Form } from "./semantic_ui_react_wrappers/Form" -export { Header } from "./semantic_ui_react_wrappers/Header" -export { Label } from "./semantic_ui_react_wrappers/Label" -export { Message } from "./semantic_ui_react_wrappers/Message" -export { Popup } from "./semantic_ui_react_wrappers/Popup" -export { Segment } from "./semantic_ui_react_wrappers/Segment" -export { Tab } from "./semantic_ui_react_wrappers/Tab" -export { Table } from "./semantic_ui_react_wrappers/Table" diff --git a/components/frontend/src/semantic_ui_react_wrappers/Card.css b/components/frontend/src/semantic_ui_react_wrappers/Card.css deleted file mode 100644 index a98355e504..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Card.css +++ /dev/null @@ -1,11 +0,0 @@ -.ui.inverted.card { - background: rgba(50, 50, 50, 0.8); -} - -.ui.inverted.card:hover { - background: rgba(30, 30, 30, 0.8); -} - -.ui.inverted.card > .content > .header { - color: rgba(255, 255, 255, 0.87); -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Card.js b/components/frontend/src/semantic_ui_react_wrappers/Card.js deleted file mode 100644 index 8ad57a5d80..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Card.js +++ /dev/null @@ -1,17 +0,0 @@ -import "./Card.css" - -import { useContext } from "react" -import { Card as SemanticUICard } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" -import { addInvertedClassNameWhenInDarkMode } from "./dark_mode" - -export function Card(props) { - return <SemanticUICard {...addInvertedClassNameWhenInDarkMode(props, useContext(DarkMode))} /> -} - -Card.Content = SemanticUICard.Content -Card.Description = SemanticUICard.Description -Card.Group = SemanticUICard.Group -Card.Header = SemanticUICard.Header -Card.Meta = SemanticUICard.Meta diff --git a/components/frontend/src/semantic_ui_react_wrappers/Dropdown.css b/components/frontend/src/semantic_ui_react_wrappers/Dropdown.css deleted file mode 100644 index 4f51f41ff1..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Dropdown.css +++ /dev/null @@ -1,3 +0,0 @@ -.ui.dropdown.inline.inverted { - background-color: black !important; -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Dropdown.js b/components/frontend/src/semantic_ui_react_wrappers/Dropdown.js deleted file mode 100644 index 4a7c58c4a0..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Dropdown.js +++ /dev/null @@ -1,16 +0,0 @@ -import { useContext } from "react" -import { Dropdown as SemanticUIDropdown } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" -import { addInvertedClassNameWhenInDarkMode } from "./dark_mode" - -export function Dropdown(props) { - return <SemanticUIDropdown {...addInvertedClassNameWhenInDarkMode(props, useContext(DarkMode))} /> -} - -Dropdown.Divider = SemanticUIDropdown.Divider -Dropdown.Header = SemanticUIDropdown.Header -Dropdown.Item = SemanticUIDropdown.Item -Dropdown.Menu = SemanticUIDropdown.Menu -Dropdown.SearchInput = SemanticUIDropdown.SearchInput -Dropdown.Text = SemanticUIDropdown.Text diff --git a/components/frontend/src/semantic_ui_react_wrappers/Form.css b/components/frontend/src/semantic_ui_react_wrappers/Form.css deleted file mode 100644 index fc689c6eb2..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Form.css +++ /dev/null @@ -1,59 +0,0 @@ -form.ui.inverted.form input { - background-color: rgba(50, 50, 50) !important; - color: rgba(255, 255, 255, 0.87) !important; -} - -form.ui.inverted.form .ui.search.dropdown:not(.multiple) > input.search { - border: 1px solid rgba(255, 255, 255, 0.1) !important; -} - -form.ui.inverted.form .ui.multiple.search.dropdown { - border: 1px solid rgba(255, 255, 255, 0.1) !important; -} - -form.ui.inverted.form .ui.label:not(.circular) { - background-color: rgba(100, 100, 100) !important; - color: rgba(255, 255, 255, 0.87) !important; -} - -form.ui.inverted.form textarea { - background-color: rgba(50, 50, 50) !important; - border: 1px solid rgba(255, 255, 255, 0.1) !important; - color: rgba(255, 255, 255, 0.87) !important; -} - -form.ui.inverted.form div.dropdown:not(.inline) { - background-color: rgba(50, 50, 50) !important; - color: rgba(255, 255, 255, 0.87) !important; -} - -form.ui.inverted.form div.menu { - background-color: rgba(50, 50, 50) !important; - color: rgba(255, 255, 255, 0.87) !important; -} - -form.ui.inverted.form div.menu .item { - border-top: 1px solid transparent; - background: rgba(50, 50, 50) !important; - color: rgba(255, 255, 255, 0.87) !important; -} - -form.ui.inverted.form div.menu .active.selected.item { - background: rgba(255, 255, 255, 0.15); -} - -form.ui.inverted.form div.menu .item:hover { - background: rgba(255, 255, 255, 0.05); -} - -form.ui.inverted.form .ui.header { - color: rgba(255, 255, 255, 0.87) !important; -} - -form.ui.inverted.form .sub.header { - color: rgba(255, 255, 255, 0.8) !important; -} - -form.ui.inverted.form .icon { - color: rgba(255, 255, 255, 0.87); -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Form.js b/components/frontend/src/semantic_ui_react_wrappers/Form.js deleted file mode 100644 index 24728ec73a..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Form.js +++ /dev/null @@ -1,23 +0,0 @@ -import "./Form.css" - -import { useContext } from "react" -import { Form as SemanticUIForm } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" - -export function Form(props) { - return <SemanticUIForm inverted={useContext(DarkMode)} {...props} /> -} - -function Input(props) { - return <SemanticUIForm.Input inverted={useContext(DarkMode)} {...props} /> -} - -function Dropdown(props) { - return <SemanticUIForm.Dropdown inverted={useContext(DarkMode) ? "true" : undefined} {...props} /> -} - -Form.Button = SemanticUIForm.Button -Form.Dropdown = Dropdown -Form.Input = Input -Form.TextArea = SemanticUIForm.TextArea diff --git a/components/frontend/src/semantic_ui_react_wrappers/Form.test.js b/components/frontend/src/semantic_ui_react_wrappers/Form.test.js deleted file mode 100644 index 8aeae8be21..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Form.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import { render } from "@testing-library/react" - -import { DarkMode } from "../context/DarkMode" -import { Form } from "../semantic_ui_react_wrappers" - -it("shows the form dropdown in darkmode", () => { - let result - result = render( - <DarkMode.Provider value={true}> - <Form> - <Form.Dropdown options={[{ key: "Hi", value: "Hi", text: "Hi" }]} value={"Hi"} /> - </Form> - </DarkMode.Provider>, - ) - expect(result.container.querySelector(".inverted")).not.toBe(null) -}) - -it("shows the form dropdown in light mode", () => { - let result - result = render( - <DarkMode.Provider value={false}> - <Form> - <Form.Dropdown options={[{ key: "Hi", value: "Hi", text: "Hi" }]} value={"Hi"} /> - </Form> - </DarkMode.Provider>, - ) - expect(result.container.querySelector(".inverted")).toBe(null) -}) diff --git a/components/frontend/src/semantic_ui_react_wrappers/Header.css b/components/frontend/src/semantic_ui_react_wrappers/Header.css deleted file mode 100644 index 1a4485e873..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Header.css +++ /dev/null @@ -1,3 +0,0 @@ -.ui.inverted.header { - color: rgba(255, 255, 255, 0.87); -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Header.js b/components/frontend/src/semantic_ui_react_wrappers/Header.js deleted file mode 100644 index 6767e44e7d..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Header.js +++ /dev/null @@ -1,13 +0,0 @@ -import "./Header.css" - -import { useContext } from "react" -import { Header as SemanticUIHeader } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" - -export function Header(props) { - return <SemanticUIHeader inverted={useContext(DarkMode)} {...props} /> -} - -Header.Content = SemanticUIHeader.Content -Header.Subheader = SemanticUIHeader.Subheader diff --git a/components/frontend/src/semantic_ui_react_wrappers/Label.css b/components/frontend/src/semantic_ui_react_wrappers/Label.css deleted file mode 100644 index cb9f47505f..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Label.css +++ /dev/null @@ -1,15 +0,0 @@ -.ui.inverted.label { - color: rgba(255, 255, 255, 0.87) !important; -} - -.ui.inverted.grey.label { - background-color: rgba(118, 118, 118, 0.87) !important; -} - -.ui.inverted.yellow.label { - background-color: rgba(253, 197, 54, 0.87) !important; -} - -.ui.label > a { - opacity: 1; -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Label.js b/components/frontend/src/semantic_ui_react_wrappers/Label.js deleted file mode 100644 index 766e1f454a..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Label.js +++ /dev/null @@ -1,13 +0,0 @@ -import "./Label.css" - -import { useContext } from "react" -import { Label as SemanticUILabel } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" -import { addInvertedClassNameWhenInDarkMode } from "./dark_mode" - -export function Label(props) { - return <SemanticUILabel {...addInvertedClassNameWhenInDarkMode(props, useContext(DarkMode))} /> -} - -Label.Detail = SemanticUILabel.Detail diff --git a/components/frontend/src/semantic_ui_react_wrappers/Message.js b/components/frontend/src/semantic_ui_react_wrappers/Message.js deleted file mode 100644 index dfdad0232a..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Message.js +++ /dev/null @@ -1,14 +0,0 @@ -import { useContext } from "react" -import { Message as SemanticUIMessage } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" -import { addInvertedClassNameWhenInDarkMode } from "./dark_mode" - -export function Message(props) { - return <SemanticUIMessage {...addInvertedClassNameWhenInDarkMode(props, useContext(DarkMode))} /> -} - -Message.Content = SemanticUIMessage.Content -Message.Header = SemanticUIMessage.Header -Message.Item = SemanticUIMessage.Item -Message.List = SemanticUIMessage.List diff --git a/components/frontend/src/semantic_ui_react_wrappers/Popup.css b/components/frontend/src/semantic_ui_react_wrappers/Popup.css deleted file mode 100644 index 750287c6ec..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Popup.css +++ /dev/null @@ -1,14 +0,0 @@ -.ui.inverted.popup { - background-color: rgba(60, 65, 70); - box-shadow: - 0 2px 4px 0 rgba(255, 255, 255, 0.1), - 0 2px 8px 0 rgba(255, 255, 255, 0.15); -} - -.ui.inverted.popup .negative.message .header { - color: #912d2b; /* For some reason the header color is white within an inverted popup. Override. */ -} - -.ui.inverted.popup:before { - background-color: rgba(60, 65, 70) !important; -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Popup.js b/components/frontend/src/semantic_ui_react_wrappers/Popup.js deleted file mode 100644 index e5cdcacbbc..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Popup.js +++ /dev/null @@ -1,12 +0,0 @@ -import "./Popup.css" - -import { useContext } from "react" -import { Popup as SemanticUIPopup } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" - -export function Popup(props) { - return <SemanticUIPopup inverted={useContext(DarkMode)} {...props} /> -} - -Popup.Content = SemanticUIPopup.Content diff --git a/components/frontend/src/semantic_ui_react_wrappers/Segment.css b/components/frontend/src/semantic_ui_react_wrappers/Segment.css deleted file mode 100644 index c973062943..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Segment.css +++ /dev/null @@ -1,12 +0,0 @@ -.ui.inverted.segment, -.ui.inverted.segments .segment, -.ui.primary.inverted.segment { - background-color: rgba(40, 40, 40); -} - -.ui.inverted.segment > .ui.header, -.ui.inverted.segment > .ui.header .sub.header, -.ui.inverted.segments .segment > .ui.header, -.ui.inverted.segments .segment > .ui.header .sub.header { - color: rgba(255, 255, 255, 0.87); -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Segment.js b/components/frontend/src/semantic_ui_react_wrappers/Segment.js deleted file mode 100644 index c22bcfd22e..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Segment.js +++ /dev/null @@ -1,10 +0,0 @@ -import "./Segment.css" - -import { useContext } from "react" -import { Segment as SemanticUISegment } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" - -export function Segment(props) { - return <SemanticUISegment inverted={useContext(DarkMode)} {...props} /> -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Tab.css b/components/frontend/src/semantic_ui_react_wrappers/Tab.css deleted file mode 100644 index 2b6181b440..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Tab.css +++ /dev/null @@ -1,3 +0,0 @@ -.ui.inverted.menu:not(.fixed) { - background-color: rgba(0, 0, 0, 0); -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Tab.js b/components/frontend/src/semantic_ui_react_wrappers/Tab.js deleted file mode 100644 index 240f802f10..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Tab.js +++ /dev/null @@ -1,17 +0,0 @@ -import "./Tab.css" - -import { useContext } from "react" -import { Tab as SemanticUITab } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" - -export function Tab(props) { - const darkMode = useContext(DarkMode) - return <SemanticUITab menu={{ inverted: darkMode, attached: !darkMode, tabular: !darkMode }} {...props} /> -} - -function Pane(props) { - return <SemanticUITab.Pane inverted={useContext(DarkMode)} {...props} /> -} - -Tab.Pane = Pane diff --git a/components/frontend/src/semantic_ui_react_wrappers/Table.css b/components/frontend/src/semantic_ui_react_wrappers/Table.css deleted file mode 100644 index b7868f829a..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Table.css +++ /dev/null @@ -1,20 +0,0 @@ -.ui.inverted.table, -.ui.ui.inverted.table > tbody > tr > th, -.ui.ui.inverted.table > tfoot > tr > td, -.ui.ui.inverted.table > tfoot > tr > th, -.ui.ui.inverted.table > thead > tr > th, -.ui.ui.inverted.table > tr > th { - color: rgba(255, 255, 255, 0.87); -} - -.ui.sortable.table:not(.basic) thead th.sorted { - background-color: rgba(242, 242, 242, 1) !important; -} - -.ui.sortable.table:not(.basic):not(.inverted) thead th.sorted:hover { - background-color: rgba(232, 232, 232, 1) !important; -} - -.ui.sortable.table.inverted:not(.basic) thead th.sorted:hover { - background-color: rgba(140, 140, 140, 1) !important; -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/Table.js b/components/frontend/src/semantic_ui_react_wrappers/Table.js deleted file mode 100644 index a8fff4973c..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/Table.js +++ /dev/null @@ -1,17 +0,0 @@ -import "./Table.css" - -import { useContext } from "react" -import { Table as SemanticUITable } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" - -export function Table(props) { - return <SemanticUITable inverted={useContext(DarkMode)} {...props} /> -} - -Table.Body = SemanticUITable.Body -Table.Cell = SemanticUITable.Cell -Table.Footer = SemanticUITable.Footer -Table.Header = SemanticUITable.Header -Table.HeaderCell = SemanticUITable.HeaderCell -Table.Row = SemanticUITable.Row diff --git a/components/frontend/src/semantic_ui_react_wrappers/dark_mode.js b/components/frontend/src/semantic_ui_react_wrappers/dark_mode.js deleted file mode 100644 index 74b9c7e3d5..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/dark_mode.js +++ /dev/null @@ -1,8 +0,0 @@ -export function addInvertedClassNameWhenInDarkMode(props, darkMode) { - let { className, ...otherProps } = props - className = className ?? "" - if (darkMode) { - className += " inverted" - } - return { className: className, ...otherProps } -} diff --git a/components/frontend/src/semantic_ui_react_wrappers/dark_mode.test.js b/components/frontend/src/semantic_ui_react_wrappers/dark_mode.test.js deleted file mode 100644 index 73e4f6c203..0000000000 --- a/components/frontend/src/semantic_ui_react_wrappers/dark_mode.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { addInvertedClassNameWhenInDarkMode } from "./dark_mode" - -it("adds inverted when in dark mode", () => { - expect(addInvertedClassNameWhenInDarkMode({ foo: "bar" }, true)).toEqual({ - className: " inverted", - foo: "bar", - }) -}) - -it("does not add inverted when in light mode", () => { - expect(addInvertedClassNameWhenInDarkMode({ foo: "bar" }, false)).toEqual({ - className: "", - foo: "bar", - }) -}) diff --git a/components/frontend/src/sharedPropTypes.js b/components/frontend/src/sharedPropTypes.js index 147a55277a..b653d714cc 100644 --- a/components/frontend/src/sharedPropTypes.js +++ b/components/frontend/src/sharedPropTypes.js @@ -186,6 +186,8 @@ export const metricPropType = shape({ tags: stringsPropType, }) +export const targetType = oneOf(["debt_target", "near_target", "target"]) + export const metricsPropType = arrayOf(metricPropType) export const metricTypePropType = shape({ diff --git a/components/frontend/src/source/Logo.js b/components/frontend/src/source/Logo.js index fb201a4fd0..492ebbae13 100644 --- a/components/frontend/src/source/Logo.js +++ b/components/frontend/src/source/Logo.js @@ -1,9 +1,18 @@ import { string } from "prop-types" -export function Logo({ alt, logo }) { - return <img src={`api/internal/logo/${logo}`} alt={`${alt} logo`} width="32px" height="32px" /> +export function Logo({ alt, logo, marginBottom, width, height }) { + return ( + <img + style={{ marginBottom: marginBottom || "0px", height: height || "32px", width: width || "32px" }} + src={`api/internal/logo/${logo}`} + alt={`${alt} logo`} + /> + ) } Logo.propTypes = { alt: string, logo: string, + marginBottom: string, + width: string, + height: string, } diff --git a/components/frontend/src/source/Source.js b/components/frontend/src/source/Source.js index b94f115789..cb5de56930 100644 --- a/components/frontend/src/source/Source.js +++ b/components/frontend/src/source/Source.js @@ -1,14 +1,14 @@ +import HistoryIcon from "@mui/icons-material/History" +import SettingsIcon from "@mui/icons-material/Settings" +import Grid from "@mui/material/Grid2" import { bool, func, object, oneOfType, string } from "prop-types" import { useContext } from "react" -import { Grid } from "semantic-ui-react" import { delete_source, set_source_attribute } from "../api/source" import { ChangeLog } from "../changelog/ChangeLog" import { DataModel } from "../context/DataModel" -import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions" -import { ErrorMessage } from "../errorMessage" -import { StringInput } from "../fields/StringInput" -import { Tab } from "../semantic_ui_react_wrappers" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions, ReadOnlyOrEditable } from "../context/Permissions" +import { TextField } from "../fields/TextField" import { measurementSourcePropType, metricPropType, @@ -20,8 +20,9 @@ import { getMetricName, getSourceName, referenceDocumentationURL } from "../util import { ButtonRow } from "../widgets/ButtonRow" import { DeleteButton } from "../widgets/buttons/DeleteButton" import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup" +import { ErrorMessage } from "../widgets/ErrorMessage" import { HyperLink } from "../widgets/HyperLink" -import { changelogTabPane, configurationTabPane } from "../widgets/TabPane" +import { Tabs } from "../widgets/Tabs" import { SourceParameters } from "./SourceParameters" import { SourceType } from "./SourceType" import { SourceTypeHeader } from "./SourceTypeHeader" @@ -71,41 +72,39 @@ function Parameters({ source_uuid, }) { const dataModel = useContext(DataModel) + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const source_type = dataModel.sources[source.type] return ( - <Grid stackable> - <Grid.Row columns={2}> - <Grid.Column> - <SourceType - metric_type={metric.type} - set_source_attribute={(a, v) => set_source_attribute(source_uuid, a, v, reload)} - source_uuid={source_uuid} - source_type={source.type} - /> - </Grid.Column> - <Grid.Column> - <StringInput - requiredPermissions={[EDIT_REPORT_PERMISSION]} - id="source-name" - label="Source name" - placeholder={source_type.name} - set_value={(value) => set_source_attribute(source_uuid, "name", value, reload)} - value={source.name} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row columns={1}> - <Grid.Column> - <SourceParameters - changed_param_keys={select_sources_parameter_keys(changed_fields, source_uuid)} - metric={metric} - reload={reload} - report={report} - source={source} - source_uuid={source_uuid} - /> - </Grid.Column> - </Grid.Row> + <Grid container alignItems="flex-start" spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 2, md: 2 }}> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <SourceType + metric_type={metric.type} + set_source_attribute={(a, v) => set_source_attribute(source_uuid, a, v, reload)} + source_uuid={source_uuid} + source_type={source.type} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id="source-name" + label="Source name" + placeholder={source_type.name} + onChange={(value) => set_source_attribute(source_uuid, "name", value, reload)} + value={source.name} + /> + </Grid> + <Grid size={{ xs: 1, sm: 2, md: 2 }}> + <SourceParameters + changed_param_keys={select_sources_parameter_keys(changed_fields, source_uuid)} + metric={metric} + reload={reload} + report={report} + source={source} + source_uuid={source_uuid} + /> + </Grid> {connection_error && <ErrorMessage title="Connection error" message={connection_error} />} {parse_error && <ErrorMessage title="Parse error" message={parse_error} />} {config_error && <ErrorMessage title="Configuration error" message={config_error} formatAsText={true} />} @@ -168,27 +167,29 @@ export function Source({ </> ) const configError = dataModel.metrics[metric.type].sources.includes(source.type) ? "" : configErrorMessage - const panes = [ - configurationTabPane( - <Parameters - metric={metric} - source={source} - source_uuid={source_uuid} - connection_error={connectionError} - parse_error={parseError} - config_error={configError} - report={report} - changed_fields={changed_fields} - reload={reload} - />, - { error: Boolean(configError || connectionError || parseError) }, - ), - changelogTabPane(<ChangeLog source_uuid={source_uuid} timestamp={report.timestamp} />), - ] + const anyError = Boolean(configError || connectionError || parseError) return ( <> <SourceTypeHeader metricTypeId={metric.type} sourceTypeId={source.type} sourceType={sourceType} /> - <Tab panes={panes} /> + <Tabs + tabs={[ + { error: anyError, label: "Configuration", icon: <SettingsIcon /> }, + { label: "Changelog", icon: <HistoryIcon /> }, + ]} + > + <Parameters + metric={metric} + source={source} + source_uuid={source_uuid} + connection_error={connectionError} + parse_error={parseError} + config_error={configError} + report={report} + changed_fields={changed_fields} + reload={reload} + /> + <ChangeLog source_uuid={source_uuid} timestamp={report.timestamp} /> + </Tabs> <SourceButtonRow first_source={first_source} last_source={last_source} diff --git a/components/frontend/src/source/Source.test.js b/components/frontend/src/source/Source.test.js index 757b25c008..7a91f850f7 100644 --- a/components/frontend/src/source/Source.test.js +++ b/components/frontend/src/source/Source.test.js @@ -41,7 +41,7 @@ it("invokes the callback on clicking delete", () => { it("changes the source type", () => { renderSource(metric) - fireEvent.click(screen.getAllByText(/Source type 1/)[0]) + fireEvent.mouseDown(screen.getByLabelText(/Source type/)) fireEvent.click(screen.getByText(/Source type 2/)) expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "source/source_uuid/attribute/type", { type: "source_type2", diff --git a/components/frontend/src/source/SourceEntities.css b/components/frontend/src/source/SourceEntities.css index 005c4c4988..235d4007a6 100644 --- a/components/frontend/src/source/SourceEntities.css +++ b/components/frontend/src/source/SourceEntities.css @@ -1,11 +1,3 @@ -.ui.sortable.table.entities.stickyHeader > thead { - /* Make thead sticky by positioning the th's */ - position: sticky; - /* Leave room for the menu bar, the subject title, and the subject table header row */ - top: 187px; - z-index: 1; -} - @media print { button.ui { display: none !important; diff --git a/components/frontend/src/source/SourceEntities.js b/components/frontend/src/source/SourceEntities.js index 15b44f2447..bb72e1c289 100644 --- a/components/frontend/src/source/SourceEntities.js +++ b/components/frontend/src/source/SourceEntities.js @@ -1,13 +1,22 @@ import "./SourceEntities.css" import HelpIcon from "@mui/icons-material/Help" -import { IconButton, Tooltip } from "@mui/material" +import { + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + Tooltip, +} from "@mui/material" import { bool, func, object, string } from "prop-types" import { useContext, useState } from "react" -import { Message } from "semantic-ui-react" import { DataModel } from "../context/DataModel" -import { Popup, Table } from "../semantic_ui_react_wrappers" import { alignmentPropType, childrenPropType, @@ -25,7 +34,7 @@ import { import { capitalize } from "../utils" import { IgnoreIcon, ShowIcon } from "../widgets/icons" import { LoadingPlaceHolder } from "../widgets/Placeholder" -import { FailedToLoadMeasurementsWarningMessage } from "../widgets/WarningMessage" +import { FailedToLoadMeasurementsWarningMessage, InfoMessage } from "../widgets/WarningMessage" import { SourceEntity } from "./SourceEntity" function entityStatus(source, entity) { @@ -99,6 +108,10 @@ sort.propTypes = { sortDirection: sortDirectionPropType, } +function MuiSortDirection(sortDirection) { + return sortDirection === "ascending" ? "asc" : "desc" +} + function SortableHeaderCell({ children, column, @@ -111,15 +124,17 @@ function SortableHeaderCell({ textAlign, }) { return ( - <Table.HeaderCell - onClick={() => - sort(column, columnType, setColumnType, setSortColumn, setSortDirection, sortColumn, sortDirection) - } - sorted={sorted(column, sortColumn, sortDirection)} - textAlign={textAlign} - > - {children} - </Table.HeaderCell> + <TableCell align={textAlign} direction={sorted(column, sortColumn, sortDirection)}> + <TableSortLabel + active={column === sortColumn} + direction={column === sortColumn ? MuiSortDirection(sortDirection) : "asc"} + onClick={() => + sort(column, columnType, setColumnType, setSortColumn, setSortDirection, sortColumn, sortDirection) + } + > + {children} + </TableSortLabel> + </TableCell> ) } SortableHeaderCell.propTypes = { @@ -144,16 +159,12 @@ function EntityAttributeHeaderCell({ entityAttribute, ...sortProps }) { > <span>{entityAttribute.name}</span> {entityAttribute.help ? ( - <Popup - on={["hover", "focus"]} - trigger={ - <span> - - <HelpIcon fontSize="inherit" sx={{ verticalAlign: "middle" }} tabIndex="0" /> - </span> - } - content={entityAttribute.help} - /> + <Tooltip title={entityAttribute.help}> + <span> + + <HelpIcon fontSize="inherit" sx={{ verticalAlign: "middle" }} tabIndex="0" /> + </span> + </Tooltip> ) : null} </SortableHeaderCell> ) @@ -178,8 +189,8 @@ function sourceEntitiesHeaders( const entityNamePlural = metricEntities.name_plural const hideIgnoredEntitiesLabel = `${hideIgnoredEntities ? "Show" : "Hide"} ignored ${entityNamePlural}` return ( - <Table.Row> - <Table.HeaderCell collapsing textAlign="center"> + <TableRow> + <TableCell align="center"> <Tooltip title={hideIgnoredEntitiesLabel}> <IconButton aria-label={hideIgnoredEntitiesLabel} @@ -188,7 +199,7 @@ function sourceEntitiesHeaders( {hideIgnoredEntities ? <ShowIcon /> : <IgnoreIcon />} </IconButton> </Tooltip> - </Table.HeaderCell> + </TableCell> <SortableHeaderCell column="entity_status" columnType="text" {...sortProps}> {`${capitalize(entityName)} status`} </SortableHeaderCell> @@ -204,19 +215,15 @@ function sourceEntitiesHeaders( {entityAttributes.map((entityAttribute) => ( <EntityAttributeHeaderCell entityAttribute={entityAttribute} key={entityAttribute.key} {...sortProps} /> ))} - </Table.Row> + </TableRow> ) } sourceEntitiesHeaders.propTypes = { entityAttributes: entityAttributesPropType, hideIgnoredEntities: bool, metricEntities: object, - setColumnType: func, setHideIgnoredEntities: func, - setSortColumn: func, - setSortDirection: func, - sortColumn: string, - sortDirection: sortDirectionPropType, + sortProps: object, } function sortedEntities(columnType, sortColumn, sortDirection, source) { @@ -270,10 +277,9 @@ export function SourceEntities({ loading, measurements, metric, metric_uuid, rel const unit = dataModel.metrics[metric.type].unit || "entities" const sourceTypeName = dataModel.sources[sourceType].name return ( - <Message - header="Measurement details not supported" - content={`Showing individual ${unit} is not supported when using ${sourceTypeName} as source.`} - /> + <InfoMessage title="Measurement details not supported"> + {`Showing individual ${unit} is not supported when using ${sourceTypeName} as source.`} + </InfoMessage> ) } if (loading === "failed") { @@ -284,20 +290,18 @@ export function SourceEntities({ loading, measurements, metric, metric_uuid, rel } if (measurements.length === 0) { return ( - <Message - header="No measurements available" - content="Measurement details not available because Quality-time has not collected any measurements yet." - /> + <InfoMessage title="No measurements available"> + Measurement details not available because Quality-time has not collected any measurements yet. + </InfoMessage> ) } const lastMeasurement = measurements[measurements.length - 1] const source = lastMeasurement.sources.find((source) => source.source_uuid === source_uuid) if (!Array.isArray(source.entities) || source.entities.length === 0) { return ( - <Message - header="Measurement details not available" - content="There are currently no measurement details available." - /> + <InfoMessage title="Measurement details not available"> + There are currently no measurement details available. + </InfoMessage> ) } const entityAttributes = metricEntities.attributes.filter((attribute) => attribute?.visible ?? true) @@ -333,10 +337,12 @@ export function SourceEntities({ loading, measurements, metric, metric_uuid, rel /> )) return ( - <Table className="entities stickyHeader" sortable size="small"> - <Table.Header>{headers}</Table.Header> - <Table.Body>{rows}</Table.Body> - </Table> + <TableContainer component={Paper} sx={{ maxHeight: "50vh" }}> + <Table padding="none" size="small" stickyHeader> + <TableHead>{headers}</TableHead> + <TableBody>{rows}</TableBody> + </Table> + </TableContainer> ) } SourceEntities.propTypes = { diff --git a/components/frontend/src/source/SourceEntities.test.js b/components/frontend/src/source/SourceEntities.test.js index 98cf824b89..844d0b14fb 100644 --- a/components/frontend/src/source/SourceEntities.test.js +++ b/components/frontend/src/source/SourceEntities.test.js @@ -131,7 +131,7 @@ it("renders a message if the metric does not support measurement entities", () = ).toBe(1) }) -it("renders a message if the metric does not support measurement entities andhas no unit", () => { +it("renders a message if the metric does not support measurement entities and has no unit", () => { renderSourceEntities({ metric: { type: "metric_type_without_unit", diff --git a/components/frontend/src/source/SourceEntity.css b/components/frontend/src/source/SourceEntity.css deleted file mode 100644 index 6e0e39d1a3..0000000000 --- a/components/frontend/src/source/SourceEntity.css +++ /dev/null @@ -1,43 +0,0 @@ -tr.positive_status { - background-color: rgb(30, 148, 78, 0.15) !important; -} - -tr.positive_status:hover { - background-color: rgb(30, 148, 78, 0.25) !important; -} - -tr.negative_status { - background-color: rgb(211, 59, 55, 0.2) !important; -} - -tr.negative_status:hover { - background-color: rgb(211, 59, 55, 0.3) !important; -} - -tr.warning_status { - background-color: rgb(253, 197, 54, 0.15) !important; -} - -tr.warning_status:hover { - background-color: rgb(253, 197, 54, 0.25) !important; -} - -tr.active_status { - background-color: rgb(150, 150, 150, 0.2) !important; -} - -tr.active_status:hover { - background-color: rgb(150, 150, 150, 0.3) !important; -} - -tr.unknown_status { - background-color: rgb(245, 245, 245, 0.15) !important; -} - -tr.unknown_status:hover { - background-color: rgb(245, 245, 245, 0.65) !important; -} - -td > a { - color: rgb(0, 88, 176) !important; -} diff --git a/components/frontend/src/source/SourceEntity.js b/components/frontend/src/source/SourceEntity.js index 923ecb9a31..4a6b48cefb 100644 --- a/components/frontend/src/source/SourceEntity.js +++ b/components/frontend/src/source/SourceEntity.js @@ -1,8 +1,6 @@ -import "./SourceEntity.css" - +import { TableCell } from "@mui/material" import { bool, func, string } from "prop-types" import { useState } from "react" -import { Table } from "semantic-ui-react" import { entityAttributesPropType, entityPropType, entityStatusPropType, reportPropType } from "../sharedPropTypes" import { DivWithHTML } from "../widgets/DivWithHTML" @@ -71,28 +69,30 @@ export function SourceEntity({ return ( <TableRowWithDetails className={statusClassName} + color={statusClassName} details={details} - key={entity.key} expanded={expanded} + id={entity.key} + key={entity.key} onExpand={setExpanded} style={{ maxHeight: "100px", overflow: "auto" }} > - <Table.Cell style={style}>{SOURCE_ENTITY_STATUS_NAME[status]}</Table.Cell> - <Table.Cell style={style}>{status === "unconfirmed" ? "" : status_end_date}</Table.Cell> - <Table.Cell style={style}> + <TableCell sx={style}>{SOURCE_ENTITY_STATUS_NAME[status]}</TableCell> + <TableCell sx={style}>{status === "unconfirmed" ? "" : status_end_date}</TableCell> + <TableCell sx={style}> <DivWithHTML>{rationale}</DivWithHTML> - </Table.Cell> - <Table.Cell style={style}> + </TableCell> + <TableCell sx={style}> {entity.first_seen ? <TimeAgoWithDate dateFirst date={entity.first_seen} /> : ""} - </Table.Cell> + </TableCell> {entity_attributes.map((entity_attribute) => ( - <Table.Cell + <TableCell + align={alignment(entity_attribute.type, entity_attribute.alignment)} key={entity_attribute.key} - style={style} - textAlign={alignment(entity_attribute.type, entity_attribute.alignment)} + sx={style} > <SourceEntityAttribute entity={entity} entityAttribute={entity_attribute} /> - </Table.Cell> + </TableCell> ))} </TableRowWithDetails> ) diff --git a/components/frontend/src/source/SourceEntity.test.js b/components/frontend/src/source/SourceEntity.test.js index bc4d7c75b7..bc14431493 100644 --- a/components/frontend/src/source/SourceEntity.test.js +++ b/components/frontend/src/source/SourceEntity.test.js @@ -1,5 +1,8 @@ +import { Table, TableBody } from "@mui/material" +import { LocalizationProvider } from "@mui/x-date-pickers" +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs" import { fireEvent, render, screen } from "@testing-library/react" -import { Table } from "semantic-ui-react" +import { locale_en_gb } from "dayjs/locale/en-gb" import { SourceEntity } from "./SourceEntity" @@ -11,27 +14,29 @@ function renderSourceEntity({ first_seen = null, }) { return render( - <Table> - <Table.Body> - <SourceEntity - entity={{ attr1: "good", attr2: "bad", first_seen: first_seen }} - entity_attributes={[{ key: "attr1" }, { key: "attr2", color: { bad: "warning" } }]} - entity_name="entity" - hide_ignored_entities={hide_ignored_entities} - rationale={rationale} - status={status} - status_end_date={status_end_date} - /> - </Table.Body> - </Table>, + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}> + <Table> + <TableBody> + <SourceEntity + entity={{ attr1: "good", attr2: "bad", first_seen: first_seen }} + entity_attributes={[{ key: "attr1" }, { key: "attr2", color: { bad: "warning" } }]} + entity_name="entity" + hide_ignored_entities={hide_ignored_entities} + rationale={rationale} + status={status} + status_end_date={status_end_date} + /> + </TableBody> + </Table> + </LocalizationProvider>, ) } it("renders the unconfirmed status", () => { renderSourceEntity({}) fireEvent.click(screen.getByRole("button")) - expect(screen.getAllByText(/Unconfirmed/).length).toBe(1) - expect(screen.getByText(/Unconfirmed/).closest("tr").className).toContain("warning_status") + expect(screen.getAllByText(/Unconfirmed/).length).toBe(2) + expect(screen.getAllByText(/Unconfirmed/)[0].closest("tr").className).toContain("warning_status") }) it("renders the fixed status", () => { diff --git a/components/frontend/src/source/SourceEntityDetails.js b/components/frontend/src/source/SourceEntityDetails.js index 632babc659..83adc3838d 100644 --- a/components/frontend/src/source/SourceEntityDetails.js +++ b/components/frontend/src/source/SourceEntityDetails.js @@ -1,14 +1,16 @@ +import { MenuItem } from "@mui/material" +import Grid from "@mui/material/Grid2" +import { DatePicker } from "@mui/x-date-pickers" +import dayjs from "dayjs" import { func, node, oneOf, string } from "prop-types" -import { Grid, Header } from "semantic-ui-react" +import { useContext } from "react" import { set_source_entity_attribute } from "../api/source" -import { EDIT_ENTITY_PERMISSION } from "../context/Permissions" -import { DateInput } from "../fields/DateInput" -import { SingleChoiceInput } from "../fields/SingleChoiceInput" -import { TextInput } from "../fields/TextInput" +import { accessGranted, EDIT_ENTITY_PERMISSION, Permissions } from "../context/Permissions" +import { TextField } from "../fields/TextField" import { entityPropType, entityStatusPropType, reportPropType } from "../sharedPropTypes" import { capitalize, getDesiredResponseTime } from "../utils" -import { LabelWithDate } from "../widgets/LabelWithDate" +import { Header } from "../widgets/Header" import { SOURCE_ENTITY_STATUS_ACTION, SOURCE_ENTITY_STATUS_NAME } from "./source_entity_status" function entityStatusOption(status, subheader) { @@ -16,7 +18,7 @@ function entityStatusOption(status, subheader) { key: status, text: SOURCE_ENTITY_STATUS_NAME[status], value: status, - content: <Header as="h5" content={SOURCE_ENTITY_STATUS_ACTION[status]} subheader={subheader} />, + content: <Header level="h4" header={SOURCE_ENTITY_STATUS_ACTION[status]} subheader={subheader} />, } } entityStatusOption.propTypes = { @@ -76,66 +78,65 @@ export function SourceEntityDetails({ status_end_date, source_uuid, }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_ENTITY_PERMISSION]) return ( - <Grid stackable> - <Grid.Row> - <Grid.Column width={4}> - <SingleChoiceInput - requiredPermissions={[EDIT_ENTITY_PERMISSION]} - label={`${capitalize(name)} status`} - options={entityStatusOptions(name, report)} - set_value={(value) => - set_source_entity_attribute(metric_uuid, source_uuid, entity.key, "status", value, reload) - } - value={status} - sort={false} - /> - </Grid.Column> - <Grid.Column width={4}> - <DateInput - requiredPermissions={[EDIT_ENTITY_PERMISSION]} - label={ - <LabelWithDate - date={status_end_date} - labelId={entity.key} - label={`${capitalize(name)} status end date`} - help={`Consider the status of this ${name} to be 'Unconfirmed' after the selected date.`} - /> - } - placeholder="YYYY-MM-DD" - set_value={(value) => - set_source_entity_attribute( - metric_uuid, - source_uuid, - entity.key, - "status_end_date", - value, - reload, - ) - } - value={status_end_date} - /> - </Grid.Column> - <Grid.Column width={8}> - <TextInput - requiredPermissions={[EDIT_ENTITY_PERMISSION]} - label={`${capitalize(name)} status rationale`} - placeholder={`Rationale for the ${name} status...`} - rows={Math.min(5, rationale?.split("\n").length ?? 1)} - set_value={(value) => - set_source_entity_attribute( - metric_uuid, - source_uuid, - entity.key, - "rationale", - value, - reload, - ) - } - value={rationale} - /> - </Grid.Column> - </Grid.Row> + <Grid container spacing={{ xs: 1, sm: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} sx={{ margin: "10px" }}> + <Grid size={{ xs: 1, sm: 2, md: 3 }}> + <TextField + disabled={disabled} + label={`${capitalize(name)} status`} + onChange={(value) => + set_source_entity_attribute(metric_uuid, source_uuid, entity.key, "status", value, reload) + } + select + value={status} + > + {entityStatusOptions(name, report).map((option) => ( + <MenuItem key={option.key} value={option.value}> + {option.content} + </MenuItem> + ))} + </TextField> + </Grid> + <Grid size={{ xs: 1, sm: 2, md: 3 }}> + <DatePicker + defaultValue={status_end_date ? dayjs(status_end_date) : null} + disabled={disabled} + format="YYYY-MM-DD" + label={`${capitalize(name)} status end date`} + onChange={(value) => + set_source_entity_attribute( + metric_uuid, + source_uuid, + entity.key, + "status_end_date", + value, + reload, + ) + } + slotProps={{ + field: { clearable: true }, + textField: { + helperText: `Consider the status of this ${name} to be 'Unconfirmed' after the selected date.`, + }, + }} + sx={{ width: "100%" }} + timezone="default" + /> + </Grid> + <Grid size={{ xs: 2, sm: 4, md: 6 }}> + <TextField + disabled={disabled} + id={`${entity.key}-rationale`} + label={`${capitalize(name)} status rationale`} + multiline + onChange={(value) => + set_source_entity_attribute(metric_uuid, source_uuid, entity.key, "rationale", value, reload) + } + value={rationale} + /> + </Grid> </Grid> ) } diff --git a/components/frontend/src/source/SourceEntityDetails.test.js b/components/frontend/src/source/SourceEntityDetails.test.js index 4fe511f270..12c3123224 100644 --- a/components/frontend/src/source/SourceEntityDetails.test.js +++ b/components/frontend/src/source/SourceEntityDetails.test.js @@ -1,5 +1,9 @@ +import { LocalizationProvider } from "@mui/x-date-pickers" +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs" import { fireEvent, render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" +import dayjs from "dayjs" +import { locale_en_gb } from "dayjs/locale/en-gb" import * as source from "../api/source" import { EDIT_ENTITY_PERMISSION, Permissions } from "../context/Permissions" @@ -9,25 +13,28 @@ jest.mock("../api/source.js") const reload = jest.fn -function renderSourceEntityDetails(report) { +function renderSourceEntityDetails({ report = null, status_end_date = null } = {}) { render( - <Permissions.Provider value={[EDIT_ENTITY_PERMISSION]}> - <SourceEntityDetails - metric_uuid="metric_uuid" - source_uuid="source_uuid" - entity={{ key: "key" }} - status="unconfirmed" - name="violation" - reload={reload} - report={report} - /> - </Permissions.Provider>, + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}> + <Permissions.Provider value={[EDIT_ENTITY_PERMISSION]}> + <SourceEntityDetails + metric_uuid="metric_uuid" + source_uuid="source_uuid" + entity={{ key: "key" }} + status="unconfirmed" + name="violation" + reload={reload} + report={report} + status_end_date={status_end_date} + /> + </Permissions.Provider> + </LocalizationProvider>, ) } it("shows the default desired response times when the report has no desired response times", () => { renderSourceEntityDetails() - fireEvent.click(screen.getByText(/Unconfirmed/)) + fireEvent.mouseDown(screen.getByText("Unconfirm")) const expectedMenuItemDescriptions = [ "This violation has been reviewed and should be addressed within 180 days.", "Ignore this violation for 7 days because it has been fixed or will be fixed shortly.", @@ -41,8 +48,8 @@ it("shows the default desired response times when the report has no desired resp it("shows the configured desired response times", () => { const report = { desired_response_times: { confirmed: "2", fixed: "4", false_positive: "600", wont_fix: "100" } } - renderSourceEntityDetails(report) - fireEvent.click(screen.getByText(/Unconfirmed/)) + renderSourceEntityDetails({ report: report }) + fireEvent.mouseDown(screen.getByText("Unconfirm")) const expectedMenuItemDescriptions = [ "This violation has been reviewed and should be addressed within 2 days.", "Ignore this violation for 4 days because it has been fixed or will be fixed shortly.", @@ -56,8 +63,8 @@ it("shows the configured desired response times", () => { it("shows no desired response times when the report has been configured to not have desired response times", () => { const report = { desired_response_times: { confirmed: null, fixed: null, false_positive: null, wont_fix: null } } - renderSourceEntityDetails(report) - fireEvent.click(screen.getByText(/Unconfirmed/)) + renderSourceEntityDetails({ report: report }) + fireEvent.mouseDown(screen.getByText("Unconfirm")) const expectedMenuItemDescriptions = [ "This violation has been reviewed and should be addressed.", "Ignore this violation because it has been fixed or will be fixed shortly.", @@ -72,6 +79,7 @@ it("shows no desired response times when the report has been configured to not h it("changes the entity status", () => { source.set_source_entity_attribute = jest.fn() renderSourceEntityDetails() + fireEvent.mouseDown(screen.getByText("Unconfirm")) fireEvent.click(screen.getByText(/Confirm/)) expect(source.set_source_entity_attribute).toHaveBeenCalledWith( "metric_uuid", @@ -83,32 +91,30 @@ it("changes the entity status", () => { ) }) +it("shows the entity status end date", async () => { + source.set_source_entity_attribute = jest.fn() + renderSourceEntityDetails({ status_end_date: "20250112" }) + expect(screen.queryAllByDisplayValue(/2025-01-12/).length).toBe(1) +}) + it("changes the entity status end date", async () => { - // Suppress "Warning: An update to t inside a test was not wrapped in act(...)." caused by interacting with - // the date picker. - const consoleLog = console.log - console.error = jest.fn() source.set_source_entity_attribute = jest.fn() renderSourceEntityDetails() - await userEvent.type(screen.getByPlaceholderText(/YYYY-MM-DD/), "2222-01-01{Tab}", { - initialSelectionStart: 0, - initialSelectionEnd: 10, - }) + await userEvent.type(screen.getByPlaceholderText(/YYYY-MM-DD/), "22220101{Enter}") expect(source.set_source_entity_attribute).toHaveBeenCalledWith( "metric_uuid", "source_uuid", "key", "status_end_date", - "2222-01-01", + dayjs("2222-01-01"), reload, ) - console.log = consoleLog }) it("changes the rationale", async () => { source.set_source_entity_attribute = jest.fn() renderSourceEntityDetails() - await userEvent.type(screen.getByPlaceholderText(/Rationale/), "Rationale") + await userEvent.type(screen.getByLabelText(/rationale/), "Rationale") await userEvent.tab() expect(source.set_source_entity_attribute).toHaveBeenCalledWith( "metric_uuid", diff --git a/components/frontend/src/source/SourceParameter.js b/components/frontend/src/source/SourceParameter.js index 35ae091a25..86eb65b2eb 100644 --- a/components/frontend/src/source/SourceParameter.js +++ b/components/frontend/src/source/SourceParameter.js @@ -1,15 +1,16 @@ +import EditIcon from "@mui/icons-material/Edit" +import { FormControl, IconButton, Menu, MenuItem, Typography } from "@mui/material" +import { DatePicker } from "@mui/x-date-pickers/DatePicker" +import dayjs from "dayjs" import { bool, func, number, oneOfType, string } from "prop-types" -import { useState } from "react" +import { useContext, useState } from "react" +import TimeAgo from "react-timeago" import { set_source_parameter } from "../api/source" -import { DateInput } from "../fields/DateInput" -import { IntegerInput } from "../fields/IntegerInput" -import { MultipleChoiceInput } from "../fields/MultipleChoiceInput" -import { PasswordInput } from "../fields/PasswordInput" -import { SingleChoiceInput } from "../fields/SingleChoiceInput" -import { StringInput } from "../fields/StringInput" +import { accessGranted, Permissions } from "../context/Permissions" +import { MultipleChoiceField } from "../fields/MultipleChoiceField" +import { TextField } from "../fields/TextField" import { - labelPropType, permissionsPropType, popupContentPropType, reportPropType, @@ -17,75 +18,78 @@ import { stringsPropType, } from "../sharedPropTypes" import { dropdownOptions } from "../utils" -import { LabelDate } from "../widgets/LabelWithDate" -import { LabelWithDropdown } from "../widgets/LabelWithDropdown" -import { LabelWithHelp } from "../widgets/LabelWithHelp" -import { LabelWithHyperLink } from "../widgets/LabelWithHyperLink" +import { HyperLink } from "../widgets/HyperLink" -function SourceParameterLabel({ edit_scope, index, label, parameter_short_name, setEditScope, source_type_name }) { - const scope_options = [ +function EditScopeSelect({ editScope, setEditScope }) { + const scopeOptions = [ { - key: "source", value: "source", text: "Apply change to source", - description: `Change the ${parameter_short_name} of this ${source_type_name} source only`, - label: { color: "grey", empty: true, circular: true }, + color: "edit_scope_source", }, { - key: "metric", value: "metric", text: "Apply change to metric", - description: `Change the ${parameter_short_name} of ${source_type_name} sources in this metric that have the same ${parameter_short_name}`, - label: { color: "black", empty: true, circular: true }, + color: "edit_scope_metric", }, { - key: "subject", value: "subject", text: "Apply change to subject", - description: `Change the ${parameter_short_name} of ${source_type_name} sources in this subject that have the same ${parameter_short_name}`, - label: { color: "yellow", empty: true, circular: true }, + color: "edit_scope_subject", }, { - key: "report", value: "report", text: "Apply change to report", - description: `Change the ${parameter_short_name} of ${source_type_name} sources in this report that have the same ${parameter_short_name}`, - label: { color: "orange", empty: true, circular: true }, + color: "edit_scope_report", }, { - key: "reports", value: "reports", text: "Apply change to all reports", - description: `Change the ${parameter_short_name} of ${source_type_name} sources in all reports that have the same ${parameter_short_name}`, - label: { color: "red", empty: true, circular: true }, + color: "edit_scope_reports", }, ] + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) return ( - <LabelWithDropdown - color={ - { - source: "grey", - metric: "black", - subject: "gold", - report: "orange", - reports: "red", - }[edit_scope] - } - direction={index % 2 === 0 ? "right" : "left"} - label={label} - onChange={(_event, data) => setEditScope(data.value)} - options={scope_options} - value={edit_scope} - /> + <FormControl> + <IconButton + aria-controls="edit-scope-menu" + aria-expanded={open} + aria-haspopup="true" + aria-label="Edit scope" + color={scopeOptions.find((option) => option.value === editScope).color} + id="edit-scope-button" + onClick={(event) => setAnchorEl(event.currentTarget)} + > + <EditIcon /> + </IconButton> + <Menu + id="edit-scope-menu" + anchorEl={anchorEl} + open={open} + onClose={() => setAnchorEl(null)} + MenuListProps={{ "aria-labelledby": "edit-scope-button" }} + > + {scopeOptions.map((option) => ( + <MenuItem + key={option.value} + onClick={() => { + setEditScope(option.value) + setAnchorEl(null) + }} + selected={editScope === option.value} + value={option.value} + > + <Typography color={option.color}>{option.text}</Typography> + </MenuItem> + ))} + </Menu> + </FormControl> ) } -SourceParameterLabel.propTypes = { - edit_scope: string, - index: number, - label: labelPropType, - parameter_short_name: string, +EditScopeSelect.propTypes = { + editScope: string, setEditScope: func, - source_type_name: string, } function sources(report) { @@ -128,11 +132,9 @@ parameterValues.propTypes = { export function SourceParameter({ help, help_url, - index, parameter_key, parameter_type, parameter_name, - parameter_short_name, parameter_unit, parameter_min, parameter_max, @@ -144,83 +146,103 @@ export function SourceParameter({ required, requiredPermissions, source, - source_type_name, source_uuid, warning, }) { const [editScope, setEditScope] = useState("source") + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, requiredPermissions) let label = parameter_name + let helperText = null if (help_url) { - label = <LabelWithHyperLink label={parameter_name} url={help_url} /> + helperText = ( + <> + See <HyperLink url={help_url}>{help_url}</HyperLink> for more information. + </> + ) } if (help) { - label = <LabelWithHelp label={parameter_name} help={help} /> + helperText = help } - if (parameter_type === "date") { - const date = new Date(Date.parse(parameter_value)) - label = ( - <span> - {label} - <LabelDate date={date} /> - </span> - ) + if (parameter_type === "date" && parameter_value) { + helperText = <TimeAgo date={dayjs(parameter_value)} /> } - let parameter_props = { - requiredPermissions: requiredPermissions, - editableLabel: ( - <SourceParameterLabel - edit_scope={editScope} - label={label} - setEditScope={setEditScope} - source_type_name={source_type_name} - parameter_short_name={parameter_short_name} - index={index} - /> - ), + let parameterProps = { + disabled: disabled, + helperText: helperText, label: label, - placeholder: placeholder, - required: required, - set_value: (value) => { + onChange: (value) => { set_source_parameter(source_uuid, parameter_key, value, editScope, reload) setEditScope("source") // Reset the edit scope of the parameter to source only }, - value: parameter_value, + placeholder: placeholder, + required: required, } + const startAdornment = <EditScopeSelect editScope={editScope} setEditScope={setEditScope} /> + let parameterInput = null if (parameter_type === "date") { - return <DateInput {...parameter_props} /> + parameterInput = ( + <DatePicker + {...parameterProps} + defaultValue={parameter_value ? dayjs(parameter_value) : null} + format="YYYY-MM-DD" + slotProps={{ + field: { clearable: true }, + textField: { helperText: helperText, InputProps: { startAdornment: startAdornment } }, + }} + sx={{ width: "100%" }} + timezone="default" + /> + ) } + parameterProps["value"] = parameter_value + parameterProps["startAdornment"] = startAdornment if (parameter_type === "password") { - return <PasswordInput {...parameter_props} /> + parameterInput = <TextField {...parameterProps} type="password" /> } if (parameter_type === "integer") { - return <IntegerInput {...parameter_props} max={parameter_max} min={parameter_min} unit={parameter_unit} /> + parameterInput = ( + <TextField + {...parameterProps} + max={parameter_max || null} + min={parameter_min || null} + type="number" + unit={parameter_unit} + /> + ) } if (parameter_type === "single_choice") { - return <SingleChoiceInput {...parameter_props} options={dropdownOptions(parameter_values)} /> + parameterInput = ( + <TextField {...parameterProps} select> + {dropdownOptions(parameter_values).map((option) => ( + <MenuItem key={option.key} value={option.value}> + {option.text} + </MenuItem> + ))} + </TextField> + ) } if (parameter_type === "multiple_choice") { - return <MultipleChoiceInput {...parameter_props} options={dropdownOptions(parameter_values)} /> + parameterInput = <MultipleChoiceField {...parameterProps} options={parameter_values} /> } if (parameter_type === "multiple_choice_with_addition") { - return <MultipleChoiceInput {...parameter_props} options={dropdownOptions(parameter_values)} allowAdditions /> + parameterInput = <MultipleChoiceField {...parameterProps} options={parameter_values} freeSolo /> } - parameter_props["options"] = parameterValues(report, source.type, parameter_key) + parameterProps["options"] = parameterValues(report, source.type, parameter_key) if (parameter_type === "string") { - return <StringInput {...parameter_props} /> + parameterInput = <TextField {...parameterProps} /> } if (parameter_type === "url") { - return <StringInput {...parameter_props} error={warning} /> + parameterInput = <TextField {...parameterProps} error={warning} /> } - return null + return parameterInput } SourceParameter.propTypes = { help: popupContentPropType, help_url: string, - index: number, parameter_key: string, parameter_type: string, parameter_name: string, - parameter_short_name: string, parameter_unit: string, parameter_min: number, parameter_max: number, @@ -232,7 +254,6 @@ SourceParameter.propTypes = { required: bool, requiredPermissions: permissionsPropType, source: sourcePropType, - source_type_name: string, source_uuid: string, warning: bool, } diff --git a/components/frontend/src/source/SourceParameter.test.js b/components/frontend/src/source/SourceParameter.test.js index 0ea0323b5f..42684b16ac 100644 --- a/components/frontend/src/source/SourceParameter.test.js +++ b/components/frontend/src/source/SourceParameter.test.js @@ -1,5 +1,8 @@ -import { render, screen, waitFor } from "@testing-library/react" +import { LocalizationProvider } from "@mui/x-date-pickers" +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs" +import { fireEvent, render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" +import { locale_en_gb } from "dayjs/locale/en-gb" import * as fetch_server_api from "../api/fetch_server_api" import { EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" @@ -34,9 +37,8 @@ const report = { } function renderSourceParameter({ - help = null, - help_url = null, - index = 0, + help = "", + help_url = "", parameter_key = "key1", parameter_name = "URL", parameter_type = "url", @@ -46,50 +48,49 @@ function renderSourceParameter({ warning = false, }) { return render( - <Permissions.Provider value={[EDIT_REPORT_PERMISSION]}> - <SourceParameter - help={help} - help_url={help_url} - index={index} - parameter_key={parameter_key} - parameter_name={parameter_name} - parameter_type={parameter_type} - parameter_value={parameter_value} - parameter_values={parameter_values} - placeholder={placeholder} - report={report} - source={{ type: "source_type" }} - source_uuid="source_uuid" - warning={warning} - /> - </Permissions.Provider>, + <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}> + <Permissions.Provider value={[EDIT_REPORT_PERMISSION]}> + <SourceParameter + help={help} + help_url={help_url} + parameter_key={parameter_key} + parameter_name={parameter_name} + parameter_type={parameter_type} + parameter_value={parameter_value} + parameter_values={parameter_values} + placeholder={placeholder} + report={report} + requiredPermissions={[EDIT_REPORT_PERMISSION]} + source={{ type: "source_type" }} + source_uuid="source_uuid" + warning={warning} + /> + </Permissions.Provider> + </LocalizationProvider>, ) } it("renders an url parameter", () => { renderSourceParameter({}) - expect(screen.queryAllByText(/URL/).length).toBe(1) - expect(screen.queryAllByText(/placeholder/).length).toBe(1) + expect(screen.queryAllByLabelText(/URL/).length).toBe(1) expect(screen.getByDisplayValue(/https:\/\/test/)).toBeValid() }) it("renders an url parameter with warning", () => { - renderSourceParameter({ warning: true, index: 1 }) - expect(screen.queryAllByText(/URL/).length).toBe(1) - expect(screen.queryAllByText(/placeholder/).length).toBe(1) - expect(screen.getByRole("combobox")).toBeInvalid() + renderSourceParameter({ warning: true }) + expect(screen.queryAllByLabelText(/URL/).length).toBe(1) + expect(screen.getByDisplayValue(/https:\/\/test/)).not.toBeValid() }) it("renders a string parameter", () => { renderSourceParameter({ parameter_name: "String", parameter_type: "string" }) - expect(screen.queryAllByText(/String/).length).toBe(1) - expect(screen.queryAllByText(/placeholder/).length).toBe(1) + expect(screen.queryAllByLabelText(/String/).length).toBe(1) + expect(screen.queryAllByDisplayValue(/https/).length).toBe(1) }) it("renders a password parameter", () => { renderSourceParameter({ parameter_name: "Password", parameter_type: "password" }) - expect(screen.queryAllByText(/Password/).length).toBe(1) - expect(screen.queryAllByPlaceholderText(/placeholder/).length).toBe(1) + expect(screen.queryAllByLabelText(/Password/).length).toBe(1) }) it("renders a date parameter", () => { @@ -98,14 +99,13 @@ it("renders a date parameter", () => { parameter_type: "date", parameter_value: "2021-10-10", }) - expect(screen.queryAllByText(/Date/).length).toBe(1) + expect(screen.queryAllByLabelText(/Date/).length).toBe(1) expect(screen.queryAllByDisplayValue("2021-10-10").length).toBe(1) }) it("renders an integer parameter", () => { renderSourceParameter({ parameter_name: "Integer", parameter_type: "integer" }) - expect(screen.queryAllByText(/Integer/).length).toBe(1) - expect(screen.queryAllByPlaceholderText(/placeholder/).length).toBe(1) + expect(screen.queryAllByLabelText(/Integer/).length).toBe(1) }) it("renders a single choice parameter", () => { @@ -115,8 +115,8 @@ it("renders a single choice parameter", () => { parameter_value: "option 1", parameter_values: ["option 1", "option 2"], }) - expect(screen.queryAllByText(/Single choice/).length).toBe(1) - expect(screen.queryAllByText(/option 1/).length).toBe(2) + expect(screen.queryAllByLabelText(/Single choice/).length).toBe(1) + expect(screen.queryAllByText(/option 1/).length).toBe(1) }) it("renders a multiple choice parameter", () => { @@ -126,7 +126,7 @@ it("renders a multiple choice parameter", () => { parameter_value: ["option 1", "option 2"], parameter_values: ["option 1", "option 2", "option 3"], }) - expect(screen.queryAllByText(/Multiple choice/).length).toBe(1) + expect(screen.queryAllByLabelText(/Multiple choice/).length).toBe(1) expect(screen.queryAllByText(/option 1/).length).toBe(1) }) @@ -137,7 +137,7 @@ it("renders a multiple choice with addition parameter", () => { parameter_value: ["option 1", "option 2"], placeholder: null, }) - expect(screen.queryAllByText(/Multiple choice/).length).toBe(1) + expect(screen.queryAllByLabelText(/Multiple choice/).length).toBe(1) }) it("renders nothing on unknown parameter type", () => { @@ -147,21 +147,18 @@ it("renders nothing on unknown parameter type", () => { it("renders a help url", () => { renderSourceParameter({ help_url: "https://help" }) - expect(screen.queryByTitle(/Opens new window/).closest("a").href).toBe("https://help/") + expect(screen.queryAllByTitle(/Opens new window/)[0].closest("a").href).toBe("https://help/") }) it("renders a help text", async () => { renderSourceParameter({ help: "Help text" }) - await userEvent.hover(screen.queryByTestId("HelpIcon")) - await waitFor(() => { - expect(screen.queryAllByText(/Help text/).length).toBe(1) - }) + expect(screen.queryAllByText(/Help text/).length).toBe(1) }) it("changes the value", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) renderSourceParameter({}) - await userEvent.type(screen.queryByText(/test/), "/new{Enter}") + await userEvent.type(screen.getByLabelText(/URL/), "/new{Enter}") expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "source/source_uuid/parameter/key1", { key1: "https://test/new", edit_scope: "source", @@ -171,8 +168,9 @@ it("changes the value", async () => { it("changes the value via mass edit", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) renderSourceParameter({}) - await userEvent.click(screen.queryByText(/Apply change to subject/)) - await userEvent.type(screen.queryByText(/test/), "/new{Enter}") + fireEvent.click(screen.getByLabelText(/Edit scope/)) + fireEvent.click(screen.getByText(/Apply change to subject/)) + await userEvent.type(screen.getByLabelText(/URL/), "/new{Enter}") expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "source/source_uuid/parameter/key1", { key1: "https://test/new", edit_scope: "subject", diff --git a/components/frontend/src/source/SourceParameters.js b/components/frontend/src/source/SourceParameters.js index fc3ac78908..40d80bb0bf 100644 --- a/components/frontend/src/source/SourceParameters.js +++ b/components/frontend/src/source/SourceParameters.js @@ -1,10 +1,10 @@ +import { Paper, Typography } from "@mui/material" +import Grid from "@mui/material/Grid2" import { func, string } from "prop-types" import { useContext } from "react" -import { Grid } from "semantic-ui-react" import { DataModel } from "../context/DataModel" import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { Header, Segment } from "../semantic_ui_react_wrappers" import { metricPropType, reportPropType, sourcePropType, stringsPropType } from "../sharedPropTypes" import { formatMetricScaleAndUnit } from "../utils" import { SourceParameter } from "./SourceParameter" @@ -44,17 +44,15 @@ export function SourceParameters({ changed_param_keys, metric, reload, report, s if (parameterKeys.length === 0) { return null } - const parameters = parameterKeys.map((parameterKey, index) => ( + const parameters = parameterKeys.map((parameterKey) => ( <div key={parameterKey} style={{ paddingTop: "10px" }}> <SourceParameter report={report} source={source} source_uuid={source_uuid} - source_type_name={dataModel.sources[source.type].name} parameter_key={parameterKey} parameter_type={allParameters[parameterKey].type} parameter_name={allParameters[parameterKey].name} - parameter_short_name={allParameters[parameterKey].short_name} parameter_unit={allParameters[parameterKey].unit || metricUnit} parameter_min={allParameters[parameterKey].min_value || null} parameter_max={allParameters[parameterKey].max_value || null} @@ -71,24 +69,21 @@ export function SourceParameters({ changed_param_keys, metric, reload, report, s required={allParameters[parameterKey].mandatory} warning={changed_param_keys?.indexOf(parameterKey) !== -1} reload={reload} - index={index} /> </div> )) return ( - <Grid.Column key={parameterGroup.name}> - <Segment> - <Header as="h5" color="grey"> - {parameterGroup.name} - </Header> + <Grid key={parameterGroup.name} size={{ xs: 1, sm: 1, md: 1 }}> + <Paper elevation={2} sx={{ padding: "8px" }}> + <Typography variant="subtitle">{parameterGroup.name}</Typography> {parameters} - </Segment> - </Grid.Column> + </Paper> + </Grid> ) }) return ( - <Grid> - <Grid.Row columns={2}>{groups}</Grid.Row> + <Grid container alignItems="flex-start" spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 1, md: 2 }}> + {groups} </Grid> ) } diff --git a/components/frontend/src/source/SourceParameters.test.js b/components/frontend/src/source/SourceParameters.test.js index 149b9e5b0c..7be5615d70 100644 --- a/components/frontend/src/source/SourceParameters.test.js +++ b/components/frontend/src/source/SourceParameters.test.js @@ -60,7 +60,7 @@ function renderSourceParameters({ it("renders a string parameter", () => { renderSourceParameters({}) - expect(screen.queryAllByText(/Parameter/).length).toBe(1) + expect(screen.queryAllByLabelText(/Parameter/).length).toBe(1) }) it("renders a string parameter with placeholder", () => { @@ -96,5 +96,5 @@ it("renders parameter groups", () => { it("renders ungrouped parameters in the group without explicitly listed parameters", () => { renderSourceParameters({}) - expect(screen.queryAllByText(/Other parameter/).length).toBe(2) + expect(screen.queryAllByLabelText(/Other parameter/).length).toBe(1) }) diff --git a/components/frontend/src/source/SourceType.js b/components/frontend/src/source/SourceType.js index 617c9817af..fa7fb3ab1b 100644 --- a/components/frontend/src/source/SourceType.js +++ b/components/frontend/src/source/SourceType.js @@ -1,10 +1,10 @@ -import { Chip, Stack, Typography } from "@mui/material" +import { Chip, MenuItem, Stack, Typography } from "@mui/material" import { func, string } from "prop-types" import { useContext } from "react" import { DataModel } from "../context/DataModel" -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { SingleChoiceInput } from "../fields/SingleChoiceInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { TextField } from "../fields/TextField" import { dataModelPropType, sourceTypePropType } from "../sharedPropTypes" import { Logo } from "./Logo" @@ -25,15 +25,21 @@ function sourceTypeOption(key, sourceType) { text: sourceType.name, value: key, content: ( - <Stack direction="row"> + <Stack direction="row" sx={{ maxWidth: "40vw" }}> <span style={{ paddingRight: "10px" }}> <Logo logo={key} alt={sourceType.name} /> </span> - <p> - {sourceType.name} - {sourceType.deprecated && <Chip color="warning" label="Deprecated" sx={{ marginLeft: "8px" }} />} - <Typography variant="body2">{sourceTypeDescription(sourceType)}</Typography> - </p> + <Stack direction="column"> + <Stack direction="row" alignItems="center"> + {sourceType.name} + {sourceType.deprecated && ( + <Chip color="warning" label="Deprecated" sx={{ marginLeft: "8px" }} /> + )} + </Stack> + <Typography variant="body2" sx={{ whiteSpace: "normal" }}> + {sourceTypeDescription(sourceType)} + </Typography> + </Stack> </Stack> ), } @@ -54,19 +60,27 @@ sourceTypeOptions.propTypes = { export function SourceType({ metric_type, set_source_attribute, source_type }) { const dataModel = useContext(DataModel) + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) const options = sourceTypeOptions(dataModel, metric_type) const sourceTypes = options.map((option) => option.key) if (!sourceTypes.includes(source_type)) { options.push(sourceTypeOption(source_type, dataModel.sources[source_type])) } return ( - <SingleChoiceInput - requiredPermissions={[EDIT_REPORT_PERMISSION]} + <TextField + disabled={disabled} label="Source type" - options={options} - set_value={(value) => set_source_attribute("type", value)} + onChange={(value) => set_source_attribute("type", value)} + select value={source_type} - /> + > + {options.map((option) => ( + <MenuItem key={option.key} sx={{ width: "50vw" }} value={option.value}> + {option.content} + </MenuItem> + ))} + </TextField> ) } SourceType.propTypes = { diff --git a/components/frontend/src/source/SourceType.test.js b/components/frontend/src/source/SourceType.test.js index 7431c328f2..9006b5863e 100644 --- a/components/frontend/src/source/SourceType.test.js +++ b/components/frontend/src/source/SourceType.test.js @@ -1,4 +1,4 @@ -import { act, render, screen } from "@testing-library/react" +import { act, fireEvent, render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { DataModel } from "../context/DataModel" @@ -60,19 +60,20 @@ it("shows the metric type even when not supported by the subject type", async () await act(async () => { renderSourceType("violations", "unsupported") }) - expect(screen.queryAllByText(/Unsupported/).length).toBe(2) + expect(screen.getAllByText(/Unsupported/).length).toBe(1) }) it("shows the supported source versions", async () => { await act(async () => { renderSourceType("violations", "sonarqube") }) - expect(screen.queryAllByText(/Supported SonarQube versions: >=8.2/).length).toBe(1) + expect(screen.getAllByText(/Supported SonarQube versions: >=8.2/).length).toBe(1) }) it("shows sources as deprecated if they are deprecated", async () => { await act(async () => { renderSourceType("violations", "sonarqube") }) + fireEvent.mouseDown(screen.getByLabelText(/Source type/)) expect(screen.getAllByText(/Deprecated/).length).toBe(1) }) diff --git a/components/frontend/src/source/SourceTypeHeader.js b/components/frontend/src/source/SourceTypeHeader.js index 419a9c84f6..3bb33ee92b 100644 --- a/components/frontend/src/source/SourceTypeHeader.js +++ b/components/frontend/src/source/SourceTypeHeader.js @@ -1,9 +1,9 @@ import { Chip } from "@mui/material" import { string } from "prop-types" -import { Header } from "../semantic_ui_react_wrappers" import { sourceTypePropType } from "../sharedPropTypes" import { referenceDocumentationURL } from "../utils" +import { Header } from "../widgets/Header" import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink" import { Logo } from "./Logo" import { sourceTypeDescription } from "./SourceType" @@ -14,18 +14,22 @@ export function SourceTypeHeader({ metricTypeId, sourceTypeId, sourceType }) { howToConfigure = " for specific information on how to configure this source type." } return ( - <Header> - <Header.Content> - <Logo logo={sourceTypeId} alt={sourceType.name} /> - {sourceType.name} - {sourceType.deprecated && <Chip color="warning" label="Deprecated" sx={{ marginLeft: "8px" }} />} - <Header.Subheader> + <Header + header={ + <> + <Logo logo={sourceTypeId} alt={sourceType.name} /> {sourceType.name} + {sourceType.deprecated && <Chip color="warning" label="Deprecated" sx={{ marginLeft: "8px" }} />} + </> + } + level="h4" + subheader={ + <> {`${sourceTypeDescription(sourceType)} `} <ReadTheDocsLink url={referenceDocumentationURL(sourceType.name)} /> {howToConfigure} - </Header.Subheader> - </Header.Content> - </Header> + </> + } + /> ) } SourceTypeHeader.propTypes = { diff --git a/components/frontend/src/source/Sources.js b/components/frontend/src/source/Sources.js index 15a6a3b9a3..cdfdae322b 100644 --- a/components/frontend/src/source/Sources.js +++ b/components/frontend/src/source/Sources.js @@ -1,10 +1,10 @@ +import { Box } from "@mui/material" import { func, number, string } from "prop-types" import { useContext } from "react" import { add_source, copy_source, move_source } from "../api/source" import { DataModel } from "../context/DataModel" import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions" -import { Message, Segment } from "../semantic_ui_react_wrappers" import { measurementPropType, measurementSourcePropType, @@ -20,6 +20,7 @@ import { CopyButton } from "../widgets/buttons/CopyButton" import { MoveButton } from "../widgets/buttons/MoveButton" import { source_options } from "../widgets/menu_options" import { showMessage } from "../widgets/toast" +import { InfoMessage } from "../widgets/WarningMessage" import { Source } from "./Source" import { sourceTypeOptions } from "./SourceType" @@ -59,7 +60,10 @@ ButtonSegment.propTypes = { function SourceSegment({ changed_fields, index, last_index, measurement_source, metric, reload, report, sourceUuid }) { return ( - <Segment vertical id={sourceUuid}> + <Box + id={sourceUuid} + sx={{ border: 1, borderColor: "divider", padding: "8px", paddingBottom: "16px", marginBottom: "16px" }} + > <Source first_source={index === 0} last_source={index === last_index} @@ -70,7 +74,7 @@ function SourceSegment({ changed_fields, index, last_index, measurement_source, source_uuid={sourceUuid} changed_fields={changed_fields} /> - </Segment> + </Box> ) } SourceSegment.propTypes = { @@ -118,10 +122,7 @@ export function Sources({ reports, report, metric, metric_uuid, measurement, cha return ( <> {sourceSegments.length === 0 ? ( - <Message> - <Message.Header>No sources</Message.Header> - <p>No sources have been configured yet.</p> - </Message> + <InfoMessage title="No sources">No sources have been configured yet.</InfoMessage> ) : ( sourceSegments )} diff --git a/components/frontend/src/source/Sources.test.js b/components/frontend/src/source/Sources.test.js index 3e1f1de5b0..c27c3f5068 100644 --- a/components/frontend/src/source/Sources.test.js +++ b/components/frontend/src/source/Sources.test.js @@ -106,7 +106,7 @@ it("creates a new source", async () => { fireEvent.click(screen.getByText(/Add source/)) }) await act(async () => { - fireEvent.click(screen.getAllByText(/Source type 2/)[1]) + fireEvent.click(screen.getByText(/Source type 2/)) }) expect(fetch_server_api.fetch_server_api).toHaveBeenNthCalledWith(1, "post", "source/new/metric_uuid", { type: "source_type2", @@ -168,10 +168,9 @@ it("updates a parameter of a source", async () => { it("mass updates a parameter of a source", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true, nr_sources_mass_edited: 2 }) renderSources() - await act(async () => { - fireEvent.click(screen.getByText(/Apply change to subject/)) - }) - expect(screen.getAllByText(/Apply change to subject/).length).toBe(2) + fireEvent.click(screen.getByLabelText(/Edit scope/)) + fireEvent.click(screen.getByText(/Apply change to subject/)) + expect(screen.getAllByText(/Apply change to subject/).length).toBe(1) await userEvent.type(screen.getByDisplayValue(/https:\/\/test.nl/), "https://other{Enter}", { initialSelectionStart: 0, initialSelectionEnd: 15, @@ -185,5 +184,4 @@ it("mass updates a parameter of a source", async () => { url: "https://other", }) expect(toast.showMessage).toHaveBeenCalledTimes(1) - expect(screen.getAllByText(/Apply change to subject/).length).toBe(1) }) diff --git a/components/frontend/src/subject/Subject.css b/components/frontend/src/subject/Subject.css index 1957375822..b17fe6a6ea 100644 --- a/components/frontend/src/subject/Subject.css +++ b/components/frontend/src/subject/Subject.css @@ -1,5 +1,5 @@ div.sticky { position: sticky; /* Make the div sticky */ - top: 15px; /* The menu bar is about 60px high, move the top margin under it */ + top: 60px; /* The menu bar is about 60px high, move the top margin under it */ z-index: 3; } diff --git a/components/frontend/src/subject/Subject.js b/components/frontend/src/subject/Subject.js index 2e22900287..ec831c9a5d 100644 --- a/components/frontend/src/subject/Subject.js +++ b/components/frontend/src/subject/Subject.js @@ -1,5 +1,6 @@ import "./Subject.css" +import { Divider, Paper } from "@mui/material" import { bool, func, string } from "prop-types" import { useContext } from "react" @@ -164,7 +165,7 @@ export function Subject({ } return ( - <div id={subject_uuid}> + <Paper id={subject_uuid} elevation={5} sx={{ marginTop: "50px" }}> <div className="sticky"> <SubjectTitle atReportsOverview={atReportsOverview} @@ -178,6 +179,7 @@ export function Subject({ /> </div> <CommentSegment comment={subject.comment} /> + <Divider sx={{ padding: "0px" }} /> <SubjectTable changed_fields={changed_fields} dates={dates} @@ -192,7 +194,7 @@ export function Subject({ subject={subject} subject_uuid={subject_uuid} /> - </div> + </Paper> ) } Subject.propTypes = { diff --git a/components/frontend/src/subject/SubjectParameters.js b/components/frontend/src/subject/SubjectParameters.js index ec40812d73..ef875c4e67 100644 --- a/components/frontend/src/subject/SubjectParameters.js +++ b/components/frontend/src/subject/SubjectParameters.js @@ -1,53 +1,53 @@ +import Grid from "@mui/material/Grid2" import { func, string } from "prop-types" -import { Grid } from "semantic-ui-react" +import { useContext } from "react" import { set_subject_attribute } from "../api/subject" -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { Comment } from "../fields/Comment" -import { StringInput } from "../fields/StringInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { CommentField } from "../fields/CommentField" +import { TextField } from "../fields/TextField" import { subjectPropType } from "../sharedPropTypes" import { SubjectType } from "./SubjectType" export function SubjectParameters({ subject, subject_uuid, subject_name, reload }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) return ( - <Grid stackable> - <Grid.Row columns={3}> - <Grid.Column> - <SubjectType - id={`${subject_uuid}-type`} - setValue={(value) => set_subject_attribute(subject_uuid, "type", value, reload)} - subjectType={subject.type} - /> - </Grid.Column> - <Grid.Column> - <StringInput - id={`${subject_uuid}-title`} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label="Subject title" - placeholder={subject_name} - set_value={(value) => set_subject_attribute(subject_uuid, "name", value, reload)} - value={subject.name} - /> - </Grid.Column> - <Grid.Column> - <StringInput - id={`${subject_uuid}-subtitle`} - label="Subject subtitle" - requiredPermissions={[EDIT_REPORT_PERMISSION]} - set_value={(value) => set_subject_attribute(subject_uuid, "subtitle", value, reload)} - value={subject.subtitle} - /> - </Grid.Column> - </Grid.Row> - <Grid.Row> - <Grid.Column> - <Comment - id={`${subject_uuid}-comment`} - set_value={(value) => set_subject_attribute(subject_uuid, "comment", value, reload)} - value={subject.comment} - /> - </Grid.Column> - </Grid.Row> + <Grid container spacing={{ xs: 1, sm: 1, md: 2 }} columns={{ xs: 1, sm: 1, md: 3 }}> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <SubjectType + id={`${subject_uuid}-type`} + setValue={(value) => set_subject_attribute(subject_uuid, "type", value, reload)} + subjectType={subject.type} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id={`${subject_uuid}-title`} + label="Subject title" + placeholder={subject_name} + onChange={(value) => set_subject_attribute(subject_uuid, "name", value, reload)} + value={subject.name} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 1 }}> + <TextField + disabled={disabled} + id={`${subject_uuid}-subtitle`} + label="Subject subtitle" + onChange={(value) => set_subject_attribute(subject_uuid, "subtitle", value, reload)} + value={subject.subtitle} + /> + </Grid> + <Grid size={{ xs: 1, sm: 1, md: 3 }}> + <CommentField + disabled={disabled} + id={`${subject_uuid}-comment`} + onChange={(value) => set_subject_attribute(subject_uuid, "comment", value, reload)} + value={subject.comment} + /> + </Grid> </Grid> ) } diff --git a/components/frontend/src/subject/SubjectTable.css b/components/frontend/src/subject/SubjectTable.css index 925a569965..05c629a823 100644 --- a/components/frontend/src/subject/SubjectTable.css +++ b/components/frontend/src/subject/SubjectTable.css @@ -1,123 +1,11 @@ -.ui.sortable.table.stickyHeader > thead { +table.MuiTable-stickyHeader > thead { /* Make thead sticky by positioning the th's */ position: sticky; /* Leave room for the menu bar and the subject title */ - top: 143px; + top: 140px; z-index: 2; } -.ui.sortable.table.stickyHeader > thead > tr > th { - /* Apply the top table border to the th as the table border scrolls out of view */ - border-top: 1px solid rgba(34, 36, 38, 0.15); -} - -.ui.sortable.table.stickyHeader { - /* The top table border is applied to the th's because the top table border scrolls out of view */ - border-top: 0px; -} - -/* Remove opacity from the th background, otherwise the rows underneath are visible. */ - -.ui.inverted.sortable.table > thead > tr > th { - background-color: rgba(50, 50, 50, 1); -} - -.ui.sortable.table > thead > tr > th.sorted { - background-color: rgba(242, 242, 242, 1); -} - -.ui.inverted.sortable.table > thead > tr > th.sorted { - background-color: rgba(80, 80, 80, 1) !important; -} - -.ui.sortable.table > thead > tr > th:not(.unsortable):hover { - background-color: rgba(242, 242, 242, 1); -} - -.ui.inverted.sortable.table > thead > tr > th:not(.unsortable):hover { - background-color: rgba(100, 100, 100, 1); -} - -.ui.sortable.table > thead > tr > th.unsortable:hover { - /* Don't highlight unsortable columns */ - background-color: rgba(249, 250, 251, 1); -} - -.ui.inverted.sortable.table > thead > tr > th.unsortable:hover { - /* Don't highlight unsortable columns */ - background-color: rgba(50, 50, 50, 1); -} - -tr.target_met, -td.target_met { - background-color: rgb(30, 148, 78, 0.15) !important; -} - -tr.target_met:hover, -td.target_met:hover { - background-color: rgb(30, 148, 78, 0.25) !important; -} - -tr.target_not_met, -td.target_not_met { - background-color: rgb(211, 59, 55, 0.2) !important; -} - -tr.target_not_met:hover, -td.target_not_met:hover { - background-color: rgb(211, 59, 55, 0.3) !important; -} - -tr.near_target_met, -td.near_target_met { - background-color: rgb(253, 197, 54, 0.15) !important; -} - -tr.near_target_met:hover, -td.near_target_met:hover { - background-color: rgb(253, 197, 54, 0.25) !important; -} - -tr.debt_target_met, -td.debt_target_met { - background-color: rgb(150, 150, 150, 0.2) !important; -} - -tr.informative, -td.informative { - background-color: rgb(0, 125, 200, 0.2) !important; -} - -tr.debt_target_met:hover, -td.debt_target_met:hover { - background-color: rgb(150, 150, 150, 0.3) !important; -} - -tr.informative:hover, -td.informative:hover { - background-color: rgb(0, 125, 200, 0.3) !important; -} - -tr.unknown, -td.unknown { - background-color: rgb(245, 245, 245, 0.15) !important; -} - -.ui.table.inverted > tbody > tr.unknown:hover, -.ui.table.inverted > tbody > tr > td.unknown:hover { - background-color: rgb(245, 245, 245, 0.25) !important; -} - -tr.unknown:hover, -td.unknown:hover { - background-color: rgb(245, 245, 245, 0.65) !important; -} - -td > a { - color: rgb(0, 88, 176) !important; -} - -.ui.sortable.table thead th.unsortable:hover { - /* Allow for specifying that some columns in a sortable table aren't sortable */ - background: #f9fafb; +tbody td.MuiTableCell-root { + padding: 4px; } diff --git a/components/frontend/src/subject/SubjectTable.js b/components/frontend/src/subject/SubjectTable.js index 48457c3402..799ae53213 100644 --- a/components/frontend/src/subject/SubjectTable.js +++ b/components/frontend/src/subject/SubjectTable.js @@ -1,8 +1,8 @@ import "./SubjectTable.css" +import { Table, TableContainer } from "@mui/material" import { array, func, object, string } from "prop-types" -import { Table } from "../semantic_ui_react_wrappers" import { datesPropType, measurementsPropType, @@ -33,34 +33,36 @@ export function SubjectTable({ // Sort measurements in reverse order so that if there multiple measurements on a day, we find the most recent one: const reversedMeasurements = measurements.slice().sort((m1, m2) => (m1.start < m2.start ? 1 : -1)) return ( - <Table sortable className="stickyHeader" style={{ marginTop: "0px" }}> - <SubjectTableHeader columnDates={dates} handleSort={handleSort} settings={settings} /> - <SubjectTableBody - changed_fields={changed_fields} - dates={dates} - handleSort={handleSort} - measurements={measurements} - metricEntries={metricEntries} - reload={reload} - report={report} - reportDate={reportDate} - reports={reports} - reversedMeasurements={reversedMeasurements} - settings={settings} - subject_uuid={subject_uuid} - /> - <SubjectTableFooter - subjectUuid={subject_uuid} - subject={subject} - reload={reload} - reports={reports} - stopFilteringAndSorting={() => { - handleSort(null) - settings.hiddenTags.reset() - settings.metricsToHide.reset() - }} - /> - </Table> + <TableContainer sx={{ overflowX: "visible" }}> + <Table stickyHeader> + <SubjectTableHeader columnDates={dates} handleSort={handleSort} settings={settings} /> + <SubjectTableBody + changed_fields={changed_fields} + dates={dates} + handleSort={handleSort} + measurements={measurements} + metricEntries={metricEntries} + reload={reload} + report={report} + reportDate={reportDate} + reports={reports} + reversedMeasurements={reversedMeasurements} + settings={settings} + subject_uuid={subject_uuid} + /> + <SubjectTableFooter + subjectUuid={subject_uuid} + subject={subject} + reload={reload} + reports={reports} + stopFilteringAndSorting={() => { + handleSort(null) + settings.hiddenTags.reset() + settings.metricsToHide.reset() + }} + /> + </Table> + </TableContainer> ) } SubjectTable.propTypes = { diff --git a/components/frontend/src/subject/SubjectTable.test.js b/components/frontend/src/subject/SubjectTable.test.js index 3057af4959..7b9ccef512 100644 --- a/components/frontend/src/subject/SubjectTable.test.js +++ b/components/frontend/src/subject/SubjectTable.test.js @@ -202,7 +202,7 @@ it("hides the tags column", () => { it("expands the details via the button", () => { const expandedItems = renderHook(() => useExpandedItemsSearchQuery()) renderSubjectTable({ expandedItems: expandedItems.result.current }) - const expand = screen.getAllByRole("button")[0] + const expand = screen.getAllByRole("button", { name: "Expand/collapse" })[0] fireEvent.click(expand) expandedItems.rerender() expect(expandedItems.result.current.value).toStrictEqual(["1:0"]) @@ -212,7 +212,7 @@ it("collapses the details via the button", async () => { history.push("?expanded=1:0") const expandedItems = renderHook(() => useExpandedItemsSearchQuery()) renderSubjectTable({ expandedItems: expandedItems.result.current }) - const expand = screen.getAllByRole("button")[0] + const expand = screen.getAllByRole("button", { name: "Expand/collapse" })[0] await act(async () => fireEvent.click(expand)) expandedItems.rerender() expect(expandedItems.result.current.value).toStrictEqual([]) @@ -237,6 +237,9 @@ it("moves a metric", async () => { it("adds a source", async () => { history.push("?expanded=1:1") renderSubjectTable() + await act(async () => { + fireEvent.click(screen.getByRole("tab", { name: /Sources/ })) + }) const addButton = await screen.findByText("Add source") await act(async () => fireEvent.click(addButton)) fireEvent.click(await screen.findByText("Source type")) diff --git a/components/frontend/src/subject/SubjectTableBody.js b/components/frontend/src/subject/SubjectTableBody.js index 28d187c3b6..4132f12c4c 100644 --- a/components/frontend/src/subject/SubjectTableBody.js +++ b/components/frontend/src/subject/SubjectTableBody.js @@ -1,6 +1,6 @@ +import { TableBody } from "@mui/material" import { array, func, string } from "prop-types" -import { Table } from "../semantic_ui_react_wrappers" import { datesPropType, measurementsPropType, @@ -28,7 +28,7 @@ export function SubjectTableBody({ }) { const lastIndex = metricEntries.length - 1 return ( - <Table.Body> + <TableBody> {metricEntries.map(([metric_uuid, metric], index) => { return ( <SubjectTableRow @@ -51,7 +51,7 @@ export function SubjectTableBody({ /> ) })} - </Table.Body> + </TableBody> ) } SubjectTableBody.propTypes = { diff --git a/components/frontend/src/subject/SubjectTableFooter.js b/components/frontend/src/subject/SubjectTableFooter.js index ca66d032d1..fbda961b44 100644 --- a/components/frontend/src/subject/SubjectTableFooter.js +++ b/components/frontend/src/subject/SubjectTableFooter.js @@ -1,6 +1,6 @@ +import { TableCell, TableFooter, TableRow } from "@mui/material" import { func, string } from "prop-types" import { useContext } from "react" -import { Table } from "semantic-ui-react" import { add_metric, copy_metric, move_metric } from "../api/metric" import { DataModel } from "../context/DataModel" @@ -16,8 +16,8 @@ import { metric_options } from "../widgets/menu_options" function SubjectTableFooterButtonRow({ subject, subjectUuid, reload, reports, stopFilteringAndSorting }) { const dataModel = useContext(DataModel) return ( - <Table.Row> - <Table.HeaderCell colSpan="99"> + <TableRow> + <TableCell colSpan="99"> <ButtonRow> <AddDropdownButton allItemSubtypes={allMetricTypeOptions(dataModel)} @@ -46,8 +46,8 @@ function SubjectTableFooterButtonRow({ subject, subjectUuid, reload, reports, st get_options={() => metric_options(reports, dataModel, subject.type, subjectUuid)} /> </ButtonRow> - </Table.HeaderCell> - </Table.Row> + </TableCell> + </TableRow> ) } SubjectTableFooterButtonRow.propTypes = { @@ -63,9 +63,9 @@ export function SubjectTableFooter(props) { <ReadOnlyOrEditable requiredPermissions={[EDIT_REPORT_PERMISSION]} editableComponent={ - <Table.Footer> + <TableFooter> <SubjectTableFooterButtonRow {...props} /> - </Table.Footer> + </TableFooter> } /> ) diff --git a/components/frontend/src/subject/SubjectTableFooter.test.js b/components/frontend/src/subject/SubjectTableFooter.test.js index 72de24050c..62f2293724 100644 --- a/components/frontend/src/subject/SubjectTableFooter.test.js +++ b/components/frontend/src/subject/SubjectTableFooter.test.js @@ -1,5 +1,5 @@ +import { Table } from "@mui/material" import { act, fireEvent, render, screen } from "@testing-library/react" -import { Table } from "semantic-ui-react" import { dataModel, report } from "../__fixtures__/fixtures" import * as fetch_server_api from "../api/fetch_server_api" diff --git a/components/frontend/src/subject/SubjectTableHeader.js b/components/frontend/src/subject/SubjectTableHeader.js index c30cd58081..8a2dfe26cf 100644 --- a/components/frontend/src/subject/SubjectTableHeader.js +++ b/components/frontend/src/subject/SubjectTableHeader.js @@ -1,10 +1,8 @@ -import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material" +import { Chip, List, ListItem, ListItemIcon, ListItemText, Paper, TableHead, TableRow, Typography } from "@mui/material" import { bool, func, string } from "prop-types" -import { Table } from "semantic-ui-react" import { StatusIcon } from "../measurement/StatusIcon" import { STATUS_DESCRIPTION, STATUSES } from "../metric/status" -import { Label } from "../semantic_ui_react_wrappers" import { datesPropType, settingsPropType } from "../sharedPropTypes" import { HyperLink } from "../widgets/HyperLink" import { IgnoreIcon, TriangleRightIcon } from "../widgets/icons" @@ -78,9 +76,9 @@ const measurementHelp = ( </p> <p> If the measurement value has a{" "} - <Label as="span" horizontal color="red"> + <Typography display="inline" bgcolor="error.main"> red background - </Label> + </Typography> , the metric has not been measured recently. This indicates a problem with <em>Quality-time</em> itself, and a system administrator should be notified. </p> @@ -99,9 +97,9 @@ const targetHelp = ( <p>The value against which measurements are evaluated to determine whether a metric needs action.</p> <p> The target value has a{" "} - <Label as="span" horizontal color="grey"> + <Typography display="inline" bgcolor="grey"> grey background - </Label>{" "} + </Typography>{" "} if the metric has accepted technical debt that is not applied because the technical debt end date is in the past or all issues linked to the metric have been resolved. </p> @@ -172,9 +170,9 @@ const sourcesHelp = ( <p>The tools and reports accessed to collect the measurement data. One metric can have multiple sources.</p> <p> If a source has a{" "} - <Label as="span" horizontal color="red"> + <Typography display="inline" bgcolor="error.main"> red background - </Label> + </Typography> , the source could not be accessed or the data could not be parsed. <Expand>metric</Expand> and navigate to the source to see the error details. </p> @@ -194,9 +192,9 @@ const issuesHelp = ( </p> <p> If an issue has a{" "} - <Label as="span" horizontal color="red"> + <Typography display="inline" bgcolor="error.main"> red background - </Label> + </Typography> , the issue tracker could not be accessed or the data could not be parsed. <Expand>metric</Expand> and navigate to the technical debt tab to see the error details. </p> @@ -222,6 +220,29 @@ const tagsHelp = ( </> ) +function InlineChip({ color, label }) { + return ( + <Paper + component="span" // Default component is div Use span to prevent "Warning: validateDOMNesting(...): <div> cannot appear as a descendant of <p>." + elevation={0} + sx={{ display: "inline-flex" }} + > + <Chip + color={color} + component="span" // Default component is div Use span to prevent "Warning: validateDOMNesting(...): <div> cannot appear as a descendant of <p>." + label={label} + size="small" + sx={{ borderRadius: 1 }} + variant="outlined" + /> + </Paper> + ) +} +InlineChip.propTypes = { + color: string, + label: string, +} + function MeasurementHeaderCells({ columnDates, showDeltaColumns }) { const cells = [] columnDates.forEach((date, index) => { @@ -236,29 +257,16 @@ function MeasurementHeaderCells({ columnDates, showDeltaColumns }) { and next date. </p> <p> - A plus sign{" "} - <Label basic color="blue"> - + - </Label>{" "} - indicates that the newer value is higher. A minus sign{" "} - <Label basic color="blue"> - - - </Label>{" "} - indicates that the newer value is lower. + A plus sign <InlineChip color="info" label="+" /> indicates that the newer value is + higher. A minus sign <InlineChip color="info" label="+" /> indicates that the newer + value is lower. </p> <p> - A{" "} - <Label basic color="green"> - green outline - </Label>{" "} + A <InlineChip color="success" label="green outline" /> indicates that the newer value is better. A{" "} - <Label basic color="red"> - red outline - </Label>{" "} + <InlineChip color="error" label="red outline" /> indicates that the newer value is worse. A{" "} - <Label basic color="blue"> - blue outline - </Label>{" "} + <InlineChip color="info" label="blue outline" /> is used for metrics that are informative. </p> <p> @@ -289,8 +297,8 @@ export function SubjectTableHeader({ columnDates, handleSort, settings }) { } const nrDates = columnDates.length return ( - <Table.Header> - <Table.Row> + <TableHead sx={{ bgcolor: "background.default" }}> + <TableRow> <SortableTableHeaderCell colSpan="2" column="name" label="Metric" help={metricHelp} {...sortProps} /> {nrDates > 1 && ( <MeasurementHeaderCells @@ -349,8 +357,8 @@ export function SubjectTableHeader({ columnDates, handleSort, settings }) { {settings.hiddenColumns.excludes("tags") && ( <SortableTableHeaderCell column="tags" label="Tags" help={tagsHelp} {...sortProps} /> )} - </Table.Row> - </Table.Header> + </TableRow> + </TableHead> ) } SubjectTableHeader.propTypes = { diff --git a/components/frontend/src/subject/SubjectTableHeader.test.js b/components/frontend/src/subject/SubjectTableHeader.test.js index a6b6f3eceb..2e49aa59c8 100644 --- a/components/frontend/src/subject/SubjectTableHeader.test.js +++ b/components/frontend/src/subject/SubjectTableHeader.test.js @@ -1,7 +1,7 @@ +import { Table } from "@mui/material" import { render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import history from "history/browser" -import { Table } from "semantic-ui-react" import { createTestableSettings } from "../__fixtures__/fixtures" import { SubjectTableHeader } from "./SubjectTableHeader" @@ -83,3 +83,13 @@ it("shows help for column headers", async () => { expect(screen.queryByText(/Click the column header to sort the metrics by name/)).not.toBe(null) }) }) + +it("shows help for delta column headers", async () => { + const date1 = new Date("2022-02-02") + const date2 = new Date("2022-02-03") + renderSubjectTableHeader([date1, date2]) + await userEvent.hover(screen.getByText(/𝚫/)) + await waitFor(() => { + expect(screen.queryByText(/shows the difference/)).not.toBe(null) + }) +}) diff --git a/components/frontend/src/subject/SubjectTableRow.js b/components/frontend/src/subject/SubjectTableRow.js index d338f4a430..1e1d041fee 100644 --- a/components/frontend/src/subject/SubjectTableRow.js +++ b/components/frontend/src/subject/SubjectTableRow.js @@ -1,7 +1,7 @@ +import { Chip, TableCell, Tooltip } from "@mui/material" import { bool, func, number, object, string } from "prop-types" import { useContext } from "react" -import { DarkMode } from "../context/DarkMode" import { DataModel } from "../context/DataModel" import { IssueStatus } from "../issue/IssueStatus" import { MeasurementSources } from "../measurement/MeasurementSources" @@ -12,7 +12,6 @@ import { StatusIcon } from "../measurement/StatusIcon" import { TimeLeft } from "../measurement/TimeLeft" import { TrendSparkline } from "../measurement/TrendSparkline" import { MetricDetails } from "../metric/MetricDetails" -import { Label, Popup, Table } from "../semantic_ui_react_wrappers" import { dataModelPropType, datePropType, @@ -68,9 +67,9 @@ didValueImprove.propTypes = { function deltaColor(metric, improved) { const evaluateTarget = metric.evaluate_targets ?? true if (evaluateTarget) { - return improved ? "green" : "red" + return improved ? "success" : "error" } - return "blue" + return "info" } deltaColor.propTypes = { metric: metricPropType, @@ -131,20 +130,15 @@ function DeltaCell({ dateOrderAscending, index, metric, metricValue, previousVal const description = deltaDescription(dataModel, metric, scale, delta, improved, oldValue, newValue) const color = deltaColor(metric, improved) label = ( - <Popup - content={description} - trigger={ - <Label aria-label={description} basic color={color}> - {delta} - </Label> - } - /> + <Tooltip title={description}> + <Chip color={color} label={delta} size="small" sx={{ borderRadius: 1 }} variant="outlined" /> + </Tooltip> ) } return ( - <Table.Cell className={status} singleLine textAlign="right"> + <TableCell align="right" className={status}> {label} - </Table.Cell> + </TableCell> ) } DeltaCell.propTypes = { @@ -208,10 +202,10 @@ function MeasurementCells({ dates, metric, metric_uuid, measurements, settings } ) } cells.push( - <Table.Cell className={status} key={date} textAlign="right"> + <TableCell align="right" className={status} key={date}> {formatMetricValue(scale, metricValue)} {formatMetricScale(metric, dataModel)} - </Table.Cell>, + </TableCell>, ) previousValue = metricValue === "?" ? previousValue : metricValue }) @@ -252,15 +246,14 @@ export function SubjectTableRow({ subject_uuid, }) { const dataModel = useContext(DataModel) - const darkMode = useContext(DarkMode) const metricName = getMetricName(metric, dataModel) const scale = getMetricScale(metric, dataModel) const unit = getMetricUnit(metric, dataModel) const nrDates = dates.length - const style = nrDates > 1 ? { background: darkMode ? "rgba(60, 60, 60, 1)" : "#f9fafb" } : {} return ( <TableRowWithDetails className={nrDates === 1 ? metric.status || "unknown" : ""} + color={nrDates === 1 ? metric.status || "unknown" : ""} details={ <MetricDetails changed_fields={changed_fields} @@ -277,15 +270,13 @@ export function SubjectTableRow({ settings.metricsToHide.reset() }} subject_uuid={subject_uuid} - expandedItems={settings.expandedItems} /> } expanded={settings.expandedItems.value.filter((item) => item?.startsWith(metric_uuid)).length > 0} id={metric_uuid} onExpand={(expand) => expandOrCollapseItem(expand, metric_uuid, settings.expandedItems)} - style={style} > - <Table.Cell style={style}>{metricName}</Table.Cell> + <TableCell>{metricName}</TableCell> {nrDates > 1 && ( <MeasurementCells dates={dates} @@ -296,38 +287,38 @@ export function SubjectTableRow({ /> )} {nrDates === 1 && settings.hiddenColumns.excludes("trend") && ( - <Table.Cell> + <TableCell sx={{ width: "150px" }}> <TrendSparkline measurements={metric.recent_measurements} report_date={reportDate} scale={scale} /> - </Table.Cell> + </TableCell> )} {nrDates === 1 && settings.hiddenColumns.excludes("status") && ( - <Table.Cell textAlign="center"> + <TableCell align="center"> <StatusIcon status={metric.status} statusStart={metric.status_start} /> - </Table.Cell> + </TableCell> )} {nrDates === 1 && settings.hiddenColumns.excludes("measurement") && ( - <Table.Cell textAlign="right"> + <TableCell align="right"> <MeasurementValue metric={metric} reportDate={reportDate} /> - </Table.Cell> + </TableCell> )} {nrDates === 1 && settings.hiddenColumns.excludes("target") && ( - <Table.Cell textAlign="right"> + <TableCell align="right"> <MeasurementTarget metric={metric} /> - </Table.Cell> + </TableCell> )} - {settings.hiddenColumns.excludes("unit") && <Table.Cell style={style}>{unit}</Table.Cell>} + {settings.hiddenColumns.excludes("unit") && <TableCell>{unit}</TableCell>} {settings.hiddenColumns.excludes("source") && ( - <Table.Cell style={style}> + <TableCell> <MeasurementSources metric={metric} /> - </Table.Cell> + </TableCell> )} {settings.hiddenColumns.excludes("time_left") && ( - <Table.Cell style={style}> + <TableCell> <TimeLeft metric={metric} report={report} /> - </Table.Cell> + </TableCell> )} {nrDates > 1 && settings.hiddenColumns.excludes("overrun") && ( - <Table.Cell style={style}> + <TableCell> <Overrun metric={metric} metric_uuid={metric_uuid} @@ -335,24 +326,24 @@ export function SubjectTableRow({ measurements={measurements} dates={dates} /> - </Table.Cell> + </TableCell> )} {settings.hiddenColumns.excludes("comment") && ( - <Table.Cell style={style}> + <TableCell> <DivWithHTML>{metric.comment}</DivWithHTML> - </Table.Cell> + </TableCell> )} {settings.hiddenColumns.excludes("issues") && ( - <Table.Cell style={style}> + <TableCell> <IssueStatus metric={metric} issueTrackerMissing={!report.issue_tracker} settings={settings} /> - </Table.Cell> + </TableCell> )} {settings.hiddenColumns.excludes("tags") && ( - <Table.Cell style={style}> + <TableCell> {getMetricTags(metric).map((tag) => ( <Tag key={tag} tag={tag} /> ))} - </Table.Cell> + </TableCell> )} </TableRowWithDetails> ) diff --git a/components/frontend/src/subject/SubjectTableRow.test.js b/components/frontend/src/subject/SubjectTableRow.test.js index 1d254d2470..8cbc517666 100644 --- a/components/frontend/src/subject/SubjectTableRow.test.js +++ b/components/frontend/src/subject/SubjectTableRow.test.js @@ -1,9 +1,9 @@ +import { Table, TableBody } from "@mui/material" import { render, screen } from "@testing-library/react" import history from "history/browser" import { createTestableSettings, dataModel, report } from "../__fixtures__/fixtures" import { DataModel } from "../context/DataModel" -import { Table } from "../semantic_ui_react_wrappers" import { SubjectTableRow } from "./SubjectTableRow" beforeEach(() => { @@ -47,7 +47,7 @@ function renderSubjectTableRow({ render( <DataModel.Provider value={dataModel}> <Table> - <Table.Body> + <TableBody> <SubjectTableRow dates={dates} measurements={[]} @@ -65,7 +65,7 @@ function renderSubjectTableRow({ reversedMeasurements={reverseMeasurements} settings={createTestableSettings()} /> - </Table.Body> + </TableBody> </Table> </DataModel.Provider>, ) diff --git a/components/frontend/src/subject/SubjectTitle.js b/components/frontend/src/subject/SubjectTitle.js index 23bc9fcc2d..a8ea598a5e 100644 --- a/components/frontend/src/subject/SubjectTitle.js +++ b/components/frontend/src/subject/SubjectTitle.js @@ -1,33 +1,35 @@ +import HistoryIcon from "@mui/icons-material/History" +import SettingsIcon from "@mui/icons-material/Settings" import { bool, func, object, string } from "prop-types" import { useContext } from "react" import { delete_subject, set_subject_attribute } from "../api/subject" -import { activeTabIndex, tabChangeHandler } from "../app_ui_settings" import { ChangeLog } from "../changelog/ChangeLog" import { DataModel } from "../context/DataModel" import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions" -import { Header, Tab } from "../semantic_ui_react_wrappers" import { reportPropType, settingsPropType } from "../sharedPropTypes" import { getSubjectType, referenceDocumentationURL } from "../utils" import { ButtonRow } from "../widgets/ButtonRow" import { DeleteButton } from "../widgets/buttons/DeleteButton" import { PermLinkButton } from "../widgets/buttons/PermLinkButton" import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup" +import { Header } from "../widgets/Header" import { HeaderWithDetails } from "../widgets/HeaderWithDetails" import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink" -import { changelogTabPane, configurationTabPane } from "../widgets/TabPane" +import { Tabs } from "../widgets/Tabs" import { SubjectParameters } from "./SubjectParameters" function SubjectHeader({ subjectType }) { return ( - <Header> - <Header.Content> - {subjectType.name} - <Header.Subheader> + <Header + header={subjectType.name} + level="h3" + subheader={ + <> {subjectType.description} <ReadTheDocsLink url={referenceDocumentationURL(subjectType.name)} /> - </Header.Subheader> - </Header.Content> - </Header> + </> + } + /> ) } SubjectHeader.propTypes = { @@ -74,42 +76,33 @@ export function SubjectTitle({ settings, }) { const dataModel = useContext(DataModel) - const tabIndex = activeTabIndex(settings.expandedItems, subject_uuid) const subjectType = getSubjectType(subject.type, dataModel.subjects) || { name: "Unknown subject type" } const subjectName = subject.name || subjectType.name const subjectTitle = (atReportsOverview ? report.title + " ❯ " : "") + subjectName const subjectUrl = `${window.location}#${subject_uuid}` - const panes = [ - configurationTabPane( - <SubjectParameters - subject={subject} - subject_uuid={subject_uuid} - subject_name={subjectName} - reload={reload} - />, - ), - changelogTabPane(<ChangeLog subject_uuid={subject_uuid} timestamp={report.timestamp} />), - ] - return ( <HeaderWithDetails - className="sticky" header={subjectTitle} - item_uuid={`${subject_uuid}:${tabIndex}`} + item_uuid={subject_uuid} level="h2" settings={settings} - style={{ - marginTop: 50 /* Whitespace between dashboard or previous subject and this subject */, - height: 50 /* Ensure that the header takes the same amount of vertical space with or without subtitle */, - }} subheader={subject.subtitle} > <SubjectHeader subjectType={subjectType} /> - <Tab - defaultActiveIndex={tabIndex} - onTabChange={tabChangeHandler(settings.expandedItems, subject_uuid)} - panes={panes} - /> + <Tabs + tabs={[ + { label: "Configuration", icon: <SettingsIcon /> }, + { label: "Changelog", icon: <HistoryIcon /> }, + ]} + > + <SubjectParameters + subject={subject} + subject_uuid={subject_uuid} + subject_name={subjectName} + reload={reload} + /> + <ChangeLog subject_uuid={subject_uuid} timestamp={report.timestamp} /> + </Tabs> <SubjectTitleButtonRow subject_uuid={subject_uuid} firstSubject={firstSubject} diff --git a/components/frontend/src/subject/SubjectTitle.test.js b/components/frontend/src/subject/SubjectTitle.test.js index 568c305e9a..e6ef5acc84 100644 --- a/components/frontend/src/subject/SubjectTitle.test.js +++ b/components/frontend/src/subject/SubjectTitle.test.js @@ -9,7 +9,7 @@ import { EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" import { SubjectTitle } from "./SubjectTitle" beforeEach(() => { - history.push("?expanded=subject_uuid:0") + history.push("?expanded=subject_uuid") }) const dataModel = { @@ -53,19 +53,14 @@ async function renderSubjectTitle(subject_type = "subject_type") { it("changes the subject type", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) await renderSubjectTitle() - await userEvent.click(screen.getAllByText(/Default subject type/)[1]) + fireEvent.mouseDown(screen.getByLabelText(/Subject type/)) + //await userEvent.click(screen.getAllByText(/Default subject type/)[1]) await userEvent.click(screen.getByText(/Other subject type/)) expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "subject/subject_uuid/attribute/type", { type: "subject_type2", }) }) -it("deals with unknown subject types", async () => { - fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) - await renderSubjectTitle("unknown_subject_type") - expect(screen.getAllByText("Unknown subject type").length).toBe(2) -}) - it("changes the subject title", async () => { fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true }) await renderSubjectTitle() diff --git a/components/frontend/src/subject/SubjectType.js b/components/frontend/src/subject/SubjectType.js index d471c5bbb0..f04244e9ad 100644 --- a/components/frontend/src/subject/SubjectType.js +++ b/components/frontend/src/subject/SubjectType.js @@ -1,11 +1,11 @@ import CircleIcon from "@mui/icons-material/Circle" -import { Stack, Typography } from "@mui/material" +import { MenuItem, Stack, Typography } from "@mui/material" import { func, number, objectOf, string } from "prop-types" import { useContext } from "react" import { DataModel } from "../context/DataModel" -import { EDIT_REPORT_PERMISSION } from "../context/Permissions" -import { SingleChoiceInput } from "../fields/SingleChoiceInput" +import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions" +import { TextField } from "../fields/TextField" import { subjectPropType } from "../sharedPropTypes" export function subjectTypes(subjectTypesMapping, level = 0) { @@ -30,10 +30,10 @@ export function subjectTypes(subjectTypesMapping, level = 0) { content: ( <Stack direction="row"> {bullet} - <p> + <Stack direction="column"> {subjectType.name} <Typography variant="body2">{subjectType.description}</Typography> - </p> + </Stack> </Stack> ), }) @@ -47,15 +47,22 @@ subjectTypes.propTypes = { } export function SubjectType({ subjectType, setValue }) { + const permissions = useContext(Permissions) + const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION]) return ( - <SingleChoiceInput - requiredPermissions={[EDIT_REPORT_PERMISSION]} + <TextField + disabled={disabled} label="Subject type" - options={subjectTypes(useContext(DataModel).subjects)} - set_value={(value) => setValue(value)} - sort={false} + onChange={(value) => setValue(value)} + select value={subjectType} - /> + > + {subjectTypes(useContext(DataModel).subjects).map((subjectType) => ( + <MenuItem key={subjectType.key} value={subjectType.value}> + {subjectType.content} + </MenuItem> + ))} + </TextField> ) } SubjectType.propTypes = { diff --git a/components/frontend/src/subject/SubjectsButtonRow.js b/components/frontend/src/subject/SubjectsButtonRow.js index 450169b2a7..37fe01c417 100644 --- a/components/frontend/src/subject/SubjectsButtonRow.js +++ b/components/frontend/src/subject/SubjectsButtonRow.js @@ -23,7 +23,7 @@ export function SubjectsButtonRow({ reload, report, reports, settings }) { <ReadOnlyOrEditable requiredPermissions={[EDIT_REPORT_PERMISSION]} editableComponent={ - <Box sx={{ pt: "50px" }}> + <Box sx={{ paddingTop: "50px" }}> <ButtonRow> <AddDropdownButton itemType="subject" diff --git a/components/frontend/src/theme.js b/components/frontend/src/theme.js new file mode 100644 index 0000000000..a8388002a2 --- /dev/null +++ b/components/frontend/src/theme.js @@ -0,0 +1,136 @@ +import { grey, orange } from "@mui/material/colors" +import { alpha, createTheme, responsiveFontSizes } from "@mui/material/styles" + +// Construct the theme in a few phases so we can reuse components defined in earlier phases + +const theme1 = createTheme({ + colorSchemes: { + dark: true, // Add a dark theme (light theme is available by default) + }, + components: { + MuiTooltip: { + defaultProps: { arrow: true }, + styleOverrides: { tooltip: { fontSize: "0.9em" } }, + }, + }, +}) + +const theme2 = createTheme(theme1, { + palette: { + contrastThreshold: 4.5, + todo: theme1.palette.augmentColor({ color: { main: grey[600] }, name: "todo" }), + doing: theme1.palette.augmentColor({ color: { main: theme1.palette.info.main }, name: "doing" }), + done: theme1.palette.augmentColor({ color: { main: theme1.palette.success.main }, name: "done" }), + target_not_met: theme1.palette.augmentColor({ + color: { main: theme1.palette.error.main }, + name: "target_not_met", + }), + target_met: theme1.palette.augmentColor({ color: { main: theme1.palette.success.main }, name: "target_met" }), + near_target_met: theme1.palette.augmentColor({ color: { main: orange[300] }, name: "near_target_met" }), + debt_target_met: theme1.palette.augmentColor({ color: { main: grey[500] }, name: "debt_target_met" }), + informative: theme1.palette.augmentColor({ color: { main: theme1.palette.info.main }, name: "informative" }), + unknown: theme1.palette.augmentColor({ color: { main: grey[300] }, name: "unknown" }), + total: theme1.palette.augmentColor({ color: { main: grey[800] }, name: "total" }), + positive_status: theme1.palette.augmentColor({ + color: { main: theme1.palette.success.main }, + name: "positive_status", + }), + negative_status: theme1.palette.augmentColor({ + color: { main: theme1.palette.error.main }, + name: "negative_status", + }), + warning_status: theme1.palette.augmentColor({ color: { main: orange[300] }, name: "warning_status" }), + active_status: theme1.palette.augmentColor({ color: { main: grey[500] }, name: "active_status" }), + unknown_status: theme1.palette.augmentColor({ color: { main: grey[300] }, name: "unknown_status" }), + edit_scope_source: theme1.palette.augmentColor({ color: { main: grey[300] }, name: "edit_scope_source" }), + edit_scope_metric: theme1.palette.augmentColor({ + color: { main: theme1.palette.primary.main }, + name: "edit_scope_metric", + }), + edit_scope_subject: theme1.palette.augmentColor({ + color: { main: orange[300] }, + name: "edit_scope_subject", + }), + edit_scope_report: theme1.palette.augmentColor({ + color: { main: theme1.palette.warning.main }, + name: "edit_scope_report", + }), + edit_scope_reports: theme1.palette.augmentColor({ + color: { main: theme1.palette.error.main }, + name: "edit_scope_reports", + }), + }, + typography: { + h1: { + fontSize: theme1.typography.h4.fontSize, + fontWeight: 700, + }, + h2: { + fontSize: theme1.typography.h5.fontSize, + fontWeight: 600, + }, + h3: { + fontSize: theme1.typography.h6.fontSize, + }, + h4: { + fontSize: theme1.typography.subtitle1.fontSize, + }, + h5: { + fontSize: theme1.typography.subtitle2.fontSize, + }, + }, +}) + +const bgcolorTransparency = 0.2 +const hoverTransparency = 0.25 + +const theme3 = createTheme(theme2, { + palette: { + target_not_met: { + bgcolor: alpha(theme2.palette.target_not_met.main, bgcolorTransparency), + hover: alpha(theme2.palette.target_not_met.main, hoverTransparency), + }, + target_met: { + bgcolor: alpha(theme2.palette.target_met.main, bgcolorTransparency), + hover: alpha(theme2.palette.target_met.main, hoverTransparency), + }, + near_target_met: { + bgcolor: alpha(theme2.palette.near_target_met.main, bgcolorTransparency), + hover: alpha(theme2.palette.near_target_met.main, hoverTransparency), + }, + debt_target_met: { + bgcolor: alpha(theme2.palette.debt_target_met.main, bgcolorTransparency), + hover: alpha(theme2.palette.debt_target_met.main, hoverTransparency), + }, + informative: { + bgcolor: alpha(theme2.palette.informative.main, bgcolorTransparency), + hover: alpha(theme2.palette.informative.main, hoverTransparency), + }, + unknown: { + bgcolor: alpha(theme2.palette.unknown.main, bgcolorTransparency), + hover: alpha(theme2.palette.unknown.main, hoverTransparency), + }, + positive_status: { + bgcolor: alpha(theme2.palette.positive_status.main, bgcolorTransparency), + hover: alpha(theme2.palette.positive_status.main, hoverTransparency), + }, + negative_status: { + bgcolor: alpha(theme2.palette.negative_status.main, bgcolorTransparency), + hover: alpha(theme2.palette.negative_status.main, hoverTransparency), + }, + warning_status: { + bgcolor: alpha(theme2.palette.warning_status.main, bgcolorTransparency), + hover: alpha(theme2.palette.warning_status.main, hoverTransparency), + }, + active_status: { + bgcolor: alpha(theme2.palette.active_status.main, bgcolorTransparency), + hover: alpha(theme2.palette.active_status.main, hoverTransparency), + }, + unknown_status: { + bgcolor: alpha(theme2.palette.unknown_status.main, bgcolorTransparency), + hover: alpha(theme2.palette.unknown_status.main, hoverTransparency), + }, + }, +}) + +export const theme = responsiveFontSizes(theme3) diff --git a/components/frontend/src/utils.js b/components/frontend/src/utils.js index 857c710d17..2970a3b521 100644 --- a/components/frontend/src/utils.js +++ b/components/frontend/src/utils.js @@ -21,7 +21,6 @@ export const MILLISECONDS_PER_HOUR = 60 * 60 * 1000 const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR export const ISSUE_STATUS_COLORS = { todo: "grey", doing: "blue", done: "green", unknown: null } -export const ISSUE_STATUS_THEME_COLORS = { todo: "grey", doing: "info", done: "success", unknown: "" } export function getMetricDirection(metric, dataModel) { // Old versions of the data model may contain the unicode version of the direction, be prepared: diff --git a/components/frontend/src/widgets/CommentSegment.js b/components/frontend/src/widgets/CommentSegment.js index 45526d3137..43a773eadc 100644 --- a/components/frontend/src/widgets/CommentSegment.js +++ b/components/frontend/src/widgets/CommentSegment.js @@ -1,13 +1,12 @@ +import { Box, Typography } from "@mui/material" import { string } from "prop-types" -import { Segment } from "../semantic_ui_react_wrappers" - export function CommentSegment({ comment }) { if (comment) { return ( - <Segment basic style={{ marginTop: "10px" }}> - <div dangerouslySetInnerHTML={{ __html: comment }} /> - </Segment> + <Box sx={{ margin: "12px" }}> + <Typography color="text.primary" dangerouslySetInnerHTML={{ __html: comment }} /> + </Box> ) } return null diff --git a/components/frontend/src/widgets/DatePicker.css b/components/frontend/src/widgets/DatePicker.css deleted file mode 100644 index 61710facf0..0000000000 --- a/components/frontend/src/widgets/DatePicker.css +++ /dev/null @@ -1,3 +0,0 @@ -.react-datepicker__close-icon::after { - background-color: grey !important; -} diff --git a/components/frontend/src/widgets/DatePicker.js b/components/frontend/src/widgets/DatePicker.js deleted file mode 100644 index a99669b1d3..0000000000 --- a/components/frontend/src/widgets/DatePicker.js +++ /dev/null @@ -1,38 +0,0 @@ -import "react-datepicker/dist/react-datepicker.css" -import "./DatePicker.css" - -import { func } from "prop-types" -import { default as ReactDatePicker } from "react-datepicker" - -import { isValidDate_YYYYMMDD } from "../utils" - -export function DatePicker(props) { - const { onChange, ...otherProps } = props - return ( - <ReactDatePicker - dateFormat="yyyy-MM-dd" - dropdownMode="select" - onChange={(date) => { - if (date === null) { - onChange(null) - } - }} // See https://github.com/Hacker0x01/react-datepicker/discussions/3636 - onChangeRaw={(event) => { - if (isValidDate_YYYYMMDD(event.target.value)) { - onChange(new Date(event.target.value)) - } - }} - onSelect={onChange} - placeholderText="YYYY-MM-DD" - showIcon={false} - showMonthDropdown - showPopperArrow={false} - showYearDropdown - todayButton="Today" - {...otherProps} - /> - ) -} -DatePicker.propTypes = { - onChange: func, -} diff --git a/components/frontend/src/widgets/ErrorMessage.js b/components/frontend/src/widgets/ErrorMessage.js new file mode 100644 index 0000000000..02e03bc64a --- /dev/null +++ b/components/frontend/src/widgets/ErrorMessage.js @@ -0,0 +1,23 @@ +import Grid from "@mui/material/Grid2" +import { bool, object, oneOfType, string } from "prop-types" + +import { WarningMessage } from "./WarningMessage" + +export function ErrorMessage({ formatAsText, message, title }) { + return ( + <Grid size={{ xs: 1, sm: 2, md: 2 }}> + <WarningMessage title={title}> + {formatAsText ? ( + message + ) : ( + <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>{message}</pre> + )} + </WarningMessage> + </Grid> + ) +} +ErrorMessage.propTypes = { + formatAsText: bool, + message: oneOfType([object, string]), + title: string, +} diff --git a/components/frontend/src/widgets/Header.js b/components/frontend/src/widgets/Header.js new file mode 100644 index 0000000000..a48ae577cc --- /dev/null +++ b/components/frontend/src/widgets/Header.js @@ -0,0 +1,18 @@ +import { Typography } from "@mui/material" +import { element, oneOfType, string } from "prop-types" + +export function Header({ header, level, subheader }) { + return ( + <div> + <Typography variant={level}>{header}</Typography> + <Typography variant={`h${parseInt(level[1]) + 1}`} sx={{ color: "text.secondary" }}> + {subheader} + </Typography> + </div> + ) +} +Header.propTypes = { + header: oneOfType([element, string]), + level: string, + subheader: oneOfType([element, string]), +} diff --git a/components/frontend/src/widgets/HeaderWithDetails.css b/components/frontend/src/widgets/HeaderWithDetails.css deleted file mode 100644 index f0f4f35cd9..0000000000 --- a/components/frontend/src/widgets/HeaderWithDetails.css +++ /dev/null @@ -1,9 +0,0 @@ -@media print { - .Caret { - display: none !important; - } -} - -div.sticky { - background-color: white; -} diff --git a/components/frontend/src/widgets/HeaderWithDetails.js b/components/frontend/src/widgets/HeaderWithDetails.js index 5d287a780d..ed0703b358 100644 --- a/components/frontend/src/widgets/HeaderWithDetails.js +++ b/components/frontend/src/widgets/HeaderWithDetails.js @@ -1,43 +1,52 @@ -import "./HeaderWithDetails.css" +import { Accordion, AccordionDetails, AccordionSummary } from "@mui/material" +import { accordionSummaryClasses } from "@mui/material/AccordionSummary" +import { string } from "prop-types" -import { node, object, string } from "prop-types" - -import { Header, Segment } from "../semantic_ui_react_wrappers" import { childrenPropType, settingsPropType } from "../sharedPropTypes" -import { ExpandButton } from "./buttons/ExpandButton" +import { Header } from "./Header" +import { CaretRight } from "./icons" -export function HeaderWithDetails({ children, className, header, item_uuid, level, style, settings, subheader }) { - const showDetails = settings.expandedItems.includes(item_uuid) - const segmentStyle = { paddingLeft: "0px", paddingRight: "0px" } +export function HeaderWithDetails({ children, header, item_uuid, level, settings, subheader }) { + const showDetails = Boolean(settings.expandedItems.includes(item_uuid)) return ( - <Segment basic aria-expanded={showDetails} className={className} style={segmentStyle}> - <Header - as={level} - onClick={() => settings.expandedItems.toggle(item_uuid)} - onKeyPress={(event) => { - event.preventDefault() - settings.expandedItems.toggle(item_uuid) + <Accordion + disableGutters // Prevent the accordion summary from moving down when expanding the accordion + elevation={0} + expanded={showDetails} + onChange={() => settings.expandedItems.toggle(item_uuid)} + slotProps={{ transition: { unmountOnExit: true } }} // Make testing for (dis)appearance of contents easier + sx={{ + "&:before": { + display: "none", // Remove top border + }, + }} + > + <AccordionSummary + aria-controls={`accordion-content-${item_uuid}`} + expandIcon={<CaretRight />} + id={`accordion-header-${item_uuid}`} + sx={{ + border: "0", + flexDirection: "row-reverse", + height: "80px", + padding: "0px", + [`& .${accordionSummaryClasses.expandIconWrapper}.${accordionSummaryClasses.expanded}`]: { + transform: "rotate(90deg)", + }, + color: "primary.main", }} - style={style} - tabIndex="0" > - <ExpandButton expand={showDetails} /> - <Header.Content style={{ verticalAlign: "middle" }}> - {header} - <Header.Subheader>{subheader}</Header.Subheader> - </Header.Content> - </Header> - {showDetails && <Segment>{children}</Segment>} - </Segment> + <Header header={header} level={level} subheader={subheader} /> + </AccordionSummary> + <AccordionDetails>{children}</AccordionDetails> + </Accordion> ) } HeaderWithDetails.propTypes = { children: childrenPropType, - className: string, - header: node, + header: string, item_uuid: string, level: string, settings: settingsPropType, - style: object, subheader: string, } diff --git a/components/frontend/src/widgets/HeaderWithDetails.test.js b/components/frontend/src/widgets/HeaderWithDetails.test.js index 78f8ea0e6e..8cc13e9502 100644 --- a/components/frontend/src/widgets/HeaderWithDetails.test.js +++ b/components/frontend/src/widgets/HeaderWithDetails.test.js @@ -11,22 +11,20 @@ beforeEach(() => { it("expands the details on click", () => { render( - <HeaderWithDetails item_uuid="uuid" settings={createTestableSettings()}> + <HeaderWithDetails item_uuid="uuid" level="h1" settings={createTestableSettings()} header="Expand"> <p>Hello</p> </HeaderWithDetails>, ) - expect(screen.queryAllByText("Hello").length).toBe(0) - fireEvent.click(screen.getByTitle("expand")) + fireEvent.click(screen.getByText("Expand")) expect(history.location.search).toBe("?expanded=uuid") }) it("expands the details on space", async () => { render( - <HeaderWithDetails header="Header" item_uuid="uuid" settings={createTestableSettings()}> + <HeaderWithDetails header="Header" item_uuid="uuid" level="h1" settings={createTestableSettings()}> <p>Hello</p> </HeaderWithDetails>, ) - expect(screen.queryAllByText("Hello").length).toBe(0) await userEvent.tab() await userEvent.keyboard(" ") expect(history.location.search).toBe("?expanded=uuid") @@ -35,7 +33,7 @@ it("expands the details on space", async () => { it("is expanded on load when listed in the query string", () => { history.push("?expanded=uuid") render( - <HeaderWithDetails header="Header" item_uuid="uuid" settings={createTestableSettings()}> + <HeaderWithDetails header="Header" item_uuid="uuid" level="h1" settings={createTestableSettings()}> <p>Hello</p> </HeaderWithDetails>, ) diff --git a/components/frontend/src/widgets/HyperLink.js b/components/frontend/src/widgets/HyperLink.js index f63eb294de..dc08102421 100644 --- a/components/frontend/src/widgets/HyperLink.js +++ b/components/frontend/src/widgets/HyperLink.js @@ -13,6 +13,7 @@ export function HyperLink({ url, children }) { target="_blank" title="Opens new window or tab" underline="always" + variant="inherit" > {children} </Link> diff --git a/components/frontend/src/widgets/Label.js b/components/frontend/src/widgets/Label.js new file mode 100644 index 0000000000..554c7e9204 --- /dev/null +++ b/components/frontend/src/widgets/Label.js @@ -0,0 +1,28 @@ +import { Box } from "@mui/material" +import { string } from "prop-types" + +import { childrenPropType } from "../sharedPropTypes" + +export function Label({ color, children }) { + const bgcolor = `${color}.main` + const fgcolor = `${color}.contrastText` + return ( + <Box + className={color} + sx={{ + bgcolor: bgcolor, + color: fgcolor, + display: "inline-flex", + margin: "1px", + borderRadius: "4px", + padding: "4px", + }} + > + {children} + </Box> + ) +} +Label.propTypes = { + color: string, + children: childrenPropType, +} diff --git a/components/frontend/src/widgets/LabelWithDate.js b/components/frontend/src/widgets/LabelWithDate.js deleted file mode 100644 index 9e2ee1c080..0000000000 --- a/components/frontend/src/widgets/LabelWithDate.js +++ /dev/null @@ -1,38 +0,0 @@ -import { oneOfType, string } from "prop-types" -import TimeAgo from "react-timeago" - -import { datePropType, labelPropType, popupContentPropType } from "../sharedPropTypes" -import { LabelWithHelp } from "./LabelWithHelp" - -export function LabelWithDate({ date, labelId, label, help }) { - return ( - <LabelWithHelp - labelId={labelId} - label={ - <> - {label} - <LabelDate date={date} /> - </> - } - help={help} - /> - ) -} -LabelWithDate.propTypes = { - date: oneOfType([datePropType, string]), - labelId: string, - label: labelPropType, - help: popupContentPropType, -} - -export function LabelDate({ date }) { - return date ? ( - <span> - {" "} - (<TimeAgo date={date} />) - </span> - ) : null -} -LabelDate.propTypes = { - date: oneOfType([datePropType, string]), -} diff --git a/components/frontend/src/widgets/LabelWithDropdown.js b/components/frontend/src/widgets/LabelWithDropdown.js deleted file mode 100644 index 52e16c3d01..0000000000 --- a/components/frontend/src/widgets/LabelWithDropdown.js +++ /dev/null @@ -1,31 +0,0 @@ -import { array, func, string } from "prop-types" - -import { Dropdown } from "../semantic_ui_react_wrappers" -import { alignmentPropType, labelPropType } from "../sharedPropTypes" - -export function LabelWithDropdown({ color, direction, label, onChange, options, value }) { - return ( - <label> - {label} - <span style={{ paddingLeft: "6mm", color: color || "black" }}> - <Dropdown - color={color} - direction={direction} - inline - onChange={onChange} - options={options} - tabIndex="0" - value={value} - /> - </span> - </label> - ) -} -LabelWithDropdown.propTypes = { - color: string, - direction: alignmentPropType, - label: labelPropType, - onChange: func, - options: array, - value: string, -} diff --git a/components/frontend/src/widgets/LabelWithDropdown.test.js b/components/frontend/src/widgets/LabelWithDropdown.test.js deleted file mode 100644 index 6d38d0771b..0000000000 --- a/components/frontend/src/widgets/LabelWithDropdown.test.js +++ /dev/null @@ -1,80 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react" - -import { LabelWithDropdown } from "./LabelWithDropdown" - -it("shows the label", () => { - render(<LabelWithDropdown label="Hello" />) - expect(screen.getByText(/Hello/)).not.toBe(null) -}) - -it("can be colored", () => { - render( - <LabelWithDropdown - label="Hello" - color="red" - options={[ - { - key: "1", - value: "1", - text: "Option 1", - description: "1", - label: { color: "red" }, - }, - ]} - />, - ) - expect(screen.getByRole("listbox")).toHaveAttribute("color", "red") -}) - -it("has default color black", () => { - render( - <LabelWithDropdown - label="Hello" - options={[ - { - key: "1", - value: "1", - text: "Option 1", - description: "1", - label: { color: "red" }, - }, - ]} - />, - ) - expect(screen.getByRole("listbox")).not.toHaveAttribute("color") -}) - -it("changes the option", () => { - const mockCallback = jest.fn() - render( - <LabelWithDropdown - label="Hello" - onChange={mockCallback} - options={[ - { key: "1", value: "1", text: "Option 1" }, - { key: "2", value: "2", text: "Option 2" }, - ]} - value="1" - />, - ) - fireEvent.click(screen.getByText(/Option 2/)) - expect(mockCallback).toHaveBeenCalled() -}) - -it("opens the dropdown when clicking the current option", () => { - const mockCallback = jest.fn() - render( - <LabelWithDropdown - label="Hello" - onChange={mockCallback} - options={[ - { key: "1", value: "1", text: "Option 1" }, - { key: "2", value: "2", text: "Option 2" }, - ]} - value="1" - />, - ) - expect(screen.getByRole("listbox")).toHaveAttribute("aria-expanded", "false") - fireEvent.click(screen.getAllByText(/Option 1/)[0]) - expect(screen.getByRole("listbox")).toHaveAttribute("aria-expanded", "true") -}) diff --git a/components/frontend/src/widgets/LabelWithHelp.js b/components/frontend/src/widgets/LabelWithHelp.js deleted file mode 100644 index 1d744eb063..0000000000 --- a/components/frontend/src/widgets/LabelWithHelp.js +++ /dev/null @@ -1,27 +0,0 @@ -import HelpIcon from "@mui/icons-material/Help" -import { bool, string } from "prop-types" - -import { Popup } from "../semantic_ui_react_wrappers" -import { labelPropType, popupContentPropType } from "../sharedPropTypes" - -export function LabelWithHelp({ labelId, labelFor, label, help, hoverable }) { - return ( - <label id={labelId} htmlFor={labelFor}> - {label}{" "} - <Popup - hoverable={hoverable} - on={["hover", "focus"]} - content={help} - trigger={<HelpIcon fontSize="inherit" tabIndex="0" />} - wide - /> - </label> - ) -} -LabelWithHelp.propTypes = { - labelId: string, - labelFor: string, - label: labelPropType, - help: popupContentPropType, - hoverable: bool, -} diff --git a/components/frontend/src/widgets/LabelWithHelp.test.js b/components/frontend/src/widgets/LabelWithHelp.test.js deleted file mode 100644 index 0e2b471787..0000000000 --- a/components/frontend/src/widgets/LabelWithHelp.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react" -import userEvent from "@testing-library/user-event" - -import { LabelWithHelp } from "./LabelWithHelp" - -it("shows the label", () => { - render(<LabelWithHelp label="Hello" />) - expect(screen.getByText(/Hello/)).not.toBe(null) -}) - -it("shows the help", async () => { - render(<LabelWithHelp label="Hello" help="Help" />) - await userEvent.hover(screen.queryByTestId("HelpIcon")) - await waitFor(() => { - expect(screen.queryByText(/Help/)).not.toBe(null) - }) -}) diff --git a/components/frontend/src/widgets/LabelWithHyperLink.js b/components/frontend/src/widgets/LabelWithHyperLink.js deleted file mode 100644 index 954149b09f..0000000000 --- a/components/frontend/src/widgets/LabelWithHyperLink.js +++ /dev/null @@ -1,21 +0,0 @@ -import HelpIcon from "@mui/icons-material/Help" -import { string } from "prop-types" - -import { labelPropType } from "../sharedPropTypes" -import { HyperLink } from "./HyperLink" - -export function LabelWithHyperLink({ labelId, label, url }) { - return ( - <label id={labelId}> - {label}{" "} - <HyperLink url={url}> - <HelpIcon fontSize="inherit" tabIndex="0" /> - </HyperLink> - </label> - ) -} -LabelWithHyperLink.propTypes = { - labelId: string, - label: labelPropType, - url: string, -} diff --git a/components/frontend/src/widgets/LabelWithHyperLink.test.js b/components/frontend/src/widgets/LabelWithHyperLink.test.js deleted file mode 100644 index 9f4bd6f5f4..0000000000 --- a/components/frontend/src/widgets/LabelWithHyperLink.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from "@testing-library/react" - -import { LabelWithHyperLink } from "./LabelWithHyperLink" - -it("shows the label", () => { - render(<LabelWithHyperLink label="Hello" />) - expect(screen.getByText(/Hello/)).not.toBe(null) -}) diff --git a/components/frontend/src/widgets/ReadTheDocsLink.js b/components/frontend/src/widgets/ReadTheDocsLink.js index 87b6dde7b6..f0e8ecda5f 100644 --- a/components/frontend/src/widgets/ReadTheDocsLink.js +++ b/components/frontend/src/widgets/ReadTheDocsLink.js @@ -1,14 +1,9 @@ -import HelpIcon from "@mui/icons-material/Help" import { string } from "prop-types" import { HyperLink } from "./HyperLink" export function ReadTheDocsLink({ url }) { - return ( - <HyperLink url={url}> - Read the Docs <HelpIcon fontSize="small" sx={{ verticalAlign: "middle" }} /> - </HyperLink> - ) + return <HyperLink url={url}>Read the Docs</HyperLink> } ReadTheDocsLink.propTypes = { url: string, diff --git a/components/frontend/src/widgets/TabPane.css b/components/frontend/src/widgets/TabPane.css deleted file mode 100644 index 5dadaf7bbc..0000000000 --- a/components/frontend/src/widgets/TabPane.css +++ /dev/null @@ -1,14 +0,0 @@ -.tabbutton { - border: none; - background: none; - font: inherit; - padding: 0px; -} - -.tabbutton.inverted { - color: rgba(255, 255, 255, 0.87); -} - -.tabbutton:focus { - outline: thin dotted; -} diff --git a/components/frontend/src/widgets/TabPane.js b/components/frontend/src/widgets/TabPane.js deleted file mode 100644 index 5d578bd426..0000000000 --- a/components/frontend/src/widgets/TabPane.js +++ /dev/null @@ -1,57 +0,0 @@ -import "./TabPane.css" - -import HistoryIcon from "@mui/icons-material/History" -import SettingsIcon from "@mui/icons-material/Settings" -import { bool, element, oneOfType, string } from "prop-types" -import { useContext } from "react" -import { Menu } from "semantic-ui-react" - -import { DarkMode } from "../context/DarkMode" -import { Label, Tab } from "../semantic_ui_react_wrappers" - -function FocusableTab({ error, icon, image, label, warning }) { - const className = useContext(DarkMode) ? "tabbutton inverted" : "tabbutton" - let tabLabel = label - if (error || warning) { - const color = error ? "red" : "yellow" - tabLabel = <Label color={color}>{label}</Label> - } - return ( - <> - {icon || image} <button className={className}>{tabLabel}</button> - </> - ) -} -FocusableTab.propTypes = { - error: bool, - icon: element, - image: element, - label: oneOfType([element, string]), - warning: bool, -} - -export function tabPane(label, pane, options) { - // Return a tab and pane, to be used as follows: <Tab panes=[tabPane(...), tabPane(...)] .../> - return { - menuItem: ( - <Menu.Item key={label}> - <FocusableTab - error={options?.error} - icon={options?.icon} - image={options?.image} - label={label} - warning={options?.warning} - /> - </Menu.Item> - ), - render: () => <Tab.Pane>{pane}</Tab.Pane>, - } -} - -export function configurationTabPane(pane, options) { - return tabPane("Configuration", pane, { ...options, icon: <SettingsIcon /> }) -} - -export function changelogTabPane(pane, options) { - return tabPane("Changelog", pane, { ...options, icon: <HistoryIcon /> }) -} diff --git a/components/frontend/src/widgets/TabPane.test.js b/components/frontend/src/widgets/TabPane.test.js deleted file mode 100644 index 8587d2c9bd..0000000000 --- a/components/frontend/src/widgets/TabPane.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import StorageIcon from "@mui/icons-material/Storage" -import { render, screen } from "@testing-library/react" - -import { DarkMode } from "../context/DarkMode" -import { Tab } from "../semantic_ui_react_wrappers" -import { tabPane } from "./TabPane" - -it("shows the tab", () => { - render(<Tab panes={[tabPane("Tab")]} />) - expect(screen.queryAllByText("Tab").length).toBe(1) -}) - -it("is inverted in dark mode", () => { - const { container } = render( - <DarkMode.Provider value={true}> - <Tab panes={[tabPane("Tab")]} /> - </DarkMode.Provider>, - ) - expect(container.firstChild.firstChild.className).toEqual(expect.stringContaining("inverted")) -}) - -it("shows the tab red when there is an error", () => { - render(<Tab panes={[tabPane("Tab", <p>Pane</p>, { error: true })]} />) - expect(screen.getByText("Tab").className).toEqual(expect.stringContaining("red")) -}) - -it("shows the tab yellow when there is a warning", () => { - render(<Tab panes={[tabPane("Tab", <p>Pane</p>, { warning: true })]} />) - expect(screen.getByText("Tab").className).toEqual(expect.stringContaining("yellow")) -}) - -it("shows an icon", () => { - render(<Tab panes={[tabPane("Tab", <p>Pane</p>, { icon: <StorageIcon /> })]} />) - expect(screen.getAllByTestId("StorageIcon").length).toBe(1) -}) - -it("shows an image", () => { - const image = <img alt="" className="image" /> - const { container } = render(<Tab panes={[tabPane("Tab", <p>Pane</p>, { image: image })]} />) - expect(container.firstChild.firstChild.firstChild.firstChild.className).toEqual(expect.stringContaining("image")) -}) diff --git a/components/frontend/src/widgets/TableHeaderCell.js b/components/frontend/src/widgets/TableHeaderCell.js index f861f89f97..566a7060bf 100644 --- a/components/frontend/src/widgets/TableHeaderCell.js +++ b/components/frontend/src/widgets/TableHeaderCell.js @@ -1,7 +1,6 @@ -import { Tooltip } from "@mui/material" +import { TableCell, TableSortLabel, Tooltip } from "@mui/material" import { func, string } from "prop-types" -import { Table } from "../semantic_ui_react_wrappers" import { alignmentPropType, labelPropType, @@ -24,6 +23,10 @@ TableHeaderCellContents.propTypes = { label: labelPropType, } +function MuiSortDirection(sortDirection) { + return sortDirection === "ascending" ? "asc" : "desc" +} + export function SortableTableHeaderCell({ colSpan, column, @@ -34,16 +37,17 @@ export function SortableTableHeaderCell({ textAlign, help, }) { - const sorted = sortColumn.value === column ? sortDirection.value : null + const sorted = sortColumn.value === column ? MuiSortDirection(sortDirection.value) : null return ( - <Table.HeaderCell - colSpan={colSpan} - onClick={() => handleSort(column)} - sorted={sorted} - textAlign={textAlign || "left"} - > - <TableHeaderCellContents help={help} label={label} /> - </Table.HeaderCell> + <TableCell align={textAlign || "left"} colSpan={colSpan} sortDirection={sorted}> + <TableSortLabel + active={column === sortColumn.value} + direction={column === sortColumn.value ? MuiSortDirection(sortDirection.value) : "asc"} + onClick={() => handleSort(column)} + > + <TableHeaderCellContents help={help} label={label} /> + </TableSortLabel> + </TableCell> ) } SortableTableHeaderCell.propTypes = { @@ -59,9 +63,9 @@ SortableTableHeaderCell.propTypes = { export function UnsortableTableHeaderCell({ help, label, textAlign, width }) { return ( - <Table.HeaderCell className="unsortable" textAlign={textAlign} width={width}> + <TableCell align={textAlign} width={width}> <TableHeaderCellContents help={help} label={label} /> - </Table.HeaderCell> + </TableCell> ) } UnsortableTableHeaderCell.propTypes = { diff --git a/components/frontend/src/widgets/TableHeaderCell.test.js b/components/frontend/src/widgets/TableHeaderCell.test.js index 40b05e8df2..72e793885f 100644 --- a/components/frontend/src/widgets/TableHeaderCell.test.js +++ b/components/frontend/src/widgets/TableHeaderCell.test.js @@ -1,6 +1,6 @@ +import { Table, TableHead, TableRow } from "@mui/material" import { render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import { Table } from "semantic-ui-react" import { createTestableSettings } from "../__fixtures__/fixtures" import { SortableTableHeaderCell, UnsortableTableHeaderCell } from "./TableHeaderCell" @@ -9,16 +9,16 @@ function renderSortableTableHeaderCell(help) { const settings = createTestableSettings() render( <Table> - <Table.Header> - <Table.Row> + <TableHead> + <TableRow> <SortableTableHeaderCell label="Header" help={help} sortColumn={settings.sortColumn} sortDirection={settings.sortDirection} /> - </Table.Row> - </Table.Header> + </TableRow> + </TableHead> </Table>, ) } @@ -39,11 +39,11 @@ it("shows the help of the sortable header", async () => { function renderUnsortableTableHeaderCell(help) { render( <Table> - <Table.Header> - <Table.Row> + <TableHead> + <TableRow> <UnsortableTableHeaderCell label="Header" help={help} /> - </Table.Row> - </Table.Header> + </TableRow> + </TableHead> </Table>, ) } diff --git a/components/frontend/src/widgets/TableRowWithDetails.js b/components/frontend/src/widgets/TableRowWithDetails.js index c805f59a48..3156ac9ada 100644 --- a/components/frontend/src/widgets/TableRowWithDetails.js +++ b/components/frontend/src/widgets/TableRowWithDetails.js @@ -1,31 +1,42 @@ -import { bool, func, object } from "prop-types" +import { TableCell, TableRow } from "@mui/material" +import { bool, func, string } from "prop-types" -import { Table } from "../semantic_ui_react_wrappers" import { childrenPropType } from "../sharedPropTypes" import { ExpandButton } from "./buttons/ExpandButton" export function TableRowWithDetails(props) { - const { children, details, expanded, onExpand, style, ...otherProps } = props + const { color, children, details, expanded, onExpand, ...otherProps } = props return ( <> - <Table.Row {...otherProps}> - <Table.Cell collapsing textAlign="center" style={style}> + <TableRow + {...otherProps} + hover + sx={{ + bgcolor: `${color}.bgcolor`, + "&.MuiTableRow-hover": { + "&:hover": { + backgroundColor: `${color}.hover`, + }, + }, + }} + > + <TableCell align="center" sx={{ padding: "0px" }}> <ExpandButton expand={expanded} onClick={() => onExpand(!expanded)} size="1.5em" /> - </Table.Cell> + </TableCell> {children} - </Table.Row> + </TableRow> {expanded && ( - <Table.Row> - <Table.Cell colSpan="99">{details}</Table.Cell> - </Table.Row> + <TableRow> + <TableCell colSpan="99">{details}</TableCell> + </TableRow> )} </> ) } TableRowWithDetails.propTypes = { children: childrenPropType, + color: string, details: childrenPropType, expanded: bool, onExpand: func, - style: object, } diff --git a/components/frontend/src/widgets/TableRowWithDetails.test.js b/components/frontend/src/widgets/TableRowWithDetails.test.js index 5bcac28cb3..72da7d5376 100644 --- a/components/frontend/src/widgets/TableRowWithDetails.test.js +++ b/components/frontend/src/widgets/TableRowWithDetails.test.js @@ -1,15 +1,15 @@ +import { Table, TableBody } from "@mui/material" import { fireEvent, render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import { Table } from "semantic-ui-react" import { TableRowWithDetails } from "./TableRowWithDetails" function renderTableRowWithDetails(expanded, onExpand) { render( <Table> - <Table.Body> + <TableBody> <TableRowWithDetails expanded={expanded} onExpand={onExpand} details={"Details"} /> - </Table.Body> + </TableBody> </Table>, ) } diff --git a/components/frontend/src/widgets/Tabs.js b/components/frontend/src/widgets/Tabs.js new file mode 100644 index 0000000000..28a716c887 --- /dev/null +++ b/components/frontend/src/widgets/Tabs.js @@ -0,0 +1,51 @@ +import { Box, Stack, Tab, Tabs as MUITabs } from "@mui/material" +import { arrayOf, object } from "prop-types" +import { useId, useState } from "react" + +import { childrenPropType } from "../sharedPropTypes" +import { Label } from "./Label" + +export function Tabs({ children, tabs }) { + const tabsId = useId() + const [tabIndex, setTabIndex] = useState(0) + return ( + <Stack> + <MUITabs + value={tabIndex} + onChange={(_event, newTabIndex) => setTabIndex(newTabIndex)} + scrollButtons="auto" + sx={{ marginBottom: 1, maxWidth: "95vw" }} + variant="scrollable" + > + {tabs.map((tab, index) => { + let tabLabel = tab.label + if (tab.error || tab.warning) { + const color = tab.error ? "error" : "warning" + tabLabel = <Label color={color}>{tab.label}</Label> + } + return ( + <Tab + id={`tab-${tabsId}-${index}`} + icon={tab.icon || tab.image} + key={tab.label} + label={tabLabel} + //sx={{ flex: 1 }} + /> + ) + })} + </MUITabs> + <Box + aria-labelledby={`tab-${tabsId}-${tabIndex}`} + id={`tabpanel-${tabsId}-${tabIndex}`} + sx={{ border: 1, padding: 2, borderColor: "divider", marginBottom: 2 }} + role="tabpanel" + > + {children[tabIndex]} + </Box> + </Stack> + ) +} +Tabs.propTypes = { + children: childrenPropType, + tabs: arrayOf(object), +} diff --git a/components/frontend/src/widgets/WarningMessage.js b/components/frontend/src/widgets/WarningMessage.js index 86dea71c07..18de1cfe31 100644 --- a/components/frontend/src/widgets/WarningMessage.js +++ b/components/frontend/src/widgets/WarningMessage.js @@ -1,21 +1,40 @@ -import { bool } from "prop-types" +import { Alert, AlertTitle } from "@mui/material" +import { bool, string } from "prop-types" -import { Message } from "../semantic_ui_react_wrappers" +import { childrenPropType } from "../sharedPropTypes" -export function WarningMessage(props) { +export function WarningMessage({ children, title, showIf }) { // Show a warning message if showIf is true or undefined - const { showIf, ...messageProps } = props - return (showIf ?? true) ? <Message warning {...messageProps} /> : null + return (showIf ?? true) ? ( + <Alert severity="warning"> + <AlertTitle>{title}</AlertTitle> + {children} + </Alert> + ) : null } WarningMessage.propTypes = { + children: childrenPropType, showIf: bool, + title: string, } export function FailedToLoadMeasurementsWarningMessage() { return ( - <WarningMessage - content="Loading the measurements from the API-server failed." - header="Loading measurements failed" - /> + <WarningMessage title="Loading measurements failed"> + Loading the measurements from the API-server failed. + </WarningMessage> ) } + +export function InfoMessage({ children, title }) { + return ( + <Alert severity="info"> + <AlertTitle>{title}</AlertTitle> + {children} + </Alert> + ) +} +InfoMessage.propTypes = { + children: childrenPropType, + title: string, +} diff --git a/components/frontend/src/widgets/WarningMessage.test.js b/components/frontend/src/widgets/WarningMessage.test.js index bc859c756f..cfc5fb9ab7 100644 --- a/components/frontend/src/widgets/WarningMessage.test.js +++ b/components/frontend/src/widgets/WarningMessage.test.js @@ -3,16 +3,16 @@ import { render, screen } from "@testing-library/react" import { WarningMessage } from "./WarningMessage" it("shows a warning message if showIf is true", () => { - render(<WarningMessage header="Warning" showIf={true} />) + render(<WarningMessage showIf={true}>Warning</WarningMessage>) expect(screen.getAllByText("Warning").length).toBe(1) }) it("does not show a warning message if showIf is false", () => { - render(<WarningMessage header="Warning" showIf={false} />) + render(<WarningMessage showIf={false}>Warning</WarningMessage>) expect(screen.queryAllByText("Warning").length).toBe(0) }) it("shows a warning message if showIf is undefined", () => { - render(<WarningMessage header="Warning" />) + render(<WarningMessage>Warning</WarningMessage>) expect(screen.getAllByText("Warning").length).toBe(1) }) diff --git a/components/frontend/src/widgets/icons.js b/components/frontend/src/widgets/icons.js index 3d5fcb39cf..55fa0fe45f 100644 --- a/components/frontend/src/widgets/icons.js +++ b/components/frontend/src/widgets/icons.js @@ -42,7 +42,7 @@ export function DeleteItemIcon() { } export function IgnoreIcon() { - return <VisibilityOffIcon fontSize="inherit" sx={{ verticalAlign: "middle" }} /> + return <VisibilityOffIcon className="hide icon" fontSize="inherit" sx={{ verticalAlign: "middle" }} /> } export function MoveItemIcon() { diff --git a/tests/application_tests/src/test_report.py b/tests/application_tests/src/test_report.py index 35466abc4f..f39aeaba57 100644 --- a/tests/application_tests/src/test_report.py +++ b/tests/application_tests/src/test_report.py @@ -45,6 +45,7 @@ class OpenReportTest(unittest.TestCase): # Class names of MUI-components used in the tests DASHBOARD_CARD_CLASS_NAME = "MuiCard-root" DASHBOARD_CARD_HEADER_CONTENT_CLASS_NAME = "MuiCardHeader-content" + REPORT_HEADER_CLASS_NAME = "MuiAccordionSummary-content" def setUp(self): """Override to setup the driver.""" @@ -84,9 +85,8 @@ def test_open_report(self): report = self.dashboard_cards()[-1] # The last card is a report report_title = report.find_element(By.CLASS_NAME, self.DASHBOARD_CARD_HEADER_CONTENT_CLASS_NAME) report.click() - self.assertTrue( - expect.text_to_be_present_in_element(self.driver.find_element(By.CLASS_NAME, "header"), report_title) - ) + report_header = self.driver.find_element(By.CLASS_NAME, self.REPORT_HEADER_CLASS_NAME) + self.assertTrue(expect.text_to_be_present_in_element(report_header, report_title)) def test_login_and_logout(self): """Test that a user can login and logout."""