Implemented first draft of issue modal

This commit is contained in:
ireic
2019-12-18 03:48:42 +01:00
parent f48b2a9d40
commit 386694d28f
97 changed files with 1972 additions and 428 deletions

View File

@@ -26,6 +26,21 @@ export const StyledButton = styled.button`
}
`;
const colored = css`
color: #fff;
background: ${props => color[props.color]};
${font.medium}
&:not(:disabled) {
&:hover {
background: ${props => mixin.lighten(color[props.color], 0.15)};
}
&:active {
background: ${props => mixin.darken(color[props.color], 0.1)};
}
${props => props.isActive && `background: ${mixin.darken(color[props.color], 0.1)} !important;`}
}
`;
const secondaryAndEmptyShared = css`
color: ${color.textDark};
${font.regular}
@@ -35,36 +50,21 @@ const secondaryAndEmptyShared = css`
}
&:active {
color: ${color.primary};
background: ${mixin.rgba(color.primary, 0.15)};
background: ${color.backgroundLightPrimary};
}
${props =>
props.isActive &&
`
color: ${color.primary};
background: ${mixin.rgba(color.primary, 0.15)} !important;
background: ${color.backgroundLightPrimary} !important;
`}
}
`;
const buttonColors = {
primary: css`
color: #fff;
background: ${color.primary};
${font.medium}
&:not(:disabled) {
&:hover {
background: ${mixin.lighten(color.primary, 0.15)};
}
&:active {
background: ${mixin.darken(color.primary, 0.1)};
}
${props =>
props.isActive &&
`
background: ${mixin.darken(color.primary, 0.1)} !important;
`}
}
`,
primary: colored,
success: colored,
danger: colored,
secondary: css`
background: ${color.secondary};
${secondaryAndEmptyShared};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import { color } from 'shared/utils/styles';
@@ -8,8 +8,8 @@ import { StyledButton, StyledSpinner } from './Styles';
const propTypes = {
className: PropTypes.string,
children: PropTypes.node,
color: PropTypes.oneOf(['primary', 'secondary', 'empty']),
icon: PropTypes.string,
color: PropTypes.oneOf(['primary', 'secondary', 'empty', 'success', 'danger']),
icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
iconSize: PropTypes.number,
disabled: PropTypes.bool,
working: PropTypes.bool,
@@ -27,44 +27,55 @@ const defaultProps = {
onClick: () => {},
};
const Button = ({
children,
color: propsColor,
icon,
iconSize,
disabled,
working,
onClick = () => {},
...buttonProps
}) => (
<StyledButton
{...buttonProps}
onClick={() => {
const Button = forwardRef(
(
{
children,
color: propsColor,
icon,
iconSize,
disabled,
working,
onClick = () => {},
...buttonProps
},
ref,
) => {
const handleClick = () => {
if (!disabled && !working) {
onClick();
}
}}
color={propsColor}
disabled={disabled || working}
working={working}
iconOnly={!children}
>
{working && (
};
const renderSpinner = () => (
<StyledSpinner
iconOnly={!children}
size={26}
color={propsColor === 'primary' ? '#fff' : color.textDark}
/>
)}
{!working && icon && (
);
const renderIcon = () => (
<Icon
type={icon}
size={iconSize}
color={propsColor === 'primary' ? '#fff' : color.textDark}
/>
)}
<div>{children}</div>
</StyledButton>
);
return (
<StyledButton
{...buttonProps}
onClick={handleClick}
color={propsColor}
disabled={disabled || working}
working={working}
iconOnly={!children}
ref={ref}
>
{working && renderSpinner()}
{!working && icon && (typeof icon !== 'string' ? icon : renderIcon())}
<div>{children}</div>
</StyledButton>
);
},
);
Button.propTypes = propTypes;

View File

@@ -11,21 +11,21 @@ export const StyledConfirmModal = styled(Modal)`
export const Title = styled.div`
padding-bottom: 25px;
${font.bold}
${font.size(24)}
${font.medium}
${font.size(22)}
line-height: 1.5;
`;
export const Message = styled.p`
padding-bottom: 25px;
white-space: pre-wrap;
${font.size(16)}
${font.size(15)}
`;
export const InputLabel = styled.div`
padding-bottom: 12px;
${font.bold}
${font.size(16)}
${font.size(15)}
`;
export const StyledInput = styled(Input)`
@@ -33,6 +33,10 @@ export const StyledInput = styled(Input)`
max-width: 220px;
`;
export const Actions = styled.div`
display: flex;
`;
export const StyledButton = styled(Button)`
margin: 5px 20px 0 0;
`;

View File

@@ -7,6 +7,7 @@ import {
Message,
InputLabel,
StyledInput,
Actions,
StyledButton,
} from './Styles';
@@ -76,17 +77,19 @@ const ConfirmModal = ({
<br />
</>
)}
<StyledButton hollow onClick={modal.close}>
{cancelText}
</StyledButton>
<StyledButton
color={type}
disabled={confirmInput && !isConfirmEnabled}
working={isWorking}
onClick={() => handleConfirm(modal)}
>
{confirmText}
</StyledButton>
<Actions>
<StyledButton hollow onClick={modal.close}>
{cancelText}
</StyledButton>
<StyledButton
color={type}
disabled={confirmInput && !isConfirmEnabled}
working={isWorking}
onClick={() => handleConfirm(modal)}
>
{confirmText}
</StyledButton>
</Actions>
</>
)}
/>

View File

@@ -0,0 +1,21 @@
import React, { useState } from 'react';
import { copyToClipboard } from 'shared/utils/clipboard';
import { Button } from 'shared/components';
const CopyLinkButton = ({ ...otherProps }) => {
const [isLinkCopied, setLinkCopied] = useState(false);
const handleLinkCopy = () => {
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
copyToClipboard(window.location.href);
};
return (
<Button icon="link" onClick={handleLinkCopy} {...otherProps}>
{isLinkCopied ? 'Link Copied' : 'Copy link'}
</Button>
);
};
export default CopyLinkButton;

View File

@@ -14,7 +14,7 @@ export const Dropdown = styled.div`
width: 270px;
border-radius: 3px;
background: #fff;
${mixin.boxShadowBorderMedium}
${mixin.boxShadowDropdown}
${props => (props.withTime ? withTimeStyles : '')}
`;

View File

@@ -26,8 +26,8 @@ const codes = {
[`issues`]: '\\e908',
[`settings`]: '\\e909',
[`close`]: '\\e913',
[`help-filled`]: '\\e912',
[`feedback`]: '\\e915',
[`feedback`]: '\\e918',
[`trash`]: '\\e912',
};
const propTypes = {

View File

@@ -0,0 +1,43 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
import { Input } from 'shared/components';
const propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
};
const InputDebounced = ({ onChange, value: propsValue, ...props }) => {
const [value, setValue] = useState(propsValue);
const handleChange = useCallback(
debounce(newValue => onChange(newValue), 500),
[],
);
const valueRef = useRef(value);
valueRef.current = value;
useEffect(() => {
if (propsValue !== valueRef.current) {
setValue(propsValue);
}
}, [propsValue]);
return (
<Input
{...props}
value={value}
onChange={newValue => {
setValue(newValue);
handleChange(newValue);
}}
/>
);
};
InputDebounced.propTypes = propTypes;
export default InputDebounced;

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
import { Icon } from 'shared/components';
import { issuePriorityColors } from 'shared/utils/styles';
export const PriorityIcon = styled(Icon)`
font-size: 18px;
color: ${props => issuePriorityColors[props.color]};
`;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IssuePriority } from 'shared/constants/issues';
import { PriorityIcon } from './Styles';
const propTypes = {
priority: PropTypes.string.isRequired,
};
const IssuePriorityIcon = ({ priority, ...otherProps }) => {
const iconType = [IssuePriority.LOW || IssuePriority.LOWEST].includes(priority)
? 'arrow-down'
: 'arrow-up';
return <PriorityIcon type={iconType} color={priority} {...otherProps} />;
};
IssuePriorityIcon.propTypes = propTypes;
export default IssuePriorityIcon;

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
import { Icon } from 'shared/components';
import { issueTypeColors } from 'shared/utils/styles';
export const TypeIcon = styled(Icon)`
font-size: 18px;
color: ${props => issueTypeColors[props.color]};
`;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TypeIcon } from './Styles';
const propTypes = {
type: PropTypes.string.isRequired,
};
const IssueTypeIcon = ({ type, ...otherProps }) => (
<TypeIcon type={type} color={type} {...otherProps} />
);
IssueTypeIcon.propTypes = propTypes;
export default IssueTypeIcon;

View File

@@ -41,14 +41,14 @@ export const StyledModal = styled.div`
const modalStyles = {
center: css`
max-width: 600px;
max-width: ${props => props.width}px;
vertical-align: middle;
text-align: left;
${mixin.boxShadowMedium}
`,
aside: css`
min-height: 100vh;
max-width: 500px;
max-width: ${props => props.width}px;
text-align: left;
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15);
`,
@@ -57,7 +57,7 @@ const modalStyles = {
export const CloseIcon = styled(Icon)`
position: absolute;
font-size: 25px;
color: ${color.textDark};
color: ${color.textMedium};
${mixin.clickable}
${props => closeIconStyles[props.variant]}
`;

View File

@@ -1,7 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { uniqueId as uniqueIncreasingIntegerId } from 'lodash';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
@@ -10,6 +9,8 @@ import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Style
const propTypes = {
className: PropTypes.string,
variant: PropTypes.oneOf(['center', 'aside']),
width: PropTypes.number,
withCloseIcon: PropTypes.bool,
isOpen: PropTypes.bool,
onClose: PropTypes.func,
renderLink: PropTypes.func,
@@ -19,6 +20,8 @@ const propTypes = {
const defaultProps = {
className: undefined,
variant: 'center',
width: 600,
withCloseIcon: true,
isOpen: undefined,
onClose: () => {},
renderLink: () => {},
@@ -27,6 +30,8 @@ const defaultProps = {
const Modal = ({
className,
variant,
width,
withCloseIcon,
isOpen: propsIsOpen,
onClose: tellParentToClose,
renderLink,
@@ -37,12 +42,9 @@ const Modal = ({
const isOpen = isControlled ? propsIsOpen : stateIsOpen;
const $modalRef = useRef();
const modalIdRef = useRef(uniqueIncreasingIntegerId());
const $clickableOverlayRef = useRef();
const closeModal = useCallback(() => {
if (hasChildModal(modalIdRef.current)) {
return;
}
if (!isControlled) {
setStateOpen(false);
} else {
@@ -50,15 +52,15 @@ const Modal = ({
}
}, [isControlled, tellParentToClose]);
useOnOutsideClick($modalRef, isOpen, closeModal);
useOnOutsideClick($modalRef, isOpen, closeModal, $clickableOverlayRef);
useOnEscapeKeyDown(isOpen, closeModal);
useEffect(setBodyScrollLock, [isOpen]);
const renderModal = () => (
<ScrollOverlay data-jira-modal-id={modalIdRef.current}>
<ClickableOverlay variant={variant}>
<StyledModal className={className} variant={variant} ref={$modalRef}>
<CloseIcon type="close" variant={variant} onClick={closeModal} />
<ScrollOverlay data-jira-modal="true">
<ClickableOverlay variant={variant} ref={$clickableOverlayRef}>
<StyledModal className={className} variant={variant} width={width} ref={$modalRef}>
{withCloseIcon && <CloseIcon type="close" variant={variant} onClick={closeModal} />}
{renderContent({ close: closeModal })}
</StyledModal>
</ClickableOverlay>
@@ -75,15 +77,8 @@ const Modal = ({
const $root = document.getElementById('root');
const getIdsOfAllOpenModals = () => {
const $modalNodes = Array.from(document.querySelectorAll('[data-jira-modal-id]'));
return $modalNodes.map($node => parseInt($node.getAttribute('data-jira-modal-id')));
};
const hasChildModal = modalId => getIdsOfAllOpenModals().some(id => id > modalId);
const setBodyScrollLock = () => {
const areAnyModalsOpen = getIdsOfAllOpenModals().length > 0;
const areAnyModalsOpen = !!document.querySelector('[data-jira-modal]');
document.body.style.overflow = areAnyModalsOpen ? 'hidden' : 'visible';
};

View File

@@ -2,6 +2,6 @@ import styled from 'styled-components';
export default styled.div`
width: 100%;
padding-top: 200px;
padding: 200px 0;
text-align: center;
`;

View File

@@ -16,12 +16,14 @@ const propTypes = {
onChange: PropTypes.func.isRequired,
onCreate: PropTypes.func,
isMulti: PropTypes.bool,
propsRenderOption: PropTypes.func,
};
const defaultProps = {
value: undefined,
onCreate: undefined,
isMulti: false,
propsRenderOption: undefined,
};
const SelectDropdown = ({
@@ -35,6 +37,7 @@ const SelectDropdown = ({
onChange,
onCreate,
isMulti,
propsRenderOption,
}) => {
const [isCreatingOption, setCreatingOption] = useState(false);
@@ -143,27 +146,33 @@ const SelectDropdown = ({
.includes(searchValue.toLowerCase()),
);
const removeSelectedOptions = opts => opts.filter(option => !value.includes(option.value));
const removeSelectedOptionsMulti = opts => opts.filter(option => !value.includes(option.value));
const removeSelectedOptionsSingle = opts => opts.filter(option => value !== option.value);
const filteredOptions = isMulti
? removeSelectedOptions(optionsFilteredBySearchValue)
: optionsFilteredBySearchValue;
? removeSelectedOptionsMulti(optionsFilteredBySearchValue)
: removeSelectedOptionsSingle(optionsFilteredBySearchValue);
const searchValueNotInOptions = !options.map(option => option.label).includes(searchValue);
const isOptionCreatable = onCreate && searchValue && searchValueNotInOptions;
const renderSelectableOption = (option, i) => (
<Option
key={option.value}
className={i === 0 ? activeOptionClass : undefined}
isSelected={option.value === value}
data-select-option-value={option.value}
onMouseEnter={handleOptionMouseEnter}
onClick={() => selectOptionValue(option.value)}
>
{option.label}
</Option>
);
const renderSelectableOption = (option, i) => {
const optionProps = {
key: option.value,
value: option.value,
label: option.label,
className: i === 0 ? activeOptionClass : undefined,
isSelected: option.value === value,
'data-select-option-value': option.value,
onMouseEnter: handleOptionMouseEnter,
onClick: () => selectOptionValue(option.value),
};
return propsRenderOption ? (
propsRenderOption(optionProps)
) : (
<Option {...optionProps}>{option.label}</Option>
);
};
const renderCreatableOption = () => (
<Option

View File

@@ -6,63 +6,54 @@ import Icon from 'shared/components/Icon';
export const StyledSelect = styled.div`
position: relative;
width: 100%;
border-radius: 3px;
border: 1px solid ${color.borderLight};
border-radius: 4px;
border: 1px solid ${color.borderLightest};
background: #fff;
${font.size(14)}
&:focus {
outline: none;
border: 1px solid ${color.borderMedium};
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props => (props.hasIcon ? 'padding-left: 25px;' : '')}
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
`;
export const StyledIcon = styled(Icon)`
position: absolute;
top: 12px;
left: 14px;
font-size: 16px;
color: ${color.textMedium};
${props => props.invalid && `&, &:focus { border: 1px solid ${color.danger}; }`}
`;
export const ValueContainer = styled.div`
min-height: 38px;
display: flex;
align-items: center;
min-height: 32px;
width: 100%;
padding: 8px 5px 8px 10px;
`;
export const ChevronIcon = styled(Icon)`
position: absolute;
top: 10px;
right: 11px;
margin-left: auto;
font-size: 18px;
color: ${color.textMedium};
`;
export const Placeholder = styled.div`
padding: 11px 0 0 15px;
color: ${color.textLight};
`;
export const ValueSingle = styled.div`
padding: 11px 0 0 15px;
`;
export const ValueMulti = styled.div`
padding: 15px 5px 10px 10px;
display: flex;
align-items: center;
flex-wrap: wrap;
padding-top: 5px;
`;
export const ValueMultiItem = styled.div`
margin: 0 5px 5px 0;
${mixin.tag}
${mixin.tag()}
`;
export const AddMore = styled.div`
display: inline-block;
height: 24px;
line-height: 22px;
padding-right: 5px;
${font.size(12)}
margin-bottom: 3px;
padding: 3px 0;
${font.size(12.5)}
${mixin.link()}
i {
margin-right: 3px;
@@ -77,12 +68,13 @@ export const Dropdown = styled.div`
top: 100%;
left: 0;
width: 100%;
border-radius: 4px;
background: #fff;
${mixin.boxShadowBorderMedium}
${mixin.boxShadowDropdown}
`;
export const DropdownInput = styled.input`
padding: 10px 15px 8px;
padding: 10px 12px 8px;
width: 100%;
border: none;
color: ${color.textDarkest};
@@ -118,7 +110,7 @@ export const Option = styled.div`
margin-bottom: 8px;
}
&.jira-select-option-is-active {
background: ${mixin.lighten(color.backgroundMedium, 0.05)};
background: ${color.backgroundLightPrimary};
}
${props => (props.isSelected ? selectedOptionStyles : '')}
`;

View File

@@ -7,11 +7,9 @@ import Icon from 'shared/components/Icon';
import Dropdown from './Dropdown';
import {
StyledSelect,
StyledIcon,
ValueContainer,
ChevronIcon,
Placeholder,
ValueSingle,
ValueMulti,
ValueMultiItem,
AddMore,
@@ -19,8 +17,7 @@ import {
const propTypes = {
className: PropTypes.string,
icon: PropTypes.string,
value: PropTypes.any,
value: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
defaultValue: PropTypes.any,
placeholder: PropTypes.string,
invalid: PropTypes.bool,
@@ -28,22 +25,24 @@ const propTypes = {
onChange: PropTypes.func.isRequired,
onCreate: PropTypes.func,
isMulti: PropTypes.bool,
renderValue: PropTypes.func,
renderOption: PropTypes.func,
};
const defaultProps = {
className: undefined,
icon: undefined,
value: undefined,
defaultValue: undefined,
placeholder: '',
invalid: false,
onCreate: undefined,
isMulti: false,
renderValue: undefined,
renderOption: undefined,
};
const Select = ({
className,
icon,
value: propsValue,
defaultValue,
placeholder,
@@ -52,6 +51,8 @@ const Select = ({
onChange,
onCreate,
isMulti,
renderValue: propsRenderValue,
renderOption: propsRenderOption,
}) => {
const [stateValue, setStateValue] = useState(defaultValue || (isMulti ? [] : null));
const [isDropdownOpen, setDropdownOpen] = useState(false);
@@ -79,11 +80,18 @@ const Select = ({
useOnOutsideClick($selectRef, isDropdownOpen, deactivateDropdown);
const ensureValueType = newValue => {
if (typeof value === 'number') {
return isMulti ? newValue.map(parseInt) : parseInt(newValue);
}
return newValue;
};
const handleChange = newValue => {
if (!isControlled) {
setStateValue(newValue);
setStateValue(ensureValueType(newValue));
}
onChange(newValue);
onChange(ensureValueType(newValue));
};
const removeOptionValue = optionValue => {
@@ -106,16 +114,21 @@ const Select = ({
const isValueEmpty = isMulti ? !value.length : !getOption(value);
const renderSingleValue = () => <ValueSingle>{getOptionLabel(value)}</ValueSingle>;
const renderSingleValue = () =>
propsRenderValue ? propsRenderValue({ value }) : getOptionLabel(value);
const renderMultiValue = () => (
<ValueMulti>
{value.map(optionValue => (
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
{getOptionLabel(optionValue)}
<Icon type="close" />
</ValueMultiItem>
))}
{value.map(optionValue =>
propsRenderValue ? (
propsRenderValue({ value: optionValue, removeOptionValue })
) : (
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
{getOptionLabel(optionValue)}
<Icon type="close" />
</ValueMultiItem>
),
)}
<AddMore>
<Icon type="plus" />
Add more
@@ -128,16 +141,14 @@ const Select = ({
className={className}
ref={$selectRef}
tabIndex="0"
hasIcon={!!icon}
onKeyDown={handleFocusedSelectKeydown}
invalid={invalid}
>
<ValueContainer onClick={activateDropdown}>
{icon && <StyledIcon type={icon} />}
{(!isMulti || isValueEmpty) && <ChevronIcon type="chevron-down" />}
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
{!isValueEmpty && !isMulti && renderSingleValue()}
{!isValueEmpty && isMulti && renderMultiValue()}
{(!isMulti || isValueEmpty) && <ChevronIcon type="chevron-down" top={1} />}
</ValueContainer>
{isDropdownOpen && (
<Dropdown
@@ -152,6 +163,7 @@ const Select = ({
onChange={handleChange}
onCreate={onCreate}
isMulti={isMulti}
propsRenderOption={propsRenderOption}
/>
)}
</StyledSelect>

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
import { font } from 'shared/utils/styles';
export const Content = styled.div`
padding: 0 !important;
${font.size(15)}
${font.regular}
`;

View File

@@ -0,0 +1,21 @@
/* eslint-disable react/no-danger */
import React from 'react';
import PropTypes from 'prop-types';
import { Content } from './Styles';
import('quill/dist/quill.snow.css');
const propTypes = {
content: PropTypes.string.isRequired,
};
const TextEditedContent = ({ content, ...otherProps }) => (
<div className="ql-snow">
<Content className="ql-editor" dangerouslySetInnerHTML={{ __html: content }} {...otherProps} />
</div>
);
TextEditedContent.propTypes = propTypes;
export default TextEditedContent;

View File

@@ -0,0 +1,21 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const EditorCont = styled.div`
.ql-toolbar.ql-snow {
border-radius: 4px 4px 0 0;
border: 1px solid ${color.borderLightest};
border-bottom: none;
}
.ql-container.ql-snow {
border-radius: 0 0 4px 4px;
border: 1px solid ${color.borderLightest};
border-top: none;
${font.size(15)}
${font.regular}
}
.ql-editor {
min-height: 110px;
}
`;

View File

@@ -0,0 +1,70 @@
import React, { useLayoutEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import Quill from 'quill';
import { EditorCont } from './Styles';
import('quill/dist/quill.snow.css');
const propTypes = {
className: PropTypes.string,
placeholder: PropTypes.string,
defaultValue: PropTypes.string,
getEditor: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
placeholder: undefined,
defaultValue: undefined,
};
const TextEditor = ({ className, placeholder, defaultValue, getEditor, ...otherProps }) => {
const $editorContRef = useRef();
const $editorRef = useRef();
useLayoutEffect(() => {
let editor = null;
const setup = async () => {
editor = new Quill($editorRef.current, { placeholder, ...editorConfig });
editor.clipboard.dangerouslyPasteHTML(0, defaultValue);
getEditor({
getHTML: () => $editorContRef.current.querySelector('.ql-editor').innerHTML,
});
};
setup();
return () => {
editor = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<EditorCont className={className} ref={$editorContRef}>
<div ref={$editorRef} {...otherProps} />
</EditorCont>
);
};
const editorConfig = {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
['clean'],
],
},
};
TextEditor.propTypes = propTypes;
TextEditor.defaultProps = defaultProps;
export default TextEditor;

View File

@@ -25,7 +25,7 @@ const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps },
<TextareaAutoSize
{...textareaProps}
onChange={event => onChange(event.target.value, event)}
ref={ref}
inputRef={ref}
/>
</StyledTextarea>
));

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components';
import { zIndexValues, mixin } from 'shared/utils/styles';
export const Tooltip = styled.div`
z-index: ${zIndexValues.modal + 1};
position: fixed;
width: ${props => props.width}px;
border-radius: 3px;
background: #fff;
${mixin.hardwareAccelerate}
${mixin.boxShadowDropdown}
`;

View File

@@ -0,0 +1,110 @@
import React, { useState, useRef, useLayoutEffect } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { Tooltip } from './Styles';
const propTypes = {
className: PropTypes.string,
placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
offset: PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
}),
width: PropTypes.number.isRequired,
renderLink: PropTypes.func.isRequired,
renderContent: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
placement: 'bottom',
offset: {
top: 0,
left: 0,
},
};
const Modal = ({ className, placement, offset, width, renderLink, renderContent }) => {
const [isOpen, setIsOpen] = useState(false);
const $linkRef = useRef();
const $tooltipRef = useRef();
const openTooltip = () => setIsOpen(true);
const closeTooltip = () => setIsOpen(false);
useOnOutsideClick([$tooltipRef, $linkRef], isOpen, closeTooltip);
useLayoutEffect(() => {
const setTooltipPosition = () => {
const { top, left } = calcPosition(offset, placement, $tooltipRef, $linkRef);
$tooltipRef.current.style.top = `${top}px`;
$tooltipRef.current.style.left = `${left}px`;
};
if (isOpen) {
setTooltipPosition();
window.addEventListener('resize', setTooltipPosition);
window.addEventListener('scroll', setTooltipPosition);
}
return () => {
window.removeEventListener('resize', setTooltipPosition);
window.removeEventListener('scroll', setTooltipPosition);
};
}, [isOpen, offset, placement]);
const renderTooltip = () => (
<Tooltip className={className} ref={$tooltipRef} width={width}>
{renderContent({ close: closeTooltip })}
</Tooltip>
);
return (
<>
{renderLink({ ref: $linkRef, onClick: isOpen ? closeTooltip : openTooltip })}
{isOpen && ReactDOM.createPortal(renderTooltip(), $root)}
</>
);
};
const calcPosition = (offset, placement, $tooltipRef, $linkRef) => {
const margin = 20;
const finalOffset = { ...defaultProps.offset, ...offset };
const tooltipRect = $tooltipRef.current.getBoundingClientRect();
const linkRect = $linkRef.current.getBoundingClientRect();
const linkCenterY = linkRect.top + linkRect.height / 2;
const linkCenterX = linkRect.left + linkRect.width / 2;
const placements = {
top: {
top: linkRect.top - margin - tooltipRect.height,
left: linkCenterX - tooltipRect.width / 2,
},
right: {
top: linkCenterY - tooltipRect.height / 2,
left: linkRect.right + margin,
},
bottom: {
top: linkRect.bottom + margin,
left: linkCenterX - tooltipRect.width / 2,
},
left: {
top: linkCenterY - tooltipRect.height / 2,
left: linkRect.left - margin - tooltipRect.width,
},
};
return {
top: placements[placement].top + finalOffset.top,
left: placements[placement].left + finalOffset.left,
};
};
const $root = document.getElementById('root');
Modal.propTypes = propTypes;
Modal.defaultProps = defaultProps;
export default Modal;

View File

@@ -1,9 +1,14 @@
export { default as Avatar } from './Avatar';
export { default as Button } from './Button';
export { default as ConfirmModal } from './ConfirmModal';
export { default as CopyLinkButton } from './CopyLinkButton';
export { default as DatePicker } from './DatePicker';
export { default as Tooltip } from './Tooltip';
export { default as Icon } from './Icon';
export { default as Input } from './Input';
export { default as InputDebounced } from './InputDebounced';
export { default as IssueTypeIcon } from './IssueTypeIcon';
export { default as IssuePriorityIcon } from './IssuePriorityIcon';
export { default as Logo } from './Logo';
export { default as Modal } from './Modal';
export { default as PageError } from './PageError';
@@ -12,3 +17,5 @@ export { default as ProjectAvatar } from './ProjectAvatar';
export { default as Select } from './Select';
export { default as Spinner } from './Spinner';
export { default as Textarea } from './Textarea';
export { default as TextEditedContent } from './TextEditedContent';
export { default as TextEditor } from './TextEditor';