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 ( - 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 = ( + + + + ) + 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 = ( - - - - ) - 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 +}