components/frontend/src/widgets/Button.js
import { array, bool, func, string } from "prop-types"
import { useRef, useState } from "react"
import { Icon, Input } from "semantic-ui-react"
import { Button, Checkbox, Dropdown, Label, Popup } from "../semantic_ui_react_wrappers"
import { popupContentPropType } from "../sharedPropTypes"
import { showMessage } from "../widgets/toast"
import { ItemBreadcrumb } from "./ItemBreadcrumb"
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 = (
<span style={{ float: floated ?? "none", display: fluid ? "" : "inline-block" }}>
<Button basic disabled={disabled} icon fluid={fluid} primary {...other}>
<Icon name={icon} /> {label}
</Button>
</span>
)
return <Popup content={popup} on={["focus", "hover"]} position={position || "top left"} trigger={button} />
}
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 }) {
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 [showAllItems, setShowAllItems] = useState(false) // Show all itemSubTypes or only supported itemSubTypes?
const items = showAllItems ? allItemSubtypes : itemSubtypes
const options = items.filter((itemSubtype) => itemSubtype.text.toLowerCase().includes(query.toLowerCase()))
const inputRef = useRef(null)
return (
<Popup
content={`Add a new ${itemType} here`}
on={["focus", "hover"]}
onOpen={() => setPopupTriggered(true)}
onClose={() => setPopupTriggered(false)}
open={!menuOpen && popupTriggered}
trigger={
<Dropdown
basic
className="button icon primary"
floating
onBlur={() => setQuery("")}
onClose={() => setMenuOpen(false)}
onKeyDown={(event) => {
if (!menuOpen) {
return
}
if (event.key === "Escape") {
setQuery("")
}
if (inputRef.current?.inputRef?.current !== document.activeElement) {
// 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)
event.target
.querySelectorAll("[role='option']")
[newIndex]?.scrollIntoView({ block: "nearest" })
}
if (event.key === "Enter") {
onClick(options[selectedItem].value)
}
}}
onOpen={() => setMenuOpen(true)}
selectOnBlur={false}
selectOnNavigation={false}
trigger={
<>
<Icon name="add" /> {`Add ${itemType} `}
</>
}
value={null} // Without this, a selected item becomes active (shown bold in the menu) and can't be selected again
>
<Dropdown.Menu>
<Dropdown.Header>{`Available ${itemType} types`}</Dropdown.Header>
<Dropdown.Divider />
<Input
className="search"
focus
icon="search"
iconPosition="left"
onBlur={(event) => {
if (allItemSubtypes) {
event.stopPropagation()
} // Prevent tabbing to the checkbox from clearing the input
}}
onChange={(_event, { value }) => setQuery(value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === " ") {
event.stopPropagation() // Prevent space from closing menu
}
}}
ref={inputRef}
placeholder={`Filter ${itemType} types`}
value={query}
/>
{allItemSubtypes && (
<Checkbox
label={`Select from all ${itemType} types`}
onChange={() => setShowAllItems(!showAllItems)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === " ") {
event.stopPropagation() // Prevent space from closing menu
}
}}
style={{ paddingLeft: "10pt", paddingBottom: "10pt" }}
tabIndex={0}
value={showAllItems ? 1 : 0}
/>
)}
<Dropdown.Menu scrolling>
{options.map((option, index) => (
<Dropdown.Item
key={option.key}
onClick={(_event, { value }) => onClick(value)}
selected={selectedItem === index}
{...option}
/>
))}
</Dropdown.Menu>
</Dropdown.Menu>
</Dropdown>
}
/>
)
}
AddDropdownButton.propTypes = {
allItemSubtypes: array,
itemSubtypes: array,
itemType: string,
onClick: func,
}
export function AddButton({ itemType, onClick }) {
return (
<ActionButton
action="Add"
icon="plus"
itemType={itemType}
onClick={() => onClick()}
popup={`Add a new ${itemType} here`}
/>
)
}
AddButton.propTypes = {
itemType: string,
onClick: func,
}
export function DeleteButton(props) {
return (
<ActionButton
action="Delete"
floated="right"
icon="trash"
negative
popup={`Delete this ${props.itemType}. Careful, this can only be undone by a system administrator!`}
position="top right"
{...props}
/>
)
}
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 (
<Popup
content={label}
trigger={
<Button
aria-label={label}
basic
disabled={disabled}
icon={`angle ${icon}`}
onClick={() => props.onClick(props.direction)}
primary
/>
}
/>
)
}
ReorderButton.propTypes = {
direction: string,
first: bool,
last: bool,
moveable: string,
onClick: func,
slot: string,
}
export function ReorderButtonGroup(props) {
return (
<Button.Group style={{ marginTop: "0px" }}>
<ReorderButton {...props} direction="first" />
<ReorderButton {...props} direction="previous" />
<ReorderButton {...props} direction="next" />
<ReorderButton {...props} direction="last" />
</Button.Group>
)
}
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 (
<Popup
content={`${action} an existing ${itemType} here`}
trigger={
<Dropdown
basic
className="button icon primary"
floating
header={
<Dropdown.Header>
<ItemBreadcrumb size="tiny" {...breadcrumbProps} />
</Dropdown.Header>
}
options={options}
onChange={(_event, { value }) => onChange(value)}
onOpen={() => setOptions(get_options())}
scrolling
selectOnBlur={false}
selectOnNavigation={false}
trigger={
<>
<Icon name={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: string,
}
export function CopyButton(props) {
return <ActionAndItemPickerButton {...props} action="Copy" icon="copy" />
}
export function MoveButton(props) {
return <ActionAndItemPickerButton {...props} action="Move" icon="shuffle" />
}
export function PermLinkButton({ url }) {
if (navigator.clipboard) {
// Frontend runs in a secure context (https) so we can use the Clipboard API
return (
<Button
as="div"
labelPosition="right"
onClick={() =>
navigator.clipboard.writeText(url).then(
function () {
showMessage("success", "Copied URL to clipboard")
},
function () {
showMessage("error", "Failed to copy URL to clipboard")
},
)
}
>
<Button basic content="Copy" icon="copy" primary />
<Label as="a" color="blue">
{url}
</Label>
</Button>
)
} else {
// Frontend does not run in a secure context (https) so we cannot use the Clipboard API, and have
// to use the deprecated Document.execCommand. As document.exeCommand expects selected text, we also
// cannot use the Label component but have to use a (read only) input element so we can select the URL
// before copying it to the clipboard.
return (
<Input action actionPosition="left" color="blue" defaultValue={url} fluid readOnly>
<Button
basic
color="blue"
content="Copy"
icon="copy"
onClick={() => {
let urlText = document.querySelector("#permlink")
urlText.select()
document.execCommand("copy")
showMessage("success", "Copied URL to clipboard")
}}
style={{ fontWeight: "bold" }}
/>
<input
data-testid="permlink"
id="permlink"
style={{
border: "1px solid rgb(143, 208, 255)",
color: "rgb(143, 208, 255)",
fontWeight: "bold",
}}
/>
</Input>
)
}
}
PermLinkButton.propTypes = {
url: string,
}