diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json
index 495991b965..65490337cc 100644
--- a/components/frontend/package-lock.json
+++ b/components/frontend/package-lock.json
@@ -16,18 +16,15 @@
"@mui/x-date-pickers": "^7.23.3",
"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.5.0",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.0",
"react-hash-link": "1.0.2",
"react-is": "^18.3.1",
"react-timeago": "^7.2.0",
"react-toastify": "^11.0.2",
- "semantic-ui-react": "^2.1.5",
"victory": "^37.3.5"
},
"devDependencies": {
@@ -2474,19 +2471,6 @@
"@floating-ui/utils": "^0.2.8"
}
},
- "node_modules/@floating-ui/react": {
- "version": "0.26.27",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/react-dom": "^2.1.2",
- "@floating-ui/utils": "^0.2.8",
- "tabbable": "^6.0.0"
- },
- "peerDependencies": {
- "react": ">=16.8.0",
- "react-dom": ">=16.8.0"
- }
- },
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"license": "MIT",
@@ -2502,33 +2486,6 @@
"version": "0.2.8",
"license": "MIT"
},
- "node_modules/@fluentui/react-component-event-listener": {
- "version": "0.63.1",
- "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",
- "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",
- "license": "MIT"
- },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"dev": true,
@@ -3682,18 +3639,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@semantic-ui-react/event-stack": {
- "version": "3.1.3",
- "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,
@@ -6837,6 +6782,8 @@
"node_modules/date-fns": {
"version": "3.6.0",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -8168,10 +8115,6 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
- "node_modules/exenv": {
- "version": "1.2.2",
- "license": "BSD-3-Clause"
- },
"node_modules/exit": {
"version": "0.1.2",
"dev": true,
@@ -8519,13 +8462,6 @@
}
}
},
- "node_modules/fomantic-ui-css": {
- "version": "2.9.3",
- "license": "MIT",
- "dependencies": {
- "jquery": "^3.4.0"
- }
- },
"node_modules/for-each": {
"version": "0.3.3",
"dev": true,
@@ -10926,10 +10862,6 @@
"jiti": "bin/jiti.js"
}
},
- "node_modules/jquery": {
- "version": "3.7.1",
- "license": "MIT"
- },
"node_modules/js-tokens": {
"version": "4.0.0",
"license": "MIT"
@@ -11133,10 +11065,6 @@
"node": ">=4.0"
}
},
- "node_modules/keyboard-key": {
- "version": "1.1.0",
- "license": "MIT"
- },
"node_modules/keyv": {
"version": "4.5.4",
"dev": true,
@@ -11284,10 +11212,6 @@
"version": "4.17.21",
"license": "MIT"
},
- "node_modules/lodash-es": {
- "version": "4.17.21",
- "license": "MIT"
- },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"dev": true,
@@ -13727,20 +13651,6 @@
"node": ">=14"
}
},
- "node_modules/react-datepicker": {
- "version": "7.5.0",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/react": "^0.26.23",
- "clsx": "^2.1.1",
- "date-fns": "^3.6.0",
- "prop-types": "^15.8.1"
- },
- "peerDependencies": {
- "react": "^16.9.0 || ^17 || ^18",
- "react-dom": "^16.9.0 || ^17 || ^18"
- }
- },
"node_modules/react-dev-utils": {
"version": "12.0.1",
"dev": true,
@@ -13862,19 +13772,6 @@
"react": "*"
}
},
- "node_modules/react-popper": {
- "version": "2.3.0",
- "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,
@@ -15166,36 +15063,6 @@
"node": ">=10"
}
},
- "node_modules/semantic-ui-react": {
- "version": "2.1.5",
- "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",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/semver": {
"version": "6.3.1",
"dev": true,
@@ -15375,10 +15242,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/shallowequal": {
- "version": "1.1.0",
- "license": "MIT"
- },
"node_modules/shebang-command": {
"version": "2.0.0",
"dev": true,
@@ -16226,10 +16089,6 @@
"url": "https://opencollective.com/unts"
}
},
- "node_modules/tabbable": {
- "version": "6.2.0",
- "license": "MIT"
- },
"node_modules/tailwindcss": {
"version": "3.4.14",
"dev": true,
@@ -17326,13 +17185,6 @@
"makeerror": "1.0.12"
}
},
- "node_modules/warning": {
- "version": "4.0.3",
- "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 aea1a840b5..5d52bee333 100644
--- a/components/frontend/package.json
+++ b/components/frontend/package.json
@@ -12,18 +12,15 @@
"@mui/x-date-pickers": "^7.23.3",
"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.5.0",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.0",
"react-hash-link": "1.0.2",
"react-is": "^18.3.1",
"react-timeago": "^7.2.0",
"react-toastify": "^11.0.2",
- "semantic-ui-react": "^2.1.5",
"victory": "^37.3.5"
},
"scripts": {
diff --git a/components/frontend/src/App.css b/components/frontend/src/App.css
index 610c057fed..95ccba6113 100644
--- a/components/frontend/src/App.css
+++ b/components/frontend/src/App.css
@@ -1,21 +1,3 @@
-.MainContainer {
- flex: 1;
- margin-top: 6em;
- padding-left: 1em;
- padding-right: 1em;
-}
-
-@media print {
- .MainContainer {
- margin-top: 0em;
- }
-}
-
html {
- scroll-padding-top: 163px; /* height of sticky header */
-}
-
-:root {
- --inverted-menu-background-color: #1b1c1d;
- --selection-color: #2185d0;
+ scroll-padding-top: 176px; /* height of sticky header */
}
diff --git a/components/frontend/src/App.js b/components/frontend/src/App.js
index 6ee06731d5..0dc59896da 100644
--- a/components/frontend/src/App.js
+++ b/components/frontend/src/App.js
@@ -1,6 +1,6 @@
import "./App.css"
-import { createTheme, ThemeProvider } from "@mui/material/styles"
+import { ThemeProvider } from "@mui/material/styles"
import { Action } from "history"
import history from "history/browser"
import { Component } from "react"
@@ -11,16 +11,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)
diff --git a/components/frontend/src/AppUI.js b/components/frontend/src/AppUI.js
index 4b35645ba8..7494a249df 100644
--- a/components/frontend/src/AppUI.js
+++ b/components/frontend/src/AppUI.js
@@ -66,65 +66,55 @@ export function AppUI({
}
const darkMode = userPrefersDarkMode(mode)
- const backgroundColor = darkMode ? "rgb(40, 40, 40)" : "white"
return (
-
-
-
-
-
- }
- settings={settings}
- setUIMode={setMode}
- uiMode={mode}
- />
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ }
+ settings={settings}
+ setUIMode={setMode}
+ uiMode={mode}
+ />
+
+
+
+
+
+
+
+
+
)
}
AppUI.propTypes = {
diff --git a/components/frontend/src/PageContent.js b/components/frontend/src/PageContent.js
index 87c998c4ef..5407e1bc39 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,19 @@ export function PageContent({
}
}
return (
-
+
{content}
)
diff --git a/components/frontend/src/PageContent.test.js b/components/frontend/src/PageContent.test.js
index 7f1c28c37d..1b7007f209 100644
--- a/components/frontend/src/PageContent.test.js
+++ b/components/frontend/src/PageContent.test.js
@@ -63,7 +63,7 @@ it("shows that the report was missing", async () => {
it("shows the loading spinner", async () => {
await renderPageContent({ loading: true })
- expect(screen.getAllByLabelText(/Loading/).length).toBe(1)
+ expect(screen.getAllByRole("progressbar").length).toBe(1)
})
function expectMeasurementsCall(date, offset = 0) {
diff --git a/components/frontend/src/app_ui_settings.js b/components/frontend/src/app_ui_settings.js
index 4c6de39fb7..46e968df92 100644
--- a/components/frontend/src/app_ui_settings.js
+++ b/components/frontend/src/app_ui_settings.js
@@ -1,12 +1,9 @@
-import { string } from "prop-types"
-
import {
useArrayURLSearchQuery,
useBooleanURLSearchQuery,
useIntegerURLSearchQuery,
useStringURLSearchQuery,
} from "./hooks/url_search_query"
-import { stringsURLSearchQueryPropType } from "./sharedPropTypes"
function urlSearchQueryKey(key, report_uuid) {
// Make the settings changeable per report (and separately for the reports overview) by adding the report UUID as
@@ -147,26 +144,3 @@ export function allSettingsAreDefault(settings) {
settings.sortDirection.isDefault()
)
}
-
-export function tabChangeHandler(expandedItems, uuid) {
- // Return an event handler for Tab.onTabChange that updates the active tab
- return function onTabChange(_event, data) {
- const oldItem = expandedItems.value.filter((item) => item?.startsWith(uuid))[0]
- const newItem = `${uuid}:${data.activeIndex}`
- expandedItems.toggle(oldItem, newItem)
- }
-}
-tabChangeHandler.propTypes = {
- expandedItems: stringsURLSearchQueryPropType,
- uuid: string,
-}
-
-export function activeTabIndex(expandedItems, uuid) {
- // Return the active tab index of the expanded item, defaults to 0
- const item = expandedItems.value.filter((item) => item?.startsWith(uuid))[0] ?? `${uuid}:0`
- return Number(item.split(":")[1])
-}
-activeTabIndex.propTypes = {
- expandedItems: stringsURLSearchQueryPropType,
- uuid: string,
-}
diff --git a/components/frontend/src/context/Permissions.js b/components/frontend/src/context/Permissions.js
index 2fafa48e1d..4eee655e70 100644
--- a/components/frontend/src/context/Permissions.js
+++ b/components/frontend/src/context/Permissions.js
@@ -10,10 +10,10 @@ export const PERMISSIONS = [EDIT_REPORT_PERMISSION, EDIT_ENTITY_PERMISSION]
export const Permissions = React.createContext(null)
export function accessGranted(permissions, requiredPermissions) {
- if (!requiredPermissions) {
+ if ((requiredPermissions ?? []).length === 0) {
return true
}
- if (!permissions) {
+ if ((permissions ?? []).length === 0) {
return false
}
return requiredPermissions.every((permission) => permissions.includes(permission))
diff --git a/components/frontend/src/dashboard/CardDashboard.test.js b/components/frontend/src/dashboard/CardDashboard.test.js
index 00406dfc6d..e484628668 100644
--- a/components/frontend/src/dashboard/CardDashboard.test.js
+++ b/components/frontend/src/dashboard/CardDashboard.test.js
@@ -1,6 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react"
-import { DarkMode } from "../context/DarkMode"
import { EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
import { CardDashboard } from "./CardDashboard"
import { MetricSummaryCard } from "./MetricSummaryCard"
@@ -12,13 +11,11 @@ afterEach(() => jest.restoreAllMocks())
function renderCardDashboard({ cards = [], initialLayout = [], saveLayout = jest.fn } = {}) {
return render(
-
-
-
-
-
-
- ,
+
+
+
+
+ ,
)
}
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..e8124c9045 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..7d1a11fa28 100644
--- a/components/frontend/src/dashboard/LegendCard.js
+++ b/components/frontend/src/dashboard/LegendCard.js
@@ -9,7 +9,7 @@ export function LegendCard() {
-
+
))
diff --git a/components/frontend/src/dashboard/MetricSummaryCard.js b/components/frontend/src/dashboard/MetricSummaryCard.js
index 8bda21569c..bb17643c3b 100644
--- a/components/frontend/src/dashboard/MetricSummaryCard.js
+++ b/components/frontend/src/dashboard/MetricSummaryCard.js
@@ -1,10 +1,8 @@
import "./MetricSummaryCard.css"
import { bool, func, number, object, oneOfType, string } from "prop-types"
-import { useContext } from "react"
import { VictoryContainer, VictoryLabel, VictoryTooltip } from "victory"
-import { DarkMode } from "../context/DarkMode"
import { useBoundingBox } from "../hooks/boundingbox"
import { STATUS_COLORS_RGB, STATUSES } from "../metric/status"
import { pluralize, sum } from "../utils"
@@ -48,8 +46,6 @@ function ariaChartLabel(summary) {
export function MetricSummaryCard({ header, onClick, selected, summary, maxY }) {
const [boundingBox, ref] = useBoundingBox()
- const labelColor = useContext(DarkMode) ? "darkgrey" : "rgba(120, 120, 120)"
- const flyoutBgColor = useContext(DarkMode) ? "rgba(60, 65, 70)" : "white"
const animate = { duration: 0, onLoad: { duration: 0 } }
const colors = STATUSES.map((status) => STATUS_COLORS_RGB[status])
const bbWidth = boundingBox.width ?? 0
@@ -60,9 +56,8 @@ export function MetricSummaryCard({ header, onClick, selected, summary, maxY })
constrainToVisibleArea={true}
cornerRadius={4}
flyoutHeight={54} // If we don't pass this, a height is calculated by Victory, but it's much too high
- flyoutStyle={{ fill: flyoutBgColor }}
renderInPortal={false}
- style={{ fontFamily: "Arial", fontSize: 16, fill: labelColor }}
+ style={{ fontFamily: "Arial", fontSize: 16 }}
/>
)
const dates = Object.keys(summary)
@@ -72,7 +67,7 @@ export function MetricSummaryCard({ header, onClick, selected, summary, maxY })
height: Math.max(bbHeight, 1), // Prevent "Failed prop type: Invalid prop range supplied to VictoryBar"
label: (
(
-
- {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..f3e019e44d 100644
--- a/components/frontend/src/dashboard/ExportCard.test.js
+++ b/components/frontend/src/dashboard/PageHeader.test.js
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react"
-import { ExportCard } from "./ExportCard"
import { mockGetAnimations } from "./MockAnimations"
+import { PageHeader } from "./PageHeader"
beforeEach(() => mockGetAnimations())
@@ -15,6 +15,7 @@ const mockDateOfToday = new Date()
const report = {
report_uuid: "report_uuid",
+ title: "Title",
subjects: {
subject_uuid: {
type: "subject_type",
@@ -32,37 +33,38 @@ const report = {
},
}
-function renderExportCard({ isOverview = false, lastUpdate = new Date(), report = null, reportDate = null } = {}) {
- render( )
+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", () => {
+ window.location.search = "?report_url=https://report/"
+ renderPageHeader({ report: report })
+ expect(screen.getByText(/Title/)).toBeInTheDocument()
})
it("displays dates in en-GB format", () => {
- renderExportCard({ lastUpdate: mockLastUpdate, report: report, reportDate: mockReportDate })
+ renderPageHeader({ lastUpdate: mockLastUpdate, report: report, reportDate: mockReportDate })
expect(screen.getByText(/Report date: 24-03-2024/)).toBeInTheDocument()
expect(screen.getByText(/Generated: 26-03-2024, 12:34/)).toBeInTheDocument()
})
it("displays report URL", () => {
- renderExportCard({ report: report })
+ renderPageHeader({ report: report })
expect(screen.getByTestId("reportUrl")).toBeInTheDocument()
})
it("displays version link", () => {
- renderExportCard({ lastUpdate: mockLastUpdate, report: report })
+ renderPageHeader({ lastUpdate: mockLastUpdate, report: report })
expect(screen.getByTestId("version")).toBeInTheDocument()
})
it("displays today as report date if no report date is provided", () => {
- renderExportCard({ lastUpdate: mockLastUpdate, report: report })
+ renderPageHeader({ lastUpdate: mockLastUpdate, report: report })
expect(screen.getByText(`Report date: ${mockDateOfToday}`)).toBeInTheDocument()
})
diff --git a/components/frontend/src/errorMessage.js b/components/frontend/src/errorMessage.js
index 39a8e16017..1f9e02e741 100644
--- a/components/frontend/src/errorMessage.js
+++ b/components/frontend/src/errorMessage.js
@@ -1,22 +1,19 @@
+import Grid from "@mui/material/Grid2"
import { bool, object, oneOfType, string } from "prop-types"
-import { Grid } from "semantic-ui-react"
-import { Message } from "./semantic_ui_react_wrappers"
+import { WarningMessage } from "./widgets/WarningMessage"
export function ErrorMessage({ formatAsText, message, title }) {
return (
-
-
-
- {title}
- {formatAsText ? (
- message
- ) : (
- {message}
- )}
-
-
-
+
+
+ {formatAsText ? (
+ message
+ ) : (
+ {message}
+ )}
+
+
)
}
ErrorMessage.propTypes = {
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..cb79f60cca
--- /dev/null
+++ b/components/frontend/src/fields/MultipleChoiceField.js
@@ -0,0 +1,49 @@
+import { Autocomplete, TextField } from "@mui/material"
+import { arrayOf, bool, func, object, oneOfType, string } from "prop-types"
+
+import { stringsPropType } from "../sharedPropTypes"
+
+export function MultipleChoiceField({
+ disabled,
+ freeSolo,
+ helperText,
+ label,
+ onChange,
+ onInputChange,
+ options,
+ placeholder,
+ value,
+}) {
+ return (
+ x} // Disable built-in filtering
+ freeSolo={freeSolo} // Allow additional options
+ fullWidth
+ multiple
+ options={options}
+ onChange={(_event, value) => onChange(value.map((value) => value?.id ?? value))}
+ onInputChange={onInputChange}
+ renderInput={(params) => (
+
+ )}
+ />
+ )
+}
+MultipleChoiceField.propTypes = {
+ disabled: bool,
+ freeSolo: bool,
+ helperText: string,
+ label: string,
+ onChange: func,
+ onInputChange: func,
+ options: oneOfType([stringsPropType, arrayOf(object)]),
+ placeholder: string,
+ 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..b74c9a4cad
--- /dev/null
+++ b/components/frontend/src/fields/TextField.js
@@ -0,0 +1,99 @@
+import { InputAdornment, TextField as MUITextField } from "@mui/material"
+import { bool, element, func, number, oneOfType, string } from "prop-types"
+import { useState } from "react"
+
+import { childrenPropType } from "../sharedPropTypes"
+
+export function TextField({
+ children,
+ disabled,
+ endAdornment,
+ error,
+ helperText,
+ id,
+ label,
+ max,
+ multiline,
+ onChange,
+ placeholder,
+ required,
+ select,
+ startAdornment,
+ type,
+ value,
+}) {
+ const [textValue, setTextValue] = useState(value)
+
+ function submitIfChanged() {
+ if (textValue !== value) {
+ onChange(textValue)
+ }
+ }
+
+ function onKeyDown(event) {
+ if (event.key === "Escape") {
+ setTextValue(value)
+ }
+ if (event.key === "Enter") {
+ submitIfChanged()
+ }
+ }
+
+ const startInputAdornment = startAdornment ? (
+ {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}
+ variant="outlined"
+ >
+ {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.js b/components/frontend/src/header_footer/Footer.js
index 1cce21d25c..7fdbc41146 100644
--- a/components/frontend/src/header_footer/Footer.js
+++ b/components/frontend/src/header_footer/Footer.js
@@ -145,7 +145,7 @@ function QuoteColumn() {
export function Footer({ lastUpdate, report }) {
return (
-
+
}
sx={{ textTransform: "none" }}
>
- Quality-time
+ Quality-time
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..fbdea36756 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,24 @@ 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 || "?"}
+
+ {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 +173,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..c09779680c 100644
--- a/components/frontend/src/issue/IssuesRows.js
+++ b/components/frontend/src/issue/IssuesRows.js
@@ -1,17 +1,16 @@
+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 { accessGranted, EDIT_REPORT_PERMISSION, Permissions, ReadOnlyOrEditable } from "../context/Permissions"
import { ErrorMessage } from "../errorMessage"
-import { MultipleChoiceInput } from "../fields/MultipleChoiceInput"
+import { MultipleChoiceField } from "../fields/MultipleChoiceField"
import { metricPropType, reportPropType } from "../sharedPropTypes"
import { getMetricIssueIds } from "../utils"
import { ActionButton } from "../widgets/buttons/ActionButton"
import { AddItemIcon } from "../widgets/icons"
-import { LabelWithHelp } from "../widgets/LabelWithHelp"
import { showMessage } from "../widgets/toast"
function CreateIssueButton({ issueTrackerConfigured, issueTrackerInstruction, metric_uuid, target, reload }) {
@@ -40,33 +39,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)
return (
- {
+ disabled={disabled}
+ freeSolo
+ helperText={issueStatusHelp}
+ key={issue_ids} // 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 +71,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
/>
)
}
@@ -116,64 +107,56 @@ export function IssuesRows({ metric, metric_uuid, reload, report, target }) {
}
return (
<>
-
-
+
+
+
+ }
+ editableComponent={
+ <>
+
+
+
+
-
- }
- 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..2a5ad2d49e 100644
--- a/components/frontend/src/issue/IssuesRows.test.js
+++ b/components/frontend/src/issue/IssuesRows.test.js
@@ -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..99cfb83187 100644
--- a/components/frontend/src/measurement/SourceStatus.js
+++ b/components/frontend/src/measurement/SourceStatus.js
@@ -1,10 +1,11 @@
+import { Tooltip } 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..5a44aa0003 100644
--- a/components/frontend/src/measurement/StatusIcon.js
+++ b/components/frontend/src/measurement/StatusIcon.js
@@ -1,7 +1,7 @@
import { Avatar, Tooltip } from "@mui/material"
import { instanceOf, oneOfType, string } from "prop-types"
-import { STATUS_COLORS_MUI, STATUS_ICONS, STATUS_SHORT_NAME, statusPropType } from "../metric/status"
+import { STATUS_ICONS, STATUS_SHORT_NAME, statusPropType } from "../metric/status"
import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate"
export function StatusIcon({ status, statusStart, size }) {
@@ -9,7 +9,7 @@ export function StatusIcon({ status, statusStart, size }) {
const sizes = { small: 20, undefined: 32 }
const statusName = STATUS_SHORT_NAME[status]
// Use Avatar to create a round inverted icon:
- const iconStyle = { width: sizes[size], height: sizes[size], bgcolor: STATUS_COLORS_MUI[status] }
+ const iconStyle = { width: sizes[size], height: sizes[size], bgcolor: `${status}.main` }
const icon = (
{STATUS_ICONS[status]}
diff --git a/components/frontend/src/measurement/TimeLeft.js b/components/frontend/src/measurement/TimeLeft.js
index d89e8ae5d4..31e5c448f3 100644
--- a/components/frontend/src/measurement/TimeLeft.js
+++ b/components/frontend/src/measurement/TimeLeft.js
@@ -1,6 +1,8 @@
-import { Label, Popup } from "../semantic_ui_react_wrappers"
+import { Tooltip } from "@mui/material"
+
import { metricPropType, reportPropType } from "../sharedPropTypes"
import { days, getMetricResponseDeadline, getMetricResponseTimeLeft, pluralize } from "../utils"
+import { Label } from "../widgets/Label"
import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate"
export function TimeLeft({ metric, report }) {
@@ -12,15 +14,15 @@ export function TimeLeft({ metric, report }) {
const daysLeft = days(Math.max(0, timeLeft))
const triggerText = `${daysLeft} ${pluralize("day", daysLeft)}`
let deadlineLabel = "Deadline to address this metric was"
- let trigger = {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/metric/MetricConfigurationParameters.js b/components/frontend/src/metric/MetricConfigurationParameters.js
index abbdb6ac36..e8da8b9a16 100644
--- a/components/frontend/src/metric/MetricConfigurationParameters.js
+++ b/components/frontend/src/metric/MetricConfigurationParameters.js
@@ -1,25 +1,19 @@
+import { MenuItem, Stack, Typography } from "@mui/material"
+import Grid from "@mui/material/Grid2"
import { func, string } from "prop-types"
import { useContext } from "react"
-import { Grid, Header } from "semantic-ui-react"
import { set_metric_attribute } from "../api/metric"
import { DataModel } from "../context/DataModel"
-import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { MultipleChoiceInput } from "../fields/MultipleChoiceInput"
-import { SingleChoiceInput } from "../fields/SingleChoiceInput"
-import { StringInput } from "../fields/StringInput"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { MultipleChoiceField } from "../fields/MultipleChoiceField"
+import { TextField } from "../fields/TextField"
import { metricPropType, reportPropType, subjectPropType } from "../sharedPropTypes"
-import {
- dropdownOptions,
- formatMetricScale,
- getMetricDirection,
- getMetricScale,
- getMetricTags,
- getReportTags,
-} from "../utils"
-import { LabelWithHelp } from "../widgets/LabelWithHelp"
+import { formatMetricScale, getMetricDirection, getMetricScale, getMetricTags, getReportTags } from "../utils"
+import { Header } from "../widgets/Header"
import { MetricType } from "./MetricType"
import { Target } from "./Target"
+import { TargetVisualiser } from "./TargetVisualiser"
function metric_scale_options(metric_scales, dataModel) {
let scale_options = []
@@ -27,7 +21,7 @@ function metric_scale_options(metric_scales, dataModel) {
let scale_name = dataModel.scales ? dataModel.scales[scale].name : "Count"
let scale_description = dataModel.scales ? dataModel.scales[scale].description : ""
scale_options.push({
- content: ,
+ content: ,
key: scale,
text: scale_name,
value: scale,
@@ -38,16 +32,16 @@ function metric_scale_options(metric_scales, dataModel) {
function MetricName({ metric, metric_uuid, reload }) {
const dataModel = useContext(DataModel)
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const metricType = dataModel.metrics[metric.type]
- const labelId = `metric-name-${metric_uuid}`
return (
- 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 +52,16 @@ MetricName.propTypes = {
}
function Tags({ metric, metric_uuid, reload, report }) {
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const tags = getReportTags(report)
- const labelId = `tags-${metric_uuid}`
return (
- 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 +75,26 @@ Tags.propTypes = {
function Scale({ metric, metric_uuid, reload }) {
const dataModel = useContext(DataModel)
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const scale = getMetricScale(metric, dataModel)
const metricType = dataModel.metrics[metric.type]
const scale_options = metric_scale_options(metricType.scales || ["count"], dataModel)
- const labelId = `scale-${metric_uuid}`
return (
- 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 +105,8 @@ Scale.propTypes = {
function Direction({ metric, metric_uuid, reload }) {
const dataModel = useContext(DataModel)
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const scale = getMetricScale(metric, dataModel)
const metricType = dataModel.metrics[metric.type]
const metricUnitWithoutPercentage = metric.unit || metricType.unit
@@ -119,20 +121,21 @@ function Direction({ metric, metric_uuid, reload }) {
percentage: `A higher percentage of ${metricUnitWithoutPercentage}`,
version_number: "A higher version number",
}[scale]
- const metricDirection = getMetricDirection(metric, dataModel) ?? "<"
- const labelId = `direction-${metric_uuid}`
return (
- 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 +146,17 @@ Direction.propTypes = {
function Unit({ metric, metric_uuid, reload }) {
const dataModel = useContext(DataModel)
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const metricType = dataModel.metrics[metric.type]
- const labelId = `unit-${metric_uuid}`
return (
- 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 +167,26 @@ Unit.propTypes = {
}
function EvaluateTargets({ metric, metric_uuid, reload }) {
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const help =
"Turning off evaluation of the metric targets makes this an informative metric. Informative metrics do not turn red, green, or yellow, and can't have accepted technical debt."
- const labelId = `evaluate-targets-label-${metric_uuid}`
return (
- }
+ 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 +199,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..16544b74e7 100644
--- a/components/frontend/src/metric/MetricConfigurationParameters.test.js
+++ b/components/frontend/src/metric/MetricConfigurationParameters.test.js
@@ -94,7 +94,7 @@ it("changes the scale", async () => {
await act(async () => {
renderMetricParameters()
})
- fireEvent.click(screen.getByText(/Metric scale/))
+ fireEvent.mouseDown(screen.getByLabelText(/Metric scale/))
fireEvent.click(screen.getByText(/Percentage/))
expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/scale", {
scale: "percentage",
@@ -105,7 +105,7 @@ it("changes the direction", async () => {
await act(async () => {
renderMetricParameters()
})
- fireEvent.click(screen.getByText(/direction/))
+ fireEvent.mouseDown(screen.getByLabelText(/direction/))
fireEvent.click(screen.getByText(/More violations is better/))
expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith(
"post",
diff --git a/components/frontend/src/metric/MetricDebtParameters.js b/components/frontend/src/metric/MetricDebtParameters.js
index a473eaabc7..260cf19fe0 100644
--- a/components/frontend/src/metric/MetricDebtParameters.js
+++ b/components/frontend/src/metric/MetricDebtParameters.js
@@ -1,46 +1,34 @@
+import { MenuItem } from "@mui/material"
+import Grid from "@mui/material/Grid2"
+import { DatePicker } from "@mui/x-date-pickers/DatePicker"
+import dayjs from "dayjs"
import { func, string } from "prop-types"
-import { Grid } from "semantic-ui-react"
+import { useContext } from "react"
+import TimeAgo from "react-timeago"
import { set_metric_attribute, set_metric_debt } from "../api/metric"
-import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { Comment } from "../fields/Comment"
-import { DateInput } from "../fields/DateInput"
-import { SingleChoiceInput } from "../fields/SingleChoiceInput"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { CommentField } from "../fields/CommentField"
+import { TextField } from "../fields/TextField"
import { IssuesRows } from "../issue/IssuesRows"
import { metricPropType, reportPropType } from "../sharedPropTypes"
-import { LabelWithDate } from "../widgets/LabelWithDate"
-import { LabelWithHyperLink } from "../widgets/LabelWithHyperLink"
+import { HyperLink } from "../widgets/HyperLink"
import { Target } from "./Target"
function AcceptTechnicalDebt({ metric, metric_uuid, reload }) {
- const labelId = `accept-debt-label-${metric_uuid}`
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
return (
-
+
+ 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..c3629b3fba 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,24 +38,27 @@ const dataModel = {
function renderMetricDebtParameters({ accept_debt = false, debt_end_date = null } = {}) {
render(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
+ ,
)
}
@@ -93,25 +100,18 @@ it("adds a comment", async () => {
})
it("sets the technical debt end date", async () => {
- // Suppress "Warning: An update to t inside a test was not wrapped in act(...)." caused by interacting with
- // the date picker.
- const consoleLog = console.log
- console.error = jest.fn()
fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true })
renderMetricDebtParameters()
- await userEvent.type(screen.getByPlaceholderText(/YYYY-MM-DD/), "2022-12-31{Tab}", {
- initialSelectionStart: 0,
- initialSelectionEnd: 10,
- })
+ await userEvent.type(screen.getByPlaceholderText(/YYYY-MM-DD/), "20221231{Enter}")
+ const expectedDate = dayjs("2022-12-31")
expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith(
"post",
"metric/metric_uuid/attribute/debt_end_date",
- { debt_end_date: "2022-12-31" },
+ { debt_end_date: expectedDate },
)
- console.log = consoleLog
})
it("shows days ago for the technical debt end date", () => {
renderMetricDebtParameters({ debt_end_date: "2000-01-01" })
- expect(screen.getAllByLabelText(/years ago/).length).toBe(1)
+ expect(screen.getAllByText(/years ago/).length).toBe(1)
})
diff --git a/components/frontend/src/metric/MetricDetails.js b/components/frontend/src/metric/MetricDetails.js
index 56cc768851..7bd5c3dedb 100644
--- a/components/frontend/src/metric/MetricDetails.js
+++ b/components/frontend/src/metric/MetricDetails.js
@@ -1,4 +1,6 @@
+import HistoryIcon from "@mui/icons-material/History"
import MoneyIcon from "@mui/icons-material/Money"
+import SettingsIcon from "@mui/icons-material/Settings"
import ShowChartIcon from "@mui/icons-material/ShowChart"
import StorageIcon from "@mui/icons-material/Storage"
import { bool, func, string } from "prop-types"
@@ -6,19 +8,10 @@ import { useContext, useEffect, useState } from "react"
import { get_metric_measurements } from "../api/measurement"
import { delete_metric, set_metric_attribute } from "../api/metric"
-import { activeTabIndex, tabChangeHandler } from "../app_ui_settings"
import { ChangeLog } from "../changelog/ChangeLog"
import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions"
-import { Tab } from "../semantic_ui_react_wrappers"
-import {
- datePropType,
- metricPropType,
- reportPropType,
- reportsPropType,
- stringsPropType,
- stringsURLSearchQueryPropType,
-} from "../sharedPropTypes"
+import { datePropType, metricPropType, reportPropType, reportsPropType, stringsPropType } from "../sharedPropTypes"
import { Logo } from "../source/Logo"
import { SourceEntities } from "../source/SourceEntities"
import { Sources } from "../source/Sources"
@@ -29,7 +22,7 @@ import { DeleteButton } from "../widgets/buttons/DeleteButton"
import { PermLinkButton } from "../widgets/buttons/PermLinkButton"
import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup"
import { RefreshIcon } from "../widgets/icons"
-import { changelogTabPane, configurationTabPane, tabPane } from "../widgets/TabPane"
+import { Tabs } from "../widgets/Tabs"
import { showMessage } from "../widgets/toast"
import { MetricConfigurationParameters } from "./MetricConfigurationParameters"
import { MetricDebtParameters } from "./MetricDebtParameters"
@@ -128,7 +121,6 @@ export function MetricDetails({
report,
stopFilteringAndSorting,
subject_uuid,
- expandedItems,
}) {
const dataModel = useContext(DataModel)
const [measurements, setMeasurements] = useState([])
@@ -149,69 +141,59 @@ export function MetricDetails({
anyError ||
Object.values(metric.sources).some((source) => !dataModel.metrics[metric.type].sources.includes(source.type))
const metricUrl = `${window.location.href.split("#")[0]}#${metric_uuid}`
- let panes = []
- panes.push(
- configurationTabPane(
- ,
+ ,
+ ,
+ ,
+ ,
+ ]
+ 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}
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..90e3f32109 100644
--- a/components/frontend/src/metric/MetricType.js
+++ b/components/frontend/src/metric/MetricType.js
@@ -1,11 +1,11 @@
-import { Stack, Typography } from "@mui/material"
+import { MenuItem, Stack, Typography } from "@mui/material"
import { func, string } from "prop-types"
import { useContext } from "react"
import { set_metric_attribute } from "../api/metric"
import { DataModel } from "../context/DataModel"
-import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { SingleChoiceInput } from "../fields/SingleChoiceInput"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { TextField } from "../fields/TextField"
import { getSubjectTypeMetrics } from "../utils"
export function metricTypeOption(key, metricType) {
@@ -41,19 +41,27 @@ export function usedMetricTypes(subject) {
export function MetricType({ subjectType, metricType, metric_uuid, reload }) {
const dataModel = useContext(DataModel)
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const options = metricTypeOptions(dataModel, subjectType)
const metricTypes = options.map((option) => option.key)
if (!metricTypes.includes(metricType)) {
options.push(metricTypeOption(metricType, dataModel.metrics[metricType]))
}
return (
- 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..6a0e87faba 100644
--- a/components/frontend/src/metric/MetricType.test.js
+++ b/components/frontend/src/metric/MetricType.test.js
@@ -1,4 +1,4 @@
-import { act, render, screen } from "@testing-library/react"
+import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as fetch_server_api from "../api/fetch_server_api"
@@ -58,9 +58,7 @@ function renderMetricType(metricType) {
it("sets the metric type", async () => {
fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true })
- await act(async () => {
- renderMetricType("violations")
- })
+ renderMetricType("violations")
await userEvent.type(screen.getByRole("combobox"), "Source version{Enter}")
expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/type", {
type: "source_version",
@@ -68,8 +66,6 @@ it("sets the metric type", async () => {
})
it("shows the metric type even when not supported by the subject type", async () => {
- await act(async () => {
- renderMetricType("unsupported")
- })
- expect(screen.queryAllByText(/Unsupported/).length).toBe(2)
+ renderMetricType("unsupported")
+ expect(screen.queryAllByText(/Unsupported/).length).toBe(1)
})
diff --git a/components/frontend/src/metric/MetricTypeHeader.js b/components/frontend/src/metric/MetricTypeHeader.js
index a03bc13273..df2d281f95 100644
--- a/components/frontend/src/metric/MetricTypeHeader.js
+++ b/components/frontend/src/metric/MetricTypeHeader.js
@@ -1,6 +1,6 @@
-import { Header } from "../semantic_ui_react_wrappers"
import { metricTypePropType } from "../sharedPropTypes"
import { referenceDocumentationURL } from "../utils"
+import { Header } from "../widgets/Header"
import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
export function MetricTypeHeader({ metricType }) {
@@ -8,15 +8,16 @@ export function MetricTypeHeader({ metricType }) {
? " for specific information on how to configure this metric type."
: ""
return (
-
-
- {metricType.name}
-
+
{metricType.description}
{howToConfigure}
-
-
-
+ >
+ }
+ />
)
}
MetricTypeHeader.propTypes = {
diff --git a/components/frontend/src/metric/Target.js b/components/frontend/src/metric/Target.js
index fdaf4b49a7..c8408b175b 100644
--- a/components/frontend/src/metric/Target.js
+++ b/components/frontend/src/metric/Target.js
@@ -1,306 +1,64 @@
-import HelpIcon from "@mui/icons-material/Help"
-import { Box, Stack, Typography } from "@mui/material"
-import { bool, func, oneOf, string } from "prop-types"
+import { func, string } from "prop-types"
import { useContext } from "react"
import { set_metric_attribute } from "../api/metric"
import { DataModel } from "../context/DataModel"
-import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { IntegerInput } from "../fields/IntegerInput"
-import { StringInput } from "../fields/StringInput"
-import { StatusIcon } from "../measurement/StatusIcon"
-import { Popup } from "../semantic_ui_react_wrappers"
-import { childrenPropType, labelPropType, metricPropType, scalePropType } from "../sharedPropTypes"
-import {
- capitalize,
- formatMetricDirection,
- formatMetricScaleAndUnit,
- formatMetricValue,
- getMetricScale,
-} from "../utils"
-import { STATUS_COLORS_MUI, STATUS_SHORT_NAME, statusPropType } from "./status"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { TextField } from "../fields/TextField"
+import { metricPropType, targetType } from "../sharedPropTypes"
+import { formatMetricDirection, formatMetricScaleAndUnit, formatMetricValue, getMetricScale } from "../utils"
-function smallerThan(target1, target2) {
- const t1 = target1 ?? `${Number.POSITIVE_INFINITY}`
- const t2 = target2 ?? "0"
- return t1.localeCompare(t2, undefined, { numeric: true }) < 0
-}
-
-function maxTarget(...targets) {
- targets.sort((target1, target2) => target1.localeCompare(target2, undefined, { numeric: true }))
- return targets.at(-1)
-}
-
-function minTarget(...targets) {
- targets.sort((target1, target2) => target1.localeCompare(target2, undefined, { numeric: true }))
- return targets.at(0)
-}
-
-function debtTargetActive(metric, direction) {
- const endDate = metric.debt_end_date ? new Date(metric.debt_end_date) : null
- const active = !!metric.accept_debt && ((endDate && endDate >= new Date()) || !endDate)
- return (
- active &&
- (direction === "≦"
- ? smallerThan(metric.target, metric.debt_target)
- : smallerThan(metric.debt_target, metric.target))
- )
-}
-
-function ColoredSegment({ children, color, show, status }) {
- if (show === false) {
- return null
- }
- return (
-
-
-
- {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..6b804d0966 100644
--- a/components/frontend/src/metric/TrendGraph.js
+++ b/components/frontend/src/metric/TrendGraph.js
@@ -1,5 +1,4 @@
import { useContext } from "react"
-import { Message } from "semantic-ui-react"
import { VictoryAxis, VictoryChart, VictoryLabel, VictoryLine, VictoryTheme } from "victory"
import { DarkMode } from "../context/DarkMode"
@@ -7,7 +6,7 @@ import { DataModel } from "../context/DataModel"
import { loadingPropType, measurementsPropType, metricPropType } from "../sharedPropTypes"
import { capitalize, formatMetricScaleAndUnit, getMetricName, getMetricScale, niceNumber, scaledNumber } from "../utils"
import { LoadingPlaceHolder } from "../widgets/Placeholder"
-import { FailedToLoadMeasurementsWarningMessage, WarningMessage } from "../widgets/WarningMessage"
+import { FailedToLoadMeasurementsWarningMessage, InfoMessage, WarningMessage } from "../widgets/WarningMessage"
function measurementAttributeAsNumber(metric, measurement, field, dataModel) {
const scale = getMetricScale(metric, dataModel)
@@ -22,10 +21,9 @@ export function TrendGraph({ metric, measurements, loading }) {
const estimatedTotalChartHeight = chartHeight + 200 // Estimate of the height including title and axis
if (getMetricScale(metric, dataModel) === "version_number") {
return (
-
+
+ Trend graphs are not supported for metrics with a version number scale.
+
)
}
if (loading === "failed") {
@@ -36,10 +34,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)
diff --git a/components/frontend/src/metric/status.js b/components/frontend/src/metric/status.js
index 3ef465e9f7..f6b5990e8d 100644
--- a/components/frontend/src/metric/status.js
+++ b/components/frontend/src/metric/status.js
@@ -1,7 +1,6 @@
// Metric status constants
import { Bolt, Check, Money, QuestionMark, Warning } from "@mui/icons-material"
-import { blue, green, grey, orange, red } from "@mui/material/colors"
import { oneOf } from "prop-types"
import { HyperLink } from "../widgets/HyperLink"
@@ -25,14 +24,6 @@ export const STATUS_COLORS_RGB = {
informative: "rgb(0,165,255)",
unknown: "rgb(245,245,245)",
}
-export const STATUS_COLORS_MUI = {
- target_not_met: red[700],
- target_met: green[600],
- near_target_met: orange[300],
- debt_target_met: grey[500],
- informative: blue[500],
- unknown: grey[300],
-}
export const STATUS_ICONS = {
target_met: ,
near_target_met: ,
diff --git a/components/frontend/src/notification/NotificationDestinations.js b/components/frontend/src/notification/NotificationDestinations.js
index 28ce09fc8e..92f3fd195d 100644
--- a/components/frontend/src/notification/NotificationDestinations.js
+++ b/components/frontend/src/notification/NotificationDestinations.js
@@ -1,69 +1,64 @@
import { Stack } from "@mui/material"
+import Grid from "@mui/material/Grid2"
import { func, objectOf, string } from "prop-types"
-import { Grid } from "semantic-ui-react"
+import { useContext } from "react"
import {
add_notification_destination,
delete_notification_destination,
set_notification_destination_attributes,
} from "../api/notification"
-import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions"
-import { StringInput } from "../fields/StringInput"
-import { Message } from "../semantic_ui_react_wrappers"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions, ReadOnlyOrEditable } from "../context/Permissions"
+import { TextField } from "../fields/TextField"
import { destinationPropType } from "../sharedPropTypes"
import { ButtonRow } from "../widgets/ButtonRow"
import { AddButton } from "../widgets/buttons/AddButton"
import { DeleteButton } from "../widgets/buttons/DeleteButton"
import { HyperLink } from "../widgets/HyperLink"
-import { LabelWithHelp } from "../widgets/LabelWithHelp"
+import { InfoMessage } from "../widgets/WarningMessage"
function NotificationDestination({ destination, destination_uuid, reload, report_uuid }) {
- const help_url =
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
+ const helpUrl =
"https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook"
- const teams_hyperlink = 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
)}
@@ -121,7 +115,7 @@ export function NotificationDestinations({ destinations, reload, report_uuid })
/>
}
/>
- >
+
)
}
NotificationDestinations.propTypes = {
diff --git a/components/frontend/src/notification/NotificationDestinations.test.js b/components/frontend/src/notification/NotificationDestinations.test.js
index 23cdcd5c37..d1f421503f 100644
--- a/components/frontend/src/notification/NotificationDestinations.test.js
+++ b/components/frontend/src/notification/NotificationDestinations.test.js
@@ -53,7 +53,7 @@ it("creates a new notification destination when the add notification destination
it("edits notification destination name attribute when it is changed in the input field", async () => {
fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true })
renderNotificationDestinations(notification_destinations)
- await userEvent.type(screen.getByLabelText(/Name/), " changed{Enter}")
+ await userEvent.type(screen.getByLabelText(/Webhook name/), " changed{Enter}")
expect(fetch_server_api.fetch_server_api).toHaveBeenCalledWith(
"post",
"report/report_uuid/notification_destination/destination_uuid1/attributes",
diff --git a/components/frontend/src/report/IssueTracker.js b/components/frontend/src/report/IssueTracker.js
index 6c8a8c9169..b24e0e8bcd 100644
--- a/components/frontend/src/report/IssueTracker.js
+++ b/components/frontend/src/report/IssueTracker.js
@@ -1,30 +1,25 @@
+import { MenuItem, Stack } from "@mui/material"
+import Grid from "@mui/material/Grid2"
import { func } from "prop-types"
import { useContext, useEffect, useState } from "react"
-import { Grid, Header } from "semantic-ui-react"
import { get_report_issue_tracker_options, set_report_issue_tracker_attribute } from "../api/report"
import { DataModel } from "../context/DataModel"
-import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { MultipleChoiceInput } from "../fields/MultipleChoiceInput"
-import { PasswordInput } from "../fields/PasswordInput"
-import { SingleChoiceInput } from "../fields/SingleChoiceInput"
-import { StringInput } from "../fields/StringInput"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { MultipleChoiceField } from "../fields/MultipleChoiceField"
+import { TextField } from "../fields/TextField"
import { reportPropType } from "../sharedPropTypes"
import { Logo } from "../source/Logo"
-import { LabelWithHelp } from "../widgets/LabelWithHelp"
-import { LabelWithHyperLink } from "../widgets/LabelWithHyperLink"
+import { Header } from "../widgets/Header"
+import { HyperLink } from "../widgets/HyperLink"
import { showMessage } from "../widgets/toast"
import { WarningMessage } from "../widgets/WarningMessage"
const NONE_OPTION = {
- key: null,
+ key: "None",
text: "None",
- value: null,
- content: (
-
- ),
+ 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)
@@ -80,181 +77,188 @@ export function IssueTracker({ report, reload }) {
text: source_type.name,
value: source_name,
content: (
-
-
-
- {source_type.name}
- {source_type.description}
-
-
+
+ }
+ level="h4"
+ subheader={source_type.description}
+ />
),
}
})
trackerSources.push(NONE_OPTION)
- let privateTokenLabel = "Private token"
+ let privateTokenHelp = ""
if (report.issue_tracker) {
- const help_url = dataModel.sources[report.issue_tracker?.type]?.parameters?.private_token?.help_url
- if (help_url) {
- privateTokenLabel =
+ 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}
+ />
+
- {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..a11f85070c 100644
--- a/components/frontend/src/report/ReportTitle.js
+++ b/components/frontend/src/report/ReportTitle.js
@@ -1,16 +1,20 @@
-import { bool, func, oneOfType, string } from "prop-types"
-import { Grid } from "semantic-ui-react"
+import AssignmentIcon from "@mui/icons-material/Assignment"
+import HistoryIcon from "@mui/icons-material/History"
+import NotificationsIcon from "@mui/icons-material/Notifications"
+import SettingsIcon from "@mui/icons-material/Settings"
+import TimerIcon from "@mui/icons-material/Timer"
+import { Typography } from "@mui/material"
+import Grid from "@mui/material/Grid2"
+import { func, oneOfType, string } from "prop-types"
+import { useContext } from "react"
import { delete_report, set_report_attribute } from "../api/report"
-import { activeTabIndex, tabChangeHandler } from "../app_ui_settings"
import { ChangeLog } from "../changelog/ChangeLog"
-import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions"
-import { Comment } from "../fields/Comment"
-import { IntegerInput } from "../fields/IntegerInput"
-import { StringInput } from "../fields/StringInput"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions, ReadOnlyOrEditable } from "../context/Permissions"
+import { CommentField } from "../fields/CommentField"
+import { TextField } from "../fields/TextField"
import { STATUS_DESCRIPTION, STATUS_NAME, statusPropType } from "../metric/status"
import { NotificationDestinations } from "../notification/NotificationDestinations"
-import { Label, Segment, Tab } from "../semantic_ui_react_wrappers"
import { entityStatusPropType, reportPropType, settingsPropType } from "../sharedPropTypes"
import { SOURCE_ENTITY_STATUS_DESCRIPTION, SOURCE_ENTITY_STATUS_NAME } from "../source/source_entity_status"
import { getDesiredResponseTime } from "../utils"
@@ -18,43 +22,41 @@ import { ButtonRow } from "../widgets/ButtonRow"
import { DeleteButton } from "../widgets/buttons/DeleteButton"
import { PermLinkButton } from "../widgets/buttons/PermLinkButton"
import { HeaderWithDetails } from "../widgets/HeaderWithDetails"
-import { LabelWithHelp } from "../widgets/LabelWithHelp"
-import { changelogTabPane, configurationTabPane, tabPane } from "../widgets/TabPane"
+import { Tabs } from "../widgets/Tabs"
import { setDocumentTitle } from "./document_title"
import { IssueTracker } from "./IssueTracker"
function ReportConfiguration({ reload, report }) {
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
return (
-
-
-
- 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 = {
@@ -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 (
-
-
-
-
+
+
{
const reports = [{ report_uuid: "report_uuid", subjects: {} }]
const reportsOverview = { title: "Overview", permissions: {} }
renderReportsOverview({ reports: reports, reportsOverview: reportsOverview })
- expect(screen.getAllByText(/Overview/).length).toBe(2)
+ expect(screen.getAllByText(/Overview/).length).toBe(1)
})
it("shows the comment", async () => {
diff --git a/components/frontend/src/report/ReportsOverviewTitle.js b/components/frontend/src/report/ReportsOverviewTitle.js
index b5f383e1ee..adf7cdc0b3 100644
--- a/components/frontend/src/report/ReportsOverviewTitle.js
+++ b/components/frontend/src/report/ReportsOverviewTitle.js
@@ -1,52 +1,52 @@
+import HistoryIcon from "@mui/icons-material/History"
+import LockIcon from "@mui/icons-material/Lock"
+import SettingsIcon from "@mui/icons-material/Settings"
+import Grid from "@mui/material/Grid2"
import { func, shape } from "prop-types"
-import { Grid } from "semantic-ui-react"
+import { useContext } from "react"
import { set_reports_attribute } from "../api/report"
-import { activeTabIndex, tabChangeHandler } from "../app_ui_settings"
import { ChangeLog } from "../changelog/ChangeLog"
-import { EDIT_ENTITY_PERMISSION, EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { Comment } from "../fields/Comment"
-import { MultipleChoiceInput } from "../fields/MultipleChoiceInput"
-import { StringInput } from "../fields/StringInput"
-import { Tab } from "../semantic_ui_react_wrappers"
+import { accessGranted, EDIT_ENTITY_PERMISSION, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { CommentField } from "../fields/CommentField"
+import { MultipleChoiceField } from "../fields/MultipleChoiceField"
+import { TextField } from "../fields/TextField"
import { permissionsPropType, reportsOverviewPropType, settingsPropType } from "../sharedPropTypes"
-import { dropdownOptions } from "../utils"
import { HeaderWithDetails } from "../widgets/HeaderWithDetails"
-import { changelogTabPane, configurationTabPane, tabPane } from "../widgets/TabPane"
+import { Tabs } from "../widgets/Tabs"
import { setDocumentTitle } from "./document_title"
function ReportsOverviewConfiguration({ reports_overview, reload }) {
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
return (
-
-
-
- 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..83e6add265 100644
--- a/components/frontend/src/source/Source.js
+++ b/components/frontend/src/source/Source.js
@@ -1,14 +1,15 @@
+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 { accessGranted, EDIT_REPORT_PERMISSION, Permissions, ReadOnlyOrEditable } from "../context/Permissions"
import { ErrorMessage } from "../errorMessage"
-import { StringInput } from "../fields/StringInput"
-import { Tab } from "../semantic_ui_react_wrappers"
+import { TextField } from "../fields/TextField"
import {
measurementSourcePropType,
metricPropType,
@@ -21,7 +22,7 @@ import { ButtonRow } from "../widgets/ButtonRow"
import { DeleteButton } from "../widgets/buttons/DeleteButton"
import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup"
import { HyperLink } from "../widgets/HyperLink"
-import { changelogTabPane, configurationTabPane } from "../widgets/TabPane"
+import { Tabs } from "../widgets/Tabs"
import { SourceParameters } from "./SourceParameters"
import { SourceType } from "./SourceType"
import { SourceTypeHeader } from "./SourceTypeHeader"
@@ -71,41 +72,39 @@ function Parameters({
source_uuid,
}) {
const dataModel = useContext(DataModel)
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const source_type = dataModel.sources[source.type]
return (
-
-
-
- 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 && }
@@ -168,27 +167,29 @@ 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..bb72e1c289 100644
--- a/components/frontend/src/source/SourceEntities.js
+++ b/components/frontend/src/source/SourceEntities.js
@@ -1,13 +1,22 @@
import "./SourceEntities.css"
import HelpIcon from "@mui/icons-material/Help"
-import { IconButton, Tooltip } from "@mui/material"
+import {
+ IconButton,
+ Paper,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TableSortLabel,
+ Tooltip,
+} from "@mui/material"
import { bool, func, object, string } from "prop-types"
import { useContext, useState } from "react"
-import { Message } from "semantic-ui-react"
import { DataModel } from "../context/DataModel"
-import { Popup, Table } from "../semantic_ui_react_wrappers"
import {
alignmentPropType,
childrenPropType,
@@ -25,7 +34,7 @@ import {
import { capitalize } from "../utils"
import { IgnoreIcon, ShowIcon } from "../widgets/icons"
import { LoadingPlaceHolder } from "../widgets/Placeholder"
-import { FailedToLoadMeasurementsWarningMessage } from "../widgets/WarningMessage"
+import { FailedToLoadMeasurementsWarningMessage, InfoMessage } from "../widgets/WarningMessage"
import { SourceEntity } from "./SourceEntity"
function entityStatus(source, entity) {
@@ -99,6 +108,10 @@ sort.propTypes = {
sortDirection: sortDirectionPropType,
}
+function MuiSortDirection(sortDirection) {
+ return sortDirection === "ascending" ? "asc" : "desc"
+}
+
function SortableHeaderCell({
children,
column,
@@ -111,15 +124,17 @@ function SortableHeaderCell({
textAlign,
}) {
return (
-
- 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..be55d96ff6 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,64 @@ 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..8087698189 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"
@@ -11,23 +15,25 @@ const reload = jest.fn
function renderSourceEntityDetails(report) {
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.",
@@ -42,7 +48,7 @@ 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/))
+ 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.",
@@ -57,7 +63,7 @@ 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/))
+ 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 +78,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",
@@ -84,31 +91,23 @@ it("changes the entity status", () => {
})
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..e5d69254a7 100644
--- a/components/frontend/src/source/SourceParameter.js
+++ b/components/frontend/src/source/SourceParameter.js
@@ -1,15 +1,15 @@
+import { MenuItem } 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 +17,47 @@ import {
stringsPropType,
} from "../sharedPropTypes"
import { dropdownOptions } from "../utils"
-import { LabelDate } from "../widgets/LabelWithDate"
+import { HyperLink } from "../widgets/HyperLink"
import { LabelWithDropdown } from "../widgets/LabelWithDropdown"
-import { LabelWithHelp } from "../widgets/LabelWithHelp"
-import { LabelWithHyperLink } from "../widgets/LabelWithHyperLink"
-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: "grey",
},
{
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: "text.primary",
},
{
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: "gold",
},
{
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: "orange",
},
{
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: "red",
},
]
- return (
- setEditScope(data.value)}
- options={scope_options}
- value={edit_scope}
- />
- )
+ return setEditScope(value)} options={scopeOptions} value={editScope} />
}
-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 +100,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 +114,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 = ""
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,
+ endAdornment: disabled ? null : ,
+ helperText: helperText,
label: label,
+ onChange: (value) => {
+ set_source_parameter(source_uuid, parameter_key, value, editScope, reload)
+ setEditScope("source") // Reset the edit scope of the parameter to source only
+ },
placeholder: placeholder,
required: required,
+ requiredPermissions: requiredPermissions,
set_value: (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,
}
if (parameter_type === "date") {
- return
+ return (
+
+ )
}
+ parameterProps["value"] = parameter_value
if (parameter_type === "password") {
- return
+ return
}
if (parameter_type === "integer") {
- return
+ return (
+
+ )
}
if (parameter_type === "single_choice") {
- return
+ return (
+
+ {dropdownOptions(parameter_values).map((option) => (
+
+ {option.text}
+
+ ))}
+
+ )
}
if (parameter_type === "multiple_choice") {
- return
+ return
}
if (parameter_type === "multiple_choice_with_addition") {
- return
+ return
}
- parameter_props["options"] = parameterValues(report, source.type, parameter_key)
+ parameterProps["options"] = parameterValues(report, source.type, parameter_key)
if (parameter_type === "string") {
- return
+ return
}
if (parameter_type === "url") {
- return
+ return
}
return null
}
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 +222,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..ca98b5f00e 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 { 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,13 @@ it("renders a date parameter", () => {
parameter_type: "date",
parameter_value: "2021-10-10",
})
- expect(screen.queryAllByText(/Date/).length).toBe(1)
+ expect(screen.queryAllByLabelText(/Date/).length).toBe(1)
expect(screen.queryAllByDisplayValue("2021-10-10").length).toBe(1)
})
it("renders an integer parameter", () => {
renderSourceParameter({ parameter_name: "Integer", parameter_type: "integer" })
- expect(screen.queryAllByText(/Integer/).length).toBe(1)
- expect(screen.queryAllByPlaceholderText(/placeholder/).length).toBe(1)
+ expect(screen.queryAllByLabelText(/Integer/).length).toBe(1)
})
it("renders a single choice parameter", () => {
@@ -115,8 +115,8 @@ it("renders a single choice parameter", () => {
parameter_value: "option 1",
parameter_values: ["option 1", "option 2"],
})
- expect(screen.queryAllByText(/Single choice/).length).toBe(1)
- expect(screen.queryAllByText(/option 1/).length).toBe(2)
+ expect(screen.queryAllByLabelText(/Single choice/).length).toBe(1)
+ expect(screen.queryAllByText(/option 1/).length).toBe(1)
})
it("renders a multiple choice parameter", () => {
@@ -126,7 +126,7 @@ it("renders a multiple choice parameter", () => {
parameter_value: ["option 1", "option 2"],
parameter_values: ["option 1", "option 2", "option 3"],
})
- expect(screen.queryAllByText(/Multiple choice/).length).toBe(1)
+ expect(screen.queryAllByLabelText(/Multiple choice/).length).toBe(1)
expect(screen.queryAllByText(/option 1/).length).toBe(1)
})
@@ -137,7 +137,7 @@ it("renders a multiple choice with addition parameter", () => {
parameter_value: ["option 1", "option 2"],
placeholder: null,
})
- expect(screen.queryAllByText(/Multiple choice/).length).toBe(1)
+ expect(screen.queryAllByLabelText(/Multiple choice/).length).toBe(1)
})
it("renders nothing on unknown parameter type", () => {
@@ -147,21 +147,18 @@ it("renders nothing on unknown parameter type", () => {
it("renders a help url", () => {
renderSourceParameter({ help_url: "https://help" })
- expect(screen.queryByTitle(/Opens new window/).closest("a").href).toBe("https://help/")
+ expect(screen.queryAllByTitle(/Opens new window/)[0].closest("a").href).toBe("https://help/")
})
it("renders a help text", async () => {
renderSourceParameter({ help: "Help text" })
- await userEvent.hover(screen.queryByTestId("HelpIcon"))
- await waitFor(() => {
- expect(screen.queryAllByText(/Help text/).length).toBe(1)
- })
+ expect(screen.queryAllByText(/Help text/).length).toBe(1)
})
it("changes the value", async () => {
fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true })
renderSourceParameter({})
- await userEvent.type(screen.queryByText(/test/), "/new{Enter}")
+ await userEvent.type(screen.queryByLabelText(/URL/), "/new{Enter}")
expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "source/source_uuid/parameter/key1", {
key1: "https://test/new",
edit_scope: "source",
@@ -171,8 +168,9 @@ it("changes the value", async () => {
it("changes the value via mass edit", async () => {
fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true })
renderSourceParameter({})
+ await userEvent.click(screen.queryByText(/Apply change to source/))
await userEvent.click(screen.queryByText(/Apply change to subject/))
- await userEvent.type(screen.queryByText(/test/), "/new{Enter}")
+ await userEvent.type(screen.queryByLabelText(/URL/), "/new{Enter}")
expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "source/source_uuid/parameter/key1", {
key1: "https://test/new",
edit_scope: "subject",
diff --git a/components/frontend/src/source/SourceParameters.js b/components/frontend/src/source/SourceParameters.js
index fc3ac78908..40d80bb0bf 100644
--- a/components/frontend/src/source/SourceParameters.js
+++ b/components/frontend/src/source/SourceParameters.js
@@ -1,10 +1,10 @@
+import { Paper, Typography } from "@mui/material"
+import Grid from "@mui/material/Grid2"
import { func, string } from "prop-types"
import { useContext } from "react"
-import { Grid } from "semantic-ui-react"
import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { Header, Segment } from "../semantic_ui_react_wrappers"
import { metricPropType, reportPropType, sourcePropType, stringsPropType } from "../sharedPropTypes"
import { formatMetricScaleAndUnit } from "../utils"
import { SourceParameter } from "./SourceParameter"
@@ -44,17 +44,15 @@ export function SourceParameters({ changed_param_keys, metric, reload, report, s
if (parameterKeys.length === 0) {
return null
}
- const parameters = parameterKeys.map((parameterKey, index) => (
+ const parameters = parameterKeys.map((parameterKey) => (
))
return (
-
-
-
- {parameterGroup.name}
-
+
+
+ {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..f1c8e1b9dd 100644
--- a/components/frontend/src/source/SourceType.js
+++ b/components/frontend/src/source/SourceType.js
@@ -1,10 +1,10 @@
-import { Chip, Stack, Typography } from "@mui/material"
+import { Chip, MenuItem, Stack, Typography } from "@mui/material"
import { func, string } from "prop-types"
import { useContext } from "react"
import { DataModel } from "../context/DataModel"
-import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { SingleChoiceInput } from "../fields/SingleChoiceInput"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { TextField } from "../fields/TextField"
import { dataModelPropType, sourceTypePropType } from "../sharedPropTypes"
import { Logo } from "./Logo"
@@ -29,11 +29,15 @@ function sourceTypeOption(key, sourceType) {
-
- {sourceType.name}
- {sourceType.deprecated && }
+
+
+ {sourceType.name}
+ {sourceType.deprecated && (
+
+ )}
+
{sourceTypeDescription(sourceType)}
-
+
),
}
@@ -54,19 +58,27 @@ sourceTypeOptions.propTypes = {
export function SourceType({ metric_type, set_source_attribute, source_type }) {
const dataModel = useContext(DataModel)
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
const options = sourceTypeOptions(dataModel, metric_type)
const sourceTypes = options.map((option) => option.key)
if (!sourceTypes.includes(source_type)) {
options.push(sourceTypeOption(source_type, dataModel.sources[source_type]))
}
return (
- 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..9006b5863e 100644
--- a/components/frontend/src/source/SourceType.test.js
+++ b/components/frontend/src/source/SourceType.test.js
@@ -1,4 +1,4 @@
-import { act, render, screen } from "@testing-library/react"
+import { act, fireEvent, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { DataModel } from "../context/DataModel"
@@ -60,19 +60,20 @@ it("shows the metric type even when not supported by the subject type", async ()
await act(async () => {
renderSourceType("violations", "unsupported")
})
- expect(screen.queryAllByText(/Unsupported/).length).toBe(2)
+ expect(screen.getAllByText(/Unsupported/).length).toBe(1)
})
it("shows the supported source versions", async () => {
await act(async () => {
renderSourceType("violations", "sonarqube")
})
- expect(screen.queryAllByText(/Supported SonarQube versions: >=8.2/).length).toBe(1)
+ expect(screen.getAllByText(/Supported SonarQube versions: >=8.2/).length).toBe(1)
})
it("shows sources as deprecated if they are deprecated", async () => {
await act(async () => {
renderSourceType("violations", "sonarqube")
})
+ fireEvent.mouseDown(screen.getByLabelText(/Source type/))
expect(screen.getAllByText(/Deprecated/).length).toBe(1)
})
diff --git a/components/frontend/src/source/SourceTypeHeader.js b/components/frontend/src/source/SourceTypeHeader.js
index 419a9c84f6..3bb33ee92b 100644
--- a/components/frontend/src/source/SourceTypeHeader.js
+++ b/components/frontend/src/source/SourceTypeHeader.js
@@ -1,9 +1,9 @@
import { Chip } from "@mui/material"
import { string } from "prop-types"
-import { Header } from "../semantic_ui_react_wrappers"
import { sourceTypePropType } from "../sharedPropTypes"
import { referenceDocumentationURL } from "../utils"
+import { Header } from "../widgets/Header"
import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
import { Logo } from "./Logo"
import { sourceTypeDescription } from "./SourceType"
@@ -14,18 +14,22 @@ export function SourceTypeHeader({ metricTypeId, sourceTypeId, sourceType }) {
howToConfigure = " for specific information on how to configure this source type."
}
return (
-
-
-
- {sourceType.name}
- {sourceType.deprecated && }
-
+
+ {sourceType.name}
+ {sourceType.deprecated && }
+ >
+ }
+ level="h4"
+ subheader={
+ <>
{`${sourceTypeDescription(sourceType)} `}
{howToConfigure}
-
-
-
+ >
+ }
+ />
)
}
SourceTypeHeader.propTypes = {
diff --git a/components/frontend/src/source/Sources.js b/components/frontend/src/source/Sources.js
index 15a6a3b9a3..8b1a7b0520 100644
--- a/components/frontend/src/source/Sources.js
+++ b/components/frontend/src/source/Sources.js
@@ -1,10 +1,10 @@
+import { Paper } from "@mui/material"
import { func, number, string } from "prop-types"
import { useContext } from "react"
import { add_source, copy_source, move_source } from "../api/source"
import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions"
-import { Message, Segment } from "../semantic_ui_react_wrappers"
import {
measurementPropType,
measurementSourcePropType,
@@ -20,6 +20,7 @@ import { CopyButton } from "../widgets/buttons/CopyButton"
import { MoveButton } from "../widgets/buttons/MoveButton"
import { source_options } from "../widgets/menu_options"
import { showMessage } from "../widgets/toast"
+import { InfoMessage } from "../widgets/WarningMessage"
import { Source } from "./Source"
import { sourceTypeOptions } from "./SourceType"
@@ -59,7 +60,7 @@ ButtonSegment.propTypes = {
function SourceSegment({ changed_fields, index, last_index, measurement_source, metric, reload, report, sourceUuid }) {
return (
-
+
-
+
)
}
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..a403e60484 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)
+ await userEvent.click(screen.getByText(/Apply change to source/))
+ await userEvent.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,5 @@ 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)
+ expect(screen.getAllByText(/Apply change to source/).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..89af38163c 100644
--- a/components/frontend/src/subject/SubjectTable.css
+++ b/components/frontend/src/subject/SubjectTable.css
@@ -1,123 +1,7 @@
-.ui.sortable.table.stickyHeader > thead {
+table.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;
-}
diff --git a/components/frontend/src/subject/SubjectTable.js b/components/frontend/src/subject/SubjectTable.js
index 48457c3402..7e4440c232 100644
--- a/components/frontend/src/subject/SubjectTable.js
+++ b/components/frontend/src/subject/SubjectTable.js
@@ -1,8 +1,8 @@
import "./SubjectTable.css"
+import { Table, TableContainer } from "@mui/material"
import { array, func, object, string } from "prop-types"
-import { Table } from "../semantic_ui_react_wrappers"
import {
datesPropType,
measurementsPropType,
@@ -33,34 +33,36 @@ export function SubjectTable({
// Sort measurements in reverse order so that if there multiple measurements on a day, we find the most recent one:
const reversedMeasurements = measurements.slice().sort((m1, m2) => (m1.start < m2.start ? 1 : -1))
return (
-
-
-
- {
- 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..fbda961b44 100644
--- a/components/frontend/src/subject/SubjectTableFooter.js
+++ b/components/frontend/src/subject/SubjectTableFooter.js
@@ -1,6 +1,6 @@
+import { TableCell, TableFooter, TableRow } from "@mui/material"
import { func, string } from "prop-types"
import { useContext } from "react"
-import { Table } from "semantic-ui-react"
import { add_metric, copy_metric, move_metric } from "../api/metric"
import { DataModel } from "../context/DataModel"
@@ -16,8 +16,8 @@ import { metric_options } from "../widgets/menu_options"
function SubjectTableFooterButtonRow({ subject, subjectUuid, reload, reports, stopFilteringAndSorting }) {
const dataModel = useContext(DataModel)
return (
-
-
+
+
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..f6b5ff43c8 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,18 @@ const tagsHelp = (
>
)
+function InlineChip({ color, label }) {
+ return (
+
+
+
+ )
+}
+InlineChip.propTypes = {
+ color: string,
+ label: string,
+}
+
function MeasurementHeaderCells({ columnDates, showDeltaColumns }) {
const cells = []
columnDates.forEach((date, index) => {
@@ -236,29 +246,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 +286,8 @@ export function SubjectTableHeader({ columnDates, handleSort, settings }) {
}
const nrDates = columnDates.length
return (
-
-
+
+
{nrDates > 1 && (
)}
-
-
+
+
)
}
SubjectTableHeader.propTypes = {
diff --git a/components/frontend/src/subject/SubjectTableHeader.test.js b/components/frontend/src/subject/SubjectTableHeader.test.js
index a6b6f3eceb..2e49aa59c8 100644
--- a/components/frontend/src/subject/SubjectTableHeader.test.js
+++ b/components/frontend/src/subject/SubjectTableHeader.test.js
@@ -1,7 +1,7 @@
+import { Table } from "@mui/material"
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import history from "history/browser"
-import { Table } from "semantic-ui-react"
import { createTestableSettings } from "../__fixtures__/fixtures"
import { SubjectTableHeader } from "./SubjectTableHeader"
@@ -83,3 +83,13 @@ it("shows help for column headers", async () => {
expect(screen.queryByText(/Click the column header to sort the metrics by name/)).not.toBe(null)
})
})
+
+it("shows help for delta column headers", async () => {
+ const date1 = new Date("2022-02-02")
+ const date2 = new Date("2022-02-03")
+ renderSubjectTableHeader([date1, date2])
+ await userEvent.hover(screen.getByText(/𝚫/))
+ await waitFor(() => {
+ expect(screen.queryByText(/shows the difference/)).not.toBe(null)
+ })
+})
diff --git a/components/frontend/src/subject/SubjectTableRow.js b/components/frontend/src/subject/SubjectTableRow.js
index d338f4a430..1e1d041fee 100644
--- a/components/frontend/src/subject/SubjectTableRow.js
+++ b/components/frontend/src/subject/SubjectTableRow.js
@@ -1,7 +1,7 @@
+import { Chip, TableCell, Tooltip } from "@mui/material"
import { bool, func, number, object, string } from "prop-types"
import { useContext } from "react"
-import { DarkMode } from "../context/DarkMode"
import { DataModel } from "../context/DataModel"
import { IssueStatus } from "../issue/IssueStatus"
import { MeasurementSources } from "../measurement/MeasurementSources"
@@ -12,7 +12,6 @@ import { StatusIcon } from "../measurement/StatusIcon"
import { TimeLeft } from "../measurement/TimeLeft"
import { TrendSparkline } from "../measurement/TrendSparkline"
import { MetricDetails } from "../metric/MetricDetails"
-import { Label, Popup, Table } from "../semantic_ui_react_wrappers"
import {
dataModelPropType,
datePropType,
@@ -68,9 +67,9 @@ didValueImprove.propTypes = {
function deltaColor(metric, improved) {
const evaluateTarget = metric.evaluate_targets ?? true
if (evaluateTarget) {
- return improved ? "green" : "red"
+ return improved ? "success" : "error"
}
- return "blue"
+ return "info"
}
deltaColor.propTypes = {
metric: metricPropType,
@@ -131,20 +130,15 @@ function DeltaCell({ dateOrderAscending, index, metric, metricValue, previousVal
const description = deltaDescription(dataModel, metric, scale, delta, improved, oldValue, newValue)
const color = deltaColor(metric, improved)
label = (
-
- {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..a8ea598a5e 100644
--- a/components/frontend/src/subject/SubjectTitle.js
+++ b/components/frontend/src/subject/SubjectTitle.js
@@ -1,33 +1,35 @@
+import HistoryIcon from "@mui/icons-material/History"
+import SettingsIcon from "@mui/icons-material/Settings"
import { bool, func, object, string } from "prop-types"
import { useContext } from "react"
import { delete_subject, set_subject_attribute } from "../api/subject"
-import { activeTabIndex, tabChangeHandler } from "../app_ui_settings"
import { ChangeLog } from "../changelog/ChangeLog"
import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions"
-import { Header, Tab } from "../semantic_ui_react_wrappers"
import { reportPropType, settingsPropType } from "../sharedPropTypes"
import { getSubjectType, referenceDocumentationURL } from "../utils"
import { ButtonRow } from "../widgets/ButtonRow"
import { DeleteButton } from "../widgets/buttons/DeleteButton"
import { PermLinkButton } from "../widgets/buttons/PermLinkButton"
import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup"
+import { Header } from "../widgets/Header"
import { HeaderWithDetails } from "../widgets/HeaderWithDetails"
import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
-import { changelogTabPane, configurationTabPane } from "../widgets/TabPane"
+import { Tabs } from "../widgets/Tabs"
import { SubjectParameters } from "./SubjectParameters"
function SubjectHeader({ subjectType }) {
return (
-
-
- {subjectType.name}
-
+
{subjectType.description}
-
-
-
+ >
+ }
+ />
)
}
SubjectHeader.propTypes = {
@@ -74,42 +76,33 @@ export function SubjectTitle({
settings,
}) {
const dataModel = useContext(DataModel)
- const tabIndex = activeTabIndex(settings.expandedItems, subject_uuid)
const subjectType = getSubjectType(subject.type, dataModel.subjects) || { name: "Unknown subject type" }
const subjectName = subject.name || subjectType.name
const subjectTitle = (atReportsOverview ? report.title + " ❯ " : "") + subjectName
const subjectUrl = `${window.location}#${subject_uuid}`
- const panes = [
- configurationTabPane(
- ,
- ),
- 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..f04244e9ad 100644
--- a/components/frontend/src/subject/SubjectType.js
+++ b/components/frontend/src/subject/SubjectType.js
@@ -1,11 +1,11 @@
import CircleIcon from "@mui/icons-material/Circle"
-import { Stack, Typography } from "@mui/material"
+import { MenuItem, Stack, Typography } from "@mui/material"
import { func, number, objectOf, string } from "prop-types"
import { useContext } from "react"
import { DataModel } from "../context/DataModel"
-import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
-import { SingleChoiceInput } from "../fields/SingleChoiceInput"
+import { accessGranted, EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
+import { TextField } from "../fields/TextField"
import { subjectPropType } from "../sharedPropTypes"
export function subjectTypes(subjectTypesMapping, level = 0) {
@@ -30,10 +30,10 @@ export function subjectTypes(subjectTypesMapping, level = 0) {
content: (
{bullet}
-
+
{subjectType.name}
{subjectType.description}
-
+
),
})
@@ -47,15 +47,22 @@ subjectTypes.propTypes = {
}
export function SubjectType({ subjectType, setValue }) {
+ const permissions = useContext(Permissions)
+ const disabled = !accessGranted(permissions, [EDIT_REPORT_PERMISSION])
return (
- 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..37fe01c417 100644
--- a/components/frontend/src/subject/SubjectsButtonRow.js
+++ b/components/frontend/src/subject/SubjectsButtonRow.js
@@ -23,7 +23,7 @@ export function SubjectsButtonRow({ reload, report, reports, settings }) {
+
-
-
+
+
+
)
}
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/Header.js b/components/frontend/src/widgets/Header.js
new file mode 100644
index 0000000000..a48ae577cc
--- /dev/null
+++ b/components/frontend/src/widgets/Header.js
@@ -0,0 +1,18 @@
+import { Typography } from "@mui/material"
+import { element, oneOfType, string } from "prop-types"
+
+export function Header({ header, level, subheader }) {
+ return (
+
+ {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..ed0703b358 100644
--- a/components/frontend/src/widgets/HeaderWithDetails.js
+++ b/components/frontend/src/widgets/HeaderWithDetails.js
@@ -1,43 +1,52 @@
-import "./HeaderWithDetails.css"
+import { Accordion, AccordionDetails, AccordionSummary } from "@mui/material"
+import { accordionSummaryClasses } from "@mui/material/AccordionSummary"
+import { string } from "prop-types"
-import { node, object, string } from "prop-types"
-
-import { Header, Segment } from "../semantic_ui_react_wrappers"
import { childrenPropType, settingsPropType } from "../sharedPropTypes"
-import { ExpandButton } from "./buttons/ExpandButton"
+import { Header } from "./Header"
+import { CaretRight } from "./icons"
-export function HeaderWithDetails({ children, className, header, item_uuid, level, style, settings, subheader }) {
- const showDetails = settings.expandedItems.includes(item_uuid)
- const segmentStyle = { paddingLeft: "0px", paddingRight: "0px" }
+export function HeaderWithDetails({ children, header, item_uuid, level, settings, subheader }) {
+ const showDetails = Boolean(settings.expandedItems.includes(item_uuid))
return (
-
- 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
index 52e16c3d01..8a73eaeaa7 100644
--- a/components/frontend/src/widgets/LabelWithDropdown.js
+++ b/components/frontend/src/widgets/LabelWithDropdown.js
@@ -1,29 +1,33 @@
-import { array, func, string } from "prop-types"
+import { MenuItem, Select } from "@mui/material"
+import { array, bool, func, string } from "prop-types"
-import { Dropdown } from "../semantic_ui_react_wrappers"
-import { alignmentPropType, labelPropType } from "../sharedPropTypes"
+import { labelPropType } from "../sharedPropTypes"
-export function LabelWithDropdown({ color, direction, label, onChange, options, value }) {
+export function LabelWithDropdown({ disabled, label, onChange, options, value }) {
return (
{label}
-
-
-
+ onChange(event.target.value)}
+ value={value}
+ inputProps={{ sx: { paddingBottom: "2px", paddingTop: "2px" } }}
+ sx={{
+ color: options.find((option) => option.value === value).color,
+ marginLeft: "6px",
+ }}
+ >
+ {options.map((option) => (
+
+ {option.text}
+
+ ))}
+
)
}
LabelWithDropdown.propTypes = {
- color: string,
- direction: alignmentPropType,
+ disabled: bool,
label: labelPropType,
onChange: func,
options: array,
diff --git a/components/frontend/src/widgets/LabelWithDropdown.test.js b/components/frontend/src/widgets/LabelWithDropdown.test.js
index 6d38d0771b..6929dd4b07 100644
--- a/components/frontend/src/widgets/LabelWithDropdown.test.js
+++ b/components/frontend/src/widgets/LabelWithDropdown.test.js
@@ -1,80 +1,32 @@
-import { fireEvent, render, screen } from "@testing-library/react"
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
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", () => {
+function renderLabelWithDropdown() {
const mockCallback = jest.fn()
render(
,
)
- fireEvent.click(screen.getByText(/Option 2/))
- expect(mockCallback).toHaveBeenCalled()
+ return mockCallback
+}
+
+it("shows the label", () => {
+ renderLabelWithDropdown()
+ expect(screen.getByText(/Hello/)).not.toBe(null)
})
-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")
+it("changes the option", async () => {
+ const mockCallback = renderLabelWithDropdown()
+ await userEvent.click(screen.getByText(/Option 1/))
+ await userEvent.click(screen.getByText(/Option 2/))
+ expect(mockCallback).toHaveBeenCalledWith("2")
})
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..ebbed94fc8 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..c9d7f0f920
--- /dev/null
+++ b/components/frontend/src/widgets/Tabs.js
@@ -0,0 +1,46 @@
+import { Box, 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)}>
+ {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/tests/application_tests/src/test_report.py b/tests/application_tests/src/test_report.py
index 35466abc4f..f39aeaba57 100644
--- a/tests/application_tests/src/test_report.py
+++ b/tests/application_tests/src/test_report.py
@@ -45,6 +45,7 @@ class OpenReportTest(unittest.TestCase):
# Class names of MUI-components used in the tests
DASHBOARD_CARD_CLASS_NAME = "MuiCard-root"
DASHBOARD_CARD_HEADER_CONTENT_CLASS_NAME = "MuiCardHeader-content"
+ REPORT_HEADER_CLASS_NAME = "MuiAccordionSummary-content"
def setUp(self):
"""Override to setup the driver."""
@@ -84,9 +85,8 @@ def test_open_report(self):
report = self.dashboard_cards()[-1] # The last card is a report
report_title = report.find_element(By.CLASS_NAME, self.DASHBOARD_CARD_HEADER_CONTENT_CLASS_NAME)
report.click()
- self.assertTrue(
- expect.text_to_be_present_in_element(self.driver.find_element(By.CLASS_NAME, "header"), report_title)
- )
+ report_header = self.driver.find_element(By.CLASS_NAME, self.REPORT_HEADER_CLASS_NAME)
+ self.assertTrue(expect.text_to_be_present_in_element(report_header, report_title))
def test_login_and_logout(self):
"""Test that a user can login and logout."""