+ 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..a5370644cc 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,12 @@ 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 { ButtonRow } from "../widgets/ButtonRow"
+import { DeleteButton } from "../widgets/buttons/DeleteButton"
+import { PermLinkButton } from "../widgets/buttons/PermLinkButton"
+import { ReorderButtonGroup } from "../widgets/buttons/ReorderButtonGroup"
import { HeaderWithDetails } from "../widgets/HeaderWithDetails"
-import { HyperLink } from "../widgets/HyperLink"
+import { ReadTheDocsLink } from "../widgets/ReadTheDocsLink"
import { changelogTabPane, configurationTabPane } from "../widgets/TabPane"
import { SubjectParameters } from "./SubjectParameters"
@@ -23,10 +25,7 @@ function SubjectHeader({ subjectType }) {
{subjectType.name}
- {subjectType.description}{" "}
-
- Read the Docs
-
+ {subjectType.description}
@@ -36,12 +35,13 @@ SubjectHeader.propTypes = {
subjectType: object,
}
-function ButtonRow({ subject_uuid, firstSubject, lastSubject, reload, url }) {
+function SubjectTitleButtonRow({ subject_uuid, firstSubject, lastSubject, reload, url }) {
+ const deleteButton = delete_subject(subject_uuid, reload)} />
return (
+
- delete_subject(subject_uuid, reload)} />
- >
+
}
/>
)
}
-ButtonRow.propTypes = {
+SubjectTitleButtonRow.propTypes = {
subject_uuid: string,
firstSubject: bool,
lastSubject: bool,
@@ -112,7 +111,7 @@ export function SubjectTitle({
onTabChange={tabChangeHandler(settings.expandedItems, subject_uuid)}
panes={panes}
/>
- {
fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true })
await renderSubjectTitle()
await act(async () => {
- fireEvent.click(screen.getByLabelText(/Move subject to the next position/))
+ fireEvent.click(screen.getByRole("button", { name: /Move subject to the next position/ }))
})
expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith(
"post",
diff --git a/components/frontend/src/subject/SubjectType.js b/components/frontend/src/subject/SubjectType.js
index e353327734..d471c5bbb0 100644
--- a/components/frontend/src/subject/SubjectType.js
+++ b/components/frontend/src/subject/SubjectType.js
@@ -1,30 +1,40 @@
+import CircleIcon from "@mui/icons-material/Circle"
+import { Stack, Typography } from "@mui/material"
import { func, number, objectOf, string } from "prop-types"
import { useContext } from "react"
-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 { 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,
text: subjectType.name,
value: key,
content: (
-
+
{bullet}
-
+
{subjectType.name}
- {subjectType.description}
-
-
+ {subjectType.description}
+
+
),
})
options.push(...subjectTypes(subjectType?.subjects ?? [], level + 1))
diff --git a/components/frontend/src/subject/SubjectsButtonRow.js b/components/frontend/src/subject/SubjectsButtonRow.js
index 87d4e0e7a8..450169b2a7 100644
--- a/components/frontend/src/subject/SubjectsButtonRow.js
+++ b/components/frontend/src/subject/SubjectsButtonRow.js
@@ -1,12 +1,15 @@
+import { Box } from "@mui/material"
import { func } from "prop-types"
import { useContext } from "react"
import { add_subject, copy_subject, move_subject } from "../api/subject"
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 { ButtonRow } from "../widgets/ButtonRow"
+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"
@@ -20,33 +23,35 @@ export function SubjectsButtonRow({ reload, report, reports, settings }) {
- {
- stopFiltering()
- add_subject(report.report_uuid, subtype, reload)
- }}
- sort={false} // Don't sort the subjects by name because it's a hierarchy defined in the data model
- />
- {
- stopFiltering()
- copy_subject(source_subject_uuid, report.report_uuid, reload)
- }}
- get_options={() => subject_options(reports, dataModel)}
- />
- {
- stopFiltering()
- move_subject(source_subject_uuid, report.report_uuid, reload)
- }}
- get_options={() => subject_options(reports, dataModel, report.report_uuid)}
- />
-
+
+
+ {
+ stopFiltering()
+ add_subject(report.report_uuid, subtype, reload)
+ }}
+ sort={false} // Don't sort the subjects by name because it's a hierarchy defined in the data model
+ />
+ {
+ stopFiltering()
+ copy_subject(source_subject_uuid, report.report_uuid, reload)
+ }}
+ get_options={() => subject_options(reports, dataModel)}
+ />
+ {
+ stopFiltering()
+ move_subject(source_subject_uuid, report.report_uuid, reload)
+ }}
+ get_options={() => subject_options(reports, dataModel, report.report_uuid)}
+ />
+
+
}
/>
)
diff --git a/components/frontend/src/subject/SubjectsButtonRow.test.js b/components/frontend/src/subject/SubjectsButtonRow.test.js
index 7206c7d207..03008a9b31 100644
--- a/components/frontend/src/subject/SubjectsButtonRow.test.js
+++ b/components/frontend/src/subject/SubjectsButtonRow.test.js
@@ -15,8 +15,8 @@ jest.mock("../widgets/menu_options", () => {
__esModule: true,
...originalModule,
subject_options: jest.fn(() => [
- { key: "1", text: "dummy option 1" },
- { key: "2", text: "dummy option 2" },
+ { key: "1", text: "dummy option 1", content: "dummy option 1" },
+ { key: "2", text: "dummy option 2", content: "dummy option 2" },
]),
}
})
diff --git a/components/frontend/src/widgets/Button.js b/components/frontend/src/widgets/Button.js
deleted file mode 100644
index 25739a9233..0000000000
--- a/components/frontend/src/widgets/Button.js
+++ /dev/null
@@ -1,400 +0,0 @@
-import { array, arrayOf, bool, func, string } from "prop-types"
-import { useState } from "react"
-import { Icon, 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"
-
-function stopEventPropagation(event) {
- event.stopPropagation()
-}
-
-function stopEventPropagationOnSpace(event) {
- if (event.key === " ") {
- event.stopPropagation() // Prevent space from closing menu
- }
-}
-
-function FilterCheckbox({ label, filter, setFilter }) {
- return (
- setFilter(!filter)}
- onClick={stopEventPropagation}
- onKeyDown={stopEventPropagationOnSpace}
- style={{ paddingLeft: "10pt", paddingBottom: "10pt" }}
- tabIndex={0}
- value={filter ? 1 : 0}
- />
- )
-}
-FilterCheckbox.propTypes = {
- label: string,
- filter: bool,
- setFilter: func,
-}
-
-function FilterCheckboxes({
- itemType,
- allowHidingUnsupportedItems,
- showUnsupportedItems,
- setShowUnsupportedItems,
- allowHidingUsedItems,
- hideUsedItems,
- setHideUsedItems,
-}) {
- if (!allowHidingUnsupportedItems && !allowHidingUsedItems) {
- return null
- }
- return (
-
- {allowHidingUnsupportedItems && (
-
- )}
- {allowHidingUsedItems && (
-
- )}
-
- )
-}
-FilterCheckboxes.propTypes = {
- itemType: string,
- allowHidingUnsupportedItems: bool,
- showUnsupportedItems: bool,
- setShowUnsupportedItems: func,
- allowHidingUsedItems: bool,
- hideUsedItems: bool,
- 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
- const [menuOpen, setMenuOpen] = useState(false) // Is the menu open?
- const [popupTriggered, setPopupTriggered] = useState(false) // Is the popup triggered by hover or focus?
- const [inputHasFocus, setInputHasFocus] = useState(false) // Does the input have focus?
- const [showUnsupportedItems, setShowUnsupportedItems] = useState(false) // Show only supported itemSubTypes or also unsupported itemSubTypes?
- const [hideUsedItems, setHideUsedItems] = useState(false) // Hide itemSubTypes already used?
- let items = showUnsupportedItems ? allItemSubtypes : itemSubtypes
- if (hideUsedItems) {
- items = items.filter((item) => !usedItemSubtypeKeys.includes(item.key))
- }
- const options = items.filter((itemSubtype) => itemSubtype.text.toLowerCase().includes(query.toLowerCase()))
- // Unless specified not to, sort the options:
- if (sort !== false) {
- options.sort((a, b) => a.text.localeCompare(b.text))
- }
- return (
- setPopupTriggered(true)}
- onClose={() => setPopupTriggered(false)}
- open={!menuOpen && popupTriggered}
- trigger={
- setMenuOpen(false)}
- onKeyDown={(event) => {
- if (!menuOpen) {
- return
- }
- if (event.key === "Escape") {
- setQuery("")
- }
- if (!inputHasFocus) {
- // Allow for editing the query without the input having focus
- if (event.key === "Backspace") {
- setQuery(query.slice(0, query.length - 1))
- } else if (event.key.length === 1) {
- setQuery(query + event.key)
- }
- }
- if (options.length === 0) {
- return
- }
- if (event.key === "ArrowUp" || event.key === "ArrowDown") {
- let newIndex
- if (event.key === "ArrowUp") {
- newIndex = Math.max(selectedItem - 1, 0)
- } else {
- newIndex = Math.min(selectedItem + 1, options.length - 1)
- }
- setSelectedItem(newIndex)
- const activeMenuItem = event.target.querySelectorAll("[role='option']")[newIndex]
- activeMenuItem?.scrollIntoView({ block: "nearest" })
- }
- if (event.key === "Enter") {
- onClick(options[selectedItem].value)
- }
- }}
- onOpen={() => setMenuOpen(true)}
- selectOnBlur={false}
- selectOnNavigation={false}
- trigger={
- <>
- {`Add ${itemType} `}
- >
- }
- value={null} // Without this, a selected item becomes active (shown bold in the menu) and can't be selected again
- >
-
- {`Available ${itemType} types`}
-
- {
- setInputHasFocus(false)
- if (allItemSubtypes) {
- event.stopPropagation()
- } // Prevent tabbing to the checkbox from clearing the input
- }}
- onChange={(_event, { value }) => setQuery(value)}
- onClick={stopEventPropagation}
- onFocus={() => {
- setInputHasFocus(true)
- }}
- onKeyDown={stopEventPropagationOnSpace}
- placeholder={`Filter ${itemType} types`}
- value={query}
- />
- 0}
- showUnsupportedItems={showUnsupportedItems}
- setShowUnsupportedItems={setShowUnsupportedItems}
- allowHidingUsedItems={usedItemSubtypeKeys?.length > 0}
- hideUsedItems={hideUsedItems}
- setHideUsedItems={setHideUsedItems}
- />
-
- {options.map((option, index) => (
- onClick(value)}
- selected={selectedItem === index}
- style={{ whiteSpace: "wrap" }}
- text={option.text}
- value={option.value}
- />
- ))}
-
-
-
- }
- />
- )
-}
-AddDropdownButton.propTypes = {
- allItemSubtypes: array,
- itemSubtypes: array,
- itemType: string,
- onClick: func,
- 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/Button.test.js
deleted file mode 100644
index dc81ab5479..0000000000
--- a/components/frontend/src/widgets/Button.test.js
+++ /dev/null
@@ -1,367 +0,0 @@
-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"
-
-function renderAddDropdownButton({ nrItems = 2, totalItems = 10, usedItemKeys = [], sort = true } = {}) {
- const mockCallback = jest.fn()
- const itemSubtypes = []
- let allItemSubtypes
- if (nrItems < totalItems) {
- allItemSubtypes = []
- for (const index of Array(totalItems).keys()) {
- const text = `Sub ${index + 1}`
- const key = text.toLowerCase()
- const option = { key: key, text: text, value: key }
- allItemSubtypes.push(option)
- if (index < nrItems) {
- itemSubtypes.push(option)
- }
- }
- }
- render(
- ,
- )
- return mockCallback
-}
-
-test("AddDropdownButton mouse navigation", async () => {
- const mockCallback = renderAddDropdownButton()
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await act(async () => {
- fireEvent.click(screen.getByText(/Sub 2/))
- })
- expect(mockCallback).toHaveBeenCalledWith("sub 2")
-})
-
-test("AddDropdownButton keyboard navigation", async () => {
- const mockCallback = renderAddDropdownButton()
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await act(async () => {
- fireEvent.keyDown(screen.getByText(/Available/), { key: "ArrowDown" })
- })
- await act(async () => {
- fireEvent.keyDown(screen.getByText(/Available/), { key: "ArrowUp" })
- })
- await act(async () => {
- fireEvent.keyDown(screen.getByText(/Available/), { key: "ArrowDown" })
- })
- await act(async () => {
- fireEvent.keyDown(screen.getByText(/Sub 2/), { key: "Enter" })
- })
- expect(mockCallback).toHaveBeenCalledWith("sub 2")
-})
-
-test("AddDropdownButton is sorted", async () => {
- renderAddDropdownButton()
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- expect(screen.getAllByText(/Sub/).map((item) => item.innerHTML)).toStrictEqual(["Sub 1", "Sub 2"])
-})
-
-test("AddDropdownButton is not sorted", async () => {
- renderAddDropdownButton({ sort: false })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- expect(screen.getAllByText(/Sub/).map((item) => item.innerHTML)).toStrictEqual(["Sub 2", "Sub 1"])
-})
-
-test("AddDropdownButton hides popup when dropdown is shown", async () => {
- renderAddDropdownButton()
- await userEvent.hover(screen.getByText(/Add foo/))
- await waitFor(() => {
- expect(screen.queryAllByText(/Add a new foo here/).length).toBe(1)
- })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- expect(screen.queryAllByText(/Add a new foo here/).length).toBe(0) // Popup should disappear
- await userEvent.type(screen.getByText(/Add foo/), "{Escape}") // Close dropdown
- await userEvent.hover(screen.getByText(/Add foo/))
- await waitFor(() => {
- expect(screen.queryAllByText(/Add a new foo here/).length).toBe(1)
- }) // Popup should appear again
-})
-
-test("AddDropdownButton filter one item", async () => {
- const mockCallback = renderAddDropdownButton({ nrItems: 6 })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await userEvent.type(screen.getByPlaceholderText(/Filter/), "Sub 6{Enter}")
- expect(mockCallback).toHaveBeenCalledWith("sub 6")
-})
-
-test("AddDropdownButton filter one item without focus", async () => {
- const mockCallback = renderAddDropdownButton({ nrItems: 6 })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- const dropdown = screen.getByText(/Add foo/)
- await act(async () => {
- fireEvent.keyDown(dropdown, { key: "9" })
- })
- await act(async () => {
- fireEvent.keyDown(dropdown, { key: "Backspace" })
- })
- await act(async () => {
- fireEvent.keyDown(dropdown, { key: "6" })
- })
- await act(async () => {
- fireEvent.keyDown(dropdown, { key: "Enter" })
- })
- expect(mockCallback).toHaveBeenCalledWith("sub 6")
-})
-
-test("AddDropdownButton filter zero items", async () => {
- const mockCallback = renderAddDropdownButton({ nrItems: 6 })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await userEvent.type(screen.getByPlaceholderText(/Filter/), "FOO{Enter}")
- expect(mockCallback).not.toHaveBeenCalled()
-})
-
-test("AddDropdownButton add all items", async () => {
- const mockCallback = renderAddDropdownButton()
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await act(async () => {
- fireEvent.click(screen.getByRole("checkbox"))
- })
- await act(async () => {
- fireEvent.click(screen.getByText(/Sub 3/))
- })
- expect(mockCallback).toHaveBeenCalledWith("sub 3")
-})
-
-test("AddDropdownButton hide used items", async () => {
- renderAddDropdownButton({ nrItems: 2, totalItems: 3, usedItemKeys: ["sub 1"] })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await act(async () => {
- fireEvent.click(screen.getAllByRole("checkbox")[1])
- })
- expect(screen.queryAllByText(/Sub 1/).length).toBe(0)
-})
-
-test("AddDropdownButton add all items by keyboard", async () => {
- const mockCallback = renderAddDropdownButton()
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await userEvent.type(screen.getByRole("checkbox"), " ")
- // Somehow the space does not trigger the checkbox, so hit Enter as well
- await userEvent.type(screen.getByRole("checkbox"), "{Enter}")
- await act(async () => {
- fireEvent.click(screen.getByText(/Sub 3/))
- })
- expect(mockCallback).toHaveBeenCalledWith("sub 3")
-})
-
-test("AddDropdownButton resets query on escape", async () => {
- const mockCallback = renderAddDropdownButton({ nrItems: 6 })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await userEvent.type(screen.getByPlaceholderText(/Filter/), "FOO{Escape}")
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await act(async () => {
- fireEvent.keyDown(screen.getByText(/Sub 1/), { key: "Enter" })
- })
- expect(mockCallback).toHaveBeenCalledWith("sub 1")
-})
-
-test("AddDropdownButton does not reset query on blur when it has checkbox", async () => {
- renderAddDropdownButton()
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await userEvent.type(screen.getByPlaceholderText(/Filter/), "Sub 3{Tab}")
- expect(screen.queryAllByText(/Sub 1/).length).toBe(0)
-})
-
-test("AddDropdownButton does reset query on blur when it has no checkbox", async () => {
- renderAddDropdownButton({ nrItems: 10 })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await userEvent.type(screen.getByPlaceholderText(/Filter/), "Sub 3{Tab}")
- expect(screen.queryAllByText(/Sub 3/).length).toBe(0)
-})
-
-test("AddDropdownButton does not add selected item on enter when menu is closed", async () => {
- const mockCallback = renderAddDropdownButton({ nrItems: 6 })
- await act(async () => {
- fireEvent.click(screen.getByText(/Add foo/))
- })
- await userEvent.type(screen.getByPlaceholderText(/Filter/), "Sub{Escape}")
- await act(async () => {
- fireEvent.keyDown(screen.getByText(/Add foo/), { key: "Enter" })
- })
- 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/ButtonRow.js b/components/frontend/src/widgets/ButtonRow.js
new file mode 100644
index 0000000000..1f968891fa
--- /dev/null
+++ b/components/frontend/src/widgets/ButtonRow.js
@@ -0,0 +1,17 @@
+import { Box } from "@mui/material"
+import { element } from "prop-types"
+
+import { childrenPropType } from "../sharedPropTypes"
+
+export function ButtonRow({ children, rightButton }) {
+ return (
+
+ {children}
+ {rightButton}
+
+ )
+}
+ButtonRow.propTypes = {
+ children: childrenPropType,
+ rightButton: element,
+}
diff --git a/components/frontend/src/widgets/HeaderWithDetails.js b/components/frontend/src/widgets/HeaderWithDetails.js
index 6389f391f4..5d287a780d 100644
--- a/components/frontend/src/widgets/HeaderWithDetails.js
+++ b/components/frontend/src/widgets/HeaderWithDetails.js
@@ -2,8 +2,9 @@ import "./HeaderWithDetails.css"
import { node, object, string } from "prop-types"
-import { Header, Icon, Segment } from "../semantic_ui_react_wrappers"
+import { Header, Segment } from "../semantic_ui_react_wrappers"
import { childrenPropType, settingsPropType } from "../sharedPropTypes"
+import { ExpandButton } from "./buttons/ExpandButton"
export function HeaderWithDetails({ children, className, header, item_uuid, level, style, settings, subheader }) {
const showDetails = settings.expandedItems.includes(item_uuid)
@@ -20,8 +21,8 @@ export function HeaderWithDetails({ children, className, header, item_uuid, leve
style={style}
tabIndex="0"
>
-
-
+
+
{header}
{subheader}
diff --git a/components/frontend/src/widgets/LabelWithHelp.js b/components/frontend/src/widgets/LabelWithHelp.js
index c1af2b1630..1d744eb063 100644
--- a/components/frontend/src/widgets/LabelWithHelp.js
+++ b/components/frontend/src/widgets/LabelWithHelp.js
@@ -1,6 +1,7 @@
+import HelpIcon from "@mui/icons-material/Help"
import { bool, string } from "prop-types"
-import { Icon, Popup } from "../semantic_ui_react_wrappers"
+import { Popup } from "../semantic_ui_react_wrappers"
import { labelPropType, popupContentPropType } from "../sharedPropTypes"
export function LabelWithHelp({ labelId, labelFor, label, help, hoverable }) {
@@ -11,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/LabelWithHyperLink.js b/components/frontend/src/widgets/LabelWithHyperLink.js
index 80dfba10cf..954149b09f 100644
--- a/components/frontend/src/widgets/LabelWithHyperLink.js
+++ b/components/frontend/src/widgets/LabelWithHyperLink.js
@@ -1,6 +1,6 @@
+import HelpIcon from "@mui/icons-material/Help"
import { string } from "prop-types"
-import { Icon } from "../semantic_ui_react_wrappers"
import { labelPropType } from "../sharedPropTypes"
import { HyperLink } from "./HyperLink"
@@ -9,7 +9,7 @@ export function LabelWithHyperLink({ labelId, label, url }) {
{label}{" "}
-
+
)
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/TabPane.js b/components/frontend/src/widgets/TabPane.js
index 95e5a3ff20..5d578bd426 100644
--- a/components/frontend/src/widgets/TabPane.js
+++ b/components/frontend/src/widgets/TabPane.js
@@ -1,13 +1,15 @@
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 { Icon, Label, Tab } from "../semantic_ui_react_wrappers"
+import { Label, Tab } from "../semantic_ui_react_wrappers"
-function FocusableTab({ error, iconName, image, label, warning }) {
+function FocusableTab({ error, icon, image, label, warning }) {
const className = useContext(DarkMode) ? "tabbutton inverted" : "tabbutton"
let tabLabel = label
if (error || warning) {
@@ -16,14 +18,13 @@ function FocusableTab({ error, iconName, image, label, warning }) {
}
return (
<>
- {iconName ? : image}
- {tabLabel}
+ {icon || image} {tabLabel}
>
)
}
FocusableTab.propTypes = {
error: bool,
- iconName: string,
+ icon: element,
image: element,
label: oneOfType([element, string]),
warning: bool,
@@ -36,7 +37,7 @@ export function tabPane(label, pane, options) {
})
}
export function changelogTabPane(pane, options) {
- return tabPane("Changelog", pane, { ...options, iconName: "history" })
+ return tabPane("Changelog", pane, { ...options, icon: })
}
diff --git a/components/frontend/src/widgets/TabPane.test.js b/components/frontend/src/widgets/TabPane.test.js
index 0b1edf5456..8587d2c9bd 100644
--- a/components/frontend/src/widgets/TabPane.test.js
+++ b/components/frontend/src/widgets/TabPane.test.js
@@ -1,3 +1,4 @@
+import StorageIcon from "@mui/icons-material/Storage"
import { render, screen } from "@testing-library/react"
import { DarkMode } from "../context/DarkMode"
@@ -29,8 +30,8 @@ it("shows the tab yellow when there is a warning", () => {
})
it("shows an icon", () => {
- const { container } = render(Pane, { iconName: "server" })]} />)
- expect(container.firstChild.firstChild.firstChild.firstChild.className).toEqual(expect.stringContaining("server"))
+ render(Pane, { icon: })]} />)
+ expect(screen.getAllByTestId("StorageIcon").length).toBe(1)
})
it("shows an image", () => {
diff --git a/components/frontend/src/widgets/TableRowWithDetails.css b/components/frontend/src/widgets/TableRowWithDetails.css
deleted file mode 100644
index 9be473499d..0000000000
--- a/components/frontend/src/widgets/TableRowWithDetails.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.ui.basic.button.expandcollapse:not(:hover, :focus) {
- box-shadow: 0 0 0 0px transparent;
-}
diff --git a/components/frontend/src/widgets/TableRowWithDetails.js b/components/frontend/src/widgets/TableRowWithDetails.js
index 542c5bcd22..c805f59a48 100644
--- a/components/frontend/src/widgets/TableRowWithDetails.js
+++ b/components/frontend/src/widgets/TableRowWithDetails.js
@@ -1,9 +1,8 @@
-import "./TableRowWithDetails.css"
-
import { bool, func, object } from "prop-types"
-import { Button, Icon, Table } from "../semantic_ui_react_wrappers"
+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
@@ -11,13 +10,7 @@ export function TableRowWithDetails(props) {
<>
- }
- onClick={() => onExpand(!expanded)}
- />
+ onExpand(!expanded)} size="1.5em" />
{children}
diff --git a/components/frontend/src/widgets/buttons/ActionAndItemPickerButton.js b/components/frontend/src/widgets/buttons/ActionAndItemPickerButton.js
new file mode 100644
index 0000000000..9f4728cc8b
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/ActionAndItemPickerButton.js
@@ -0,0 +1,67 @@
+import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"
+import { Button, Menu, MenuItem, Tooltip, Typography } from "@mui/material"
+import { element, func, string } from "prop-types"
+import { useState } from "react"
+
+import { ItemBreadcrumb } from "../ItemBreadcrumb"
+
+export function ActionAndItemPickerButton({ action, itemType, onChange, get_options, icon }) {
+ const [anchorEl, setAnchorEl] = useState()
+ const handleMenu = (event) => setAnchorEl(event.currentTarget)
+ const onClick = (value) => {
+ onChange(value)
+ setAnchorEl(null)
+ }
+
+ const breadcrumbProps = { report: "report" }
+ if (itemType !== "report") {
+ breadcrumbProps.subject = "subject"
+ if (itemType !== "subject") {
+ breadcrumbProps.metric = "metric"
+ if (itemType !== "metric") {
+ breadcrumbProps.source = "source"
+ }
+ }
+ }
+ return (
+ <>
+
+
+ {icon} {`${action} ${itemType} `}
+
+
+
+
+ >
+ )
+}
+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..05c15ac0ea
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/ActionButton.js
@@ -0,0 +1,27 @@
+import { Button, Tooltip } from "@mui/material"
+import { bool, element, func, string } from "prop-types"
+
+import { popupContentPropType } from "../../sharedPropTypes"
+
+export function ActionButton(props) {
+ const { action, color, disabled, icon, itemType, onClick, popup } = props
+ const label = `${action} ${itemType}`
+ return (
+
+
+ onClick()} startIcon={icon} variant="outlined">
+ {label}
+
+
+
+ )
+}
+ActionButton.propTypes = {
+ action: string,
+ color: string,
+ disabled: bool,
+ icon: element,
+ itemType: string,
+ onClick: func,
+ popup: popupContentPropType,
+}
diff --git a/components/frontend/src/widgets/buttons/AddButton.js b/components/frontend/src/widgets/buttons/AddButton.js
new file mode 100644
index 0000000000..ed1eaaf9a4
--- /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/buttons/AddDropdownButton.js b/components/frontend/src/widgets/buttons/AddDropdownButton.js
new file mode 100644
index 0000000000..6f41cf4a13
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/AddDropdownButton.js
@@ -0,0 +1,150 @@
+import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"
+import {
+ Button,
+ Checkbox,
+ FormControlLabel,
+ FormGroup,
+ MenuItem,
+ MenuList,
+ Popover,
+ TextField,
+ Tooltip,
+ Typography,
+} from "@mui/material"
+import { array, arrayOf, bool, func, string } from "prop-types"
+import { useState } from "react"
+
+import { AddItemIcon } from "../icons"
+
+function FilterCheckbox({ label, filter, setFilter }) {
+ return (
+ setFilter(!filter)}
+ />
+ }
+ label={label}
+ />
+ )
+}
+FilterCheckbox.propTypes = {
+ label: string,
+ filter: bool,
+ setFilter: func,
+}
+
+function FilterCheckboxes({
+ itemType,
+ allowHidingUnsupportedItems,
+ showUnsupportedItems,
+ setShowUnsupportedItems,
+ allowHidingUsedItems,
+ hideUsedItems,
+ setHideUsedItems,
+}) {
+ if (!allowHidingUnsupportedItems && !allowHidingUsedItems) {
+ return null
+ }
+ return (
+
+ {allowHidingUnsupportedItems && (
+
+ )}
+ {allowHidingUsedItems && (
+
+ )}
+
+ )
+}
+FilterCheckboxes.propTypes = {
+ itemType: string,
+ allowHidingUnsupportedItems: bool,
+ showUnsupportedItems: bool,
+ setShowUnsupportedItems: func,
+ allowHidingUsedItems: bool,
+ hideUsedItems: bool,
+ setHideUsedItems: func,
+}
+
+export function AddDropdownButton({ itemSubtypes, itemType, onClick, allItemSubtypes, usedItemSubtypeKeys, sort }) {
+ const [anchorEl, setAnchorEl] = useState()
+ const handleMenu = (event) => setAnchorEl(event.currentTarget)
+ const onClickMenuItem = (value) => {
+ onClick(value)
+ setAnchorEl(null)
+ }
+ const [query, setQuery] = useState("") // Search query to filter item subtypes
+ const [showUnsupportedItems, setShowUnsupportedItems] = useState(false) // Show only supported itemSubTypes or also unsupported itemSubTypes?
+ const [hideUsedItems, setHideUsedItems] = useState(false) // Hide itemSubTypes already used?
+ let items = showUnsupportedItems ? allItemSubtypes : itemSubtypes
+ if (hideUsedItems) {
+ items = items.filter((item) => !usedItemSubtypeKeys.includes(item.key))
+ }
+ const options = items.filter((itemSubtype) => itemSubtype.text.toLowerCase().includes(query.toLowerCase()))
+ // Unless specified not to, sort the options:
+ if (sort !== false) {
+ options.sort((a, b) => a.text.localeCompare(b.text))
+ }
+ return (
+ <>
+
+
+ {`Add ${itemType} `}
+
+
+
+ >
+ )
+}
+AddDropdownButton.propTypes = {
+ allItemSubtypes: array,
+ itemSubtypes: array,
+ itemType: string,
+ onClick: func,
+ sort: bool,
+ usedItemSubtypeKeys: arrayOf(string),
+}
diff --git a/components/frontend/src/widgets/buttons/AddDropdownButton.test.js b/components/frontend/src/widgets/buttons/AddDropdownButton.test.js
new file mode 100644
index 0000000000..6c09d13de5
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/AddDropdownButton.test.js
@@ -0,0 +1,145 @@
+import { act, fireEvent, render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+
+import { AddDropdownButton } from "./AddDropdownButton"
+
+function renderAddDropdownButton({ nrItems = 2, totalItems = 10, usedItemKeys = [], sort = true } = {}) {
+ const mockCallback = jest.fn()
+ const itemSubtypes = []
+ let allItemSubtypes
+ if (nrItems < totalItems) {
+ allItemSubtypes = []
+ for (const index of Array(totalItems).keys()) {
+ const text = `Sub ${index + 1}`
+ const key = text.toLowerCase()
+ const option = { key: key, text: text, value: key, content: text }
+ allItemSubtypes.push(option)
+ if (index < nrItems) {
+ itemSubtypes.push(option)
+ }
+ }
+ }
+ render(
+ ,
+ )
+ return mockCallback
+}
+
+test("AddDropdownButton mouse navigation", async () => {
+ const mockCallback = renderAddDropdownButton()
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Sub 2/))
+ })
+ expect(mockCallback).toHaveBeenCalledWith("sub 2")
+})
+
+test("AddDropdownButton keyboard navigation", async () => {
+ const mockCallback = renderAddDropdownButton()
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ await act(async () => {
+ fireEvent.keyDown(screen.getByText(/Available/), { key: "ArrowDown" })
+ })
+ await act(async () => {
+ fireEvent.keyDown(screen.getByText(/Available/), { key: "ArrowUp" })
+ })
+ await act(async () => {
+ fireEvent.keyDown(screen.getByText(/Available/), { key: "ArrowDown" })
+ })
+ await act(async () => {
+ fireEvent.keyDown(screen.getByText(/Sub 2/), { key: "Enter" })
+ })
+ expect(mockCallback).toHaveBeenCalledWith("sub 2")
+})
+
+test("AddDropdownButton is sorted", async () => {
+ renderAddDropdownButton()
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ expect(screen.getAllByText(/Sub/).map((item) => item.innerHTML)).toStrictEqual(["Sub 1", "Sub 2"])
+})
+
+test("AddDropdownButton is not sorted", async () => {
+ renderAddDropdownButton({ sort: false })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ expect(screen.getAllByText(/Sub/).map((item) => item.innerHTML)).toStrictEqual(["Sub 2", "Sub 1"])
+})
+
+test("AddDropdownButton filter one item", async () => {
+ renderAddDropdownButton({ nrItems: 6 })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ await userEvent.type(screen.getByRole("searchbox"), "Sub 3")
+ expect(screen.getAllByText(/Sub/).map((item) => item.innerHTML)).toStrictEqual(["Sub 3"])
+})
+
+test("AddDropdownButton filter zero items", async () => {
+ renderAddDropdownButton({ nrItems: 6 })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ await userEvent.type(screen.getByRole("searchbox"), "FOO{Enter}")
+ expect(screen.queryAllByText(/Sub/).length).toBe(0)
+})
+
+test("AddDropdownButton select from all items", async () => {
+ const mockCallback = renderAddDropdownButton()
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ await act(async () => {
+ fireEvent.click(screen.getByRole("checkbox"))
+ })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Sub 3/))
+ })
+ expect(mockCallback).toHaveBeenCalledWith("sub 3")
+})
+
+test("AddDropdownButton hide used items", async () => {
+ renderAddDropdownButton({ nrItems: 2, totalItems: 3, usedItemKeys: ["sub 1"] })
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ await act(async () => {
+ fireEvent.click(screen.getAllByRole("checkbox")[1])
+ })
+ expect(screen.queryAllByText(/Sub 1/).length).toBe(0)
+})
+
+test("AddDropdownButton add all items by keyboard", async () => {
+ const mockCallback = renderAddDropdownButton()
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ await userEvent.type(screen.getByRole("checkbox"), "{Enter}")
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Sub 3/))
+ })
+ expect(mockCallback).toHaveBeenCalledWith("sub 3")
+})
+
+test("AddDropdownButton menu can be closed without selecting an item", async () => {
+ const mockCallback = renderAddDropdownButton()
+ await act(async () => {
+ fireEvent.click(screen.getByText(/Add foo/))
+ })
+ await userEvent.keyboard("{Escape}")
+ expect(screen.queryAllByText(/Sub/).length).toBe(0)
+ expect(mockCallback).not.toHaveBeenCalled()
+})
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..e77e4454c5
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/CopyButton.test.js
@@ -0,0 +1,69 @@
+import { act, fireEvent, render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+
+import { CopyButton } from "./CopyButton"
+
+function renderCopyButton(itemType, mockCallback) {
+ render(
+ {
+ return [{ key: "1", text: "Item", value: "1", content: "Item" }]
+ }}
+ />,
+ )
+}
+
+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()
+ renderCopyButton(itemType, mockCallback)
+ 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 menu can be closed without selecting an item", async () => {
+ const mockCallback = jest.fn()
+ renderCopyButton(itemType, mockCallback)
+ await act(async () => {
+ fireEvent.click(screen.getByText(new RegExp(`Copy ${itemType}`)))
+ })
+ await userEvent.keyboard("{Escape}")
+ expect(screen.queryAllByText(/Item/).length).toBe(0)
+ expect(mockCallback).not.toHaveBeenCalled()
+ })
+
+ 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", content: "Item" }]
+ }}
+ />,
+ )
+ 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(4)
+ })
+})
diff --git a/components/frontend/src/widgets/buttons/DeleteButton.js b/components/frontend/src/widgets/buttons/DeleteButton.js
new file mode 100644
index 0000000000..fc53dd882c
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/DeleteButton.js
@@ -0,0 +1,19 @@
+import { string } from "prop-types"
+
+import { DeleteItemIcon } from "../icons"
+import { ActionButton } from "./ActionButton"
+
+export function DeleteButton(props) {
+ return (
+ }
+ popup={`Delete this ${props.itemType}. Careful, this can only be undone by a system administrator!`}
+ {...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/ExpandButton.js b/components/frontend/src/widgets/buttons/ExpandButton.js
new file mode 100644
index 0000000000..9731189b82
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/ExpandButton.js
@@ -0,0 +1,17 @@
+import { IconButton } from "@mui/material"
+import { bool, func, string } from "prop-types"
+
+import { CaretDown, CaretRight } from "../icons"
+
+export function ExpandButton({ expand, onClick, size }) {
+ return (
+
+ {expand ? : }
+
+ )
+}
+ExpandButton.propTypes = {
+ expand: bool,
+ onClick: func,
+ size: string,
+}
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..181bda307e
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/MoveButton.test.js
@@ -0,0 +1,10 @@
+import { 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)
+ })
+})
diff --git a/components/frontend/src/widgets/buttons/PermLinkButton.js b/components/frontend/src/widgets/buttons/PermLinkButton.js
new file mode 100644
index 0000000000..0ef21ac489
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/PermLinkButton.js
@@ -0,0 +1,32 @@
+import { Button, Tooltip } from "@mui/material"
+import { string } from "prop-types"
+
+import { OpenLink } from "../icons"
+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}`))
+ }
+ startIcon={ }
+ variant="outlined"
+ >
+ {`Share ${itemType}`}
+
+
+ )
+ }
+ 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..e187908c46
--- /dev/null
+++ b/components/frontend/src/widgets/buttons/ReorderButtonGroup.js
@@ -0,0 +1,47 @@
+import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"
+import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"
+import KeyboardDoubleArrowDownIcon from "@mui/icons-material/KeyboardDoubleArrowDown"
+import KeyboardDoubleArrowUpIcon from "@mui/icons-material/KeyboardDoubleArrowUp"
+import { Button, ButtonGroup, Tooltip } from "@mui/material"
+import { bool, element, func, string } from "prop-types"
+
+function ReorderButton(props) {
+ const label = `Move ${props.moveable} to the ${props.direction} ${props.slot || "position"}`
+ const disabled =
+ (props.first && (props.direction === "first" || props.direction === "previous")) ||
+ (props.last && (props.direction === "last" || props.direction === "next"))
+ return (
+
+
+ props.onClick(props.direction)}
+ variant="outlined"
+ >
+ {props.icon}
+
+
+
+ )
+}
+ReorderButton.propTypes = {
+ direction: string,
+ first: bool,
+ icon: element,
+ 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..fbf50a4c29
--- /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.getByRole("button", { name: `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.getByRole("button", { name: `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..3d5fcb39cf
--- /dev/null
+++ b/components/frontend/src/widgets/icons.js
@@ -0,0 +1,85 @@
+import AddIcon from "@mui/icons-material/Add"
+import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"
+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 OpenInNewIcon from "@mui/icons-material/OpenInNew"
+import VisibilityIcon from "@mui/icons-material/Visibility"
+import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"
+import { string } from "prop-types"
+
+export function AddItemIcon() {
+ return
+}
+
+export function CalendarIcon() {
+ return
+}
+
+export function CaretDown({ size }) {
+ return
+}
+CaretDown.propTypes = {
+ size: string,
+}
+
+export function CaretRight({ size }) {
+ return
+}
+CaretRight.propTypes = {
+ size: string,
+}
+
+export function CopyItemIcon() {
+ return
+}
+
+export function DeleteItemIcon() {
+ return
+}
+
+export function IgnoreIcon() {
+ return
+}
+
+export function MoveItemIcon() {
+ return
+}
+
+export function OpenLink() {
+ return
+}
+
+export function LoadingIcon() {
+ return (
+
+ )
+}
+
+export function RefreshIcon() {
+ return
+}
+
+export function ShowIcon() {
+ return
+}
+
+export function TriangleRightIcon() {
+ return
+}