From ceffc4df6399a0281ac1a2f873578f5c9618a776 Mon Sep 17 00:00:00 2001
From: Frank Niessink
Date: Tue, 17 Dec 2024 16:09:10 +0100
Subject: [PATCH] Migrate components from SUIR to MUI.
---
components/frontend/src/fields/DateInput.js | 7 +-
components/frontend/src/issue/IssuesRows.js | 5 +-
.../src/measurement/MeasurementValue.js | 10 +-
.../src/measurement/MeasurementValue.test.js | 5 +-
.../frontend/src/metric/MetricDetails.js | 8 +-
.../frontend/src/metric/MetricTypeHeader.js | 9 +-
.../notification/NotificationDestinations.js | 3 +-
components/frontend/src/report/ReportTitle.js | 3 +-
.../frontend/src/report/ReportsOverview.js | 3 +-
components/frontend/src/source/Source.js | 3 +-
.../frontend/src/source/SourceEntities.js | 5 +-
.../src/source/SourceEntities.test.js | 2 +-
.../src/source/SourceParameter.test.js | 2 +-
.../frontend/src/source/SourceTypeHeader.js | 8 +-
components/frontend/src/source/Sources.js | 4 +-
.../src/subject/SubjectTableFooter.js | 4 +-
.../src/subject/SubjectTableHeader.js | 64 +++---
.../src/subject/SubjectTableHeader.test.js | 12 +-
.../frontend/src/subject/SubjectTitle.js | 12 +-
.../frontend/src/subject/SubjectType.js | 11 +-
.../frontend/src/subject/SubjectsButtonRow.js | 4 +-
.../frontend/src/widgets/LabelWithHelp.js | 2 +-
.../src/widgets/LabelWithHelp.test.js | 2 +-
.../frontend/src/widgets/ReadTheDocsLink.js | 15 ++
.../buttons/ActionAndItemPickerButton.js | 56 +++++
.../src/widgets/buttons/ActionButton.js | 29 +++
.../frontend/src/widgets/buttons/AddButton.js | 20 ++
.../src/widgets/buttons/AddButton.test.js | 8 +
.../AddDropdownButton.js} | 199 +-----------------
.../AddDropdownButton.test.js} | 149 +------------
.../src/widgets/buttons/CopyButton.js | 6 +
.../src/widgets/buttons/CopyButton.test.js | 53 +++++
.../src/widgets/buttons/DeleteButton.js | 21 ++
.../src/widgets/buttons/DeleteButton.test.js | 8 +
.../src/widgets/buttons/MoveButton.js | 6 +
.../src/widgets/buttons/MoveButton.test.js | 30 +++
.../src/widgets/buttons/PermLinkButton.js | 34 +++
.../widgets/buttons/PermLinkButton.test.js | 39 ++++
.../src/widgets/buttons/ReorderButtonGroup.js | 45 ++++
.../buttons/ReorderButtonGroup.test.js | 21 ++
components/frontend/src/widgets/icons.js | 59 ++++++
41 files changed, 567 insertions(+), 419 deletions(-)
create mode 100644 components/frontend/src/widgets/ReadTheDocsLink.js
create mode 100644 components/frontend/src/widgets/buttons/ActionAndItemPickerButton.js
create mode 100644 components/frontend/src/widgets/buttons/ActionButton.js
create mode 100644 components/frontend/src/widgets/buttons/AddButton.js
create mode 100644 components/frontend/src/widgets/buttons/AddButton.test.js
rename components/frontend/src/widgets/{Button.js => buttons/AddDropdownButton.js} (57%)
rename components/frontend/src/widgets/{Button.test.js => buttons/AddDropdownButton.test.js} (59%)
create mode 100644 components/frontend/src/widgets/buttons/CopyButton.js
create mode 100644 components/frontend/src/widgets/buttons/CopyButton.test.js
create mode 100644 components/frontend/src/widgets/buttons/DeleteButton.js
create mode 100644 components/frontend/src/widgets/buttons/DeleteButton.test.js
create mode 100644 components/frontend/src/widgets/buttons/MoveButton.js
create mode 100644 components/frontend/src/widgets/buttons/MoveButton.test.js
create mode 100644 components/frontend/src/widgets/buttons/PermLinkButton.js
create mode 100644 components/frontend/src/widgets/buttons/PermLinkButton.test.js
create mode 100644 components/frontend/src/widgets/buttons/ReorderButtonGroup.js
create mode 100644 components/frontend/src/widgets/buttons/ReorderButtonGroup.test.js
create mode 100644 components/frontend/src/widgets/icons.js
diff --git a/components/frontend/src/fields/DateInput.js b/components/frontend/src/fields/DateInput.js
index 1f6ed54a7c..a987f1a7db 100644
--- a/components/frontend/src/fields/DateInput.js
+++ b/components/frontend/src/fields/DateInput.js
@@ -3,18 +3,19 @@ import "./DateInput.css"
import { bool, func, string } from "prop-types"
import { ReadOnlyOrEditable } from "../context/Permissions"
-import { Form, Icon, Label } from "../semantic_ui_react_wrappers"
+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 (
-
-
+
+
}
itemType="issue"
onClick={() => add_metric_issue(metric_uuid, reload)}
popup={
diff --git a/components/frontend/src/measurement/MeasurementValue.js b/components/frontend/src/measurement/MeasurementValue.js
index 19349b78ff..f9d7e58c63 100644
--- a/components/frontend/src/measurement/MeasurementValue.js
+++ b/components/frontend/src/measurement/MeasurementValue.js
@@ -4,7 +4,7 @@ import { bool, string } from "prop-types"
import { useContext } from "react"
import { DataModel } from "../context/DataModel"
-import { Icon, Label, Message, Popup } from "../semantic_ui_react_wrappers"
+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 {
@@ -17,13 +17,14 @@ import {
isMeasurementStale,
sum,
} from "../utils"
+import { IgnoreIcon, LoadingIcon } from "../widgets/icons"
import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate"
import { WarningMessage } from "../widgets/WarningMessage"
function measurementValueLabel(hasIgnoredEntities, stale, updating, value) {
const measurementValue = hasIgnoredEntities ? (
<>
- {value}
+ {value}
>
) : (
value
@@ -34,7 +35,8 @@ function measurementValueLabel(hasIgnoredEntities, stale, updating, value) {
if (updating) {
return (
- {measurementValue}
+
+ {measurementValue}
)
}
@@ -124,7 +126,7 @@ export function MeasurementValue({ metric, reportDate }) {
info
header={
- {`Ignored ${unit}`}
+ {`Ignored ${unit}`}
}
content={ignoredEntitiesMessage(metric.latest_measurement, unit)}
diff --git a/components/frontend/src/measurement/MeasurementValue.test.js b/components/frontend/src/measurement/MeasurementValue.test.js
index 6b0a2b984e..1af6c4721e 100644
--- a/components/frontend/src/measurement/MeasurementValue.test.js
+++ b/components/frontend/src/measurement/MeasurementValue.test.js
@@ -56,7 +56,7 @@ it("renders an outdated value", async () => {
})
const measurementValue = screen.getByText(/1/)
expect(measurementValue.className).toContain("yellow")
- expect(measurementValue.children[0].className).toContain("loading")
+ expect(screen.getAllByTestId("LoopIcon").length).toBe(1)
await userEvent.hover(measurementValue)
await waitFor(() => {
expect(screen.queryByText(/Latest measurement out of date/)).not.toBe(null)
@@ -74,7 +74,7 @@ it("renders a value for which a measurement was requested", async () => {
})
const measurementValue = screen.getByText(/1/)
expect(measurementValue.className).toContain("yellow")
- expect(measurementValue.children[0].className).toContain("loading")
+ expect(screen.getAllByTestId("LoopIcon").length).toBe(1)
await userEvent.hover(measurementValue)
await waitFor(() => {
expect(screen.queryByText(/Measurement requested/)).not.toBe(null)
@@ -90,6 +90,7 @@ it("renders a value for which a measurement was requested, but which is now up t
})
const measurementValue = screen.getByText(/1/)
expect(measurementValue.className).not.toContain("yellow")
+ expect(screen.queryAllByTestId("LoopIcon").length).toBe(0)
await userEvent.hover(measurementValue)
await waitFor(() => {
expect(screen.queryByText(/Measurement requested/)).toBe(null)
diff --git a/components/frontend/src/metric/MetricDetails.js b/components/frontend/src/metric/MetricDetails.js
index edf3576c4e..62f5e8ebeb 100644
--- a/components/frontend/src/metric/MetricDetails.js
+++ b/components/frontend/src/metric/MetricDetails.js
@@ -20,7 +20,11 @@ import { Logo } from "../source/Logo"
import { SourceEntities } from "../source/SourceEntities"
import { Sources } from "../source/Sources"
import { getSourceName, isMeasurementRequested } from "../utils"
-import { ActionButton, DeleteButton, PermLinkButton, ReorderButtonGroup } from "../widgets/Button"
+import { ActionButton } from "../widgets/buttons/ActionButton"
+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 { showMessage } from "../widgets/toast"
import { MetricConfigurationParameters } from "./MetricConfigurationParameters"
@@ -34,7 +38,7 @@ function RequestMeasurementButton({ metric, metric_uuid, reload }) {
}
itemType="metric"
loading={measurementRequested}
onClick={() => set_metric_attribute(metric_uuid, "measurement_requested", new Date().toISOString(), reload)}
diff --git a/components/frontend/src/metric/MetricTypeHeader.js b/components/frontend/src/metric/MetricTypeHeader.js
index 76ff362f4e..bc91a5c021 100644
--- a/components/frontend/src/metric/MetricTypeHeader.js
+++ b/components/frontend/src/metric/MetricTypeHeader.js
@@ -1,9 +1,7 @@
-import { Icon } from "semantic-ui-react"
-
import { Header } from "../semantic_ui_react_wrappers"
import { metricTypePropType } from "../sharedPropTypes"
import { slugify } from "../utils"
-import { HyperLink } from "../widgets/HyperLink"
+import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
export function MetricTypeHeader({ metricType }) {
const url = `https://quality-time.readthedocs.io/en/v${process.env.REACT_APP_VERSION}/reference.html${slugify(metricType.name)}`
@@ -15,10 +13,7 @@ export function MetricTypeHeader({ metricType }) {
{metricType.name}
- {metricType.description}{" "}
-
- Read the Docs
-
+ {metricType.description}
{howToConfigure}
diff --git a/components/frontend/src/notification/NotificationDestinations.js b/components/frontend/src/notification/NotificationDestinations.js
index 24e8d2d5c4..7c61322046 100644
--- a/components/frontend/src/notification/NotificationDestinations.js
+++ b/components/frontend/src/notification/NotificationDestinations.js
@@ -10,7 +10,8 @@ import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissio
import { StringInput } from "../fields/StringInput"
import { Message, Segment } from "../semantic_ui_react_wrappers"
import { destinationPropType } from "../sharedPropTypes"
-import { AddButton, DeleteButton } from "../widgets/Button"
+import { AddButton } from "../widgets/buttons/AddButton"
+import { DeleteButton } from "../widgets/buttons/DeleteButton"
import { HyperLink } from "../widgets/HyperLink"
import { LabelWithHelp } from "../widgets/LabelWithHelp"
diff --git a/components/frontend/src/report/ReportTitle.js b/components/frontend/src/report/ReportTitle.js
index 3f21c8eded..82e4bd7e09 100644
--- a/components/frontend/src/report/ReportTitle.js
+++ b/components/frontend/src/report/ReportTitle.js
@@ -14,7 +14,8 @@ 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"
-import { DeleteButton, PermLinkButton } from "../widgets/Button"
+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"
diff --git a/components/frontend/src/report/ReportsOverview.js b/components/frontend/src/report/ReportsOverview.js
index cac56db0de..01fa6385b7 100644
--- a/components/frontend/src/report/ReportsOverview.js
+++ b/components/frontend/src/report/ReportsOverview.js
@@ -16,7 +16,8 @@ import {
} from "../sharedPropTypes"
import { Subjects } from "../subject/Subjects"
import { getReportsTags } from "../utils"
-import { AddButton, CopyButton } from "../widgets/Button"
+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"
diff --git a/components/frontend/src/source/Source.js b/components/frontend/src/source/Source.js
index 51e7b88370..22224cdc7f 100644
--- a/components/frontend/src/source/Source.js
+++ b/components/frontend/src/source/Source.js
@@ -17,7 +17,8 @@ import {
stringsPropType,
} from "../sharedPropTypes"
import { getMetricName, getSourceName } from "../utils"
-import { DeleteButton, ReorderButtonGroup } from "../widgets/Button"
+import { DeleteButton } from "../widgets/buttons/DeleteButton"
+import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup"
import { HyperLink } from "../widgets/HyperLink"
import { changelogTabPane, configurationTabPane } from "../widgets/TabPane"
import { SourceParameters } from "./SourceParameters"
diff --git a/components/frontend/src/source/SourceEntities.js b/components/frontend/src/source/SourceEntities.js
index 6a56a8446d..87897fe20a 100644
--- a/components/frontend/src/source/SourceEntities.js
+++ b/components/frontend/src/source/SourceEntities.js
@@ -1,11 +1,12 @@
import "./SourceEntities.css"
+import HelpIcon from "@mui/icons-material/Help"
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 { Button, Icon, Popup, Table } from "../semantic_ui_react_wrappers"
+import { Button, Popup, Table } from "../semantic_ui_react_wrappers"
import {
alignmentPropType,
childrenPropType,
@@ -146,7 +147,7 @@ function EntityAttributeHeaderCell({ entityAttribute, ...sortProps }) {
trigger={
-
+
}
content={entityAttribute.help}
diff --git a/components/frontend/src/source/SourceEntities.test.js b/components/frontend/src/source/SourceEntities.test.js
index b4e341170a..31ad813d00 100644
--- a/components/frontend/src/source/SourceEntities.test.js
+++ b/components/frontend/src/source/SourceEntities.test.js
@@ -281,7 +281,7 @@ it("sorts the entities by minutes", async () => {
it("shows help", async () => {
renderSourceEntities()
- await userEvent.hover(screen.queryByRole("tooltip", { name: /help/ }))
+ await userEvent.hover(screen.queryByTestId("HelpIcon"))
await waitFor(() => {
expect(screen.queryByText(/help text/)).not.toBe(null)
})
diff --git a/components/frontend/src/source/SourceParameter.test.js b/components/frontend/src/source/SourceParameter.test.js
index e1a395dc8e..0ea0323b5f 100644
--- a/components/frontend/src/source/SourceParameter.test.js
+++ b/components/frontend/src/source/SourceParameter.test.js
@@ -152,7 +152,7 @@ it("renders a help url", () => {
it("renders a help text", async () => {
renderSourceParameter({ help: "Help text" })
- await userEvent.hover(screen.queryByTestId("help-icon"))
+ await userEvent.hover(screen.queryByTestId("HelpIcon"))
await waitFor(() => {
expect(screen.queryAllByText(/Help text/).length).toBe(1)
})
diff --git a/components/frontend/src/source/SourceTypeHeader.js b/components/frontend/src/source/SourceTypeHeader.js
index cf05d2071a..a874a54087 100644
--- a/components/frontend/src/source/SourceTypeHeader.js
+++ b/components/frontend/src/source/SourceTypeHeader.js
@@ -1,9 +1,9 @@
import { string } from "prop-types"
-import { Header, Icon, Label } from "../semantic_ui_react_wrappers"
+import { Header, Label } from "../semantic_ui_react_wrappers"
import { sourceTypePropType } from "../sharedPropTypes"
import { slugify } from "../utils"
-import { HyperLink } from "../widgets/HyperLink"
+import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
import { Logo } from "./Logo"
import { sourceTypeDescription } from "./SourceType"
@@ -21,9 +21,7 @@ export function SourceTypeHeader({ metricTypeId, sourceTypeId, sourceType }) {
{sourceType.deprecated && Deprecated }
{`${sourceTypeDescription(sourceType)} `}
-
- Read the Docs
-
+
{howToConfigure}
diff --git a/components/frontend/src/source/Sources.js b/components/frontend/src/source/Sources.js
index b88612d110..0820ac2c3b 100644
--- a/components/frontend/src/source/Sources.js
+++ b/components/frontend/src/source/Sources.js
@@ -14,7 +14,9 @@ import {
stringsPropType,
} from "../sharedPropTypes"
import { pluralize } from "../utils"
-import { AddDropdownButton, CopyButton, MoveButton } from "../widgets/Button"
+import { AddDropdownButton } from "../widgets/buttons/AddDropdownButton"
+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 { Source } from "./Source"
diff --git a/components/frontend/src/subject/SubjectTableFooter.js b/components/frontend/src/subject/SubjectTableFooter.js
index f62c96356a..14c9f1fdf4 100644
--- a/components/frontend/src/subject/SubjectTableFooter.js
+++ b/components/frontend/src/subject/SubjectTableFooter.js
@@ -7,7 +7,9 @@ import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions"
import { allMetricTypeOptions, metricTypeOptions, usedMetricTypes } from "../metric/MetricType"
import { reportsPropType, subjectPropType } from "../sharedPropTypes"
-import { AddDropdownButton, CopyButton, MoveButton } from "../widgets/Button"
+import { AddDropdownButton } from "../widgets/buttons/AddDropdownButton"
+import { CopyButton } from "../widgets/buttons/CopyButton"
+import { MoveButton } from "../widgets/buttons/MoveButton"
import { metric_options } from "../widgets/menu_options"
function SubjectTableFooterButtonRow({ subject, subjectUuid, reload, reports, stopFilteringAndSorting }) {
diff --git a/components/frontend/src/subject/SubjectTableHeader.js b/components/frontend/src/subject/SubjectTableHeader.js
index 9018a8aa26..a55251f835 100644
--- a/components/frontend/src/subject/SubjectTableHeader.js
+++ b/components/frontend/src/subject/SubjectTableHeader.js
@@ -1,20 +1,33 @@
-import { bool, func } from "prop-types"
+import { bool, func, string } from "prop-types"
import { useContext } from "react"
import { List, Table } from "semantic-ui-react"
import { DarkMode } from "../context/DarkMode"
import { StatusIcon } from "../measurement/StatusIcon"
import { STATUS_DESCRIPTION, STATUSES } from "../metric/status"
-import { Icon, Label } from "../semantic_ui_react_wrappers"
+import { Label } from "../semantic_ui_react_wrappers"
import { datesPropType, settingsPropType } from "../sharedPropTypes"
import { HyperLink } from "../widgets/HyperLink"
+import { IgnoreIcon, TriangleRightIcon } from "../widgets/icons"
import { SortableTableHeaderCell, UnsortableTableHeaderCell } from "../widgets/TableHeaderCell"
+function Expand({ children }) {
+ return (
+ <>
+ Expand the {children} (click
+ )
+ >
+ )
+}
+Expand.propTypes = {
+ children: string,
+}
+
const metricHelp = (
<>
The name of the metric.
- Expand the metric (click ) to edit its name in the configuration tab.
+ metric to edit its name in the configuration tab.
Click the column header to sort the metrics by name.
>
@@ -31,8 +44,7 @@ const trendHelp = (
number scale.
- Expand the metric (click ) and navigate to the trend graph tab to see a
- graph of all measurements.
+ metric and navigate to the trend graph tab to see a graph of all measurements.
>
)
@@ -65,8 +77,8 @@ const measurementHelp = (
The latest measurement value. Metrics are measured periodically.
If the measurement value is ?, no sources have been configured for the metric yet or the measurement data
- could not be collected. Expand the metric (click ) and navigate to the
- sources tab to add sources or see the error details.
+ could not be collected. metric and navigate to the sources tab to add sources or see the
+ error details.
If the measurement value has a{" "}
@@ -77,10 +89,9 @@ const measurementHelp = (
a system administrator should be notified.
- If there is a before the measurement value, it means one or more measurement entities
- are being ignored. Hover over the measurement value to see how many entities are ignored. Expand the metric
- (click ) and navigate to the entities tab to see why individual
- entities are ignored.
+ If there is a before the measurement value, it means one or more measurement entities are
+ being ignored. Hover over the measurement value to see how many entities are ignored.{" "}
+ metric and navigate to the entities tab to see why individual entities are ignored.
Hover over the measurement value to see when the metric was last measured.
Click the column header to sort the metrics by measurement value.
@@ -99,8 +110,7 @@ const targetHelp = (
past or all issues linked to the metric have been resolved.
- Expand the metric (click ) to edit the target value in the
- configuration tab.
+ metric to edit the target value in the configuration tab.
Click the column header to sort the metrics by target value.
>
@@ -113,8 +123,7 @@ const unitHelp = (
and target values.
- Expand the metric (click ) to edit the unit name in the configuration
- tab.
+ metric to edit the unit name in the configuration tab.
Click the column header to sort the metrics by unit.
>
@@ -124,13 +133,12 @@ const timeLeftHelp = (
<>
The number of days left to address the metric.
- If the metric needs action, the time left is based on the desired reaction times. Expand the report title
- (click ) to change the desired reaction times.
+ If the metric needs action, the time left is based on the desired reaction times.{" "}
+ report title to change the desired reaction times.
- If the metric has accepted technical debt, the time left is based on the technical debt end date. Expand the
- metric (click ) to edit technical debt end date in the technical debt
- tab.
+ If the metric has accepted technical debt, the time left is based on the technical debt end date.{" "}
+ metric to edit technical debt end date in the technical debt tab.
Hover over the number of days to see the exact deadline.
Click the column header to sort the metrics by time left.
@@ -141,7 +149,7 @@ const overrunHelp = (
<>
The number of days that the desired reaction time was exceeded, in the displayed period.
- Expand the report title (click ) to change the desired reaction times.
+ report title to change the desired reaction times.
Hover over the number of days to see an overview of when and for which statuses the metric had overruns, in
@@ -158,7 +166,7 @@ const commentHelp = (
and URLs are supported.
- Expand the metric (click ) to edit the comments.
+ metric to edit the comments.
>
)
@@ -171,11 +179,11 @@ const sourcesHelp = (
red background
- , the source could not be accessed or the data could not be parsed. Expand the metric (click{" "}
- ) and navigate to the source to see the error details.
+ , the source could not be accessed or the data could not be parsed. metric and navigate to
+ the source to see the error details.
- Expand the metric (click ) to configure sources.
+ metric to configure sources.
Click a source to open the tool or report in a new tab.
Click the column header to sort the metrics by source.
@@ -193,8 +201,8 @@ const issuesHelp = (
red background
- , the issue tracker could not be accessed or the data could not be parsed. Expand the metric and navigate to
- the technical debt tab to see the error details.
+ , 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.
Hover over an issue to see more information about the issue.
Click an issue to open the issue in a new tab.
@@ -212,7 +220,7 @@ const tagsHelp = (
selected tags.
- Expand the metric (click ) to add or remove tags.
+ metric to add or remove tags.
Click the column header to sort the metrics by tag.
>
diff --git a/components/frontend/src/subject/SubjectTableHeader.test.js b/components/frontend/src/subject/SubjectTableHeader.test.js
index 21a3ac1e00..a6b6f3eceb 100644
--- a/components/frontend/src/subject/SubjectTableHeader.test.js
+++ b/components/frontend/src/subject/SubjectTableHeader.test.js
@@ -1,4 +1,5 @@
-import { render, screen } from "@testing-library/react"
+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"
@@ -73,3 +74,12 @@ it("hides the delta columns", () => {
renderSubjectTableHeader([date1, date2])
expect(screen.queryAllByText("𝚫").length).toBe(0)
})
+
+it("shows help for column headers", async () => {
+ const date1 = new Date("2022-02-02")
+ renderSubjectTableHeader([date1])
+ await userEvent.hover(screen.getByText("Metric"))
+ await waitFor(() => {
+ expect(screen.queryByText(/Click the column header to sort the metrics by name/)).not.toBe(null)
+ })
+})
diff --git a/components/frontend/src/subject/SubjectTitle.js b/components/frontend/src/subject/SubjectTitle.js
index 67a0c14943..4d4f5f2d42 100644
--- a/components/frontend/src/subject/SubjectTitle.js
+++ b/components/frontend/src/subject/SubjectTitle.js
@@ -1,6 +1,5 @@
import { bool, func, object, string } from "prop-types"
import { useContext } from "react"
-import { Icon } from "semantic-ui-react"
import { delete_subject, set_subject_attribute } from "../api/subject"
import { activeTabIndex, tabChangeHandler } from "../app_ui_settings"
@@ -10,9 +9,11 @@ import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissio
import { Header, Tab } from "../semantic_ui_react_wrappers"
import { reportPropType, settingsPropType } from "../sharedPropTypes"
import { getSubjectType, slugify } from "../utils"
-import { DeleteButton, PermLinkButton, ReorderButtonGroup } from "../widgets/Button"
+import { DeleteButton } from "../widgets/buttons/DeleteButton"
+import { PermLinkButton } from "../widgets/buttons/PermLinkButton"
+import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup"
import { HeaderWithDetails } from "../widgets/HeaderWithDetails"
-import { HyperLink } from "../widgets/HyperLink"
+import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
import { changelogTabPane, configurationTabPane } from "../widgets/TabPane"
import { SubjectParameters } from "./SubjectParameters"
@@ -23,10 +24,7 @@ function SubjectHeader({ subjectType }) {
{subjectType.name}
- {subjectType.description}{" "}
-
- Read the Docs
-
+ {subjectType.description}
diff --git a/components/frontend/src/subject/SubjectType.js b/components/frontend/src/subject/SubjectType.js
index e353327734..7409b159f4 100644
--- a/components/frontend/src/subject/SubjectType.js
+++ b/components/frontend/src/subject/SubjectType.js
@@ -1,3 +1,4 @@
+import CircleIcon from "@mui/icons-material/Circle"
import { func, number, objectOf, string } from "prop-types"
import { useContext } from "react"
import { HeaderContent, HeaderSubheader } from "semantic-ui-react"
@@ -5,13 +6,19 @@ import { HeaderContent, HeaderSubheader } from "semantic-ui-react"
import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
import { SingleChoiceInput } from "../fields/SingleChoiceInput"
-import { Header, Icon } from "../semantic_ui_react_wrappers"
+import { Header } from "../semantic_ui_react_wrappers"
import { subjectPropType } from "../sharedPropTypes"
export function subjectTypes(subjectTypesMapping, level = 0) {
const options = []
const headingLevel = `h${Math.min(level, 2) + 4}` // Ensure the heading level is at least h4 and at most h6
- const bullet = level === 0 ? null :
+ const bullet =
+ level === 0 ? null : (
+
+ )
Object.entries(subjectTypesMapping).forEach(([key, subjectType]) => {
options.push({
key: key,
diff --git a/components/frontend/src/subject/SubjectsButtonRow.js b/components/frontend/src/subject/SubjectsButtonRow.js
index 87d4e0e7a8..93582ecd62 100644
--- a/components/frontend/src/subject/SubjectsButtonRow.js
+++ b/components/frontend/src/subject/SubjectsButtonRow.js
@@ -6,7 +6,9 @@ import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissions"
import { Segment } from "../semantic_ui_react_wrappers"
import { reportPropType, reportsPropType, settingsPropType } from "../sharedPropTypes"
-import { AddDropdownButton, CopyButton, MoveButton } from "../widgets/Button"
+import { AddDropdownButton } from "../widgets/buttons/AddDropdownButton"
+import { CopyButton } from "../widgets/buttons/CopyButton"
+import { MoveButton } from "../widgets/buttons/MoveButton"
import { subject_options } from "../widgets/menu_options"
import { subjectTypes } from "./SubjectType"
diff --git a/components/frontend/src/widgets/LabelWithHelp.js b/components/frontend/src/widgets/LabelWithHelp.js
index f09b9849c7..1d744eb063 100644
--- a/components/frontend/src/widgets/LabelWithHelp.js
+++ b/components/frontend/src/widgets/LabelWithHelp.js
@@ -12,7 +12,7 @@ export function LabelWithHelp({ labelId, labelFor, label, help, hoverable }) {
hoverable={hoverable}
on={["hover", "focus"]}
content={help}
- trigger={ }
+ trigger={ }
wide
/>
diff --git a/components/frontend/src/widgets/LabelWithHelp.test.js b/components/frontend/src/widgets/LabelWithHelp.test.js
index 05e4143cb4..0e2b471787 100644
--- a/components/frontend/src/widgets/LabelWithHelp.test.js
+++ b/components/frontend/src/widgets/LabelWithHelp.test.js
@@ -10,7 +10,7 @@ it("shows the label", () => {
it("shows the help", async () => {
render( )
- await userEvent.hover(screen.queryByTestId("help-icon"))
+ await userEvent.hover(screen.queryByTestId("HelpIcon"))
await waitFor(() => {
expect(screen.queryByText(/Help/)).not.toBe(null)
})
diff --git a/components/frontend/src/widgets/ReadTheDocsLink.js b/components/frontend/src/widgets/ReadTheDocsLink.js
new file mode 100644
index 0000000000..87b6dde7b6
--- /dev/null
+++ b/components/frontend/src/widgets/ReadTheDocsLink.js
@@ -0,0 +1,15 @@
+import HelpIcon from "@mui/icons-material/Help"
+import { string } from "prop-types"
+
+import { HyperLink } from "./HyperLink"
+
+export function ReadTheDocsLink({ url }) {
+ return (
+
+ Read the Docs
+
+ )
+}
+ReadTheDocsLink.propTypes = {
+ url: string,
+}
diff --git a/components/frontend/src/widgets/buttons/ActionAndItemPickerButton.js b/components/frontend/src/widgets/buttons/ActionAndItemPickerButton.js
new file mode 100644
index 0000000000..eaaa56faa6
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/ActionAndItemPickerButton.js
@@ -0,0 +1,56 @@
+import { element, func, string } from "prop-types"
+import { useState } from "react"
+
+import { Dropdown, Popup } from "../../semantic_ui_react_wrappers"
+import { ItemBreadcrumb } from "../ItemBreadcrumb"
+
+export function ActionAndItemPickerButton({ action, itemType, onChange, get_options, icon }) {
+ const [options, setOptions] = useState([])
+
+ const breadcrumbProps = { report: "report" }
+ if (itemType !== "report") {
+ breadcrumbProps.subject = "subject"
+ if (itemType !== "subject") {
+ breadcrumbProps.metric = "metric"
+ if (itemType !== "metric") {
+ breadcrumbProps.source = "source"
+ }
+ }
+ }
+ return (
+
+
+
+ }
+ options={options}
+ onChange={(_event, { value }) => onChange(value)}
+ onOpen={() => setOptions(get_options())}
+ scrolling
+ selectOnBlur={false}
+ selectOnNavigation={false}
+ trigger={
+ <>
+ {icon} {`${action} ${itemType} `}
+ >
+ }
+ value={null} // Without this, a selected item becomes active (shown bold in the menu) and can't be selected again
+ />
+ }
+ />
+ )
+}
+ActionAndItemPickerButton.propTypes = {
+ action: string,
+ itemType: string,
+ onChange: func,
+ get_options: func,
+ icon: element,
+}
diff --git a/components/frontend/src/widgets/buttons/ActionButton.js b/components/frontend/src/widgets/buttons/ActionButton.js
new file mode 100644
index 0000000000..71f2d6ca79
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/ActionButton.js
@@ -0,0 +1,29 @@
+import { bool, element, string } from "prop-types"
+
+import { Button, Popup } from "../../semantic_ui_react_wrappers"
+import { popupContentPropType } from "../../sharedPropTypes"
+
+export function ActionButton(props) {
+ const { action, disabled, icon, itemType, floated, fluid, popup, position, ...other } = props
+ const label = `${action} ${itemType}`
+ // Put the button in a span so that a disabled button can still have a popup
+ // See https://github.com/Semantic-Org/Semantic-UI-React/issues/2804
+ const button = (
+
+
+ {icon} {label}
+
+
+ )
+ return
+}
+ActionButton.propTypes = {
+ action: string,
+ disabled: bool,
+ icon: element,
+ itemType: string,
+ floated: string,
+ fluid: bool,
+ popup: popupContentPropType,
+ position: string,
+}
diff --git a/components/frontend/src/widgets/buttons/AddButton.js b/components/frontend/src/widgets/buttons/AddButton.js
new file mode 100644
index 0000000000..f2471c7803
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/AddButton.js
@@ -0,0 +1,20 @@
+import { func, string } from "prop-types"
+
+import { AddItemIcon } from "../icons"
+import { ActionButton } from "./ActionButton"
+
+export function AddButton({ itemType, onClick }) {
+ return (
+ }
+ itemType={itemType}
+ onClick={() => onClick()}
+ popup={`Add a new ${itemType} here`}
+ />
+ )
+}
+AddButton.propTypes = {
+ itemType: string,
+ onClick: func,
+}
diff --git a/components/frontend/src/widgets/buttons/AddButton.test.js b/components/frontend/src/widgets/buttons/AddButton.test.js
new file mode 100644
index 0000000000..e7703ae62a
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/AddButton.test.js
@@ -0,0 +1,8 @@
+import { render, screen } from "@testing-library/react"
+
+import { AddButton } from "./AddButton"
+
+test("AddButton has the correct label", () => {
+ render( )
+ expect(screen.getAllByText(/bar/).length).toBe(1)
+})
diff --git a/components/frontend/src/widgets/Button.js b/components/frontend/src/widgets/buttons/AddDropdownButton.js
similarity index 57%
rename from components/frontend/src/widgets/Button.js
rename to components/frontend/src/widgets/buttons/AddDropdownButton.js
index 25739a9233..9861d4d084 100644
--- a/components/frontend/src/widgets/Button.js
+++ b/components/frontend/src/widgets/buttons/AddDropdownButton.js
@@ -1,11 +1,9 @@
import { array, arrayOf, bool, func, string } from "prop-types"
import { useState } from "react"
-import { Icon, Input } from "semantic-ui-react"
+import { Input } from "semantic-ui-react"
-import { Button, Checkbox, Dropdown, Popup } from "../semantic_ui_react_wrappers"
-import { popupContentPropType } from "../sharedPropTypes"
-import { showMessage } from "../widgets/toast"
-import { ItemBreadcrumb } from "./ItemBreadcrumb"
+import { Checkbox, Dropdown, Popup } from "../../semantic_ui_react_wrappers"
+import { AddItemIcon } from "../icons"
function stopEventPropagation(event) {
event.stopPropagation()
@@ -77,31 +75,6 @@ FilterCheckboxes.propTypes = {
setHideUsedItems: func,
}
-export function ActionButton(props) {
- const { action, disabled, icon, itemType, floated, fluid, popup, position, ...other } = props
- const label = `${action} ${itemType}`
- // Put the button in a span so that a disabled button can still have a popup
- // See https://github.com/Semantic-Org/Semantic-UI-React/issues/2804
- const button = (
-
-
- {label}
-
-
- )
- return
-}
-ActionButton.propTypes = {
- action: string,
- disabled: bool,
- icon: string,
- itemType: string,
- floated: string,
- fluid: bool,
- popup: popupContentPropType,
- position: string,
-}
-
export function AddDropdownButton({ itemSubtypes, itemType, onClick, allItemSubtypes, usedItemSubtypeKeys, sort }) {
const [selectedItem, setSelectedItem] = useState(0) // Index of selected item in the dropdown
const [query, setQuery] = useState("") // Search query to filter item subtypes
@@ -170,7 +143,7 @@ export function AddDropdownButton({ itemSubtypes, itemType, onClick, allItemSubt
selectOnNavigation={false}
trigger={
<>
- {`Add ${itemType} `}
+ {`Add ${itemType} `}
>
}
value={null} // Without this, a selected item becomes active (shown bold in the menu) and can't be selected again
@@ -234,167 +207,3 @@ AddDropdownButton.propTypes = {
sort: bool,
usedItemSubtypeKeys: arrayOf(string),
}
-
-export function AddButton({ itemType, onClick }) {
- return (
- onClick()}
- popup={`Add a new ${itemType} here`}
- />
- )
-}
-AddButton.propTypes = {
- itemType: string,
- onClick: func,
-}
-
-export function DeleteButton(props) {
- return (
-
- )
-}
-DeleteButton.propTypes = {
- itemType: string,
-}
-
-function ReorderButton(props) {
- const label = `Move ${props.moveable} to the ${props.direction} ${props.slot || "position"}`
- const icon = { first: "double up", last: "double down", previous: "up", next: "down" }[props.direction]
- const disabled =
- (props.first && (props.direction === "first" || props.direction === "previous")) ||
- (props.last && (props.direction === "last" || props.direction === "next"))
- return (
- props.onClick(props.direction)}
- primary
- />
- }
- />
- )
-}
-ReorderButton.propTypes = {
- direction: string,
- first: bool,
- last: bool,
- moveable: string,
- onClick: func,
- slot: string,
-}
-
-export function ReorderButtonGroup(props) {
- return (
-
-
-
-
-
-
- )
-}
-
-function ActionAndItemPickerButton({ action, itemType, onChange, get_options, icon }) {
- const [options, setOptions] = useState([])
-
- const breadcrumbProps = { report: "report" }
- if (itemType !== "report") {
- breadcrumbProps.subject = "subject"
- if (itemType !== "subject") {
- breadcrumbProps.metric = "metric"
- if (itemType !== "metric") {
- breadcrumbProps.source = "source"
- }
- }
- }
- return (
-
-
-
- }
- options={options}
- onChange={(_event, { value }) => onChange(value)}
- onOpen={() => setOptions(get_options())}
- scrolling
- selectOnBlur={false}
- selectOnNavigation={false}
- trigger={
- <>
- {`${action} ${itemType} `}
- >
- }
- value={null} // Without this, a selected item becomes active (shown bold in the menu) and can't be selected again
- />
- }
- />
- )
-}
-ActionAndItemPickerButton.propTypes = {
- action: string,
- itemType: string,
- onChange: func,
- get_options: func,
- icon: string,
-}
-
-export function CopyButton(props) {
- return
-}
-
-export function MoveButton(props) {
- return
-}
-
-export function PermLinkButton({ itemType, url }) {
- if (window.isSecureContext) {
- // Frontend runs in a secure context (https) so we can use the Clipboard API
- return (
-
- navigator.clipboard
- .writeText(url)
- .then(() => showMessage("success", "Copied URL to clipboard"))
- .catch((error) => showMessage("error", "Could not copy URL to clipboard", `${error}`))
- }
- primary
- />
- }
- />
- )
- }
- return null
-}
-PermLinkButton.propTypes = {
- itemType: string,
- url: string,
-}
diff --git a/components/frontend/src/widgets/Button.test.js b/components/frontend/src/widgets/buttons/AddDropdownButton.test.js
similarity index 59%
rename from components/frontend/src/widgets/Button.test.js
rename to components/frontend/src/widgets/buttons/AddDropdownButton.test.js
index dc81ab5479..a63918909a 100644
--- a/components/frontend/src/widgets/Button.test.js
+++ b/components/frontend/src/widgets/buttons/AddDropdownButton.test.js
@@ -1,16 +1,7 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
-import {
- AddButton,
- AddDropdownButton,
- CopyButton,
- DeleteButton,
- MoveButton,
- PermLinkButton,
- ReorderButtonGroup,
-} from "./Button"
-import * as toast from "./toast"
+import { AddDropdownButton } from "./AddDropdownButton"
function renderAddDropdownButton({ nrItems = 2, totalItems = 10, usedItemKeys = [], sort = true } = {}) {
const mockCallback = jest.fn()
@@ -227,141 +218,3 @@ test("AddDropdownButton does not add selected item on enter when menu is closed"
})
expect(mockCallback).not.toHaveBeenCalled()
})
-
-test("AddButton has the correct label", () => {
- render( )
- expect(screen.getAllByText(/bar/).length).toBe(1)
-})
-
-test("DeleteButton has the correct label", () => {
- render( )
- expect(screen.getAllByText(/bar/).length).toBe(1)
-})
-
-Array("report", "subject", "metric", "source").forEach((itemType) => {
- test("CopyButton has the correct label", () => {
- render( )
- expect(screen.getAllByText(new RegExp(`Copy ${itemType}`)).length).toBe(1)
- })
-
- test("CopyButton can be used to select an item", async () => {
- const mockCallback = jest.fn()
- render(
- {
- return [{ key: "1", text: "Item", value: "1" }]
- }}
- />,
- )
- await act(async () => {
- fireEvent.click(screen.getByText(new RegExp(`Copy ${itemType}`)))
- })
- await act(async () => {
- fireEvent.click(screen.getByText(/Item/))
- })
- expect(mockCallback).toHaveBeenCalledWith("1")
- })
-
- test("CopyButton loads the options every time the menu is opened", async () => {
- const mockCallback = jest.fn()
- let get_options_called = 0
- render(
- {
- get_options_called++
- return [{ key: "1", text: "Item", value: "1" }]
- }}
- />,
- )
- await act(async () => {
- fireEvent.click(screen.getByText(new RegExp(`Copy ${itemType}`)))
- })
- fireEvent.click(screen.getByText(/Item/))
- await act(async () => {
- fireEvent.click(screen.getByText(new RegExp(`Copy ${itemType}`)))
- })
- expect(get_options_called).toBe(2)
- })
-
- test("MoveButton has the correct label", () => {
- render( )
- expect(screen.getAllByText(new RegExp(`Move ${itemType}`)).length).toBe(1)
- })
-
- test("MoveButton can be used to select an item", async () => {
- const mockCallback = jest.fn()
- render(
- {
- return [{ key: "1", text: "Item", value: "1" }]
- }}
- />,
- )
- await act(async () => {
- fireEvent.click(screen.getByText(new RegExp(`Move ${itemType}`)))
- })
- await act(async () => {
- fireEvent.click(screen.getByText(/Item/))
- })
- expect(mockCallback).toHaveBeenCalledWith("1")
- })
-})
-
-Array("first", "last", "previous", "next").forEach((direction) => {
- test("ReorderButtonGroup calls the callback on click direction", async () => {
- const mockCallback = jest.fn()
- render( )
- await act(async () => {
- fireEvent.click(screen.getByLabelText(`Move item to the ${direction} position`))
- })
- expect(mockCallback).toHaveBeenCalledWith(direction)
- })
-
- test("ReorderButtonGroup does not call the callback on click direction when the button group is already there", () => {
- const mockCallback = jest.fn()
- render( )
- fireEvent.click(screen.getByLabelText(`Move item to the ${direction} position`))
- expect(mockCallback).not.toHaveBeenCalled()
- })
-})
-
-test("PermLinkButton is not shown in an insecure context", () => {
- Object.assign(window, { isSecureContext: false })
- render( )
- expect(screen.queryAllByText(/Share/).length).toBe(0)
-})
-
-test("PermLinkButton copies URL to clipboard", async () => {
- toast.showMessage = jest.fn()
- Object.assign(window, { isSecureContext: true })
- Object.assign(navigator, {
- clipboard: { writeText: jest.fn().mockImplementation(() => Promise.resolve()) },
- })
- render( )
- await act(async () => {
- fireEvent.click(screen.getByText(/Share/))
- })
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith("https://example.org")
- expect(toast.showMessage).toHaveBeenCalledWith("success", "Copied URL to clipboard")
-})
-
-test("PermLinkButton shows error message if copying fails", async () => {
- toast.showMessage = jest.fn()
- Object.assign(window, { isSecureContext: true })
- Object.assign(navigator, {
- clipboard: {
- writeText: jest.fn().mockImplementation(() => Promise.reject(new Error("fail"))),
- },
- })
- render( )
- await act(async () => {
- fireEvent.click(screen.getByText(/Share/))
- })
- expect(toast.showMessage).toHaveBeenCalledWith("error", "Could not copy URL to clipboard", "Error: fail")
-})
diff --git a/components/frontend/src/widgets/buttons/CopyButton.js b/components/frontend/src/widgets/buttons/CopyButton.js
new file mode 100644
index 0000000000..6a1f9a90ba
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/CopyButton.js
@@ -0,0 +1,6 @@
+import { CopyItemIcon } from "../icons"
+import { ActionAndItemPickerButton } from "./ActionAndItemPickerButton"
+
+export function CopyButton(props) {
+ return } />
+}
diff --git a/components/frontend/src/widgets/buttons/CopyButton.test.js b/components/frontend/src/widgets/buttons/CopyButton.test.js
new file mode 100644
index 0000000000..cc0b2396bd
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/CopyButton.test.js
@@ -0,0 +1,53 @@
+import { act, fireEvent, render, screen } from "@testing-library/react"
+
+import { CopyButton } from "./CopyButton"
+
+Array("report", "subject", "metric", "source").forEach((itemType) => {
+ test("CopyButton has the correct label", () => {
+ render( )
+ expect(screen.getAllByText(new RegExp(`Copy ${itemType}`)).length).toBe(1)
+ })
+
+ test("CopyButton can be used to select an item", async () => {
+ const mockCallback = jest.fn()
+ render(
+ {
+ return [{ key: "1", text: "Item", value: "1" }]
+ }}
+ />,
+ )
+ await act(async () => {
+ fireEvent.click(screen.getByText(new RegExp(`Copy ${itemType}`)))
+ })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Item/))
+ })
+ expect(mockCallback).toHaveBeenCalledWith("1")
+ })
+
+ test("CopyButton loads the options every time the menu is opened", async () => {
+ const mockCallback = jest.fn()
+ let get_options_called = 0
+ render(
+ {
+ get_options_called++
+ return [{ key: "1", text: "Item", value: "1" }]
+ }}
+ />,
+ )
+ await act(async () => {
+ fireEvent.click(screen.getByText(new RegExp(`Copy ${itemType}`)))
+ })
+ fireEvent.click(screen.getByText(/Item/))
+ await act(async () => {
+ fireEvent.click(screen.getByText(new RegExp(`Copy ${itemType}`)))
+ })
+ expect(get_options_called).toBe(2)
+ })
+})
diff --git a/components/frontend/src/widgets/buttons/DeleteButton.js b/components/frontend/src/widgets/buttons/DeleteButton.js
new file mode 100644
index 0000000000..9aab745559
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/DeleteButton.js
@@ -0,0 +1,21 @@
+import { string } from "prop-types"
+
+import { DeleteItemIcon } from "../icons"
+import { ActionButton } from "./ActionButton"
+
+export function DeleteButton(props) {
+ return (
+ }
+ negative
+ popup={`Delete this ${props.itemType}. Careful, this can only be undone by a system administrator!`}
+ position="top right"
+ {...props}
+ />
+ )
+}
+DeleteButton.propTypes = {
+ itemType: string,
+}
diff --git a/components/frontend/src/widgets/buttons/DeleteButton.test.js b/components/frontend/src/widgets/buttons/DeleteButton.test.js
new file mode 100644
index 0000000000..0ac989c767
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/DeleteButton.test.js
@@ -0,0 +1,8 @@
+import { render, screen } from "@testing-library/react"
+
+import { DeleteButton } from "./DeleteButton"
+
+test("DeleteButton has the correct label", () => {
+ render( )
+ expect(screen.getAllByText(/bar/).length).toBe(1)
+})
diff --git a/components/frontend/src/widgets/buttons/MoveButton.js b/components/frontend/src/widgets/buttons/MoveButton.js
new file mode 100644
index 0000000000..35dfbf5a57
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/MoveButton.js
@@ -0,0 +1,6 @@
+import { MoveItemIcon } from "../icons"
+import { ActionAndItemPickerButton } from "./ActionAndItemPickerButton"
+
+export function MoveButton(props) {
+ return } />
+}
diff --git a/components/frontend/src/widgets/buttons/MoveButton.test.js b/components/frontend/src/widgets/buttons/MoveButton.test.js
new file mode 100644
index 0000000000..161dd06c26
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/MoveButton.test.js
@@ -0,0 +1,30 @@
+import { act, fireEvent, render, screen } from "@testing-library/react"
+
+import { MoveButton } from "./MoveButton"
+
+Array("report", "subject", "metric", "source").forEach((itemType) => {
+ test("MoveButton has the correct label", () => {
+ render( )
+ expect(screen.getAllByText(new RegExp(`Move ${itemType}`)).length).toBe(1)
+ })
+
+ test("MoveButton can be used to select an item", async () => {
+ const mockCallback = jest.fn()
+ render(
+ {
+ return [{ key: "1", text: "Item", value: "1" }]
+ }}
+ />,
+ )
+ await act(async () => {
+ fireEvent.click(screen.getByText(new RegExp(`Move ${itemType}`)))
+ })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Item/))
+ })
+ expect(mockCallback).toHaveBeenCalledWith("1")
+ })
+})
diff --git a/components/frontend/src/widgets/buttons/PermLinkButton.js b/components/frontend/src/widgets/buttons/PermLinkButton.js
new file mode 100644
index 0000000000..a7ca963580
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/PermLinkButton.js
@@ -0,0 +1,34 @@
+import { string } from "prop-types"
+
+import { Button, Popup } from "../../semantic_ui_react_wrappers"
+import { showMessage } from "../toast"
+
+export function PermLinkButton({ itemType, url }) {
+ if (window.isSecureContext) {
+ // Frontend runs in a secure context (https) so we can use the Clipboard API
+ return (
+
+ navigator.clipboard
+ .writeText(url)
+ .then(() => showMessage("success", "Copied URL to clipboard"))
+ .catch((error) => showMessage("error", "Could not copy URL to clipboard", `${error}`))
+ }
+ primary
+ />
+ }
+ />
+ )
+ }
+ return null
+}
+PermLinkButton.propTypes = {
+ itemType: string,
+ url: string,
+}
diff --git a/components/frontend/src/widgets/buttons/PermLinkButton.test.js b/components/frontend/src/widgets/buttons/PermLinkButton.test.js
new file mode 100644
index 0000000000..815a007472
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/PermLinkButton.test.js
@@ -0,0 +1,39 @@
+import { act, fireEvent, render, screen } from "@testing-library/react"
+
+import * as toast from "../toast"
+import { PermLinkButton } from "./PermLinkButton"
+
+test("PermLinkButton is not shown in an insecure context", () => {
+ Object.assign(window, { isSecureContext: false })
+ render( )
+ expect(screen.queryAllByText(/Share/).length).toBe(0)
+})
+
+test("PermLinkButton copies URL to clipboard", async () => {
+ toast.showMessage = jest.fn()
+ Object.assign(window, { isSecureContext: true })
+ Object.assign(navigator, {
+ clipboard: { writeText: jest.fn().mockImplementation(() => Promise.resolve()) },
+ })
+ render( )
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Share/))
+ })
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith("https://example.org")
+ expect(toast.showMessage).toHaveBeenCalledWith("success", "Copied URL to clipboard")
+})
+
+test("PermLinkButton shows error message if copying fails", async () => {
+ toast.showMessage = jest.fn()
+ Object.assign(window, { isSecureContext: true })
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockImplementation(() => Promise.reject(new Error("fail"))),
+ },
+ })
+ render( )
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Share/))
+ })
+ expect(toast.showMessage).toHaveBeenCalledWith("error", "Could not copy URL to clipboard", "Error: fail")
+})
diff --git a/components/frontend/src/widgets/buttons/ReorderButtonGroup.js b/components/frontend/src/widgets/buttons/ReorderButtonGroup.js
new file mode 100644
index 0000000000..d5132656b4
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/ReorderButtonGroup.js
@@ -0,0 +1,45 @@
+import { bool, func, string } from "prop-types"
+
+import { Button, Popup } from "../../semantic_ui_react_wrappers"
+
+function ReorderButton(props) {
+ const label = `Move ${props.moveable} to the ${props.direction} ${props.slot || "position"}`
+ const icon = { first: "double up", last: "double down", previous: "up", next: "down" }[props.direction]
+ const disabled =
+ (props.first && (props.direction === "first" || props.direction === "previous")) ||
+ (props.last && (props.direction === "last" || props.direction === "next"))
+ return (
+ props.onClick(props.direction)}
+ primary
+ />
+ }
+ />
+ )
+}
+ReorderButton.propTypes = {
+ direction: string,
+ first: bool,
+ last: bool,
+ moveable: string,
+ onClick: func,
+ slot: string,
+}
+
+export function ReorderButtonGroup(props) {
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/components/frontend/src/widgets/buttons/ReorderButtonGroup.test.js b/components/frontend/src/widgets/buttons/ReorderButtonGroup.test.js
new file mode 100644
index 0000000000..df984b2cc6
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/ReorderButtonGroup.test.js
@@ -0,0 +1,21 @@
+import { act, fireEvent, render, screen } from "@testing-library/react"
+
+import { ReorderButtonGroup } from "./ReorderButtonGroup"
+
+Array("first", "last", "previous", "next").forEach((direction) => {
+ test("ReorderButtonGroup calls the callback on click direction", async () => {
+ const mockCallback = jest.fn()
+ render( )
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText(`Move item to the ${direction} position`))
+ })
+ expect(mockCallback).toHaveBeenCalledWith(direction)
+ })
+
+ test("ReorderButtonGroup does not call the callback on click direction when the button group is already there", () => {
+ const mockCallback = jest.fn()
+ render( )
+ fireEvent.click(screen.getByLabelText(`Move item to the ${direction} position`))
+ expect(mockCallback).not.toHaveBeenCalled()
+ })
+})
diff --git a/components/frontend/src/widgets/icons.js b/components/frontend/src/widgets/icons.js
new file mode 100644
index 0000000000..3bed0dff0e
--- /dev/null
+++ b/components/frontend/src/widgets/icons.js
@@ -0,0 +1,59 @@
+import AddIcon from "@mui/icons-material/Add"
+import ArrowRightIcon from "@mui/icons-material/ArrowRight"
+import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"
+import ContentCopyIcon from "@mui/icons-material/ContentCopy"
+import DeleteIcon from "@mui/icons-material/Delete"
+import LoopIcon from "@mui/icons-material/Loop"
+import MoveDownIcon from "@mui/icons-material/MoveDown"
+import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"
+
+export function AddItemIcon() {
+ return
+}
+
+export function CalendarIcon() {
+ return
+}
+
+export function CopyItemIcon() {
+ return
+}
+
+export function DeleteItemIcon() {
+ return
+}
+
+export function IgnoreIcon() {
+ return
+}
+
+export function MoveItemIcon() {
+ return
+}
+
+export function LoadingIcon() {
+ return (
+
+ )
+}
+
+export function RefreshIcon() {
+ return
+}
+
+export function TriangleRightIcon() {
+ return
+}