From 5b80abd80eb7bf02d81fc3591a3326f6df72fd34 Mon Sep 17 00:00:00 2001
From: Frank Niessink
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 | 98 +++---
components/frontend/src/AppUI.test.js | 36 +-
components/frontend/src/PageContent.js | 35 +-
components/frontend/src/PageContent.test.js | 4 +-
components/frontend/src/app_ui_settings.js | 26 --
components/frontend/src/context/DarkMode.js | 3 -
.../frontend/src/context/Permissions.js | 7 +-
.../frontend/src/context/Permissions.test.js | 24 ++
.../src/dashboard/CardDashboard.test.js | 7 +-
.../frontend/src/dashboard/DashboardCard.js | 16 +-
.../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 | 14 +-
.../dashboard/MetricsRequiringActionCard.js | 34 +-
.../frontend/src/dashboard/PageHeader.js | 43 +++
...{ExportCard.test.js => PageHeader.test.js} | 27 +-
.../src/dashboard/StatusBarChart.test.js | 8 +-
.../src/dashboard/StatusPieChart.test.js | 8 +-
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 | 65 ++++
.../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 | 98 ++++++
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 | 38 +-
.../src/header_footer/buttons/HomeButton.js | 2 +-
.../settings_menu/SettingsMenu.js | 20 +-
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 | 140 +++-----
.../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 | 20 +-
.../frontend/src/measurement/TimeLeft.js | 14 +-
.../src/measurement/TrendSparkline.js | 7 +-
.../src/measurement/TrendSparkline.test.js | 10 -
.../metric/MetricConfigurationParameters.js | 246 +++++++------
.../MetricConfigurationParameters.test.js | 6 +-
.../src/metric/MetricDebtParameters.js | 160 ++++-----
.../src/metric/MetricDebtParameters.test.js | 69 ++--
.../frontend/src/metric/MetricDetails.js | 124 +++----
.../frontend/src/metric/MetricDetails.test.js | 59 ++--
components/frontend/src/metric/MetricType.js | 47 ++-
.../frontend/src/metric/MetricType.test.js | 23 +-
.../frontend/src/metric/MetricTypeHeader.js | 24 --
.../src/metric/MetricTypeHeader.test.js | 27 --
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 | 42 +--
.../frontend/src/metric/TrendGraph.test.js | 23 +-
components/frontend/src/metric/status.js | 38 +-
.../notification/NotificationDestinations.js | 113 +++---
.../NotificationDestinations.test.js | 2 +-
.../frontend/src/report/IssueTracker.js | 332 ++++++++----------
.../frontend/src/report/IssueTracker.test.js | 48 +--
components/frontend/src/report/Report.js | 50 +--
components/frontend/src/report/Report.test.js | 33 +-
.../src/report/ReportDashboard.test.js | 14 +-
.../frontend/src/report/ReportErrorMessage.js | 33 --
components/frontend/src/report/ReportTitle.js | 223 ++++++------
.../frontend/src/report/ReportTitle.test.js | 76 ++--
.../frontend/src/report/ReportsOverview.js | 19 +-
.../src/report/ReportsOverview.test.js | 32 +-
.../report/ReportsOverviewDashboard.test.js | 19 +-
.../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 | 120 ++++---
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 | 115 +++---
.../frontend/src/source/SourceParameters.js | 29 +-
.../src/source/SourceParameters.test.js | 4 +-
components/frontend/src/source/SourceType.js | 55 ++-
.../frontend/src/source/SourceType.test.js | 30 +-
.../frontend/src/source/SourceTypeHeader.js | 35 --
.../src/source/SourceTypeHeader.test.js | 55 ---
components/frontend/src/source/Sources.js | 14 +-
.../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 | 65 ++--
.../frontend/src/subject/SubjectTable.test.js | 7 +-
.../frontend/src/subject/SubjectTableBody.js | 6 +-
.../src/subject/SubjectTableFooter.js | 16 +-
.../src/subject/SubjectTableFooter.test.js | 2 +-
.../src/subject/SubjectTableHeader.js | 84 ++---
.../src/subject/SubjectTableHeader.test.js | 12 +-
.../frontend/src/subject/SubjectTableRow.js | 75 ++--
.../src/subject/SubjectTableRow.test.js | 6 +-
.../frontend/src/subject/SubjectTitle.js | 67 ++--
.../frontend/src/subject/SubjectTitle.test.js | 11 +-
.../frontend/src/subject/SubjectType.js | 32 +-
.../frontend/src/subject/SubjectsButtonRow.js | 57 ++-
components/frontend/src/theme.js | 161 +++++++++
components/frontend/src/utils.js | 5 -
components/frontend/src/utils.test.js | 32 --
components/frontend/src/widgets/ButtonRow.js | 20 +-
.../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 | 16 +
.../src/widgets/HeaderWithDetails.css | 9 -
.../frontend/src/widgets/HeaderWithDetails.js | 67 ++--
.../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 +-
.../frontend/src/widgets/menu_options.js | 2 +-
.../frontend/src/widgets/menu_options.test.js | 4 +-
components/frontend/src/widgets/toast.js | 2 +-
components/frontend/src/widgets/toast.test.js | 8 +-
docs/src/changelog.md | 6 +
docs/src/screenshots/adding_metric.png | Bin 65706 -> 77230 bytes
docs/src/screenshots/adding_metric_dark.png | Bin 57262 -> 111197 bytes
docs/src/screenshots/adding_source.png | Bin 84613 -> 106078 bytes
docs/src/screenshots/adding_source_dark.png | Bin 85482 -> 227844 bytes
docs/src/screenshots/adding_subject.png | Bin 39007 -> 147305 bytes
docs/src/screenshots/adding_subject_dark.png | Bin 37435 -> 272483 bytes
docs/src/screenshots/dashboard_tags.png | Bin 98075 -> 92568 bytes
docs/src/screenshots/dashboard_tags_dark.png | Bin 92683 -> 114513 bytes
.../editing_metric_configuration.png | Bin 198875 -> 293208 bytes
.../editing_metric_configuration_dark.png | Bin 200741 -> 723221 bytes
.../editing_metric_technical_debt.png | Bin 187745 -> 243223 bytes
.../editing_metric_technical_debt_dark.png | Bin 187647 -> 620498 bytes
.../editing_quality_time_source.png | Bin 222101 -> 285838 bytes
.../editing_quality_time_source_dark.png | Bin 222639 -> 1059546 bytes
docs/src/screenshots/editing_report.png | Bin 114509 -> 118466 bytes
docs/src/screenshots/editing_report_dark.png | Bin 110776 -> 119251 bytes
docs/src/screenshots/editing_source.png | Bin 213715 -> 324587 bytes
docs/src/screenshots/editing_source_dark.png | Bin 209581 -> 1094895 bytes
docs/src/screenshots/editing_subject.png | Bin 128700 -> 143299 bytes
docs/src/screenshots/editing_subject_dark.png | Bin 131405 -> 143711 bytes
docs/src/screenshots/login_dialog.png | Bin 27466 -> 81512 bytes
docs/src/screenshots/login_dialog_dark.png | Bin 29501 -> 112379 bytes
docs/src/screenshots/menubar_logged_in.png | Bin 14982 -> 43729 bytes
.../screenshots/menubar_logged_in_dark.png | Bin 0 -> 41100 bytes
docs/src/screenshots/menubar_logged_out.png | Bin 12748 -> 40291 bytes
.../screenshots/menubar_logged_out_dark.png | Bin 0 -> 37720 bytes
docs/src/screenshots/metric_details.png | Bin 91516 -> 365280 bytes
docs/src/screenshots/metric_details_dark.png | Bin 93621 -> 1151242 bytes
docs/src/screenshots/metric_entities.png | Bin 153704 -> 463077 bytes
docs/src/screenshots/metric_entities_dark.png | Bin 158176 -> 970931 bytes
docs/src/screenshots/metric_trendgraph.png | Bin 65433 -> 223398 bytes
.../screenshots/metric_trendgraph_dark.png | Bin 72728 -> 741733 bytes
docs/src/screenshots/metrics.png | Bin 91299 -> 234237 bytes
docs/src/screenshots/metrics_dark.png | Bin 92885 -> 470392 bytes
docs/src/screenshots/projects_dashboard.png | Bin 278305 -> 230510 bytes
.../screenshots/projects_dashboard_dark.png | Bin 246459 -> 234851 bytes
docs/src/usage.md | 38 +-
tests/application_tests/src/test_report.py | 6 +-
240 files changed, 3539 insertions(+), 5682 deletions(-)
delete mode 100644 components/frontend/src/context/DarkMode.js
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
delete mode 100644 components/frontend/src/metric/MetricTypeHeader.js
delete mode 100644 components/frontend/src/metric/MetricTypeHeader.test.js
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
delete mode 100644 components/frontend/src/source/SourceTypeHeader.js
delete mode 100644 components/frontend/src/source/SourceTypeHeader.test.js
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
create mode 100644 docs/src/screenshots/menubar_logged_in_dark.png
create mode 100644 docs/src/screenshots/menubar_logged_out_dark.png
diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json
index 656f641f36..adcf0c3d06 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.3",
- "semantic-ui-react": "^2.1.5",
"victory": "^37.3.6"
},
"devDependencies": {
@@ -2486,21 +2483,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",
@@ -2513,42 +2495,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": {
@@ -3708,20 +3655,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,
@@ -6909,6 +6842,8 @@
"node_modules/date-fns": {
"version": "3.6.0",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -8254,12 +8189,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,
@@ -8607,15 +8536,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,
@@ -11018,12 +10938,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"
@@ -11229,12 +11143,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,
@@ -11382,12 +11290,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,
@@ -13827,21 +13729,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,
@@ -13963,21 +13850,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,
@@ -15271,40 +15143,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,
@@ -15484,12 +15322,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,
@@ -16337,12 +16169,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,
@@ -17581,15 +17407,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 e29b9bb622..80045822b9 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.3",
- "semantic-ui-react": "^2.1.5",
"victory": "^37.3.6"
},
"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 (
+
-
-
-
-
+
+
+ }
+ settings={settings}
+ setUIMode={setMode}
+ uiMode={mode}
+ />
+
+
+
+
- }
+ reports={reports}
+ reports_overview={reports_overview}
settings={settings}
- setUIMode={setMode}
- uiMode={mode}
/>
-
-
-
-
-
-
-
-
-
-
+
+
+
+
)
}
AppUI.propTypes = {
diff --git a/components/frontend/src/AppUI.test.js b/components/frontend/src/AppUI.test.js
index 861dd0dba8..b2c406c58c 100644
--- a/components/frontend/src/AppUI.test.js
+++ b/components/frontend/src/AppUI.test.js
@@ -1,3 +1,4 @@
+import { ThemeProvider } from "@mui/material/styles"
import { act, fireEvent, render, screen } from "@testing-library/react"
import history from "history/browser"
@@ -5,6 +6,7 @@ import { dataModel, report } from "./__fixtures__/fixtures"
import * as fetch_server_api from "./api/fetch_server_api"
import { AppUI } from "./AppUI"
import { mockGetAnimations } from "./dashboard/MockAnimations"
+import { theme } from "./theme"
beforeEach(() => {
fetch_server_api.fetch_server_api = jest.fn().mockReturnValue({
@@ -17,22 +19,30 @@ beforeEach(() => {
afterEach(() => jest.restoreAllMocks())
it("shows an error message when there are no reports", async () => {
- await act(async () => render( ))
+ await act(async () =>
+ render(
+
+
+ ,
+ ),
+ )
expect(screen.getAllByText(/Sorry, no reports/).length).toBe(1)
})
it("handles sorting", async () => {
await act(async () =>
render(
- ,
+
+
+ ,
),
)
fireEvent.click(screen.getAllByText("Comment")[0])
@@ -52,7 +62,11 @@ it("handles sorting", async () => {
async function renderAppUI() {
return await act(async () =>
- render( ),
+ render(
+
+
+ ,
+ ),
)
}
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 = (
-
-
-
+
+
+
)
} else {
const commonProps = {
@@ -108,7 +116,22 @@ export function PageContent({
}
}
return (
-
+
{content}
)
diff --git a/components/frontend/src/PageContent.test.js b/components/frontend/src/PageContent.test.js
index 7f1c28c37d..f11cba0fa2 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) {
@@ -113,7 +113,7 @@ it("fails to load measurements", async () => {
await renderPageContent()
expect(react_toastify.toast.mock.calls[0][0]).toStrictEqual(
-
Could not fetch measurements
+
Could not fetch measurements
Error description
,
)
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/DarkMode.js b/components/frontend/src/context/DarkMode.js
deleted file mode 100644
index fe2074937c..0000000000
--- a/components/frontend/src/context/DarkMode.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import React from "react"
-
-export const DarkMode = React.createContext(null)
diff --git a/components/frontend/src/context/Permissions.js b/components/frontend/src/context/Permissions.js
index 2fafa48e1d..9e0fd0395c 100644
--- a/components/frontend/src/context/Permissions.js
+++ b/components/frontend/src/context/Permissions.js
@@ -10,10 +10,13 @@ export const PERMISSIONS = [EDIT_REPORT_PERMISSION, EDIT_ENTITY_PERMISSION]
export const Permissions = React.createContext(null)
export function accessGranted(permissions, requiredPermissions) {
- if (!requiredPermissions) {
+ if (!(requiredPermissions instanceof Array)) {
+ return false
+ }
+ 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..9030cd7427 100644
--- a/components/frontend/src/context/Permissions.test.js
+++ b/components/frontend/src/context/Permissions.test.js
@@ -50,3 +50,27 @@ 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(
+
+ }
+ editableComponent={ }
+ />
+ ,
+ )
+ expect(screen.queryAllByText("One").length).toBe(0)
+ expect(screen.queryAllByText("Two").length).toBe(1)
+})
+
+it("shows the read-only component if required permissions are missing", () => {
+ render(
+
+ } editableComponent={ } />
+ ,
+ )
+ expect(screen.queryAllByText("One").length).toBe(1)
+ expect(screen.queryAllByText("Two").length).toBe(0)
+})
diff --git a/components/frontend/src/dashboard/CardDashboard.test.js b/components/frontend/src/dashboard/CardDashboard.test.js
index 00406dfc6d..dd566138e6 100644
--- a/components/frontend/src/dashboard/CardDashboard.test.js
+++ b/components/frontend/src/dashboard/CardDashboard.test.js
@@ -1,7 +1,8 @@
+import { ThemeProvider } from "@mui/material/styles"
import { fireEvent, render, screen } from "@testing-library/react"
-import { DarkMode } from "../context/DarkMode"
import { EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { theme } from "../theme"
import { CardDashboard } from "./CardDashboard"
import { MetricSummaryCard } from "./MetricSummaryCard"
import { mockGetAnimations } from "./MockAnimations"
@@ -12,13 +13,13 @@ afterEach(() => jest.restoreAllMocks())
function renderCardDashboard({ cards = [], initialLayout = [], saveLayout = jest.fn } = {}) {
return render(
-
+
- ,
+ ,
)
}
diff --git a/components/frontend/src/dashboard/DashboardCard.js b/components/frontend/src/dashboard/DashboardCard.js
index 27078e8df6..8addbf7771 100644
--- a/components/frontend/src/dashboard/DashboardCard.js
+++ b/components/frontend/src/dashboard/DashboardCard.js
@@ -4,18 +4,17 @@ import { bool, element, func, oneOfType, string } from "prop-types"
import { childrenPropType } from "../sharedPropTypes"
export function DashboardCard({ children, onClick, selected, title, titleFirst }) {
- const color = selected ? "info.main" : null
+ const color = selected ? "info.main" : "divider"
const header = (
)
@@ -24,7 +23,12 @@ export function DashboardCard({ children, onClick, selected, title, titleFirst }
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 ? (
-
- {item}
-
- ) : (
- {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 = [
-
-
- {report.title}
-
- ,
-
-
- {"Report date: " + formatDate(reportDate ?? new Date())}
-
- ,
-
-
-
- {"Generated: " + formatDate(lastUpdate) + ", " + formatTime(lastUpdate)}
-
-
- ,
-
-
-
- Quality-time v{process.env.REACT_APP_VERSION}
-
-
- ,
- ]
- return (
-
-
-
- {title}
-
- {listItems}
-
-
- )
-}
-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 (
-
- {children}
+
)
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) => (
-
- {capitalize(status)}
-
-
-
-
+
+ {capitalize(status)}
+
+
+
+
))
}
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) => (
-
-
+
))
return (
- {listItems}
+ {listItems}
)
}
diff --git a/components/frontend/src/dashboard/MetricSummaryCard.js b/components/frontend/src/dashboard/MetricSummaryCard.js
index 8bda21569c..3c9c9724b7 100644
--- a/components/frontend/src/dashboard/MetricSummaryCard.js
+++ b/components/frontend/src/dashboard/MetricSummaryCard.js
@@ -1,12 +1,11 @@
import "./MetricSummaryCard.css"
+import { useTheme } from "@mui/material"
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 { STATUSES } from "../metric/status"
import { pluralize, sum } from "../utils"
import { DashboardCard } from "./DashboardCard"
import { StatusBarChart } from "./StatusBarChart"
@@ -48,10 +47,8 @@ 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 colors = STATUSES.map((status) => useTheme().palette[status].main)
const bbWidth = boundingBox.width ?? 0
const bbHeight = boundingBox.height ?? 0
const tooltip = (
@@ -60,9 +57,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 +68,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: (
(
-
- {STATUS_NAME[status]}
-
-
- {statuses[status]}
-
-
-
+
+ {STATUS_NAME[status]}
+
+
+
+
))
rows.push(
-
-
+
+
Total
-
-
-
- {sum(Object.values(statuses))}
-
-
- ,
+
+
+
+
+ ,
)
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 (
+
+
+ {title}
+
+ {"Report date: " + formatDate(reportDate ?? new Date())}
+
+ {"Generated: " + formatDate(lastUpdate) + ", " + formatTime(lastUpdate)}
+
+
+ Quality-time v{process.env.REACT_APP_VERSION}
+
+
+ )
+}
+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..f2eade23f9 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,37 @@ const report = {
},
}
-function renderExportCard({ isOverview = false, lastUpdate = new Date(), report = null, reportDate = null } = {}) {
- render( )
+function renderPageHeader({ lastUpdate = new Date(), report = null, reportDate = null } = {}) {
+ render( )
}
-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", () => {
+ 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/dashboard/StatusBarChart.test.js b/components/frontend/src/dashboard/StatusBarChart.test.js
index 7d6666d211..f8d9873e0a 100644
--- a/components/frontend/src/dashboard/StatusBarChart.test.js
+++ b/components/frontend/src/dashboard/StatusBarChart.test.js
@@ -1,6 +1,8 @@
+import { ThemeProvider } from "@mui/material/styles"
import { queryAllByRole, queryAllByText, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
+import { theme } from "../theme"
import { MetricSummaryCard } from "./MetricSummaryCard"
function renderBarChart(maxY, red) {
@@ -8,7 +10,11 @@ function renderBarChart(maxY, red) {
"2023-01-02": { blue: 0, red: red, green: 0, yellow: 0, white: 0, grey: 0 },
"2023-01-01": { blue: 0, red: red, green: 0, yellow: 0, white: 0, grey: 0 },
}
- return render( )
+ return render(
+
+
+ ,
+ )
}
const dateString = new Date("2023-01-02").toLocaleDateString()
diff --git a/components/frontend/src/dashboard/StatusPieChart.test.js b/components/frontend/src/dashboard/StatusPieChart.test.js
index c1b95c21ff..26844f538e 100644
--- a/components/frontend/src/dashboard/StatusPieChart.test.js
+++ b/components/frontend/src/dashboard/StatusPieChart.test.js
@@ -1,10 +1,16 @@
+import { ThemeProvider } from "@mui/material/styles"
import { queryAllByRole, queryAllByText, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
+import { theme } from "../theme"
import { MetricSummaryCard } from "./MetricSummaryCard"
function renderPieChart({ summary = { blue: 0, red: 0, green: 0, yellow: 0, white: 0, grey: 0 } } = {}) {
- return render( )
+ return render(
+
+
+ ,
+ )
}
const dateString = new Date("2023-01-01").toLocaleDateString()
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 (
-
-
-
- {title}
- {formatAsText ? (
- message
- ) : (
- {message}
- )}
-
-
-
- )
-}
-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 (
- Comment}
- 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( )
- 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 (
+
+ )
+}
+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 (
-
-
-
-
- {
- let dateValue = null
- if (newDate !== null) {
- dateValue = toISODateStringInCurrentTZ(newDate)
- }
- set_value(dateValue)
- }}
- placeholderText={placeholder}
- />
-
- )
-}
-EditableDateInput.propTypes = {
- ariaLabelledBy: string,
- label: labelPropType,
- placeholder: string,
- required: bool,
- set_value: func,
- value: string,
-}
-
-export function DateInput(props) {
- return (
-
- )
-}
-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(
-
-
- ,
- )
-}
-
-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 (
- {
- submit_if_changed()
- }}
- onChange={(event) => setValue(event.target.value)}
- onKeyDown={onKeyDown}
- value={value}
- >
- {prefix ? {prefix} : null}
-
-
- )
-}
-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( )
- 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(
- <>
-
-
- >,
- )
- 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( )
- 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( )
- 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( )
- expect(container.getElementsByTagName("input")[0]).toBeInvalid()
-})
-
-it("does not show an error for required non-empty fields", () => {
- const { container } = render( )
- expect(container.getElementsByTagName("input")[0]).toBeValid()
-})
-
-it("does not show an error for non-required empty fields", () => {
- const { container } = render( )
- expect(container.getElementsByTagName("input")[0]).toBeValid()
-})
-
-it("renders in error state if the warning props is true", () => {
- const { container } = render( )
- 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 (
- {
- 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 ? {prefix} : null}
-
- {unit ? {unit} : null}
-
-
- )
-}
-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 (
-
-
-
- }
- editableComponent={ }
- />
- )
-}
-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( )
- expect(screen.queryAllByDisplayValue(/42/).length).toBe(1)
-})
-
-it("renders and edits the value", async () => {
- let setValue = jest.fn()
- render( )
- 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(
- <>
-
-
- >,
- )
- 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( )
- 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( )
- 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( )
- 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( )
- 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( )
- 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( )
- 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( )
- 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( )
- expect(screen.getByDisplayValue(/12/)).toBeInvalid()
-})
-
-it("renders values more than the minimum as valid", () => {
- render( )
- expect(screen.getByDisplayValue(/42/)).toBeValid()
-})
-
-it("renders values more than the maximum as invalid", () => {
- render( )
- expect(screen.getByDisplayValue(/42/)).toBeInvalid()
-})
-
-it("renders values less than the maximum as valid", () => {
- render( )
- expect(screen.getByDisplayValue(/42/)).toBeValid()
-})
-
-it("renders missing value as 0", () => {
- render( )
- expect(screen.queryAllByDisplayValue(/0/).length).toBe(1)
-})
-
-it("renders missing value as empty if empty allowed", () => {
- render( )
- 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..56420f9007
--- /dev/null
+++ b/components/frontend/src/fields/MultipleChoiceField.js
@@ -0,0 +1,65 @@
+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 (
+ x} // Disable built-in filtering
+ filterSelectedOptions
+ freeSolo={freeSolo} // Allow additional options
+ fullWidth
+ multiple
+ options={options}
+ onChange={(_event, value) => onChange(value.map((value) => value?.id ?? value))}
+ onInputChange={onInputChange}
+ renderInput={(params) => {
+ return (
+
+ {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 (
- 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}
- />
- }
- />
-
- )
-}
-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(
- ,
- )
- expect(screen.getByDisplayValue(/hello, world/)).not.toBe(null)
-})
-
-it("renders an empty read only value", () => {
- render( )
- expect(screen.queryByDisplayValue(/hello/)).toBe(null)
-})
-
-it("renders in error state if a required value is missing", () => {
- render( )
- expect(screen.getByRole("combobox")).toBeInvalid()
-})
-
-it("does not render in error state if a required value is present", () => {
- render( )
- expect(screen.getByRole("combobox")).toBeValid()
-})
-
-function renderMultipleChoiceInput(options = [], value = ["hello"]) {
- let mockSetValue = jest.fn()
- render(
-
-
- ,
- )
- 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 (
-
- )
-}
-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( )
-}
-
-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 (
-
- {prefix ? {prefix} : null}
-
- {unit ? {unit} : null}
-
- )
-}
-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(
- ,
- )
-}
-
-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 (
- {
- 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 (
-
- )
-}
-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(
- ,
- )
- expect(screen.getByDisplayValue(/hello/)).not.toBe(null)
-})
-
-it("renders the editable value", () => {
- render(
-
-
- ,
- )
- expect(screen.getByDisplayValue(/hello/)).not.toBe(null)
-})
-
-it("invokes the callback on a change", () => {
- let mockSetValue = jest.fn()
- render(
- ,
- )
- 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(
- ,
- )
- fireEvent.click(screen.getAllByText(/hello/)[1])
- expect(mockSetValue).not.toHaveBeenCalled()
-})
-
-it("does sort by default", () => {
- render(
- ,
- )
- 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(
- ,
- )
- const options = screen.getAllByRole("option")
- expect(options[0]).toHaveTextContent("option-b")
- expect(options[1]).toHaveTextContent("option-a")
-})
-
-it("does sort when told to", () => {
- render(
- ,
- )
- 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: {placeholder} , value: "", key: "" },
- ])
- const [searchQuery, setSearchQuery] = useState(initialValue)
- return (
- {
- 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 =
- const inputWithSuggestions =
- return (
-
- )
-}
-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(
-
-
- ,
- )
-}
-
-it("renders the value of the input", () => {
- renderStringInput()
- expect(screen.getByDisplayValue(/Option 1/)).not.toBe(null)
-})
-
-it("renders a missing value", () => {
- render( )
- 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( )
- 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( )
- expect(screen.getByRole("combobox")).toBeInvalid()
-})
-
-it("does not show an error for required non-empty fields", () => {
- render( )
- expect(screen.getByRole("combobox")).toBeValid()
-})
-
-it("does not show an error for non-required empty fields", () => {
- render( )
- expect(screen.getByRole("combobox")).toBeValid()
-})
-
-it("does not show an error for non-required non-empty fields", () => {
- render( )
- expect(screen.getByRole("combobox")).toBeValid()
-})
-
-it("shows an error", () => {
- render( )
- expect(screen.getByRole("combobox")).toBeInvalid()
-})
-
-it("shows a warning", () => {
- render( )
- 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..57b89515e5
--- /dev/null
+++ b/components/frontend/src/fields/TextField.js
@@ -0,0 +1,98 @@
+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 ? (
+ {startAdornment}
+ ) : null
+ const endInputAdornment = endAdornment ? {endAdornment} : null
+ return (
+ 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}
+ >
+ {children}
+
+ )
+}
+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 (
-
-
- )
-}
-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 (
- setText(event.target.value)}
- onKeyDown={onKeyDown}
- onKeyPress={onKeyPress}
- value={text}
- />
-
- )
-}
-EditableTextInput.propTypes = {
- label: labelPropType,
- required: bool,
- set_value: func,
- value: string,
-}
-
-export function TextInput(props) {
- let { requiredPermissions, ...otherProps } = props
- return (
- }
- editableComponent={ }
- />
- )
-}
-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( )
- expect(screen.queryByText("Hello")).not.toBe(null)
-})
-
-it("changes the value", async () => {
- const mockCallback = jest.fn()
- render( )
- 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( )
- 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( )
- 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( )
- 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( )
- expect(container.getElementsByTagName("textarea")[0]).toBeInvalid()
-})
-
-it("does not show an error for required non-empty fields, when read only", () => {
- const { container } = render( )
- expect(container.getElementsByTagName("textarea")[0]).toBeValid()
-})
-
-it("does not show an error for non-required empty fields, when read only", () => {
- const { container } = render( )
- expect(container.getElementsByTagName("textarea")[0]).toBeValid()
-})
-
-it("shows an error for required empty fields, when editable", () => {
- const { container } = render( )
- expect(container.getElementsByTagName("textarea")[0]).toBeInvalid()
-})
-
-it("does not show an error for required non-empty fields, when editable", () => {
- const { container } = render( )
- expect(container.getElementsByTagName("textarea")[0]).toBeValid()
-})
-
-it("does not show an error for non-required empty fields, when editable", () => {
- const { container } = render( )
- expect(container.getElementsByTagName("textarea")[0]).toBeValid()
-})
-
-it("shows the label", () => {
- render( )
- 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..12e3300b51 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,8 @@ import MenuBookIcon from "@mui/icons-material/MenuBook"
import PersonIcon from "@mui/icons-material/Person"
import ScienceIcon from "@mui/icons-material/Science"
import {
+ AppBar,
+ Box,
Container,
Divider,
List,
@@ -19,13 +19,15 @@ import {
Stack,
Typography,
} from "@mui/material"
+import { grey } from "@mui/material/colors"
+import Grid from "@mui/material/Grid2"
import { element, object, oneOfType, string } from "prop-types"
import { alignmentPropType, childrenPropType, datePropType, reportPropType } from "../sharedPropTypes"
import { DOCUMENTATION_URL, REPOSITORY_URL } from "../utils"
function FooterItem({ children, icon, url }) {
- const color = "silver"
+ const color = grey[300]
let item = {children}
if (icon) {
item = (
@@ -145,24 +147,26 @@ function QuoteColumn() {
export function Footer({ lastUpdate, report }) {
return (
-
-
-
-
- {report ? : }
-
-
+
+
+
+
+
+
+
+ {report ? : }
+
+
+
+
+
+
+
-
+
)
}
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={ }
sx={{ textTransform: "none" }}
>
- Quality-time
+ Quality-time
diff --git a/components/frontend/src/header_footer/settings_menu/SettingsMenu.js b/components/frontend/src/header_footer/settings_menu/SettingsMenu.js
index b87b069ca3..2693340cd7 100644
--- a/components/frontend/src/header_footer/settings_menu/SettingsMenu.js
+++ b/components/frontend/src/header_footer/settings_menu/SettingsMenu.js
@@ -5,7 +5,7 @@ import { childrenPropType, popupContentPropType } from "../../sharedPropTypes"
export function SettingsMenuGroup({ children }) {
return (
-
+
{children}
)
@@ -17,8 +17,12 @@ SettingsMenuGroup.propTypes = {
export function SettingsMenu({ children, title }) {
return (
- {title}
- {children}
+
+ {title}
+
+
+ {children}
+
)
}
@@ -48,12 +52,18 @@ export function SettingsMenuItem({ active, children, disabled, disabledHelp, hel
return (
- {children}
+
+ {children}
+
)
}
- return {children}
+ return (
+
+ {children}
+
+ )
}
SettingsMenuItem.propTypes = {
active: bool,
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 (
-
+ No issue tracker configured
+
+ Please configure an issue tracker by expanding the report title, selecting the ‘Issue
+ tracker’ tab, and configuring an issue tracker.
+
+ >
}
- header={"No issue tracker configured"}
- trigger={{issueId} }
- />
+ >
+
+
+
+
+
+ {issueId} - ?
+
+
+
+
+
+
)
}
IssueWithoutTracker.propTypes = {
@@ -35,41 +50,47 @@ IssuesWithoutTracker.propTypes = {
issueIds: stringsPropType,
}
-function labelDetails(issueStatus, settings) {
- let details = [{issueStatus.name || "?"} ]
+function cardDetails(issueStatus, settings) {
+ let details = []
if (issueStatus.summary && settings.showIssueSummary.value) {
- details.push({issueStatus.summary} )
+ details.push({issueStatus.summary} )
}
if (issueStatus.created && settings.showIssueCreationDate.value) {
details.push(
-
- Created
- ,
+
+
+ Created
+
+ ,
)
}
if (issueStatus.updated && settings.showIssueUpdateDate.value) {
details.push(
-
- Updated
- ,
+
+
+ Updated
+
+ ,
)
}
if (issueStatus.duedate && settings.showIssueDueDate.value) {
details.push(
-
- Due
- ,
+
+
+ Due
+
+ ,
)
}
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 ? {details}
: 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 ? : null
return (
-
+
{prefixName(issueStatus.release_name, "Release")} {releaseStatus(issueStatus)} {date}
-
+
)
}
-releaseLabel.propTypes = {
+release.propTypes = {
issueStatus: issueStatusPropType,
}
-function sprintLabel(issueStatus) {
+function sprint(issueStatus) {
const sprintEnd = issueStatus.sprint_enddate ? (
<>
ends
>
) : null
return (
-
+
{prefixName(issueStatus.sprint_name, "Sprint")} ({issueStatus.sprint_state}) {sprintEnd}
-
+
)
}
-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 = (
-
- {issueStatus.issue_id}
- {labelDetails(issueStatus, settings)}
-
+ const color = error ? "error" : (issueStatus.status_category ?? "unknown")
+ const onClick = issueStatus.landing_url ? () => window.open(issueStatus.landing_url) : null
+ return (
+
+
+
+
+ {issueStatus.issue_id} - {issueStatus.name || "?"}
+
+ cannot appear as a descendant of ."
+ variant="body2"
+ >
+ {cardDetails(issueStatus, settings)}
+
+
+
+
)
- if (issueStatus.landing_url) {
- // Without the span, the popup doesn't work
- return (
-
- {label}
-
- )
- }
- 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 =
if (!popupContent && issueStatus.created) {
popupHeader = issueStatus.summary
popupContent = issuePopupContent(issueStatus)
}
if (popupContent) {
- label =
+ card = (
+
+ {popupHeader}
+ {popupContent}
+ >
+ }
+ >
+ {card}
+
+ )
}
- 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..98250191a8 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 (
}
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 = (
- <>
-
- Identifiers of issues in the configured issue tracker that track the progress of fixing this {target}.
-
-
+ 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.
-
- {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 (
- {
+ 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={ }
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}
/>
)
}
@@ -100,12 +93,9 @@ export function IssuesRows({ metric, metric_uuid, reload, report, target }) {
const issueTrackerConfigured = Boolean(
report?.issue_tracker?.type && parameters?.url && parameters?.project_key && parameters?.issue_type,
)
- const issueTrackerInstruction = issueTrackerConfigured ? null : (
-
- Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker'
- tab, and configuring an issue tracker.
-
- )
+ const issueTrackerInstruction = issueTrackerConfigured
+ ? null
+ : " Please configure an issue tracker by expanding the report title, selecting the Issue tracker tab, and configuring an issue tracker."
const issueIdentifiersProps = {
issueTrackerInstruction: issueTrackerInstruction,
metric: metric,
@@ -116,64 +106,44 @@ export function IssuesRows({ metric, metric_uuid, reload, report, target }) {
}
return (
<>
-
-
-
-
- }
- editableComponent={
- <>
-
-
-
-
-
-
- >
- }
+
+
-
+
+
+
+
{getMetricIssueIds(metric).length > 0 && !issueTrackerConfigured && (
-
-
-
-
-
+
+
+
)}
{(metric.issue_status ?? [])
.filter((issue_status) => issue_status.connection_error)
.map((issue_status) => (
-
-
-
-
-
+
+
+
))}
{(metric.issue_status ?? [])
.filter((issue_status) => issue_status.parse_error)
.map((issue_status) => (
-
-
-
-
-
+
+
+
))}
>
)
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) => [
,
])
}
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", () => {
/>
,
)
- 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 ? {target} : {target}
+ const label = allIssuesDone || debtEndDateInThePast ? {target} : target
return (
-
- {popupText(metric, debtEndDateInThePast, allIssuesDone, dataModel)}
-
+ {popupText(metric, debtEndDateInThePast, allIssuesDone, dataModel)}}>
+ {label}
+
)
}
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 {measurementValue}
+ return (
+
+ {measurementValue}
+
+ )
}
if (updating) {
return (
-
-
- {measurementValue}
-
+
+
+
+ {measurementValue}
+
+
)
}
return {measurementValue}
@@ -101,49 +108,43 @@ export function MeasurementValue({ metric, reportDate }) {
const requested = isMeasurementRequested(metric)
const hasIgnoredEntities = sum(ignoredEntitiesCount(metric.latest_measurement)) > 0
return (
-
+
+ This may indicate a problem with Quality-time itself. Please contact a system administrator.
+
+
+ The source configuration of this metric was changed after the latest measurement.
+
+
+ An update of the latest measurement was requested by a user.
+
+ {hasIgnoredEntities && (
+
+
+ {`Ignored ${unit}`}
+
+ {ignoredEntitiesMessage(metric.latest_measurement, unit)}
+
+ )}
+ {metric.latest_measurement && (
+ <>
+
+ {metric.status ? "The metric was last measured" : "Last measurement attempt"}
+
+
+
+ {metric.status ? "The current value was first measured" : "The value is unknown since"}
+
+ >
+ )}
+
+ }
>
-
-
-
- {hasIgnoredEntities && (
-
- {`Ignored ${unit}`}
-
- }
- content={ignoredEntitiesMessage(metric.latest_measurement, unit)}
- />
- )}
- {metric.latest_measurement && (
- <>
-
- {metric.status ? "The metric was last measured" : "Last measurement attempt"}
-
-
-
- {metric.status ? "The current value was first measured" : "The value is unknown since"}
-
- >
- )}
-
+ {measurementValueLabel(hasIgnoredEntities, stale, outdated || requested, value)}
+
)
}
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 = (
<>
-
-
- Metric reaction time overruns
- In the period {period}
-
-
-
-
-
-
- When did the metric need action?
-
-
- How long did it take to react?
-
-
-
- Status
- Start
- End
- Actual
- Desired
- Overrun
-
-
-
- {overruns.map((overrun) => (
-
-
-
-
- {overrun.start.split("T")[0]}
- {overrun.end.split("T")[0]}
- {formatDays(overrun.actual_response_time)}
- {formatDays(overrun.desired_response_time)}
- {formatDays(overrun.overrun)}
-
- ))}
-
-
-
-
- Total
-
-
- {triggerText}
-
-
-
-
+ Metric reaction time overruns in the period {period}
+
+
+
+
+
+ When did the metric need action?
+
+
+ How long did it take to react?
+
+
+
+ Status
+ Start
+ End
+ Actual
+ Desired
+ Overrun
+
+
+
+ {overruns.map((overrun) => (
+
+
+
+
+ {overrun.start.split("T")[0]}
+ {overrun.end.split("T")[0]}
+ {formatDays(overrun.actual_response_time)}
+ {formatDays(overrun.desired_response_time)}
+ {formatDays(overrun.overrun)}
+
+ ))}
+
+
+
+
+ Total
+
+
+ {triggerText}
+
+
+
+
+
>
)
- return
+ return (
+
+ {trigger}
+
+ )
}
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 (
- {source_label()}}
- />
+
+ {header}
+ {content}
+ >
+ }
+ >
+
+ {source_label()}
+
+
)
} else {
return source_label()
diff --git a/components/frontend/src/measurement/StatusIcon.js b/components/frontend/src/measurement/StatusIcon.js
index fd73ed86b3..44e94215ab 100644
--- a/components/frontend/src/measurement/StatusIcon.js
+++ b/components/frontend/src/measurement/StatusIcon.js
@@ -1,18 +1,28 @@
+import { Bolt, Check, Money, QuestionMark, Warning } from "@mui/icons-material"
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_SHORT_NAME, statusPropType } from "../metric/status"
import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate"
+const STATUS_ICONS = {
+ target_met: (size) => ,
+ near_target_met: (size) => ,
+ debt_target_met: (size) => ,
+ target_not_met: (size) => ,
+ informative: (_size) => i ,
+ unknown: (size) => ,
+}
+
export function StatusIcon({ status, statusStart, size }) {
status = status || "unknown"
- const sizes = { small: 20, undefined: 32 }
+ const sizes = { small: 22, undefined: 32 }
+ const fontSizes = { small: "0.8em", undefined: "1.3em" }
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 icon = (
-
- {STATUS_ICONS[status]}
+
+ {STATUS_ICONS[status](fontSizes[size])}
)
if (statusStart) {
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 = {triggerText}
+ let trigger = {triggerText}
if (timeLeft >= 0) {
deadlineLabel = "Time left to address this metric is"
- trigger = {triggerText}
+ trigger = triggerText
}
return (
-
- {deadlineLabel} .
-
+ {deadlineLabel}}>
+ {trigger}
+
)
}
TimeLeft.propTypes = {
diff --git a/components/frontend/src/measurement/TrendSparkline.js b/components/frontend/src/measurement/TrendSparkline.js
index c69016a112..89f479b9ab 100644
--- a/components/frontend/src/measurement/TrendSparkline.js
+++ b/components/frontend/src/measurement/TrendSparkline.js
@@ -1,12 +1,11 @@
-import { useContext } from "react"
+import { useTheme } from "@mui/material"
import { VictoryGroup, VictoryLine, VictoryTheme } from "victory"
-import { DarkMode } from "../context/DarkMode"
import { datePropType, measurementsPropType, scalePropType } from "../sharedPropTypes"
import { pluralize } from "../utils"
export function TrendSparkline({ measurements, scale, report_date }) {
- const stroke = useContext(DarkMode) ? "rgba(255, 255, 255, 0.87)" : "black"
+ const stroke = useTheme().palette.text.secondary
if (scale === "version_number") {
return null
}
@@ -44,7 +43,7 @@ export function TrendSparkline({ measurements, scale, report_date }) {
style={{
data: {
stroke: stroke,
- strokeWidth: 3,
+ strokeWidth: 5,
width: "100%",
},
}}
diff --git a/components/frontend/src/measurement/TrendSparkline.test.js b/components/frontend/src/measurement/TrendSparkline.test.js
index d61e12b30f..d999e94f24 100644
--- a/components/frontend/src/measurement/TrendSparkline.test.js
+++ b/components/frontend/src/measurement/TrendSparkline.test.js
@@ -1,6 +1,5 @@
import { render, screen } from "@testing-library/react"
-import { DarkMode } from "../context/DarkMode"
import { TrendSparkline } from "./TrendSparkline"
it("returns null when the metric scale is version number", () => {
@@ -13,15 +12,6 @@ it("renders an empty sparkline if there are no measurements", () => {
expect(screen.queryAllByLabelText(/sparkline graph showing 0 different measurement values/).length).toBe(1)
})
-it("renders an empty sparkline if there are no measurements in dark mode", () => {
- render(
-
-
- ,
- )
- expect(screen.queryAllByLabelText(/sparkline graph showing 0 different measurement values/).length).toBe(1)
-})
-
it("renders a recent measurement", () => {
render(
,
+ content: (
+
+ {scale_name}
+ {scale_description}
+
+ ),
key: scale,
text: scale_name,
value: scale,
@@ -38,16 +36,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 (
- Metric name}
+ 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 +56,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 (
- Tags}
- options={dropdownOptions(tags)}
- requiredPermissions={[EDIT_REPORT_PERMISSION]}
- set_value={(value) => set_metric_attribute(metric_uuid, "tags", value, reload)}
+ set_metric_attribute(metric_uuid, "tags", value, reload)}
value={getMetricTags(metric)}
/>
)
@@ -81,20 +79,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 (
- Metric scale}
- options={scale_options}
+ 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) => (
+
+ {option.content}
+
+ ))}
+
)
}
Scale.propTypes = {
@@ -105,6 +109,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 +125,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 (
- Metric direction}
- 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}
- />
+ set_metric_attribute(metric_uuid, "direction", value, reload)}
+ select
+ value={getMetricDirection(metric, dataModel) ?? "<"}
+ >
+
+ {`${fewer} is better`}
+
+
+ {`${more} is better`}
+
+
)
}
Direction.propTypes = {
@@ -143,17 +150,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 (
- Metric unit}
+ 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 +171,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 (
- }
+ 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)}
- />
+ >
+
+ Yes
+
+
+ No
+
+
)
}
EvaluateTargets.propTypes = {
@@ -191,61 +203,45 @@ export function MetricConfigurationParameters({ metric, metric_uuid, reload, rep
const dataModel = useContext(DataModel)
const metricScale = getMetricScale(metric, dataModel)
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {metricScale !== "version_number" && (
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {metricScale !== "version_number" && }
+
+
+
+
+
+
+
+
+
+
+
+
+ How targets are evaluated
+
+
+
)
}
diff --git a/components/frontend/src/metric/MetricConfigurationParameters.test.js b/components/frontend/src/metric/MetricConfigurationParameters.test.js
index d81aea5d30..790291cdb8 100644
--- a/components/frontend/src/metric/MetricConfigurationParameters.test.js
+++ b/components/frontend/src/metric/MetricConfigurationParameters.test.js
@@ -84,7 +84,7 @@ it("adds a tag", async () => {
await act(async () => {
renderMetricParameters()
})
- await userEvent.type(screen.getByLabelText(/Tags/), "New tag{Enter}")
+ await userEvent.type(screen.getByLabelText(/Metric tags/), "New tag{Enter}")
expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/tags", {
tags: ["New tag"],
})
@@ -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..13e43929c6 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 (
-
+
+ Read more about{" "}
+ technical debt
+ >
}
- 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"}
+ >
+
+ Yes
+
+
+ Yes, and also set technical debt target and end date
+
+
+ No
+
+
+ No, and also clear technical debt target and end date
+
+
)
}
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 = (
- <>
- 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.
-
- >
+ 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 ? (
+
+ ) : (
+ "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 (
-
- }
- placeholder="YYYY-MM-DD"
- set_value={(value) => set_metric_attribute(metric_uuid, "debt_end_date", value, reload)}
- value={metric.debt_end_date ?? ""}
+ 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 (
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
- set_metric_attribute(metric_uuid, "comment", value, reload)}
- value={metric.comment}
- />
-
-
+
+ set_metric_attribute(metric_uuid, "comment", value, reload)}
+ value={metric.comment}
+ />
+
)
}
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(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
+ ,
)
}
+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..8a7826386c 100644
--- a/components/frontend/src/metric/MetricDetails.js
+++ b/components/frontend/src/metric/MetricDetails.js
@@ -1,24 +1,18 @@
+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 { Stack } from "@mui/material"
import { bool, func, string } from "prop-types"
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,11 +23,10 @@ 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"
-import { MetricTypeHeader } from "./MetricTypeHeader"
import { TrendGraph } from "./TrendGraph"
function RequestMeasurementButton({ metric, metric_uuid, reload }) {
@@ -70,7 +63,7 @@ function MetricDetailsButtonRow({
+
!dataModel.metrics[metric.type].sources.includes(source.type))
const metricUrl = `${window.location.href.split("#")[0]}#${metric_uuid}`
- let panes = []
- panes.push(
- configurationTabPane(
- ,
+ ,
+ ,
+ ,
+ ,
+ ]
+ const tabs = [
+ { label: "Configuration", icon: },
+ { error: Boolean(anyError), label: "Sources", icon: , warning: Boolean(anyWarning) },
+ { label: "Technical debt", icon: },
+ { label: "Changelog", icon: },
+ { label: "Trend graph", icon: },
+ ]
+ Object.entries(metric.sources).forEach(([source_uuid, source]) => {
+ const sourceName = getSourceName(source, dataModel)
+ tabs.push({
+ image: ,
+ label: sourceName,
+ })
+ panes.push(
+ ,
- ),
- tabPane(
- "Sources",
- ,
- { icon: , error: Boolean(anyError), warning: Boolean(anyWarning) },
- ),
- tabPane(
- "Technical debt",
- ,
- { icon: },
- ),
- changelogTabPane( ),
- tabPane(
- "Trend graph",
- ,
- { icon: },
- ),
- )
- Object.entries(metric.sources).forEach(([source_uuid, source]) => {
- const sourceName = getSourceName(source, dataModel)
- panes.push(
- tabPane(
- sourceName,
- ,
- { image: },
- ),
)
})
-
return (
- <>
-
-
+
+ {panes}
- >
+
)
}
MetricDetails.propTypes = {
@@ -235,5 +216,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(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
),
)
}
@@ -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..aa53589bf8 100644
--- a/components/frontend/src/metric/MetricType.js
+++ b/components/frontend/src/metric/MetricType.js
@@ -1,12 +1,13 @@
-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 { getSubjectTypeMetrics } from "../utils"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { TextField } from "../fields/TextField"
+import { getSubjectTypeMetrics, referenceDocumentationURL } from "../utils"
+import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
export function metricTypeOption(key, metricType) {
return {
@@ -14,7 +15,7 @@ export function metricTypeOption(key, metricType) {
text: metricType.name,
value: key,
content: (
-
+
{metricType.name}
{metricType.description}
@@ -23,14 +24,18 @@ export function metricTypeOption(key, metricType) {
}
export function metricTypeOptions(dataModel, subjectType) {
- // Return menu options for all metric that support the subject type
- return getSubjectTypeMetrics(subjectType, dataModel.subjects).map((key) =>
+ // Return menu options for all metrics that support the subject type
+ const metricTypeOptions = getSubjectTypeMetrics(subjectType, dataModel.subjects).map((key) =>
metricTypeOption(key, dataModel.metrics[key]),
)
+ metricTypeOptions.sort((option1, option2) => option1.text > option2.text)
+ return metricTypeOptions
}
export function allMetricTypeOptions(dataModel) {
- return Object.keys(dataModel.metrics).map((key) => metricTypeOption(key, dataModel.metrics[key]))
+ const metricTypeOptions = Object.keys(dataModel.metrics).map((key) => metricTypeOption(key, dataModel.metrics[key]))
+ metricTypeOptions.sort((option1, option2) => option1.text > option2.text)
+ return metricTypeOptions
}
export function usedMetricTypes(subject) {
@@ -41,19 +46,35 @@ 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]))
}
+ const hasExtraDocs = dataModel.metrics[metricType].documentation
+ const howToConfigure = ` for ${hasExtraDocs ? "additional " : ""}information on how to configure this metric type.`
return (
-
+
+ {howToConfigure}
+ >
+ }
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) => (
+
+ {option.content}
+
+ ))}
+
)
}
MetricType.propTypes = {
diff --git a/components/frontend/src/metric/MetricType.test.js b/components/frontend/src/metric/MetricType.test.js
index 93599c78f8..e3918337df 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"
@@ -25,6 +25,7 @@ const dataModel = {
source_version: {
unit: "",
direction: "<",
+ documentation: "extra documentation",
name: "Source version",
default_scale: "version_number",
scales: ["version_number"],
@@ -58,9 +59,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 +67,16 @@ 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)
+})
+
+it("shows the metric type read the docs URL", async () => {
+ renderMetricType("violations")
+ expect(screen.queryAllByText(/Read the Docs/).length).toBe(1)
+})
+
+it("shows the metric type has extra documentation", async () => {
+ renderMetricType("source_version")
+ expect(screen.queryAllByText(/for additional information on how to configure this metric type/).length).toBe(1)
})
diff --git a/components/frontend/src/metric/MetricTypeHeader.js b/components/frontend/src/metric/MetricTypeHeader.js
deleted file mode 100644
index a03bc13273..0000000000
--- a/components/frontend/src/metric/MetricTypeHeader.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Header } from "../semantic_ui_react_wrappers"
-import { metricTypePropType } from "../sharedPropTypes"
-import { referenceDocumentationURL } from "../utils"
-import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
-
-export function MetricTypeHeader({ metricType }) {
- const howToConfigure = metricType.documentation
- ? " for specific information on how to configure this metric type."
- : ""
- return (
-
-
- {metricType.name}
-
- {metricType.description}
- {howToConfigure}
-
-
-
- )
-}
-MetricTypeHeader.propTypes = {
- metricType: metricTypePropType,
-}
diff --git a/components/frontend/src/metric/MetricTypeHeader.test.js b/components/frontend/src/metric/MetricTypeHeader.test.js
deleted file mode 100644
index c04df94ca0..0000000000
--- a/components/frontend/src/metric/MetricTypeHeader.test.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { render, screen } from "@testing-library/react"
-
-import { MetricTypeHeader } from "./MetricTypeHeader"
-
-function renderMetricTypeHeader(documentation) {
- render(
- ,
- )
-}
-
-it("shows the header", () => {
- renderMetricTypeHeader()
- expect(screen.getAllByText("Metric type").length).toBe(1)
-})
-
-it("points users to specific information in the docs if there is", () => {
- renderMetricTypeHeader()
- expect(screen.queryAllByText(/specific information/).length).toBe(0)
- renderMetricTypeHeader("Metric docs")
- expect(screen.getAllByText(/specific information/).length).toBe(1)
-})
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 (
-
-
-
- {STATUS_SHORT_NAME[status]}
-
-
-
- {capitalize(color)}
- {children}
-
- )
-}
-ColoredSegment.propTypes = {
- children: childrenPropType,
- color: string,
- show: bool,
- status: statusPropType,
-}
-
-function BlueSegment({ unit }) {
- return {`${unit} are not evaluated`}
-}
-BlueSegment.propTypes = {
- unit: string,
-}
-
-function GreenSegment({ direction, scale, target, show, unit }) {
- return (
- {`${direction} ${formatMetricValue(scale, target)}${unit}`}
- )
-}
-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 (
- {`${direction} ${formatMetricValue(scale, target)}${unit}`}
- )
-}
-RedSegment.propTypes = {
- direction: oneOf(["<", ">"]),
- scale: scalePropType,
- target: string,
- show: bool,
- unit: string,
-}
-
-function GreySegment({ lowTarget, highTarget, scale, show, unit }) {
- return (
- {`${formatMetricValue(scale, lowTarget)} - ${formatMetricValue(scale, highTarget)}${unit}`}
- )
-}
-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 (
- {`${formatMetricValue(scale, lowTarget)} - ${formatMetricValue(scale, highTarget)}${unit}`}
- )
-}
-YellowSegment.propTypes = {
- lowTarget: string,
- highTarget: string,
- scale: scalePropType,
- show: bool,
- unit: string,
-}
-
-function ColoredSegments({ children }) {
- return {children}
-}
-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 (
-
-
-
- )
- }
- 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 (
-
-
-
-
-
-
- )
- } else {
- return (
-
-
-
-
-
-
- )
- }
-}
-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 + defaultTargetLabel}{" "}
- }
- flowing
- header="How measurement values are evaluated"
- hoverable
- on={["hover", "focus"]}
- position={position}
- trigger={ }
- />
-
- )
-}
-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 =
+ : `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 (
- set_metric_attribute(metric_uuid, target_type, value, reload)}
+ 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 (
- 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 (
+
+
+ {STATUS_SHORT_NAME[status]}
+
+ {capitalize(color)}
+ {children}
+
+
+ )
+}
+ColoredSegment.propTypes = {
+ children: childrenPropType,
+ color: string,
+ show: bool,
+ status: statusPropType,
+}
+
+function BlueSegment({ unit }) {
+ return {`${unit} are not evaluated`}
+}
+BlueSegment.propTypes = {
+ unit: string,
+}
+
+function GreenSegment({ direction, scale, target, show, unit }) {
+ return (
+ {`${direction} ${formatMetricValue(scale, target)}${unit}`}
+ )
+}
+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 (
+ {`${direction} ${formatMetricValue(scale, target)}${unit}`}
+ )
+}
+RedSegment.propTypes = {
+ direction: oneOf(["<", ">"]),
+ scale: scalePropType,
+ target: string,
+ show: bool,
+ unit: string,
+}
+
+function GreySegment({ lowTarget, highTarget, scale, show, unit }) {
+ return (
+ {`${formatMetricValue(scale, lowTarget)} - ${formatMetricValue(scale, highTarget)}${unit}`}
+ )
+}
+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 (
+ {`${formatMetricValue(scale, lowTarget)} - ${formatMetricValue(scale, highTarget)}${unit}`}
+ )
+}
+YellowSegment.propTypes = {
+ lowTarget: string,
+ highTarget: string,
+ scale: scalePropType,
+ show: bool,
+ unit: string,
+}
+
+function ColoredSegments({ children }) {
+ return {children}
+}
+ColoredSegments.propTypes = {
+ children: childrenPropType,
+}
+
+export function TargetVisualiser({ metric }) {
+ const dataModel = useContext(DataModel)
+ const unit = formatMetricScaleAndUnit(metric, dataModel)
+ if (metric.evaluate_targets === false) {
+ return (
+
+
+
+ )
+ }
+ 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 (
+
+
+
+
+
+
+ )
+ } else {
+ return (
+
+
+
+
+
+
+ )
+ }
+}
+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(
+
+
+ ,
+ )
+}
+
+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..f832cab713 100644
--- a/components/frontend/src/metric/TrendGraph.js
+++ b/components/frontend/src/metric/TrendGraph.js
@@ -1,13 +1,12 @@
+import { useTheme } from "@mui/material"
import { useContext } from "react"
-import { Message } from "semantic-ui-react"
import { VictoryAxis, VictoryChart, VictoryLabel, VictoryLine, VictoryTheme } from "victory"
-import { DarkMode } from "../context/DarkMode"
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)
@@ -17,15 +16,16 @@ function measurementAttributeAsNumber(metric, measurement, field, dataModel) {
export function TrendGraph({ metric, measurements, loading }) {
const dataModel = useContext(DataModel)
- const darkMode = useContext(DarkMode)
+ const color = useTheme().palette.text.secondary
+ const bgcolor = useTheme().palette.background.secondary
+ const fontFamily = useTheme().typography.fontFamily
const chartHeight = 250
const estimatedTotalChartHeight = chartHeight + 200 // Estimate of the height including title and axis
if (getMetricScale(metric, dataModel) === "version_number") {
return (
-
+
+ Trend graphs are not supported for metrics with a version number scale.
+
)
}
if (loading === "failed") {
@@ -36,10 +36,9 @@ export function TrendGraph({ metric, measurements, loading }) {
}
if (measurements.length === 0) {
return (
-
+
+ A trend graph can not be displayed until this metric has measurements.
+
)
}
const metricName = getMetricName(metric, dataModel)
@@ -63,26 +62,22 @@ export function TrendGraph({ metric, measurements, loading }) {
previousX2 = x2
measurementPoints.push({ y: measurementValues[index], x: x1 }, { y: measurementValues[index], x: x2 })
})
- const softWhite = "rgba(255, 255, 255, 0.8)"
- const softerWhite = "rgba(255, 255, 255, 0.7)"
const axisStyle = {
- axisLabel: { padding: 30, fontSize: 11, fill: darkMode ? softWhite : null },
- tickLabels: { fontSize: 8, fill: darkMode ? softerWhite : null },
+ axisLabel: { padding: 30, fontSize: 11, fill: color, fontFamily: fontFamily },
+ tickLabels: { fontSize: 8, fill: color, fontFamily: fontFamily },
}
return (
@@ -97,12 +92,7 @@ export function TrendGraph({ metric, measurements, loading }) {
)
diff --git a/components/frontend/src/metric/TrendGraph.test.js b/components/frontend/src/metric/TrendGraph.test.js
index 624f458397..042e4d5030 100644
--- a/components/frontend/src/metric/TrendGraph.test.js
+++ b/components/frontend/src/metric/TrendGraph.test.js
@@ -1,6 +1,5 @@
import { render, screen } from "@testing-library/react"
-import { DarkMode } from "../context/DarkMode"
import { DataModel } from "../context/DataModel"
import { TrendGraph } from "./TrendGraph"
@@ -11,17 +10,11 @@ const dataModel = {
},
}
-function renderTrendgraph({ measurements = [], darkMode = false, scale = "count", loading = "loaded" } = {}) {
+function renderTrendgraph({ measurements = [], scale = "count", loading = "loaded" } = {}) {
return render(
-
-
-
-
- ,
+
+
+ ,
)
}
@@ -32,14 +25,6 @@ it("renders the measurements", () => {
expect(screen.getAllByText(/Time/).length).toBe(1)
})
-it("renders the measurements in dark mode", () => {
- renderTrendgraph({
- measurements: [{ count: { value: "1" }, start: "2019-09-29", end: "2019-09-30" }],
- darkMode: true,
- })
- expect(screen.getAllByText(/Time/).length).toBe(1)
-})
-
it("renders measurements with targets", () => {
renderTrendgraph({
measurements: [
diff --git a/components/frontend/src/metric/status.js b/components/frontend/src/metric/status.js
index 3ef465e9f7..e729fdd869 100644
--- a/components/frontend/src/metric/status.js
+++ b/components/frontend/src/metric/status.js
@@ -1,7 +1,5 @@
// 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"
@@ -17,30 +15,6 @@ export const STATUS_COLORS = {
debt_target_met: "grey",
unknown: "white",
}
-export const STATUS_COLORS_RGB = {
- target_not_met: "rgb(211,59,55)",
- target_met: "rgb(30,148,78)",
- near_target_met: "rgb(253,197,54)",
- debt_target_met: "rgb(150,150,150)",
- 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: ,
- near_target_met: ,
- debt_target_met: ,
- target_not_met: ,
- informative: i ,
- unknown: ,
-}
export const STATUS_NAME = {
informative: "Informative",
target_met: "Target met",
@@ -51,17 +25,17 @@ export const STATUS_NAME = {
}
export const STATUS_SHORT_NAME = { ...STATUS_NAME, debt_target_met: "Debt target met" }
export const STATUS_DESCRIPTION = {
- informative: `${STATUS_NAME.informative}: the measurement value is not evaluated against a target value.`,
- target_met: `${STATUS_NAME.target_met}: the measurement value meets the target value.`,
- near_target_met: `${STATUS_NAME.near_target_met}: the measurement value is close to the target value.`,
- target_not_met: `${STATUS_NAME.target_not_met}: the measurement value does not meet the target value.`,
+ informative: `${STATUS_NAME.informative} means the measurement value is not evaluated against a target value.`,
+ target_met: `${STATUS_NAME.target_met} means the measurement value meets the target value.`,
+ near_target_met: `${STATUS_NAME.near_target_met} means the measurement value is close to the target value.`,
+ target_not_met: `${STATUS_NAME.target_not_met} means the measurement value does not meet the target value.`,
debt_target_met: (
<>
- {`${STATUS_NAME.debt_target_met}: the measurement value does not meet the target value, but this is accepted as `}
+ {`${STATUS_NAME.debt_target_met} means the measurement value does not meet the target value, but this is accepted as `}
technical debt
{". The measurement value does meet the technical debt target."}
>
),
- unknown: `${STATUS_NAME.unknown}: the status could not be determined because no sources have been configured for the metric yet or the measurement data could not be collected.`,
+ unknown: `${STATUS_NAME.unknown} means that the status could not be determined because no sources have been configured for the metric yet or the measurement data could not be collected.`,
}
export const statusPropType = oneOf(STATUSES)
diff --git a/components/frontend/src/notification/NotificationDestinations.js b/components/frontend/src/notification/NotificationDestinations.js
index 28ce09fc8e..ed5ab150ab 100644
--- a/components/frontend/src/notification/NotificationDestinations.js
+++ b/components/frontend/src/notification/NotificationDestinations.js
@@ -1,74 +1,70 @@
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 = Microsoft Teams
+ const teamsHyperlink = Microsoft Teams webhook URL
return (
-
-
-
- {
- set_notification_destination_attributes(
- report_uuid,
- destination_uuid,
- { name: value },
- reload,
- )
- }}
- value={destination.name}
- />
-
-
- 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}
- />
-
-
+
+
+ {
+ set_notification_destination_attributes(
+ report_uuid,
+ destination_uuid,
+ { name: value },
+ reload,
+ )
+ }}
+ value={destination.name}
+ />
+
+
+ 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}
+ />
+
+
{notification_destinations.length === 0 ? (
-
- No notification destinations
- No notification destinations have been configured yet.
-
+
+ No notification destinations have been configured yet.
+
) : (
notification_destinations
)}
@@ -115,13 +110,15 @@ export function NotificationDestinations({ destinations, reload, report_uuid })
key="1"
requiredPermissions={[EDIT_REPORT_PERMISSION]}
editableComponent={
- add_notification_destination(report_uuid, reload)}
- />
+
+ add_notification_destination(report_uuid, reload)}
+ />
+
}
/>
- >
+
)
}
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..42cebc2a80 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 { sourceTypeOption } from "../source/SourceType"
+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: (
-
- ),
+ value: "None",
+ content: ,
}
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)
@@ -74,187 +71,170 @@ export function IssueTracker({ report, reload }) {
.filter(([_source_name, source_type]) => {
return source_type.issue_tracker === true
})
- .map(([source_name, source_type]) => {
- return {
- key: source_name,
- text: source_type.name,
- value: source_name,
- content: (
-
-
-
- {source_type.name}
- {source_type.description}
-
-
- ),
- }
- })
+ .map(([sourceName, sourceType]) => sourceTypeOption(sourceName, sourceType))
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 =
+ const helpUrl = dataModel.sources[report.issue_tracker?.type]?.parameters?.private_token?.help_url
+ if (helpUrl) {
+ privateTokenHelp = How to configure a private token
}
}
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 (
-
-
-
- set_report_issue_tracker_attribute(report_uuid, "type", value, reload)}
- value={report.issue_tracker?.type}
- />
-
-
- set_report_issue_tracker_attribute(report_uuid, "url", value, reload)}
- value={report.issue_tracker?.parameters?.url}
- />
-
-
-
-
-
- set_report_issue_tracker_attribute(report_uuid, "username", value, reload)
- }
- value={report.issue_tracker?.parameters?.username}
- />
-
-
-
- set_report_issue_tracker_attribute(report_uuid, "password", value, reload)
- }
- value={report.issue_tracker?.parameters?.password}
- />
-
-
-
-
-
- set_report_issue_tracker_attribute(report_uuid, "private_token", value, reload)
- }
- value={report.issue_tracker?.parameters?.private_token}
- />
-
-
-
-
-
- }
- options={projectOptions}
- placeholder="None"
- set_value={(value) =>
- set_report_issue_tracker_attribute(report_uuid, "project_key", value, reload)
- }
- value={project_key}
- />
-
-
-
- }
- options={issueTypeOptions}
- placeholder="None"
- set_value={(value) =>
- set_report_issue_tracker_attribute(report_uuid, "issue_type", value, reload)
- }
- value={issue_type}
- />
-
-
-
-
-
+
+ set_report_issue_tracker_attribute(report_uuid, "type", value, reload)}
+ select
+ value={report.issue_tracker?.type ?? "None"}
+ >
+ {trackerSources.map((source) => (
+
+ {source.content}
+
+ ))}
+
+
+
+ set_report_issue_tracker_attribute(report_uuid, "url", value, reload)}
+ required={!!report.issue_tracker?.type}
+ value={report.issue_tracker?.parameters?.url}
+ />
+
+
+ set_report_issue_tracker_attribute(report_uuid, "username", value, reload)}
+ value={report.issue_tracker?.parameters?.username}
+ />
+
+
+ set_report_issue_tracker_attribute(report_uuid, "password", value, reload)}
+ type="password"
+ value={report.issue_tracker?.parameters?.password}
+ />
+
+
+
+ set_report_issue_tracker_attribute(report_uuid, "private_token", value, reload)
+ }
+ type="password"
+ value={report.issue_tracker?.parameters?.private_token}
+ />
+
+
+
+ set_report_issue_tracker_attribute(report_uuid, "project_key", value, reload)}
+ select
+ value={project_key}
+ >
+ {projectOptions.map((option) => (
+
+ {option.text}
+
+ ))}
+
+
+
+ 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) => (
+
+ {option.text}
+
+ ))}
+
+
+
+
+
- }
- 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) => (
+
+ {option.text}
+
+ ))}
+
-
-
-
+ {`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.`}
+
+
+
+
+
+
- }
- 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 ?? []}
/>
-
-
+ 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.`}
+
+
+
)
}
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
+ return (
+
+ {report_date ? `Sorry, this report didn't exist at ${report_date}` : "Sorry, this report doesn't exist"}
+
+ )
}
// 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 (
-
-
-
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}
- />
+
+
+ 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}
+ />
+
{
@@ -55,20 +57,23 @@ function renderReport({
settings.hiddenTags = hiddenTags
}
render(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
+ ,
)
}
diff --git a/components/frontend/src/report/ReportDashboard.test.js b/components/frontend/src/report/ReportDashboard.test.js
index 241f48858d..ab7667223b 100644
--- a/components/frontend/src/report/ReportDashboard.test.js
+++ b/components/frontend/src/report/ReportDashboard.test.js
@@ -1,3 +1,4 @@
+import { ThemeProvider } from "@mui/material/styles"
import { fireEvent, render, renderHook, screen } from "@testing-library/react"
import history from "history/browser"
@@ -5,6 +6,7 @@ import { createTestableSettings } from "../__fixtures__/fixtures"
import { useHiddenTagsURLSearchQuery } from "../app_ui_settings"
import { DataModel } from "../context/DataModel"
import { mockGetAnimations } from "../dashboard/MockAnimations"
+import { theme } from "../theme"
import { ReportDashboard } from "./ReportDashboard"
let report
@@ -42,11 +44,13 @@ function renderDashboard({ hiddenTags = null, dates = [new Date()], onClick = je
settings.hiddenTags = hiddenTags
}
return render(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
)
}
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 (
-
- {children}
-
- )
-}
-ErrorMessage.propTypes = {
- children: string,
-}
-
-export function ReportErrorMessage({ reportDate }) {
- return (
-
- {reportDate ? `Sorry, this report didn't exist at ${reportDate}` : "Sorry, this report doesn't exist"}
-
- )
-}
-ReportErrorMessage.propTypes = {
- reportDate: optionalDatePropType,
-}
-
-export function ReportsOverviewErrorMessage({ reportDate }) {
- return {`Sorry, no reports existed at ${reportDate}`}
-}
-ReportsOverviewErrorMessage.propTypes = {
- reportDate: datePropType,
-}
diff --git a/components/frontend/src/report/ReportTitle.js b/components/frontend/src/report/ReportTitle.js
index 8d4dfdbfa8..d2d3ddf529 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 (
-
-
-
- set_report_attribute(report.report_uuid, "title", value, reload)}
- value={report.title}
- />
-
-
- set_report_attribute(report.report_uuid, "subtitle", value, reload)}
- value={report.subtitle}
- />
-
-
-
-
-
-
+
+
+ set_report_attribute(report.report_uuid, "title", value, reload)}
+ value={report.title}
+ />
+
+
+ set_report_attribute(report.report_uuid, "subtitle", value, reload)}
+ value={report.subtitle}
+ />
+
+
+
)
}
@@ -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 (
- }
- 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 (
- <>
-
-
- Desired metric response times
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ Desired metric response times
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Desired time after which to review measurement entities (violations, warnings, issues, etc.)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
ReactionTimes.propTypes = {
@@ -151,7 +145,7 @@ function ReportTitleButtonRow({ report_uuid, openReportsOverview, url }) {
+
}
@@ -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( ),
- tabPane("Desired reaction times", , { iconName: "time" }),
- tabPane(
- "Notifications",
- ,
- { iconName: "feed" },
- ),
- tabPane("Issue tracker", , { iconName: "tasks" }),
- changelogTabPane( ),
- ]
setDocumentTitle(report.title)
-
return (
-
+ },
+ { label: "Desired reaction times", icon: },
+ { label: "Notifications", icon: },
+ { label: "Issue tracker", icon: },
+ { label: "Changelog", icon: },
+ ]}
+ >
+
+
+
+
+
+
)
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 }) {
+
add_report(reload)} />
+ return {`Sorry, no reports existed at ${report_date}`}
}
// 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 (
-
-
-
-
+
+
{
@@ -25,19 +27,21 @@ function renderReportsOverview({ hiddenTags = null, reportDate = null, reports =
settings.hiddenTags = hiddenTags
}
render(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
)
}
@@ -50,7 +54,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/ReportsOverviewDashboard.test.js b/components/frontend/src/report/ReportsOverviewDashboard.test.js
index 794aa52c35..4b7edae4b1 100644
--- a/components/frontend/src/report/ReportsOverviewDashboard.test.js
+++ b/components/frontend/src/report/ReportsOverviewDashboard.test.js
@@ -1,3 +1,4 @@
+import { ThemeProvider } from "@mui/material/styles"
import { fireEvent, render, renderHook, screen } from "@testing-library/react"
import history from "history/browser"
@@ -5,6 +6,7 @@ import { createTestableSettings } from "../__fixtures__/fixtures"
import { useHiddenTagsURLSearchQuery } from "../app_ui_settings"
import { DataModel } from "../context/DataModel"
import { mockGetAnimations } from "../dashboard/MockAnimations"
+import { theme } from "../theme"
import { ReportsOverviewDashboard } from "./ReportsOverviewDashboard"
beforeEach(() => {
@@ -46,11 +48,18 @@ function renderReportsOverviewDashboard({
settings.hiddenTags = hiddenTags
}
render(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
)
}
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 (
-
-
-
- set_reports_attribute("title", value, reload)}
- value={reports_overview.title}
- />
-
-
- set_reports_attribute("subtitle", value, reload)}
- value={reports_overview.subtitle}
- />
-
-
-
-
-
-
+
+
+ set_reports_attribute("title", value, reload)}
+ value={reports_overview.title}
+ />
+
+
+ set_reports_attribute("subtitle", value, reload)}
+ value={reports_overview.subtitle}
+ />
+
+
+
)
}
@@ -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 (
-
-
-
- setPermissions(permissions, EDIT_REPORT_PERMISSION, value, reload)}
- value={permissions[EDIT_REPORT_PERMISSION]}
- />
-
-
-
-
- setPermissions(permissions, EDIT_ENTITY_PERMISSION, value, reload)}
- value={permissions[EDIT_ENTITY_PERMISSION]}
- />
-
-
+
+
+ setPermissions(permissions, EDIT_REPORT_PERMISSION, value, reload)}
+ options={permissions[EDIT_REPORT_PERMISSION] || []}
+ placeholder="All authenticated users"
+ value={permissions[EDIT_REPORT_PERMISSION] || []}
+ />
+
+
+ setPermissions(permissions, EDIT_ENTITY_PERMISSION, value, reload)}
+ options={permissions[EDIT_ENTITY_PERMISSION] || []}
+ placeholder="All authenticated users"
+ value={permissions[EDIT_ENTITY_PERMISSION] || []}
+ />
+
)
}
-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( ),
- tabPane("Permissions", , {
- iconName: "lock",
- }),
- changelogTabPane( ),
- ]
setDocumentTitle(reports_overview.title)
return (
-
+ },
+ { label: "Permissions", icon: },
+ { label: "Changelog", icon: },
+ ]}
+ >
+
+
+
+
)
}
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
-}
-
-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
-}
-
-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
-}
-
-function Input(props) {
- return
-}
-
-function Dropdown(props) {
- return
-}
-
-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(
-
-
-
- ,
- )
- expect(result.container.querySelector(".inverted")).not.toBe(null)
-})
-
-it("shows the form dropdown in light mode", () => {
- let result
- result = render(
-
-
-
- ,
- )
- 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
-}
-
-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
-}
-
-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
-}
-
-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
-}
-
-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
-}
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
-}
-
-function Pane(props) {
- return
-}
-
-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
-}
-
-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
+export function Logo({ alt, logo, marginBottom, width, height }) {
+ return (
+
+ )
}
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..3f9e8c65a8 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,11 +20,11 @@ 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"
function select_sources_parameter_keys(changed_fields, source_uuid) {
return changed_fields
@@ -38,7 +38,7 @@ function SourceButtonRow({ first_source, last_source, reload, source_uuid }) {
+
-
-
- set_source_attribute(source_uuid, a, v, reload)}
- source_uuid={source_uuid}
- source_type={source.type}
- />
-
-
- set_source_attribute(source_uuid, "name", value, reload)}
- value={source.name}
- />
-
-
-
-
-
-
-
+
+
+ set_source_attribute(source_uuid, a, v, reload)}
+ source_uuid={source_uuid}
+ source_type={source.type}
+ />
+
+
+ set_source_attribute(source_uuid, "name", value, reload)}
+ value={source.name}
+ />
+
+
+
+
{connection_error && }
{parse_error && }
{config_error && }
@@ -136,7 +134,6 @@ export function Source({
}) {
const dataModel = useContext(DataModel)
const source = metric.sources[source_uuid]
- const sourceType = dataModel.sources[source.type]
const sourceName = getSourceName(source, dataModel)
const metricName = getMetricName(metric, dataModel)
const connectionError = measurement_source?.connection_error || ""
@@ -168,27 +165,28 @@ export function Source({
>
)
const configError = dataModel.metrics[metric.type].sources.includes(source.type) ? "" : configErrorMessage
- const panes = [
- configurationTabPane(
- ,
- { error: Boolean(configError || connectionError || parseError) },
- ),
- changelogTabPane( ),
- ]
+ const anyError = Boolean(configError || connectionError || parseError)
return (
<>
-
-
+ },
+ { label: "Changelog", icon: },
+ ]}
+ >
+
+
+
{
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..a6fb76373f 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 (
-
- sort(column, columnType, setColumnType, setSortColumn, setSortDirection, sortColumn, sortDirection)
- }
- sorted={sorted(column, sortColumn, sortDirection)}
- textAlign={textAlign}
- >
- {children}
-
+
+
+ sort(column, columnType, setColumnType, setSortColumn, setSortDirection, sortColumn, sortDirection)
+ }
+ >
+ {children}
+
+
)
}
SortableHeaderCell.propTypes = {
@@ -144,16 +159,12 @@ function EntityAttributeHeaderCell({ entityAttribute, ...sortProps }) {
>
{entityAttribute.name}
{entityAttribute.help ? (
-
-
-
-
- }
- content={entityAttribute.help}
- />
+
+
+
+
+
+
) : null}
)
@@ -178,8 +189,8 @@ function sourceEntitiesHeaders(
const entityNamePlural = metricEntities.name_plural
const hideIgnoredEntitiesLabel = `${hideIgnoredEntities ? "Show" : "Hide"} ignored ${entityNamePlural}`
return (
-
-
+
+
: }
-
+
{`${capitalize(entityName)} status`}
@@ -204,19 +215,15 @@ function sourceEntitiesHeaders(
{entityAttributes.map((entityAttribute) => (
))}
-
+
)
}
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 (
-
+
+ {`Showing individual ${unit} is not supported when using ${sourceTypeName} as source.`}
+
)
}
if (loading === "failed") {
@@ -284,20 +290,18 @@ export function SourceEntities({ loading, measurements, metric, metric_uuid, rel
}
if (measurements.length === 0) {
return (
-
+
+ Measurement details not available because Quality-time has not collected any measurements yet.
+
)
}
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 (
-
+
+ There are currently no measurement details available.
+
)
}
const entityAttributes = metricEntities.attributes.filter((attribute) => attribute?.visible ?? true)
@@ -333,10 +337,12 @@ export function SourceEntities({ loading, measurements, metric, metric_uuid, rel
/>
))
return (
-
+
+
+
)
}
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 (
- {SOURCE_ENTITY_STATUS_NAME[status]}
- {status === "unconfirmed" ? "" : status_end_date}
-
+ {SOURCE_ENTITY_STATUS_NAME[status]}
+ {status === "unconfirmed" ? "" : status_end_date}
+
{rationale}
-
-
+
+
{entity.first_seen ? : ""}
-
+
{entity_attributes.map((entity_attribute) => (
-
-
+
))}
)
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(
- ,
+
+
+ ,
)
}
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: ,
+ content: ,
}
}
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 (
-
-
-
-
- set_source_entity_attribute(metric_uuid, source_uuid, entity.key, "status", value, reload)
- }
- value={status}
- sort={false}
- />
-
-
-
- }
- 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}
- />
-
-
-
- set_source_entity_attribute(
- metric_uuid,
- source_uuid,
- entity.key,
- "rationale",
- value,
- reload,
- )
- }
- value={rationale}
- />
-
-
+
+
+
+ set_source_entity_attribute(metric_uuid, source_uuid, entity.key, "status", value, reload)
+ }
+ select
+ value={status}
+ >
+ {entityStatusOptions(name, report).map((option) => (
+
+ {option.content}
+
+ ))}
+
+
+
+
+ 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"
+ />
+
+
+
+ set_source_entity_attribute(metric_uuid, source_uuid, entity.key, "rationale", value, reload)
+ }
+ value={rationale}
+ />
+
)
}
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(
-
-
- ,
+
+
+
+
+ ,
)
}
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 (
- setEditScope(data.value)}
- options={scope_options}
- value={edit_scope}
- />
+
+ option.value === editScope).color}
+ id="edit-scope-button"
+ onClick={(event) => setAnchorEl(event.currentTarget)}
+ >
+
+
+
+
)
}
-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 =
+ helperText = (
+ <>
+ See {help_url} for more information.
+ >
+ )
}
if (help) {
- label =
+ helperText = help
}
- if (parameter_type === "date") {
- const date = new Date(Date.parse(parameter_value))
- label = (
-
- {label}
-
-
- )
+ if (parameter_type === "date" && parameter_value) {
+ helperText =
}
- let parameter_props = {
- requiredPermissions: requiredPermissions,
- editableLabel: (
-
- ),
+ 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 =
+ let parameterInput = null
if (parameter_type === "date") {
- return
+ parameterInput = (
+
+ )
}
+ parameterProps["value"] = parameter_value
+ parameterProps["startAdornment"] = startAdornment
if (parameter_type === "password") {
- return
+ parameterInput =
}
if (parameter_type === "integer") {
- return
+ parameterInput = (
+
+ )
}
if (parameter_type === "single_choice") {
- return
+ parameterInput = (
+
+ {dropdownOptions(parameter_values).map((option) => (
+
+ {option.text}
+
+ ))}
+
+ )
}
if (parameter_type === "multiple_choice") {
- return
+ parameterInput =
}
if (parameter_type === "multiple_choice_with_addition") {
- return
+ parameterInput =
}
- parameter_props["options"] = parameterValues(report, source.type, parameter_key)
+ parameterProps["options"] = parameterValues(report, source.type, parameter_key)
if (parameter_type === "string") {
- return
+ parameterInput =
}
if (parameter_type === "url") {
- return
+ parameterInput =
}
- 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..d6a1d9a6dd 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(
-
-
- ,
+
+
+
+
+ ,
)
}
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,30 @@ 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 a date parameter without date", () => {
+ renderSourceParameter({
+ parameter_name: "Date",
+ parameter_type: "date",
+ parameter_value: "",
+ })
+ expect(screen.queryAllByLabelText(/Date/).length).toBe(1)
+ expect(screen.queryAllByPlaceholderText(/YYYY-MM-DD/).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("doesn't change an integer parameter with mouse wheel", () => {
+ fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true })
+ renderSourceParameter({ parameter_name: "Integer", parameter_type: "integer", parameter_value: "10" })
+ fireEvent.wheel(screen.getByLabelText(/Integer/, { target: { scrollLeft: 500 } }))
+ expect(fetch_server_api.fetch_server_api).not.toHaveBeenCalled()
})
it("renders a single choice parameter", () => {
@@ -115,8 +132,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 +143,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 +154,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 +164,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,10 +185,19 @@ 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",
})
})
+
+it("closes the mass edit menu", async () => {
+ fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true })
+ renderSourceParameter({})
+ fireEvent.click(screen.getByLabelText(/Edit scope/))
+ await userEvent.keyboard("{Escape}")
+ expect(fetch_server_api.fetch_server_api).not.toHaveBeenCalled()
+})
diff --git a/components/frontend/src/source/SourceParameters.js b/components/frontend/src/source/SourceParameters.js
index fc3ac78908..d86722d2ed 100644
--- a/components/frontend/src/source/SourceParameters.js
+++ b/components/frontend/src/source/SourceParameters.js
@@ -1,10 +1,10 @@
+import { Paper, Stack, 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) => (
))
return (
-
-
-
- {parameterGroup.name}
-
- {parameters}
-
-
+
+
+
+ {parameterGroup.name}
+ {parameters}
+
+
+
)
})
return (
-
- {groups}
+
+ {groups}
)
}
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..f9e17c79bd 100644
--- a/components/frontend/src/source/SourceType.js
+++ b/components/frontend/src/source/SourceType.js
@@ -1,11 +1,13 @@
-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 { referenceDocumentationURL } from "../utils"
+import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
import { Logo } from "./Logo"
export function sourceTypeDescription(sourceType) {
@@ -19,21 +21,27 @@ sourceTypeDescription.propTypes = {
sourceType: sourceTypePropType,
}
-function sourceTypeOption(key, sourceType) {
+export function sourceTypeOption(key, sourceType) {
return {
key: key,
text: sourceType.name,
value: key,
content: (
-
+
-
- {sourceType.name}
- {sourceType.deprecated && }
- {sourceTypeDescription(sourceType)}
-
+
+
+ {sourceType.name}
+ {sourceType.deprecated && (
+
+ )}
+
+
+ {sourceTypeDescription(sourceType)}
+
+
),
}
@@ -54,19 +62,36 @@ 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]))
}
+ const sourceType = dataModel.sources[source_type]
+ const hasExtraDocs = sourceType?.documentation?.generic || sourceType?.documentation?.[metric_type]
+ const howToConfigure = ` for ${hasExtraDocs ? "additional " : ""}information on how to configure this source type.`
return (
-
+
+ {howToConfigure}
+ >
+ }
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) => (
+
+ {option.content}
+
+ ))}
+
)
}
SourceType.propTypes = {
diff --git a/components/frontend/src/source/SourceType.test.js b/components/frontend/src/source/SourceType.test.js
index 7431c328f2..1a92cec311 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"
@@ -22,10 +22,12 @@ const dataModel = {
sonarqube: {
name: "SonarQube",
supported_versions_description: ">=8.2",
+ documentation: { violations: "metric-specific documentation" },
},
gitlab: {
name: "GitLab",
deprecated: true,
+ documentation: { generic: "generic documentation" },
},
unsupported: {
name: "Unsupported",
@@ -60,19 +62,41 @@ 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)
})
+
+it("shows the source type read the docs URL", async () => {
+ await act(async () => {
+ renderSourceType("violations", "sonarqube")
+ })
+ expect(screen.getAllByText(/Read the Docs/).length).toBe(1)
+})
+
+it("shows that the source type has extra generic documentation", async () => {
+ await act(async () => {
+ renderSourceType("violations", "gitlab")
+ })
+ expect(screen.getAllByText(/additional information on how to configure this source type/).length).toBe(1)
+})
+
+it("shows that the source type has extra metric-specific documentation", async () => {
+ await act(async () => {
+ renderSourceType("violations", "sonarqube")
+ })
+ expect(screen.getAllByText(/additional information on how to configure this source type/).length).toBe(1)
+})
diff --git a/components/frontend/src/source/SourceTypeHeader.js b/components/frontend/src/source/SourceTypeHeader.js
deleted file mode 100644
index 419a9c84f6..0000000000
--- a/components/frontend/src/source/SourceTypeHeader.js
+++ /dev/null
@@ -1,35 +0,0 @@
-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 { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
-import { Logo } from "./Logo"
-import { sourceTypeDescription } from "./SourceType"
-
-export function SourceTypeHeader({ metricTypeId, sourceTypeId, sourceType }) {
- let howToConfigure = ""
- if (sourceType?.documentation?.generic || sourceType?.documentation?.[metricTypeId]) {
- howToConfigure = " for specific information on how to configure this source type."
- }
- return (
-
-
-
- {sourceType.name}
- {sourceType.deprecated && }
-
- {`${sourceTypeDescription(sourceType)} `}
-
- {howToConfigure}
-
-
-
- )
-}
-SourceTypeHeader.propTypes = {
- metricTypeId: string,
- sourceTypeId: string,
- sourceType: sourceTypePropType,
-}
diff --git a/components/frontend/src/source/SourceTypeHeader.test.js b/components/frontend/src/source/SourceTypeHeader.test.js
deleted file mode 100644
index 5526305e60..0000000000
--- a/components/frontend/src/source/SourceTypeHeader.test.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { render, screen } from "@testing-library/react"
-
-import { SourceTypeHeader } from "./SourceTypeHeader"
-
-function renderSourceTypeHeader(documentation, metricTypeId, deprecated) {
- render(
- =1.0",
- deprecated: deprecated,
- }}
- />,
- )
-}
-
-it("shows the header", () => {
- renderSourceTypeHeader()
- expect(screen.getAllByText("Source type").length).toBe(1)
-})
-
-it("points users to specific information in the docs if there is", () => {
- renderSourceTypeHeader()
- expect(screen.queryAllByText(/specific information/).length).toBe(0)
- renderSourceTypeHeader({ generic: "Generic documentation" })
- expect(screen.getAllByText(/specific information/).length).toBe(1)
-})
-
-it("does not point users to specific information in the docs if the information is for other metric types", () => {
- renderSourceTypeHeader({ other_metric: "Generic documentation" })
- expect(screen.queryAllByText(/specific information/).length).toBe(0)
-})
-
-it("points users to specific information in the docs if the information is for the current metric type", () => {
- renderSourceTypeHeader({ metric_type: "Generic documentation" }, "metric_type")
- expect(screen.getAllByText(/specific information/).length).toBe(1)
-})
-
-it("shows the supported source versions", () => {
- renderSourceTypeHeader()
- expect(screen.getAllByText(/Supported Source type versions: >=1.0/).length).toBe(1)
-})
-
-it("does not show the source as deprecated if it is not deprecated", () => {
- renderSourceTypeHeader()
- expect(screen.queryAllByText(/Deprecated/).length).toBe(0)
-})
-
-it("shows the source as deprecated if it is deprecated", () => {
- renderSourceTypeHeader({}, null, true)
- expect(screen.getAllByText(/Deprecated/).length).toBe(1)
-})
diff --git a/components/frontend/src/source/Sources.js b/components/frontend/src/source/Sources.js
index 15a6a3b9a3..8520962a24 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"
@@ -29,7 +30,7 @@ function ButtonSegment({ metric, metric_uuid, reload, reports }) {
+
+
-
+
)
}
SourceSegment.propTypes = {
@@ -118,10 +119,7 @@ export function Sources({ reports, report, metric, metric_uuid, measurement, cha
return (
<>
{sourceSegments.length === 0 ? (
-
- No sources
- No sources have been configured yet.
-
+ No sources have been configured yet.
) : (
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 (
-
+
)
}
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 (
-
-
-
- set_subject_attribute(subject_uuid, "type", value, reload)}
- subjectType={subject.type}
- />
-
-
- set_subject_attribute(subject_uuid, "name", value, reload)}
- value={subject.name}
- />
-
-
- set_subject_attribute(subject_uuid, "subtitle", value, reload)}
- value={subject.subtitle}
- />
-
-
-
-
-
-
+
+
+ set_subject_attribute(subject_uuid, "type", value, reload)}
+ subjectType={subject.type}
+ />
+
+
+ set_subject_attribute(subject_uuid, "name", value, reload)}
+ value={subject.name}
+ />
+
+
+ set_subject_attribute(subject_uuid, "subtitle", value, reload)}
+ value={subject.subtitle}
+ />
+
+
+
)
}
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..9e89dc4bf0 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,43 @@ 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 (
-
-
-
- {
- handleSort(null)
- settings.hiddenTags.reset()
- settings.metricsToHide.reset()
+
+
-
+ >
+
+
+ {
+ handleSort(null)
+ settings.hiddenTags.reset()
+ settings.metricsToHide.reset()
+ }}
+ />
+
+
)
}
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 (
-
+
{metricEntries.map(([metric_uuid, metric], index) => {
return (
)
})}
-
+
)
}
SubjectTableBody.propTypes = {
diff --git a/components/frontend/src/subject/SubjectTableFooter.js b/components/frontend/src/subject/SubjectTableFooter.js
index ca66d032d1..16ea7fa1af 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,9 +16,9 @@ import { metric_options } from "../widgets/menu_options"
function SubjectTableFooterButtonRow({ subject, subjectUuid, reload, reports, stopFilteringAndSorting }) {
const dataModel = useContext(DataModel)
return (
-
-
-
+
+
+
metric_options(reports, dataModel, subject.type, subjectUuid)}
/>
-
-
+
+
)
}
SubjectTableFooterButtonRow.propTypes = {
@@ -63,9 +63,9 @@ export function SubjectTableFooter(props) {
+
-
+
}
/>
)
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..8a53f9be4a 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 = (
If the measurement value has a{" "}
-
+
red background
-
+
, the metric has not been measured recently. This indicates a problem with Quality-time itself, and
a system administrator should be notified.
@@ -99,9 +97,9 @@ const targetHelp = (
The value against which measurements are evaluated to determine whether a metric needs action.
The target value has a{" "}
-
+
grey background
- {" "}
+ {" "}
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.
@@ -172,9 +170,9 @@ const sourcesHelp = (
The tools and reports accessed to collect the measurement data. One metric can have multiple sources.
If a source has a{" "}
-
+
red background
-
+
, the source could not be accessed or the data could not be parsed. metric and navigate to
the source to see the error details.
@@ -194,9 +192,9 @@ const issuesHelp = (
If an issue has a{" "}
-
+
red background
-
+
, the issue tracker could not be accessed or the data could not be parsed. metric and
navigate to the technical debt tab to see the error details.
@@ -222,6 +220,29 @@ const tagsHelp = (
>
)
+function InlineChip({ color, label }) {
+ return (
+ cannot appear as a descendant of ."
+ elevation={0}
+ sx={{ display: "inline-flex" }}
+ >
+ cannot appear as a descendant of ."
+ label={label}
+ size="small"
+ sx={{ borderRadius: 1 }}
+ variant="outlined"
+ />
+
+ )
+}
+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.
- A plus sign{" "}
-
- +
- {" "}
- indicates that the newer value is higher. A minus sign{" "}
-
- -
- {" "}
- indicates that the newer value is lower.
+ A plus sign indicates that the newer value is
+ higher. A minus sign indicates that the newer
+ value is lower.
- A{" "}
-
- green outline
- {" "}
+ A
indicates that the newer value is better. A{" "}
-
- red outline
- {" "}
+
indicates that the newer value is worse. A{" "}
-
- blue outline
- {" "}
+
is used for metrics that are informative.
@@ -289,8 +297,8 @@ export function SubjectTableHeader({ columnDates, handleSort, settings }) {
}
const nrDates = columnDates.length
return (
-
-
+
+
{nrDates > 1 && (
)}
{nrDates === 1 && settings.hiddenColumns.excludes("status") && (
-
+
)}
{nrDates === 1 && settings.hiddenColumns.excludes("measurement") && (
)}
-
-
+
+
)
}
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..40e1f33cd5 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 = (
-
- {delta}
-
- }
- />
+
+
+
)
}
return (
-
+
{label}
-
+
)
}
DeltaCell.propTypes = {
@@ -208,10 +202,10 @@ function MeasurementCells({ dates, metric, metric_uuid, measurements, settings }
)
}
cells.push(
-
+
{formatMetricValue(scale, metricValue)}
{formatMetricScale(metric, dataModel)}
- ,
+ ,
)
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 (
}
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}
>
- {metricName}
+ {metricName}
{nrDates > 1 && (
)}
{nrDates === 1 && settings.hiddenColumns.excludes("trend") && (
-
+
-
+
)}
{nrDates === 1 && settings.hiddenColumns.excludes("status") && (
-
+
-
+
)}
{nrDates === 1 && settings.hiddenColumns.excludes("measurement") && (
-
+
-
+
)}
{nrDates === 1 && settings.hiddenColumns.excludes("target") && (
-
+
-
+
)}
- {settings.hiddenColumns.excludes("unit") && {unit} }
+ {settings.hiddenColumns.excludes("unit") && {unit} }
{settings.hiddenColumns.excludes("source") && (
-
+
-
+
)}
{settings.hiddenColumns.excludes("time_left") && (
-
+
-
+
)}
{nrDates > 1 && settings.hiddenColumns.excludes("overrun") && (
-
+
-
+
)}
{settings.hiddenColumns.excludes("comment") && (
-
+
{metric.comment}
-
+
)}
{settings.hiddenColumns.excludes("issues") && (
-
+
-
+
)}
{settings.hiddenColumns.excludes("tags") && (
-
+
{getMetricTags(metric).map((tag) => (
))}
-
+
)}
)
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(
,
)
diff --git a/components/frontend/src/subject/SubjectTitle.js b/components/frontend/src/subject/SubjectTitle.js
index 23bc9fcc2d..11682dffc6 100644
--- a/components/frontend/src/subject/SubjectTitle.js
+++ b/components/frontend/src/subject/SubjectTitle.js
@@ -1,46 +1,29 @@
+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 { getSubjectType } 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 { 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 (
-
-
- {subjectType.name}
-
- {subjectType.description}
-
-
-
- )
-}
-SubjectHeader.propTypes = {
- subjectType: object,
-}
-
function SubjectTitleButtonRow({ subject_uuid, firstSubject, lastSubject, reload, url }) {
const deleteButton = delete_subject(subject_uuid, reload)} />
return (
+
,
- ),
- changelogTabPane( ),
- ]
-
return (
-
-
+ },
+ { label: "Changelog", icon: },
+ ]}
+ >
+
+
+
{
- 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..187aa59492 100644
--- a/components/frontend/src/subject/SubjectType.js
+++ b/components/frontend/src/subject/SubjectType.js
@@ -1,12 +1,14 @@
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"
+import { referenceDocumentationURL } from "../utils"
+import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
export function subjectTypes(subjectTypesMapping, level = 0) {
const options = []
@@ -30,10 +32,10 @@ export function subjectTypes(subjectTypesMapping, level = 0) {
content: (
{bullet}
-
+
{subjectType.name}
{subjectType.description}
-
+
),
})
@@ -47,15 +49,23 @@ subjectTypes.propTypes = {
}
export function SubjectType({ subjectType, setValue }) {
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
return (
- }
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) => (
+
+ {subjectType.content}
+
+ ))}
+
)
}
SubjectType.propTypes = {
diff --git a/components/frontend/src/subject/SubjectsButtonRow.js b/components/frontend/src/subject/SubjectsButtonRow.js
index 450169b2a7..b93ac42440 100644
--- a/components/frontend/src/subject/SubjectsButtonRow.js
+++ b/components/frontend/src/subject/SubjectsButtonRow.js
@@ -1,4 +1,3 @@
-import { Box } from "@mui/material"
import { func } from "prop-types"
import { useContext } from "react"
@@ -23,35 +22,33 @@ export function SubjectsButtonRow({ reload, report, reports, settings }) {
-
- {
- stopFiltering()
- add_subject(report.report_uuid, subtype, reload)
- }}
- sort={false} // Don't sort the subjects by name because it's a hierarchy defined in the data model
- />
- {
- stopFiltering()
- copy_subject(source_subject_uuid, report.report_uuid, reload)
- }}
- get_options={() => subject_options(reports, dataModel)}
- />
- {
- stopFiltering()
- move_subject(source_subject_uuid, report.report_uuid, reload)
- }}
- get_options={() => subject_options(reports, dataModel, report.report_uuid)}
- />
-
-
+
+ {
+ stopFiltering()
+ add_subject(report.report_uuid, subtype, reload)
+ }}
+ sort={false} // Don't sort the subjects by name because it's a hierarchy defined in the data model
+ />
+ {
+ stopFiltering()
+ copy_subject(source_subject_uuid, report.report_uuid, reload)
+ }}
+ get_options={() => subject_options(reports, dataModel)}
+ />
+ {
+ stopFiltering()
+ move_subject(source_subject_uuid, report.report_uuid, reload)
+ }}
+ get_options={() => subject_options(reports, dataModel, report.report_uuid)}
+ />
+
}
/>
)
diff --git a/components/frontend/src/theme.js b/components/frontend/src/theme.js
new file mode 100644
index 0000000000..dcf89cb0e8
--- /dev/null
+++ b/components/frontend/src/theme.js
@@ -0,0 +1,161 @@
+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" } },
+ },
+ MuiTextField: {
+ defaultProps: { variant: "filled" },
+ },
+ },
+ palette: {
+ primary: {
+ main: "#1976D2", // Slightly darker blue than default
+ },
+ secondary: {
+ main: "#963D3D", // Secondary color created with https://m2.material.io/design/color/the-color-system.html#tools-for-picking-colors
+ },
+ },
+ typography: {
+ fontFamily: [
+ "-apple-system",
+ "BlinkMacSystemFont",
+ '"Segoe UI"',
+ "Roboto",
+ '"Helvetica Neue"',
+ "Arial",
+ "sans-serif",
+ '"Apple Color Emoji"',
+ '"Segoe UI Emoji"',
+ '"Segoe UI Symbol"',
+ ].join(","), // Use system font
+ },
+})
+
+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..2b8ea6ab87 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:
@@ -415,10 +414,6 @@ export function getUserPermissions(username, email, report_date, permissions) {
})
}
-export function userPrefersDarkMode(uiMode) {
- return uiMode === "dark" || (uiMode === "system" && window.matchMedia?.("(prefers-color-scheme: dark)").matches)
-}
-
export function dropdownOptions(options) {
return options.map((option) => ({ key: option, text: option, value: option }))
}
diff --git a/components/frontend/src/utils.test.js b/components/frontend/src/utils.test.js
index 618437c7fe..6754964ff7 100644
--- a/components/frontend/src/utils.test.js
+++ b/components/frontend/src/utils.test.js
@@ -22,7 +22,6 @@ import {
scaledNumber,
sortWithLocaleCompare,
sum,
- userPrefersDarkMode,
visibleMetrics,
} from "./utils"
@@ -202,37 +201,6 @@ it("gets the subject name from the data model if the subject has no name", () =>
)
})
-it("returns true when the user sets dark mode", () => {
- expect(userPrefersDarkMode("dark")).toBe(true)
-})
-
-it("returns false when the user sets light mode", () => {
- expect(userPrefersDarkMode("light")).toBe(false)
-})
-
-function mockMatchMedia(matches, addEventListener) {
- Object.defineProperty(window, "matchMedia", {
- value: jest.fn().mockImplementation((_query) => ({
- matches: matches ?? false,
- addEventListener: addEventListener ?? jest.fn(),
- removeEventListener: jest.fn(),
- addListener: jest.fn(), // deprecated
- removeListener: jest.fn(), // deprecated
- })),
- configurable: true,
- })
-}
-
-it("returns true when the user prefers dark mode", () => {
- mockMatchMedia(true)
- expect(userPrefersDarkMode("system")).toBe(true)
-})
-
-it("returns false when the user prefers light mode", () => {
- mockMatchMedia(false)
- expect(userPrefersDarkMode("system")).toBe(false)
-})
-
it("returns the metric response deadline", () => {
expect(getMetricResponseDeadline({}, {})).toStrictEqual(null)
})
diff --git a/components/frontend/src/widgets/ButtonRow.js b/components/frontend/src/widgets/ButtonRow.js
index 1f968891fa..c130899dc1 100644
--- a/components/frontend/src/widgets/ButtonRow.js
+++ b/components/frontend/src/widgets/ButtonRow.js
@@ -1,11 +1,21 @@
import { Box } from "@mui/material"
-import { element } from "prop-types"
+import { element, number } from "prop-types"
import { childrenPropType } from "../sharedPropTypes"
-export function ButtonRow({ children, rightButton }) {
+export function ButtonRow({ children, rightButton, paddingBottom, paddingLeft, paddingRight, paddingTop }) {
return (
-
+
{children}
{rightButton}
@@ -14,4 +24,8 @@ export function ButtonRow({ children, rightButton }) {
ButtonRow.propTypes = {
children: childrenPropType,
rightButton: element,
+ paddingBottom: number,
+ paddingLeft: number,
+ paddingRight: number,
+ paddingTop: number,
}
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 (
-
-
-
+
+
+
)
}
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 (
- {
- 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 (
+
+
+ {formatAsText ? (
+ message
+ ) : (
+ {message}
+ )}
+
+
+ )
+}
+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..1a674e9b02
--- /dev/null
+++ b/components/frontend/src/widgets/Header.js
@@ -0,0 +1,16 @@
+import { Stack, Typography } from "@mui/material"
+import { element, oneOfType, string } from "prop-types"
+
+export function Header({ header, level, subheader }) {
+ return (
+
+ {header}
+ {subheader}
+
+ )
+}
+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..928099772a 100644
--- a/components/frontend/src/widgets/HeaderWithDetails.js
+++ b/components/frontend/src/widgets/HeaderWithDetails.js
@@ -1,43 +1,54 @@
-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 (
-
- settings.expandedItems.toggle(item_uuid)}
- onKeyPress={(event) => {
- event.preventDefault()
- settings.expandedItems.toggle(item_uuid)
+ settings.expandedItems.toggle(item_uuid)}
+ slotProps={{ transition: { unmountOnExit: true } }} // Make testing for (dis)appearance of contents easier
+ sx={{
+ "&:before": {
+ display: "none", // Remove top border
+ },
+ }}
+ >
+ }
+ 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"
>
-
-
- {header}
- {subheader}
-
-
- {showDetails && {children} }
-
+
+
+
+ {children}
+
+
)
}
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(
-
+
Hello
,
)
- 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(
-
+
Hello
,
)
- 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(
-
+
Hello
,
)
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}
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 (
+
+ {children}
+
+ )
+}
+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 (
-
- {label}
-
- >
- }
- help={help}
- />
- )
-}
-LabelWithDate.propTypes = {
- date: oneOfType([datePropType, string]),
- labelId: string,
- label: labelPropType,
- help: popupContentPropType,
-}
-
-export function LabelDate({ date }) {
- return date ? (
-
- {" "}
- ( )
-
- ) : 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}
-
-
-
-
- )
-}
-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( )
- expect(screen.getByText(/Hello/)).not.toBe(null)
-})
-
-it("can be colored", () => {
- render(
- ,
- )
- expect(screen.getByRole("listbox")).toHaveAttribute("color", "red")
-})
-
-it("has default color black", () => {
- render(
- ,
- )
- expect(screen.getByRole("listbox")).not.toHaveAttribute("color")
-})
-
-it("changes the option", () => {
- const mockCallback = jest.fn()
- render(
- ,
- )
- fireEvent.click(screen.getByText(/Option 2/))
- expect(mockCallback).toHaveBeenCalled()
-})
-
-it("opens the dropdown when clicking the current option", () => {
- const mockCallback = jest.fn()
- render(
- ,
- )
- 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}{" "}
- }
- wide
- />
-
- )
-}
-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( )
- expect(screen.getByText(/Hello/)).not.toBe(null)
-})
-
-it("shows the help", async () => {
- render( )
- 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}{" "}
-
-
-
-
- )
-}
-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( )
- 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 (
-
- Read the Docs
-
- )
+ return Read the Docs
}
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}
- }
- return (
- <>
- {icon || image} {tabLabel}
- >
- )
-}
-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:
- return {
- menuItem: (
-
-
-
- ),
- render: () => {pane} ,
- }
-}
-
-export function configurationTabPane(pane, options) {
- return tabPane("Configuration", pane, { ...options, icon: })
-}
-
-export function changelogTabPane(pane, options) {
- return tabPane("Changelog", pane, { ...options, icon: })
-}
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( )
- expect(screen.queryAllByText("Tab").length).toBe(1)
-})
-
-it("is inverted in dark mode", () => {
- const { container } = render(
-
-
- ,
- )
- expect(container.firstChild.firstChild.className).toEqual(expect.stringContaining("inverted"))
-})
-
-it("shows the tab red when there is an error", () => {
- render(Pane, { error: true })]} />)
- expect(screen.getByText("Tab").className).toEqual(expect.stringContaining("red"))
-})
-
-it("shows the tab yellow when there is a warning", () => {
- render(Pane, { warning: true })]} />)
- expect(screen.getByText("Tab").className).toEqual(expect.stringContaining("yellow"))
-})
-
-it("shows an icon", () => {
- render(Pane, { icon: })]} />)
- expect(screen.getAllByTestId("StorageIcon").length).toBe(1)
-})
-
-it("shows an image", () => {
- const image =
- const { container } = render(Pane, { 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 (
- handleSort(column)}
- sorted={sorted}
- textAlign={textAlign || "left"}
- >
-
-
+
+ handleSort(column)}
+ >
+
+
+
)
}
SortableTableHeaderCell.propTypes = {
@@ -59,9 +63,9 @@ SortableTableHeaderCell.propTypes = {
export function UnsortableTableHeaderCell({ help, label, textAlign, width }) {
return (
-
+
-
+
)
}
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(
,
)
}
@@ -39,11 +39,11 @@ it("shows the help of the sortable header", async () => {
function renderUnsortableTableHeaderCell(help) {
render(
,
)
}
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 (
<>
-
-
+
+
onExpand(!expanded)} size="1.5em" />
-
+
{children}
-
+
{expanded && (
-
- {details}
-
+
+ {details}
+
)}
>
)
}
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(
,
)
}
diff --git a/components/frontend/src/widgets/Tabs.js b/components/frontend/src/widgets/Tabs.js
new file mode 100644
index 0000000000..914088db89
--- /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 (
+
+ 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 = {tab.label}
+ }
+ return (
+
+ )
+ })}
+
+
+ {children[tabIndex]}
+
+
+ )
+}
+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) ? : null
+ return (showIf ?? true) ? (
+
+ {title}
+ {children}
+
+ ) : null
}
WarningMessage.propTypes = {
+ children: childrenPropType,
showIf: bool,
+ title: string,
}
export function FailedToLoadMeasurementsWarningMessage() {
return (
-
+
+ Loading the measurements from the API-server failed.
+
)
}
+
+export function InfoMessage({ children, title }) {
+ return (
+
+ {title}
+ {children}
+
+ )
+}
+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( )
+ render(Warning )
expect(screen.getAllByText("Warning").length).toBe(1)
})
it("does not show a warning message if showIf is false", () => {
- render( )
+ render(Warning )
expect(screen.queryAllByText("Warning").length).toBe(0)
})
it("shows a warning message if showIf is undefined", () => {
- render( )
+ render(Warning )
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
+ return
}
export function MoveItemIcon() {
diff --git a/components/frontend/src/widgets/menu_options.js b/components/frontend/src/widgets/menu_options.js
index 5b62cb2ca3..8c611943e4 100644
--- a/components/frontend/src/widgets/menu_options.js
+++ b/components/frontend/src/widgets/menu_options.js
@@ -31,7 +31,7 @@ export function metric_options(reports, dataModel, current_subject_type, current
export function report_options(reports) {
let options = []
reports.forEach((report) => {
- options.push({ key: report.report_uuid, text: report.title, value: report.report_uuid })
+ options.push({ key: report.report_uuid, content: report.title, text: report.title, value: report.report_uuid })
})
options.sort((a, b) => a.text.localeCompare(b.text))
return options
diff --git a/components/frontend/src/widgets/menu_options.test.js b/components/frontend/src/widgets/menu_options.test.js
index bcf2cf9177..e0e77737f2 100644
--- a/components/frontend/src/widgets/menu_options.test.js
+++ b/components/frontend/src/widgets/menu_options.test.js
@@ -8,8 +8,8 @@ it("contains the reports", () => {
{ report_uuid: "report2", title: "A" },
]),
).toStrictEqual([
- { key: "report2", text: "A", value: "report2" },
- { key: "report1", text: "B", value: "report1" },
+ { content: "A", key: "report2", text: "A", value: "report2" },
+ { content: "B", key: "report1", text: "B", value: "report1" },
])
})
diff --git a/components/frontend/src/widgets/toast.js b/components/frontend/src/widgets/toast.js
index 71baa995a9..6b318761ab 100644
--- a/components/frontend/src/widgets/toast.js
+++ b/components/frontend/src/widgets/toast.js
@@ -8,7 +8,7 @@ export function showMessage(type, title, description, messageId) {
const toastMessage =
title && description ? (
-
{title}
+
{title}
{description}
) : (
diff --git a/components/frontend/src/widgets/toast.test.js b/components/frontend/src/widgets/toast.test.js
index e6584bf403..7ae820eeeb 100644
--- a/components/frontend/src/widgets/toast.test.js
+++ b/components/frontend/src/widgets/toast.test.js
@@ -14,7 +14,7 @@ it("shows a message", () => {
showMessage("error", "Error", "Description")
expect(react_toastify.toast.mock.calls[0][0]).toStrictEqual(
-
Error
+
Error
Description
,
)
@@ -30,7 +30,7 @@ it("shows a custom icon", () => {
showMessage("error", "Error", "Description", "question")
expect(react_toastify.toast.mock.calls[0][0]).toStrictEqual(
-
Error
+
Error
Description
,
)
@@ -50,7 +50,7 @@ it("shows a failed connection message", () => {
showConnectionMessage({ availability: [{ status_code: -1, reason: "Failure" }] })
expect(react_toastify.toast.mock.calls[0][0]).toEqual(
-
URL connection error
+
URL connection error
Failure
,
)
@@ -60,7 +60,7 @@ it("shows the http status code", () => {
showConnectionMessage({ availability: [{ status_code: 404, reason: "Not found" }] })
expect(react_toastify.toast.mock.calls[0][0]).toEqual(
-
URL connection error
+
URL connection error
[HTTP status code 404] Not found
,
)
diff --git a/docs/src/changelog.md b/docs/src/changelog.md
index a3b3dc829b..577afae379 100644
--- a/docs/src/changelog.md
+++ b/docs/src/changelog.md
@@ -12,6 +12,12 @@ If your currently installed *Quality-time* version is not the latest version, pl
+## [Unreleased]
+
+### Changed
+
+- Completed the replacement of Semantic UI React with Material UI as frontend component library. Closes [#9796](https://github.com/ICTU/quality-time/issues/9796).
+
## v5.22.0 - 2025-01-16
### Fixed
diff --git a/docs/src/screenshots/adding_metric.png b/docs/src/screenshots/adding_metric.png
index 19180b459b5d3fd17aa7c503a387f46c680a5eb4..862ba42ab05e44e89b2718e1eea0bbdfc79e9872 100644
GIT binary patch
literal 77230
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