ICTU/quality-time

View on GitHub
components/frontend/src/widgets/Button.js

Summary

Maintainability
C
7 hrs
Test Coverage
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 (
        <Checkbox
            label={label}
            onChange={() => 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 (
        <span style={{ paddingRight: "12px" }}>
            {allowHidingUnsupportedItems && (
                <FilterCheckbox
                    label={`Select from all ${itemType} types`}
                    filter={showUnsupportedItems}
                    setFilter={setShowUnsupportedItems}
                />
            )}
            {allowHidingUsedItems && (
                <FilterCheckbox
                    label={`Hide ${itemType} types already used`}
                    filter={hideUsedItems}
                    setFilter={setHideUsedItems}
                />
            )}
        </span>
    )
}
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 = (
        <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, usedItemSubtypeKeys }) {
    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()))
    options.sort((a, b) => a.text.localeCompare(b.text))
    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
                    onClose={() => 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={
                        <>
                            <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 style={{ minWidth: "50em" }}>
                        <Dropdown.Header>{`Available ${itemType} types`}</Dropdown.Header>
                        <Dropdown.Divider />
                        <Input
                            className="search"
                            focus
                            icon="search"
                            iconPosition="left"
                            onBlur={(event) => {
                                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}
                        />
                        <FilterCheckboxes
                            itemType={itemType}
                            allowHidingUnsupportedItems={allItemSubtypes?.length > 0}
                            showUnsupportedItems={showUnsupportedItems}
                            setShowUnsupportedItems={setShowUnsupportedItems}
                            allowHidingUsedItems={usedItemSubtypeKeys?.length > 0}
                            hideUsedItems={hideUsedItems}
                            setHideUsedItems={setHideUsedItems}
                        />
                        <Dropdown.Menu scrolling>
                            {options.map((option, index) => (
                                <Dropdown.Item
                                    content={option.content}
                                    key={option.key}
                                    onClick={(_event, { value }) => onClick(value)}
                                    selected={selectedItem === index}
                                    style={{ whiteSpace: "wrap" }}
                                    text={option.text}
                                    value={option.value}
                                />
                            ))}
                        </Dropdown.Menu>
                    </Dropdown.Menu>
                </Dropdown>
            }
        />
    )
}
AddDropdownButton.propTypes = {
    allItemSubtypes: array,
    itemSubtypes: array,
    itemType: string,
    onClick: func,
    usedItemSubtypeKeys: arrayOf(string),
}

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", marginRight: "5px" }}>
            <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({ itemType, url }) {
    if (window.isSecureContext) {
        // Frontend runs in a secure context (https) so we can use the Clipboard API
        return (
            <Popup
                content={`Copy a permanent link to this ${itemType} to the clipboard`}
                trigger={
                    <Button
                        basic
                        content={`Share ${itemType}`}
                        icon="share square"
                        onClick={() =>
                            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,
}