diff --git a/package.json b/package.json index 00665f0..5fa0747 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev-https": "cross-env APPMODE=development webpack-dev-server --https --port 8502 --host 0.0.0.0", "build": "webpack --mode=${APPMODE:-production} --env.config=${APPENV:-prod}", "analyze": "webpack --mode=production --env.analyze=true", - "lint": "eslint src --ext js --ext jsx", + "lint": "eslint ./src --ext .js,.jsx", "format": "prettier --write \"./**\"", "test": "cross-env BABEL_ENV=test jest", "watch-tests": "cross-env BABEL_ENV=test jest --watch", diff --git a/src/assets/images/icon-arrow-down-narrow.svg b/src/assets/images/icon-arrow-down-narrow.svg new file mode 100644 index 0000000..8e87a7e --- /dev/null +++ b/src/assets/images/icon-arrow-down-narrow.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/components/ActionsMenu/index.jsx b/src/components/ActionsMenu/index.jsx new file mode 100644 index 0000000..4eb6940 --- /dev/null +++ b/src/components/ActionsMenu/index.jsx @@ -0,0 +1,244 @@ +import React, { useCallback, useMemo, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import { usePopper } from "react-popper"; +import Button from "components/Button"; +import Tooltip from "components/Tooltip"; +import IconArrowDown from "../../assets/images/icon-arrow-down-narrow.svg"; +import { useClickOutside } from "utils/hooks"; +import { negate, stopPropagation } from "utils/misc"; +import compStyles from "./styles.module.scss"; + +/** + * Displays a clickable button with a menu. + * + * @param {Object} props component properties + * @param {'primary'|'error'|'warning'} [props.handleColor] menu handle color + * @param {'small'|'medium'} [props.handleSize] menu handle size + * @param {string} [props.handleText] text to show inside menu handle + * @param {Array} props.items menu items + * @param {'absolute'|'fixed'} [props.popupStrategy] popup positioning strategy + * @param {boolean} [props.stopClickPropagation] whether to stop click event propagation + * @returns {JSX.Element} + */ +const ActionsMenu = ({ + handleColor = "primary", + handleSize = "small", + handleText, + items = [], + popupStrategy = "absolute", + stopClickPropagation = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + + const closeMenu = useCallback(() => { + setIsOpen(false); + }, []); + + const toggleMenu = useCallback(() => { + setIsOpen(negate); + }, []); + + return ( +
+ + {isOpen && ( + + )} +
+ ); +}; + +ActionsMenu.propTypes = { + handleColor: PT.oneOf(["primary", "error", "warning"]), + handleSize: PT.oneOf(["small", "medium"]), + handleText: PT.string, + items: PT.arrayOf( + PT.shape({ + label: PT.string, + action: PT.func, + separator: PT.bool, + disabled: PT.bool, + hidden: PT.bool, + }) + ), + popupStrategy: PT.oneOf(["absolute", "fixed"]), + stopClickPropagation: PT.bool, +}; + +export default ActionsMenu; + +/** + * Displays a list of provided action items. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const Menu = ({ close, items, referenceElement, strategy }) => { + const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom", + strategy, + modifiers: [ + { + name: "flip", + options: { + fallbackPlacements: ["bottom"], + }, + }, + { + name: "offset", + options: { + // use offset to move the dropdown slightly down + offset: [0, 5], + }, + }, + { + name: "arrow", + // padding should be equal to border-radius of the dropdown + options: { element: arrowElement, padding: 8 }, + }, + { + name: "preventOverflow", + options: { + // padding from browser edges + padding: 16, + }, + }, + { + name: "computeStyles", + options: { + // to fix bug in IE 11 https://github.com/popperjs/popper-core/issues/636 + gpuAcceleration: false, + }, + }, + ], + }); + + const onClickItem = useCallback( + (event) => { + let targetData = event.target.dataset; + let index = +targetData.actionIndex; + let item = items[index]; + if (!item || targetData.disabled || item.separator) { + return; + } + close(); + item.action?.(); + }, + [close, items] + ); + + useClickOutside(popperElement, close, []); + + const menuItems = useMemo(() => { + return items.map((item, index) => { + if (item.hidden) { + return null; + } else if (item.separator) { + return
; + } else { + let disabled = !!item.disabled; + let reasonsDisabled = Array.isArray(item.disabled) + ? item.disabled + : null; + let attrs = { + key: index, + "data-action-index": index, + onClick: onClickItem, + role: "button", + tabIndex: 0, + className: cn( + compStyles.item, + { [compStyles.itemDisabled]: disabled }, + item.className + ), + }; + if (disabled) { + attrs["data-disabled"] = true; + } + return ( +
+ {reasonsDisabled ? ( + + {reasonsDisabled.map((text, index) => ( +
  • {text}
  • + ))} + + ) + } + strategy="fixed" + > + {item.label} +
    + ) : ( + item.label + )} +
    + ); + } + }); + }, [items, onClickItem]); + + return ( +
    +
    {menuItems}
    +
    +
    + ); +}; + +Menu.propTypes = { + close: PT.func.isRequired, + items: PT.arrayOf( + PT.shape({ + label: PT.string, + action: PT.func, + checkDisabled: PT.func, + disabled: PT.bool, + separator: PT.bool, + hidden: PT.bool, + }) + ), + referenceElement: PT.object, + strategy: PT.oneOf(["absolute", "fixed"]), +}; diff --git a/src/components/ActionsMenu/styles.module.scss b/src/components/ActionsMenu/styles.module.scss new file mode 100644 index 0000000..e780ce5 --- /dev/null +++ b/src/components/ActionsMenu/styles.module.scss @@ -0,0 +1,80 @@ +@import "styles/variables"; + +.container { + position: relative; + display: inline-block; +} + +.handle { + display: inline-flex; + align-items: center; + + > span { + + .iconArrowDown { + margin-left: 8px; + } + } +} + +.iconArrowDown { + display: inline-block; + width: 12px; + height: 8px; +} + +.handleMenuOpen { + .iconArrowDown { + transform: rotate(180deg); + } +} + +.popover { + z-index: 100; + border-radius: 8px; + // min-width: 175px; + background-color: #fff; + box-shadow: 0px 5px 25px #c6c6c6; +} + +.popoverArrow { + top: -9px; + border: 10px solid transparent; + border-top: none; + border-bottom-color: #fff; + width: 0; + height: 0; +} + +.items { + padding: 16px; +} + +.separator { + border-top: 1px solid #e7e7e7; + margin: 5px 0; +} + +.item { + padding: 5px 0; + font-size: 12px; + font-weight: bold; + letter-spacing: 0.8px; + text-align: left; + text-transform: uppercase; + white-space: nowrap; + color: $primary-text-color; + cursor: pointer; +} + +.danger { + color: #ef476f; +} + +.itemDisabled { + color: #bbb; + cursor: default; +} + +.hidden { + display: none; +} diff --git a/src/components/Button/index.jsx b/src/components/Button/index.jsx index 509cf17..0f65223 100644 --- a/src/components/Button/index.jsx +++ b/src/components/Button/index.jsx @@ -11,6 +11,8 @@ import styles from "./styles.module.scss"; * @param {string} [props.className] class name added to root element * @param {'primary'|'primary-dark'|'primary-light'|'error'|'warning'} [props.color] * button color + * @param {Object|function} [props.innerRef] Ref object or function to accept the + * ref for
    + ); +}; + +IntegerFieldHinted.propTypes = { + className: PT.string, + isDisabled: PT.bool, + isDisabledMinus: PT.bool, + isDisabledPlus: PT.bool, + name: PT.string.isRequired, + maxValue: PT.number, + minValue: PT.number, + maxValueMessage: PT.node, + minValueMessage: PT.node, + onChange: PT.func.isRequired, + stopClickPropagation: PT.bool, + tooltipStrategy: PT.oneOf(["absolute", "fixed"]), + value: PT.number.isRequired, +}; + +export default IntegerFieldHinted; diff --git a/src/components/IntegerFieldHinted/styles.module.scss b/src/components/IntegerFieldHinted/styles.module.scss new file mode 100644 index 0000000..1e96a7f --- /dev/null +++ b/src/components/IntegerFieldHinted/styles.module.scss @@ -0,0 +1,128 @@ +@import "styles/variables"; + +.container { + position: relative; + display: inline-flex; + align-items: center; + border: 1px solid $control-border-color; + border-radius: 6px; + width: 100px; + overflow: hidden; +} + +input.input { + flex: 1 1 0; + margin: 0; + border: none !important; + padding: 2px 0 3px; + height: 28px; + font-size: 14px; + line-height: normal; + background: #fff; + outline: none !important; + box-shadow: none !important; + text-align: center; + + &:disabled { + border-color: $control-disabled-border-color; + background-color: $control-disabled-bg-color; + color: $control-disabled-text-color; + cursor: not-allowed; + + ~ .btnMinus, + ~ .btnPlus { + cursor: not-allowed; + } + } +} + +.btnMinus, +.btnPlus { + position: absolute; + top: 0; + bottom: 0; + margin: auto; + width: 30px; + height: 30px; + + button { + display: block; + position: relative; + border: none; + padding: 0; + width: 100%; + height: 100%; + background: transparent; + outline: none !important; + cursor: pointer; + + &:disabled { + background: #ddd; + opacity: 1; + } + } +} + +.btnMinus { + left: 0; + + button { + &::before { + content: ""; + display: block; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: auto; + width: 8px; + height: 1px; + background: #7f7f7f; + } + } +} + +.btnPlus { + right: 0; + + button { + &::before, + &::after { + content: ""; + display: block; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: auto; + background: #7f7f7f; + } + + &::before { + width: 9px; + height: 1px; + } + + &::after { + width: 1px; + height: 9px; + } + } +} + +.tooltip { + max-width: 300px; + white-space: normal; +} + +.tooltipTarget { + display: block; + width: 100%; + height: 100%; + + &.notAllowed { + cursor: not-allowed; + } +} diff --git a/src/components/JobName/index.jsx b/src/components/JobName/index.jsx index e7236bf..2e20f77 100644 --- a/src/components/JobName/index.jsx +++ b/src/components/JobName/index.jsx @@ -24,7 +24,7 @@ const JobName = ({ className, jobId }) => { JobName.propTypes = { className: PT.string, - jobId: PT.oneOfType([PT.number, PT.string]).isRequired, + jobId: PT.oneOfType([PT.number, PT.string]), }; export default memo(JobName); diff --git a/src/components/Modal/index.jsx b/src/components/Modal/index.jsx index 09ea934..f122307 100644 --- a/src/components/Modal/index.jsx +++ b/src/components/Modal/index.jsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { useCallback, useMemo } from "react"; import PT from "prop-types"; +import cn from "classnames"; import { Modal as ReactModal } from "react-responsive-modal"; import Button from "components/Button"; import IconCross from "../../assets/images/icon-cross-light.svg"; @@ -7,87 +8,122 @@ import { stopImmediatePropagation } from "utils/misc"; import styles from "./styles.module.scss"; import "react-responsive-modal/styles.css"; -const classNames = { - modal: styles.modal, - modalContainer: styles.modalContainer, -}; const closeIcon = ; /** * Displays a modal with Approve- and Dismiss-button and an overlay. * * @param {Object} props component properties + * @param {'primary'|'error'|'warning'} [props.approveColor] color for the approve-button + * @param {boolean} [props.approveDisabled] whether the approve button is disabled * @param {string} [props.approveText] text for Approve-button * @param {Object} props.children elements that will be shown inside modal + * @param {string} [props.className] class name to be added to modal element * @param {?Object} [props.controls] custom controls that will be shown below * modal's contents * @param {string} [props.dismissText] text for Dismiss-button + * @param {boolean} [props.isDisabled] whether the modal is disabled * @param {boolean} props.isOpen whether to show or hide the modal * @param {() => void} [props.onApprove] function called on approve action + * @param {() => void} [props.onClose] function called when the modal is closed + * and the close animation has finished * @param {() => void} props.onDismiss function called on dismiss action * @param {string} [props.title] text for modal title * @returns {JSX.Element} */ const Modal = ({ + approveColor = "warning", + approveDisabled = false, approveText = "Apply", children, + className, controls, dismissText = "Cancel", + isDisabled = false, isOpen, onApprove, + onClose, onDismiss, title, -}) => ( - -
    { + const onAnimationEnd = useCallback(() => { + if (!isOpen) { + onClose?.(); + } + }, [isOpen, onClose]); + + const classNames = useMemo( + () => ({ + modal: cn(styles.modal, className), + modalContainer: styles.modalContainer, + }), + [className] + ); + + return ( + - {title &&
    {title}
    } -
    {children}
    - {controls || controls === null ? ( - controls - ) : ( -
    - - -
    - )} - -
    -
    -); +
    + {title &&
    {title}
    } +
    {children}
    + {controls || controls === null ? ( + controls + ) : ( +
    + + +
    + )} + +
    + + ); +}; Modal.propTypes = { + approveColor: PT.oneOf(["primary", "error", "warning"]), + approveDisabled: PT.bool, approveText: PT.string, children: PT.node, + className: PT.string, container: PT.element, controls: PT.node, dismissText: PT.string, + isDisabled: PT.bool, isOpen: PT.bool.isRequired, onApprove: PT.func, + onClose: PT.func, onDismiss: PT.func.isRequired, title: PT.string, }; diff --git a/src/components/Page/index.jsx b/src/components/Page/index.jsx index a9b51b0..bfedb09 100644 --- a/src/components/Page/index.jsx +++ b/src/components/Page/index.jsx @@ -15,7 +15,6 @@ import styles from "./styles.module.scss"; */ const Page = ({ className, children }) => (
    - {children} ( transitionIn="fadeIn" transitionOut="fadeOut" /> + {children}
    ); diff --git a/src/components/Page/styles.module.scss b/src/components/Page/styles.module.scss index 49417bf..5616900 100644 --- a/src/components/Page/styles.module.scss +++ b/src/components/Page/styles.module.scss @@ -14,6 +14,7 @@ @include desktop { flex-direction: row; + flex-wrap: wrap; } *, diff --git a/src/components/ProjectName/index.jsx b/src/components/ProjectName/index.jsx index 2550af0..29c0314 100644 --- a/src/components/ProjectName/index.jsx +++ b/src/components/ProjectName/index.jsx @@ -13,11 +13,7 @@ const ProjectName = ({ className, projectId }) => { const projectName = getName(projectId) || projectId; - return ( - - {projectName} - - ); + return {projectName}; }; ProjectName.propTypes = { diff --git a/src/components/ProjectName/styles.module.scss b/src/components/ProjectName/styles.module.scss index b61d947..fdfaa0a 100644 --- a/src/components/ProjectName/styles.module.scss +++ b/src/components/ProjectName/styles.module.scss @@ -2,9 +2,6 @@ .container { display: inline-block; - max-width: 20em; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; @include roboto-medium; } diff --git a/src/components/SelectField/index.jsx b/src/components/SelectField/index.jsx index 99c1e26..b8d257e 100644 --- a/src/components/SelectField/index.jsx +++ b/src/components/SelectField/index.jsx @@ -25,10 +25,11 @@ const selectComponents = { DropdownIndicator, IndicatorSeparator: () => null }; * @param {string} props.id control's id * @param {boolean} [props.isDisabled] whether the control should be disabled * @param {string} [props.label] control's label - * @param {(v: string) => void} props.onChange on change handler + * @param {string} [props.labelClassName] class name to be added to label element + * @param {(v: any) => void} props.onChange on change handler * @param {Object} props.options options for dropdown * @param {'medium'|'small'} [props.size] control's size - * @param {string} props.value control's value + * @param {any} props.value control's value * @returns {JSX.Element} */ const SelectField = ({ @@ -36,6 +37,7 @@ const SelectField = ({ id, isDisabled = false, label, + labelClassName, onChange, options, size = "medium", @@ -69,7 +71,7 @@ const SelectField = ({ )} > {label && ( -