Added some basic shared components, utils, hooks
This commit is contained in:
30
client/src/shared/components/Avatar/Styles.js
Normal file
30
client/src/shared/components/Avatar/Styles.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Image = styled.div`
|
||||
display: inline-block;
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
border-radius: 100%;
|
||||
background-image: url('${props => props.avatarUrl}');
|
||||
${mixin.backgroundImage}
|
||||
`;
|
||||
|
||||
export const Letter = styled.div`
|
||||
display: inline-block;
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
border-radius: 100%;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
background: ${props => props.color};
|
||||
${font.medium}
|
||||
${props => font.size(Math.round(props.size / 1.7))}
|
||||
& > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
47
client/src/shared/components/Avatar/index.jsx
Normal file
47
client/src/shared/components/Avatar/index.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Image, Letter } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
avatarUrl: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
avatarUrl: null,
|
||||
name: '',
|
||||
size: 24,
|
||||
};
|
||||
|
||||
const colors = [
|
||||
'#DA7657',
|
||||
'#6ADA57',
|
||||
'#5784DA',
|
||||
'#AA57DA',
|
||||
'#DA5757',
|
||||
'#DA5792',
|
||||
'#57DACA',
|
||||
'#57A5DA',
|
||||
];
|
||||
|
||||
const getColorFromName = name => colors[name.toLocaleLowerCase().charCodeAt(0) % colors.length];
|
||||
|
||||
const Avatar = ({ className, avatarUrl, name, size }) => {
|
||||
if (avatarUrl) {
|
||||
return <Image className={className} size={size} avatarUrl={avatarUrl} />;
|
||||
}
|
||||
return (
|
||||
<Letter className={className} size={size} color={getColorFromName(name)}>
|
||||
<span>{name.charAt(0)}</span>
|
||||
</Letter>
|
||||
);
|
||||
};
|
||||
|
||||
Avatar.propTypes = propTypes;
|
||||
Avatar.defaultProps = defaultProps;
|
||||
|
||||
export default Avatar;
|
||||
86
client/src/shared/components/Button/Styles.js
Normal file
86
client/src/shared/components/Button/Styles.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export const StyledButton = styled.button`
|
||||
display: inline-block;
|
||||
height: 36px;
|
||||
line-height: 34px;
|
||||
padding: 0 18px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.1s;
|
||||
appearance: none !important;
|
||||
${mixin.clickable}
|
||||
${font.bold}
|
||||
${font.size(14)}
|
||||
${props => (props.hollow ? hollowStyles : filledStyles)}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
i {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
right: 4px;
|
||||
margin-right: 7px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
font-size: 16px;
|
||||
}
|
||||
${props => (props.iconOnly ? iconOnlyStyles : '')}
|
||||
`;
|
||||
|
||||
const filledStyles = props => css`
|
||||
color: #fff;
|
||||
background: ${color[props.color]};
|
||||
border: 1px solid ${color[props.color]};
|
||||
${!props.disabled &&
|
||||
css`
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: ${mixin.darken(color[props.color], 0.15)};
|
||||
border: 1px solid ${mixin.darken(color[props.color], 0.15)};
|
||||
}
|
||||
&:active {
|
||||
background: ${mixin.lighten(color[props.color], 0.1)};
|
||||
border: 1px solid ${mixin.lighten(color[props.color], 0.1)};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const hollowStyles = props => css`
|
||||
color: ${color.textMediumBlue};
|
||||
background: #fff;
|
||||
border: 1px solid ${color.borderBlue};
|
||||
${!props.disabled &&
|
||||
css`
|
||||
&:hover,
|
||||
&:focus {
|
||||
border: 1px solid ${mixin.darken(color.borderBlue, 0.15)};
|
||||
}
|
||||
&:active {
|
||||
border: 1px solid ${color.borderBlue};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const iconOnlyStyles = css`
|
||||
padding: 0 12px;
|
||||
i {
|
||||
right: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledSpinner = styled(Spinner)`
|
||||
position: relative;
|
||||
right: 8px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
`;
|
||||
67
client/src/shared/components/Button/index.jsx
Normal file
67
client/src/shared/components/Button/index.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { color } from 'shared/utils/styles';
|
||||
import Icon from 'shared/components/Icon';
|
||||
import { StyledButton, StyledSpinner } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
type: PropTypes.string,
|
||||
hollow: PropTypes.bool,
|
||||
color: PropTypes.oneOf(['primary', 'success', 'danger']),
|
||||
icon: PropTypes.string,
|
||||
iconSize: PropTypes.number,
|
||||
disabled: PropTypes.bool,
|
||||
working: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
children: undefined,
|
||||
type: 'button',
|
||||
hollow: false,
|
||||
color: 'primary',
|
||||
icon: undefined,
|
||||
iconSize: undefined,
|
||||
disabled: false,
|
||||
working: false,
|
||||
onClick: () => {},
|
||||
};
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
hollow,
|
||||
icon,
|
||||
iconSize,
|
||||
disabled,
|
||||
working,
|
||||
onClick = () => {},
|
||||
...buttonProps
|
||||
}) => (
|
||||
<StyledButton
|
||||
{...buttonProps}
|
||||
hollow={hollow}
|
||||
onClick={() => {
|
||||
if (!disabled && !working) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
disabled={disabled || working}
|
||||
working={working}
|
||||
iconOnly={!children}
|
||||
>
|
||||
{working && <StyledSpinner size={26} color={hollow ? color.textMediumBlue : '#fff'} />}
|
||||
{!working && icon && (
|
||||
<Icon type={icon} size={iconSize} color={hollow ? color.textMediumBlue : '#fff'} />
|
||||
)}
|
||||
{children}
|
||||
</StyledButton>
|
||||
);
|
||||
|
||||
Button.propTypes = propTypes;
|
||||
Button.defaultProps = defaultProps;
|
||||
|
||||
export default Button;
|
||||
38
client/src/shared/components/ConfirmModal/Styles.js
Normal file
38
client/src/shared/components/ConfirmModal/Styles.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import Modal from 'shared/components/Modal';
|
||||
import Input from 'shared/components/Input';
|
||||
import Button from 'shared/components/Button';
|
||||
import { font } from 'shared/utils/styles';
|
||||
|
||||
export const StyledConfirmModal = styled(Modal)`
|
||||
padding: 45px 50px 50px;
|
||||
`;
|
||||
|
||||
export const Title = styled.div`
|
||||
padding-bottom: 25px;
|
||||
${font.bold}
|
||||
${font.size(24)}
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
export const Message = styled.p`
|
||||
padding-bottom: 25px;
|
||||
white-space: pre-wrap;
|
||||
${font.size(16)}
|
||||
`;
|
||||
|
||||
export const InputLabel = styled.div`
|
||||
padding-bottom: 12px;
|
||||
${font.bold}
|
||||
${font.size(16)}
|
||||
`;
|
||||
|
||||
export const StyledInput = styled(Input)`
|
||||
margin-bottom: 25px;
|
||||
max-width: 220px;
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
margin: 5px 20px 0 0;
|
||||
`;
|
||||
99
client/src/shared/components/ConfirmModal/index.jsx
Normal file
99
client/src/shared/components/ConfirmModal/index.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
StyledConfirmModal,
|
||||
Title,
|
||||
Message,
|
||||
InputLabel,
|
||||
StyledInput,
|
||||
StyledButton,
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
confirmText: PropTypes.string,
|
||||
cancelText: PropTypes.string,
|
||||
confirmInput: PropTypes.string,
|
||||
type: PropTypes.oneOf(['primary', 'danger']),
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
renderLink: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
title: 'Warning',
|
||||
message: 'Are you sure you want to continue with this action?',
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
confirmInput: null,
|
||||
type: 'primary',
|
||||
};
|
||||
|
||||
const ConfirmModal = ({
|
||||
className,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
confirmInput,
|
||||
type,
|
||||
onConfirm,
|
||||
renderLink,
|
||||
}) => {
|
||||
const [isConfirmEnabled, setConfirmEnabled] = useState(false);
|
||||
const [isWorking, setWorking] = useState(false);
|
||||
|
||||
const handleConfirm = modal => {
|
||||
setWorking(true);
|
||||
onConfirm({
|
||||
close: () => {
|
||||
modal.close();
|
||||
setWorking(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmInputChange = value =>
|
||||
setConfirmEnabled(value.trim().toLowerCase() === confirmInput.toLowerCase());
|
||||
|
||||
return (
|
||||
<StyledConfirmModal
|
||||
suppressClassNameWarning
|
||||
className={className}
|
||||
afterClose={() => setConfirmEnabled(false)}
|
||||
renderLink={renderLink}
|
||||
renderContent={modal => (
|
||||
<>
|
||||
<Title>{title}</Title>
|
||||
{message && <Message>{message}</Message>}
|
||||
{confirmInput && (
|
||||
<>
|
||||
<InputLabel>{`Type ${confirmInput} below to confirm.`}</InputLabel>
|
||||
<StyledInput onChange={(event, value) => handleConfirmInputChange(value)} />
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
<StyledButton hollow onClick={modal.close}>
|
||||
{cancelText}
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
color={type}
|
||||
disabled={confirmInput && !isConfirmEnabled}
|
||||
working={isWorking}
|
||||
onClick={() => handleConfirm(modal)}
|
||||
>
|
||||
{confirmText}
|
||||
</StyledButton>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ConfirmModal.propTypes = propTypes;
|
||||
ConfirmModal.defaultProps = defaultProps;
|
||||
|
||||
export default ConfirmModal;
|
||||
117
client/src/shared/components/DatePicker/DateSection.jsx
Normal file
117
client/src/shared/components/DatePicker/DateSection.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { times, range } from 'lodash';
|
||||
|
||||
import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
|
||||
import Icon from 'shared/components/Icon';
|
||||
import {
|
||||
DateSection,
|
||||
YearSelect,
|
||||
SelectedMonthYear,
|
||||
Grid,
|
||||
PrevNextIcons,
|
||||
DayName,
|
||||
Day,
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
withTime: PropTypes.bool,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
setDropdownOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
withTime: true,
|
||||
value: null,
|
||||
};
|
||||
|
||||
const DatePickerDateSection = ({ withTime, value, onChange, setDropdownOpen }) => {
|
||||
const [selectedMonth, setSelectedMonth] = useState(moment(value || undefined).startOf('month'));
|
||||
|
||||
const handleYearChange = year => {
|
||||
setSelectedMonth(moment(selectedMonth).set({ year: parseInt(year) }));
|
||||
};
|
||||
|
||||
const handleMonthChange = addOrSubtract => {
|
||||
setSelectedMonth(moment(selectedMonth)[addOrSubtract](1, 'month'));
|
||||
};
|
||||
|
||||
const handleDayChange = newDate => {
|
||||
const existingHour = value ? moment(value).hour() : '00';
|
||||
const existingMinute = value ? moment(value).minute() : '00';
|
||||
|
||||
const newDateWithExistingTime = newDate.set({
|
||||
hour: existingHour,
|
||||
minute: existingMinute,
|
||||
});
|
||||
onChange(formatDateTimeForAPI(newDateWithExistingTime));
|
||||
|
||||
if (!withTime) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateYears = () => times(50, i => ({ label: `${i + 2010}`, value: `${i + 2010}` }));
|
||||
|
||||
const generateWeekDayNames = () => moment.weekdaysMin(true);
|
||||
|
||||
const generateFillerDaysBeforeMonthStart = () => {
|
||||
const count = selectedMonth.diff(moment(selectedMonth).startOf('week'), 'days');
|
||||
return range(count);
|
||||
};
|
||||
|
||||
const generateMonthDays = () =>
|
||||
times(selectedMonth.daysInMonth()).map(i => moment(selectedMonth).add(i, 'days'));
|
||||
|
||||
const generateFillerDaysAfterMonthEnd = () => {
|
||||
const selectedMonthEnd = moment(selectedMonth).endOf('month');
|
||||
const weekEnd = moment(selectedMonthEnd).endOf('week');
|
||||
const count = weekEnd.diff(selectedMonthEnd, 'days');
|
||||
return range(count);
|
||||
};
|
||||
|
||||
return (
|
||||
<DateSection>
|
||||
<SelectedMonthYear>{formatDate(selectedMonth, 'MMM YYYY')}</SelectedMonthYear>
|
||||
<YearSelect onChange={event => handleYearChange(event.target.value)}>
|
||||
{[{ label: 'Year', value: '' }, ...generateYears()].map(option => (
|
||||
<option key={option.label} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</YearSelect>
|
||||
<PrevNextIcons>
|
||||
<Icon type="arrow-left" onClick={() => handleMonthChange('subtract')} />
|
||||
<Icon type="arrow-right" onClick={() => handleMonthChange('add')} />
|
||||
</PrevNextIcons>
|
||||
<Grid>
|
||||
{generateWeekDayNames().map(name => (
|
||||
<DayName key={name}>{name}</DayName>
|
||||
))}
|
||||
{generateFillerDaysBeforeMonthStart().map(i => (
|
||||
<Day key={`before-${i}`} isFiller />
|
||||
))}
|
||||
{generateMonthDays().map(date => (
|
||||
<Day
|
||||
key={date}
|
||||
isToday={moment().isSame(date, 'day')}
|
||||
isSelected={moment(value).isSame(date, 'day')}
|
||||
onClick={() => handleDayChange(date)}
|
||||
>
|
||||
{formatDate(date, 'D')}
|
||||
</Day>
|
||||
))}
|
||||
{generateFillerDaysAfterMonthEnd().map(i => (
|
||||
<Day key={`after-${i}`} isFiller />
|
||||
))}
|
||||
</Grid>
|
||||
</DateSection>
|
||||
);
|
||||
};
|
||||
|
||||
DatePickerDateSection.propTypes = propTypes;
|
||||
DatePickerDateSection.defaultProps = defaultProps;
|
||||
|
||||
export default DatePickerDateSection;
|
||||
116
client/src/shared/components/DatePicker/Styles.js
Normal file
116
client/src/shared/components/DatePicker/Styles.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
|
||||
|
||||
export const StyledDatePicker = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const Dropdown = styled.div`
|
||||
z-index: ${zIndexValues.dropdown};
|
||||
position: absolute;
|
||||
top: 130%;
|
||||
right: 0;
|
||||
width: 270px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
${mixin.boxShadowBorderMedium}
|
||||
${props => (props.withTime ? withTimeStyles : '')}
|
||||
`;
|
||||
|
||||
const withTimeStyles = `
|
||||
width: 360px;
|
||||
padding-right: 90px;
|
||||
`;
|
||||
|
||||
export const DateSection = styled.div`
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
export const SelectedMonthYear = styled.div`
|
||||
display: inline-block;
|
||||
padding-left: 7px;
|
||||
${font.bold}
|
||||
${font.size(16)}
|
||||
`;
|
||||
|
||||
export const YearSelect = styled.select`
|
||||
margin-left: 5px;
|
||||
width: 60px;
|
||||
height: 22px;
|
||||
${font.size(13)}
|
||||
`;
|
||||
|
||||
export const PrevNextIcons = styled.div`
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 19px;
|
||||
i {
|
||||
padding: 7px 5px 4px;
|
||||
font-size: 22px;
|
||||
color: ${color.textLight};
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
color: ${color.textDarkest};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const Grid = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 15px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const DayName = styled.div`
|
||||
width: 14.28%;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
color: ${color.textLight};
|
||||
${font.size(13)}
|
||||
`;
|
||||
|
||||
export const Day = styled.div`
|
||||
width: 14.28%;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border-radius: 4px;
|
||||
${font.size(15)}
|
||||
${props => (!props.isFiller ? hoverStyles : '')}
|
||||
${props => (props.isToday ? font.bold : '')}
|
||||
${props => (props.isSelected ? selectedStyles : '')}
|
||||
`;
|
||||
|
||||
export const TimeSection = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 90px;
|
||||
padding: 5px 0;
|
||||
border-left: 1px solid ${color.borderLight};
|
||||
${mixin.scrollableY}
|
||||
`;
|
||||
|
||||
export const Time = styled.div`
|
||||
padding: 5px 0 5px 20px;
|
||||
${font.size(14)}
|
||||
${props => (!props.isFiller ? hoverStyles : '')}
|
||||
${props => (props.isSelected ? selectedStyles : '')}
|
||||
`;
|
||||
|
||||
const hoverStyles = `
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
background: ${color.backgroundMedium};
|
||||
}
|
||||
`;
|
||||
|
||||
const selectedStyles = `
|
||||
color: #fff;
|
||||
&:hover, & {
|
||||
background: ${color.primary};
|
||||
}
|
||||
`;
|
||||
76
client/src/shared/components/DatePicker/TimeSection.jsx
Normal file
76
client/src/shared/components/DatePicker/TimeSection.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useLayoutEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { range } from 'lodash';
|
||||
|
||||
import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
|
||||
import { TimeSection, Time } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
setDropdownOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
value: null,
|
||||
};
|
||||
|
||||
const DatePickerTimeSection = ({ value, onChange, setDropdownOpen }) => {
|
||||
const $sectionRef = useRef();
|
||||
const formattedTimeValue = formatDate(value, 'HH:mm');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scrollToSelectedTime = () => {
|
||||
if (!$sectionRef.current) return;
|
||||
|
||||
const $selectedTime = $sectionRef.current.querySelector(
|
||||
`[data-time="${formattedTimeValue}"]`,
|
||||
);
|
||||
if (!$selectedTime) return;
|
||||
|
||||
$sectionRef.current.scrollTop = $selectedTime.offsetTop - 80;
|
||||
};
|
||||
scrollToSelectedTime();
|
||||
}, [formattedTimeValue]);
|
||||
|
||||
const handleTimeChange = newTime => {
|
||||
const [newHour, newMinute] = newTime.split(':');
|
||||
const existingDate = moment(value || undefined);
|
||||
|
||||
const existingDateWithNewTime = existingDate.set({
|
||||
hour: parseInt(newHour),
|
||||
minute: parseInt(newMinute),
|
||||
});
|
||||
onChange(formatDateTimeForAPI(existingDateWithNewTime));
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const generateTimes = () =>
|
||||
range(48).map(i => {
|
||||
const hour = `${Math.floor(i / 2)}`;
|
||||
const paddedHour = hour.length < 2 ? `0${hour}` : hour;
|
||||
const minute = i % 2 === 0 ? '00' : '30';
|
||||
return `${paddedHour}:${minute}`;
|
||||
});
|
||||
|
||||
return (
|
||||
<TimeSection ref={$sectionRef}>
|
||||
{generateTimes().map(time => (
|
||||
<Time
|
||||
key={time}
|
||||
data-time={time}
|
||||
isSelected={time === formattedTimeValue}
|
||||
onClick={() => handleTimeChange(time)}
|
||||
>
|
||||
{time}
|
||||
</Time>
|
||||
))}
|
||||
</TimeSection>
|
||||
);
|
||||
};
|
||||
|
||||
DatePickerTimeSection.propTypes = propTypes;
|
||||
DatePickerTimeSection.defaultProps = defaultProps;
|
||||
|
||||
export default DatePickerTimeSection;
|
||||
65
client/src/shared/components/DatePicker/index.jsx
Normal file
65
client/src/shared/components/DatePicker/index.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { formatDate, formatDateTime } from 'shared/utils/dateTime';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import Input from 'shared/components/Input';
|
||||
import DateSection from './DateSection';
|
||||
import TimeSection from './TimeSection';
|
||||
import { StyledDatePicker, Dropdown } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
withTime: PropTypes.bool,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
withTime: true,
|
||||
value: null,
|
||||
};
|
||||
|
||||
const DatePicker = ({ className, withTime, value, onChange, ...inputProps }) => {
|
||||
const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||
const $containerRef = useRef();
|
||||
|
||||
useOnOutsideClick($containerRef, isDropdownOpen, () => setDropdownOpen(false));
|
||||
|
||||
const formatValueForInput = () => {
|
||||
if (!value) return '';
|
||||
return withTime ? formatDateTime(value) : formatDate(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDatePicker ref={$containerRef}>
|
||||
<Input
|
||||
icon="calendar"
|
||||
{...inputProps}
|
||||
className={className}
|
||||
autoComplete="off"
|
||||
value={formatValueForInput()}
|
||||
onClick={() => setDropdownOpen(true)}
|
||||
/>
|
||||
{isDropdownOpen && (
|
||||
<Dropdown withTime={withTime}>
|
||||
<DateSection
|
||||
withTime={withTime}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
setDropdownOpen={setDropdownOpen}
|
||||
/>
|
||||
{withTime && (
|
||||
<TimeSection value={value} onChange={onChange} setDropdownOpen={setDropdownOpen} />
|
||||
)}
|
||||
</Dropdown>
|
||||
)}
|
||||
</StyledDatePicker>
|
||||
);
|
||||
};
|
||||
|
||||
DatePicker.propTypes = propTypes;
|
||||
DatePicker.defaultProps = defaultProps;
|
||||
|
||||
export default DatePicker;
|
||||
20
client/src/shared/components/Icon/Styles.js
Normal file
20
client/src/shared/components/Icon/Styles.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export default styled.i`
|
||||
display: inline-block;
|
||||
font-size: ${props => `${props.size}px`};
|
||||
${props =>
|
||||
props.left || props.top ? `transform: translate(${props.left}px, ${props.top}px);` : ''}
|
||||
&:before {
|
||||
content: "${props => props.code}";
|
||||
font-family: "jira" !important;
|
||||
speak: none;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
`;
|
||||
65
client/src/shared/components/Icon/index.jsx
Normal file
65
client/src/shared/components/Icon/index.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import StyledIcon from './Styles';
|
||||
|
||||
const codes = {
|
||||
[`check-circle`]: '\\e86c',
|
||||
[`check-fat`]: '\\f00c',
|
||||
[`arrow-left`]: '\\e900',
|
||||
[`arrow-right`]: '\\e912',
|
||||
[`upload-thin`]: '\\e91f',
|
||||
[`bell`]: '\\e901',
|
||||
[`calendar`]: '\\e903',
|
||||
[`check`]: '\\e904',
|
||||
[`chevron-down`]: '\\e905',
|
||||
[`chevron-left`]: '\\e906',
|
||||
[`chevron-right`]: '\\e907',
|
||||
[`chevron-up`]: '\\e908',
|
||||
[`clock`]: '\\e909',
|
||||
[`download`]: '\\e90a',
|
||||
[`plus`]: '\\e90c',
|
||||
[`refresh`]: '\\e90d',
|
||||
[`search`]: '\\e90e',
|
||||
[`upload`]: '\\e90f',
|
||||
[`close`]: '\\e910',
|
||||
[`archive`]: '\\e915',
|
||||
[`briefcase`]: '\\e916',
|
||||
[`settings`]: '\\e902',
|
||||
[`email`]: '\\e914',
|
||||
[`lock`]: '\\e913',
|
||||
[`dashboard`]: '\\e917',
|
||||
[`alert`]: '\\e911',
|
||||
[`edit`]: '\\e918',
|
||||
[`delete`]: '\\e919',
|
||||
[`sort`]: '\\f0dc',
|
||||
[`sort-up`]: '\\f0d8',
|
||||
[`sort-down`]: '\\f0d7',
|
||||
[`euro`]: '\\f153',
|
||||
[`folder-plus`]: '\\e921',
|
||||
[`folder-minus`]: '\\e920',
|
||||
[`file`]: '\\e90b',
|
||||
[`file-text`]: '\\e924',
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
type: PropTypes.oneOf(Object.keys(codes)).isRequired,
|
||||
className: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
left: PropTypes.number,
|
||||
top: PropTypes.number,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
size: 16,
|
||||
left: 0,
|
||||
top: 0,
|
||||
};
|
||||
|
||||
const Icon = ({ type, ...iconProps }) => <StyledIcon {...iconProps} code={codes[type]} />;
|
||||
|
||||
Icon.propTypes = propTypes;
|
||||
Icon.defaultProps = defaultProps;
|
||||
|
||||
export default Icon;
|
||||
34
client/src/shared/components/Input/Styles.js
Normal file
34
client/src/shared/components/Input/Styles.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export default styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
input {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.borderLight};
|
||||
box-shadow: inset 0 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
background: #fff;
|
||||
${font.regular}
|
||||
${font.size(14)}
|
||||
&:focus {
|
||||
border: 1px solid ${color.borderMedium};
|
||||
}
|
||||
${props => (props.icon ? 'padding-left: 40px;' : '')}
|
||||
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
|
||||
}
|
||||
i {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 14px;
|
||||
font-size: 16px;
|
||||
pointer-events: none;
|
||||
color: ${color.textMedium};
|
||||
}
|
||||
`;
|
||||
42
client/src/shared/components/Input/index.jsx
Normal file
42
client/src/shared/components/Input/index.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Icon from 'shared/components/Icon';
|
||||
import StyledInput from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
icon: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
invalid: PropTypes.bool,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
filter: PropTypes.instanceOf(RegExp),
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
icon: undefined,
|
||||
className: undefined,
|
||||
invalid: false,
|
||||
value: undefined,
|
||||
filter: undefined,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
const Input = forwardRef(({ icon, className, invalid, filter, onChange, ...inputProps }, ref) => {
|
||||
const handleChange = event => {
|
||||
if (!filter || filter.test(event.target.value)) {
|
||||
onChange(event, event.target.value);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<StyledInput className={className} icon={icon} invalid={invalid}>
|
||||
{icon && <Icon type={icon} />}
|
||||
<input {...inputProps} onChange={handleChange} ref={ref} />
|
||||
</StyledInput>
|
||||
);
|
||||
});
|
||||
|
||||
Input.propTypes = propTypes;
|
||||
Input.defaultProps = defaultProps;
|
||||
|
||||
export default Input;
|
||||
62
client/src/shared/components/Logo.jsx
Normal file
62
client/src/shared/components/Logo.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
width: 28,
|
||||
};
|
||||
|
||||
const Logo = ({ className, width }) => (
|
||||
<span className={className}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.76 75.76" width={width}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="linear-gradient"
|
||||
x1="34.64"
|
||||
y1="15.35"
|
||||
x2="19"
|
||||
y2="30.99"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.18" stopColor="rgba(0, 82, 204, 0.2)" />
|
||||
<stop offset="1" stopColor="#DEEBFE" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linear-gradient-2"
|
||||
x1="38.78"
|
||||
y1="60.28"
|
||||
x2="54.39"
|
||||
y2="44.67"
|
||||
xlinkHref="#linear-gradient"
|
||||
/>
|
||||
</defs>
|
||||
<title>Jira Software-blue</title>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Blue">
|
||||
<path
|
||||
style={{ fill: '#DEEBFE' }}
|
||||
d="M72.4,35.76,39.8,3.16,36.64,0h0L12.1,24.54h0L.88,35.76A3,3,0,0,0,.88,40L23.3,62.42,36.64,75.76,61.18,51.22l.38-.38L72.4,40A3,3,0,0,0,72.4,35.76ZM36.64,49.08l-11.2-11.2,11.2-11.2,11.2,11.2Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: 'url(#linear-gradient)' }}
|
||||
d="M36.64,26.68A18.86,18.86,0,0,1,36.56.09L12.05,24.59,25.39,37.93,36.64,26.68Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: 'url(#linear-gradient-2)' }}
|
||||
d="M47.87,37.85,36.64,49.08a18.86,18.86,0,0,1,0,26.68h0L61.21,51.19Z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
Logo.propTypes = propTypes;
|
||||
Logo.defaultProps = defaultProps;
|
||||
|
||||
export default Logo;
|
||||
82
client/src/shared/components/Modal/Styles.js
Normal file
82
client/src/shared/components/Modal/Styles.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import Icon from 'shared/components/Icon';
|
||||
import { color, mixin, zIndexValues } from 'shared/utils/styles';
|
||||
|
||||
export const ScrollOverlay = styled.div`
|
||||
z-index: ${zIndexValues.modal};
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
${mixin.scrollableY}
|
||||
`;
|
||||
|
||||
export const ClickableOverlay = styled.div`
|
||||
min-height: 100%;
|
||||
background: ${mixin.rgba(color.textLightBlue, 0.7)};
|
||||
${props => clickOverlayStyles[props.variant]}
|
||||
`;
|
||||
|
||||
const clickOverlayStyles = {
|
||||
center: css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 50px;
|
||||
`,
|
||||
aside: css`
|
||||
text-align: right;
|
||||
`,
|
||||
};
|
||||
|
||||
export const StyledModal = styled.div`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
${props => modalStyles[props.variant]}
|
||||
`;
|
||||
|
||||
const modalStyles = {
|
||||
center: css`
|
||||
max-width: 600px;
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
${mixin.boxShadowMedium}
|
||||
`,
|
||||
aside: css`
|
||||
min-height: 100vh;
|
||||
max-width: 500px;
|
||||
text-align: left;
|
||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15);
|
||||
`,
|
||||
};
|
||||
|
||||
export const CloseIcon = styled(Icon)`
|
||||
position: absolute;
|
||||
font-size: 25px;
|
||||
color: ${color.textDark};
|
||||
${mixin.clickable}
|
||||
${props => closeIconStyles[props.variant]}
|
||||
`;
|
||||
|
||||
const closeIconStyles = {
|
||||
center: css`
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
padding: 7px 7px 0;
|
||||
`,
|
||||
aside: css`
|
||||
top: 10px;
|
||||
left: -50px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding-top: 8px;
|
||||
border-radius: 40px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
opacity: 0.5;
|
||||
`,
|
||||
};
|
||||
94
client/src/shared/components/Modal/index.jsx
Normal file
94
client/src/shared/components/Modal/index.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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';
|
||||
import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
variant: PropTypes.oneOf(['center', 'aside']),
|
||||
isOpen: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
renderLink: PropTypes.func,
|
||||
renderContent: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
variant: 'center',
|
||||
isOpen: undefined,
|
||||
onClose: () => {},
|
||||
renderLink: () => {},
|
||||
};
|
||||
|
||||
const Modal = ({
|
||||
className,
|
||||
variant,
|
||||
isOpen: propsIsOpen,
|
||||
onClose: tellParentToClose,
|
||||
renderLink,
|
||||
renderContent,
|
||||
}) => {
|
||||
const [stateIsOpen, setStateOpen] = useState(false);
|
||||
const isControlled = typeof propsIsOpen === 'boolean';
|
||||
const isOpen = isControlled ? propsIsOpen : stateIsOpen;
|
||||
|
||||
const $modalRef = useRef();
|
||||
const modalIdRef = useRef(uniqueIncreasingIntegerId());
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
if (shouldNotCloseBecauseHasOpenChildModal(modalIdRef.current)) {
|
||||
return;
|
||||
}
|
||||
if (!isControlled) {
|
||||
setStateOpen(false);
|
||||
} else {
|
||||
tellParentToClose();
|
||||
}
|
||||
}, [isControlled, tellParentToClose]);
|
||||
|
||||
useOnOutsideClick($modalRef, isOpen, closeModal);
|
||||
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} />
|
||||
{renderContent({ close: closeModal })}
|
||||
</StyledModal>
|
||||
</ClickableOverlay>
|
||||
</ScrollOverlay>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isControlled && renderLink({ open: () => setStateOpen(true) })}
|
||||
{isOpen && ReactDOM.createPortal(renderModal(), $root)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 shouldNotCloseBecauseHasOpenChildModal = modalId =>
|
||||
getIdsOfAllOpenModals().some(id => id > modalId);
|
||||
|
||||
const setBodyScrollLock = () => {
|
||||
const areAnyModalsOpen = getIdsOfAllOpenModals().length > 0;
|
||||
document.body.style.overflow = areAnyModalsOpen ? 'hidden' : 'visible';
|
||||
};
|
||||
|
||||
Modal.propTypes = propTypes;
|
||||
Modal.defaultProps = defaultProps;
|
||||
|
||||
export default Modal;
|
||||
7
client/src/shared/components/PageLoader/Styles.js
Normal file
7
client/src/shared/components/PageLoader/Styles.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export default styled.div`
|
||||
width: 100%;
|
||||
padding: 100px;
|
||||
text-align: center;
|
||||
`;
|
||||
12
client/src/shared/components/PageLoader/index.jsx
Normal file
12
client/src/shared/components/PageLoader/index.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import StyledPageLoader from './Styles';
|
||||
|
||||
const PageLoader = () => (
|
||||
<StyledPageLoader>
|
||||
<Spinner size={70} />
|
||||
</StyledPageLoader>
|
||||
);
|
||||
|
||||
export default PageLoader;
|
||||
204
client/src/shared/components/Select/Dropdown.jsx
Normal file
204
client/src/shared/components/Select/Dropdown.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
import { KeyCodes } from 'shared/constants/keyCodes';
|
||||
import { ClearIcon, Dropdown, DropdownInput, Options, Option, OptionsNoResults } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
value: PropTypes.any,
|
||||
isValueEmpty: PropTypes.bool.isRequired,
|
||||
searchValue: PropTypes.string.isRequired,
|
||||
setSearchValue: PropTypes.func.isRequired,
|
||||
$inputRef: PropTypes.object.isRequired,
|
||||
deactivateDropdown: PropTypes.func.isRequired,
|
||||
options: PropTypes.array.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onCreate: PropTypes.func,
|
||||
isMulti: PropTypes.bool,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
value: undefined,
|
||||
onCreate: undefined,
|
||||
isMulti: false,
|
||||
};
|
||||
|
||||
const SelectDropdown = ({
|
||||
value,
|
||||
isValueEmpty,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
$inputRef,
|
||||
deactivateDropdown,
|
||||
options,
|
||||
onChange,
|
||||
onCreate,
|
||||
isMulti,
|
||||
}) => {
|
||||
const [isCreatingOption, setCreatingOption] = useState(false);
|
||||
|
||||
const $optionsRef = useRef();
|
||||
|
||||
const selectOptionValue = optionValue => {
|
||||
deactivateDropdown();
|
||||
if (isMulti) {
|
||||
onChange(uniq([...value, optionValue]));
|
||||
} else {
|
||||
onChange(optionValue);
|
||||
}
|
||||
};
|
||||
|
||||
const createOption = newOptionLabel => {
|
||||
setCreatingOption(true);
|
||||
onCreate(newOptionLabel, createdOptionValue => {
|
||||
setCreatingOption(false);
|
||||
selectOptionValue(createdOptionValue);
|
||||
});
|
||||
};
|
||||
|
||||
const clearOptionValues = () => {
|
||||
$inputRef.current.value = '';
|
||||
$inputRef.current.focus();
|
||||
onChange(isMulti ? [] : null);
|
||||
};
|
||||
|
||||
const handleInputKeyDown = event => {
|
||||
if (event.keyCode === KeyCodes.escape) {
|
||||
handleInputEscapeKeyDown(event);
|
||||
} else if (event.keyCode === KeyCodes.enter) {
|
||||
handleInputEnterKeyDown(event);
|
||||
} else if (event.keyCode === KeyCodes.arrowDown || event.keyCode === KeyCodes.arrowUp) {
|
||||
handleInputArrowUpOrDownKeyDown(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputEscapeKeyDown = event => {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
deactivateDropdown();
|
||||
};
|
||||
|
||||
const handleInputEnterKeyDown = event => {
|
||||
event.preventDefault();
|
||||
const $active = getActiveOptionNode();
|
||||
if (!$active) return;
|
||||
|
||||
const optionValueToSelect = $active.getAttribute('data-select-option-value');
|
||||
const optionLabelToCreate = $active.getAttribute('data-create-option-label');
|
||||
|
||||
if (optionValueToSelect) {
|
||||
selectOptionValue(optionValueToSelect);
|
||||
} else if (optionLabelToCreate) {
|
||||
createOption(optionLabelToCreate);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputArrowUpOrDownKeyDown = event => {
|
||||
const $active = getActiveOptionNode();
|
||||
if (!$active) return;
|
||||
|
||||
const $options = $optionsRef.current;
|
||||
const $optionsHeight = $options.getBoundingClientRect().height;
|
||||
const $activeHeight = $active.getBoundingClientRect().height;
|
||||
|
||||
if (event.keyCode === KeyCodes.arrowDown) {
|
||||
if ($options.lastElementChild === $active) {
|
||||
$active.classList.remove(activeOptionClass);
|
||||
$options.firstElementChild.classList.add(activeOptionClass);
|
||||
$options.scrollTop = 0;
|
||||
} else {
|
||||
$active.classList.remove(activeOptionClass);
|
||||
$active.nextElementSibling.classList.add(activeOptionClass);
|
||||
if ($active.offsetTop > $options.scrollTop + $optionsHeight / 1.4) {
|
||||
$options.scrollTop += $activeHeight;
|
||||
}
|
||||
}
|
||||
} else if (event.keyCode === KeyCodes.arrowUp) {
|
||||
if ($options.firstElementChild === $active) {
|
||||
$active.classList.remove(activeOptionClass);
|
||||
$options.lastElementChild.classList.add(activeOptionClass);
|
||||
$options.scrollTop = $options.scrollHeight;
|
||||
} else {
|
||||
$active.classList.remove(activeOptionClass);
|
||||
$active.previousElementSibling.classList.add(activeOptionClass);
|
||||
if ($active.offsetTop < $options.scrollTop + $optionsHeight / 2.4) {
|
||||
$options.scrollTop -= $activeHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionMouseEnter = event => {
|
||||
const $active = getActiveOptionNode();
|
||||
if ($active) $active.classList.remove(activeOptionClass);
|
||||
event.target.classList.add(activeOptionClass);
|
||||
};
|
||||
|
||||
const getActiveOptionNode = () => $optionsRef.current.querySelector(`.${activeOptionClass}`);
|
||||
|
||||
const optionsFilteredBySearchValue = options.filter(option =>
|
||||
option.label
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes(searchValue.toLowerCase()),
|
||||
);
|
||||
|
||||
const removeSelectedOptions = opts => opts.filter(option => !value.includes(option.value));
|
||||
|
||||
const filteredOptions = isMulti
|
||||
? removeSelectedOptions(optionsFilteredBySearchValue)
|
||||
: 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 renderCreatableOption = () => (
|
||||
<Option
|
||||
className={filteredOptions.length === 0 ? activeOptionClass : undefined}
|
||||
data-create-option-label={searchValue}
|
||||
onMouseEnter={handleOptionMouseEnter}
|
||||
onClick={() => createOption(searchValue)}
|
||||
>
|
||||
{isCreatingOption ? `Creating "${searchValue}"...` : `Create "${searchValue}"`}
|
||||
</Option>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownInput
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
ref={$inputRef}
|
||||
autoFocus
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onChange={event => setSearchValue(event.target.value)}
|
||||
/>
|
||||
{!isValueEmpty && <ClearIcon type="close" onClick={clearOptionValues} />}
|
||||
<Options ref={$optionsRef}>
|
||||
{filteredOptions.map(renderSelectableOption)}
|
||||
{isOptionCreatable && renderCreatableOption()}
|
||||
{filteredOptions.length === 0 && <OptionsNoResults>No results</OptionsNoResults>}
|
||||
</Options>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const activeOptionClass = 'jira-select-option-is-active';
|
||||
|
||||
SelectDropdown.propTypes = propTypes;
|
||||
SelectDropdown.defaultProps = defaultProps;
|
||||
|
||||
export default SelectDropdown;
|
||||
134
client/src/shared/components/Select/Styles.js
Normal file
134
client/src/shared/components/Select/Styles.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
|
||||
import Icon from 'shared/components/Icon';
|
||||
|
||||
export const StyledSelect = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.borderLight};
|
||||
background: #fff;
|
||||
${font.size(14)}
|
||||
&:focus {
|
||||
outline: none;
|
||||
border: 1px solid ${color.borderMedium};
|
||||
}
|
||||
${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};
|
||||
`;
|
||||
|
||||
export const ValueContainer = styled.div`
|
||||
min-height: 38px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const ChevronIcon = styled(Icon)`
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
font-size: 18px;
|
||||
color: ${color.textMedium};
|
||||
`;
|
||||
|
||||
export const Placeholder = styled.div`
|
||||
padding: 11px 0 0 15px;
|
||||
color: ${color.textLightBlue};
|
||||
`;
|
||||
|
||||
export const ValueSingle = styled.div`
|
||||
padding: 11px 0 0 15px;
|
||||
`;
|
||||
|
||||
export const ValueMulti = styled.div`
|
||||
padding: 15px 5px 10px 10px;
|
||||
`;
|
||||
|
||||
export const ValueMultiItem = styled.div`
|
||||
margin: 0 5px 5px 0;
|
||||
${mixin.tag}
|
||||
`;
|
||||
|
||||
export const AddMore = styled.div`
|
||||
display: inline-block;
|
||||
height: 24px;
|
||||
line-height: 22px;
|
||||
padding-right: 5px;
|
||||
${font.size(12)}
|
||||
${mixin.link()}
|
||||
i {
|
||||
margin-right: 3px;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Dropdown = styled.div`
|
||||
z-index: ${zIndexValues.dropdown};
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
${mixin.boxShadowBorderMedium}
|
||||
`;
|
||||
|
||||
export const DropdownInput = styled.input`
|
||||
padding: 10px 15px 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
color: ${color.textDarkest};
|
||||
background: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ClearIcon = styled(Icon)`
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 7px;
|
||||
padding: 5px;
|
||||
font-size: 16px;
|
||||
color: ${color.textMedium};
|
||||
${mixin.clickable}
|
||||
`;
|
||||
|
||||
export const Options = styled.div`
|
||||
max-height: 200px;
|
||||
${mixin.scrollableY};
|
||||
${mixin.customScrollbar()};
|
||||
`;
|
||||
|
||||
export const Option = styled.div`
|
||||
padding: 5px 15px;
|
||||
word-break: break-word;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
&.jira-select-option-is-active {
|
||||
background: ${mixin.lighten(color.backgroundMedium, 0.05)};
|
||||
}
|
||||
${props => (props.isSelected ? selectedOptionStyles : '')}
|
||||
`;
|
||||
|
||||
const selectedOptionStyles = css`
|
||||
color: #fff !important;
|
||||
background: ${color.primary} !important;
|
||||
`;
|
||||
|
||||
export const OptionsNoResults = styled.div`
|
||||
padding: 5px 15px 15px;
|
||||
color: ${color.textLight};
|
||||
`;
|
||||
164
client/src/shared/components/Select/index.jsx
Normal file
164
client/src/shared/components/Select/index.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { KeyCodes } from 'shared/constants/keyCodes';
|
||||
import Icon from 'shared/components/Icon';
|
||||
import Dropdown from './Dropdown';
|
||||
import {
|
||||
StyledSelect,
|
||||
StyledIcon,
|
||||
ValueContainer,
|
||||
ChevronIcon,
|
||||
Placeholder,
|
||||
ValueSingle,
|
||||
ValueMulti,
|
||||
ValueMultiItem,
|
||||
AddMore,
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
value: PropTypes.any,
|
||||
defaultValue: PropTypes.any,
|
||||
placeholder: PropTypes.string,
|
||||
invalid: PropTypes.bool,
|
||||
options: PropTypes.array.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onCreate: PropTypes.func,
|
||||
isMulti: PropTypes.bool,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
icon: undefined,
|
||||
value: undefined,
|
||||
defaultValue: undefined,
|
||||
placeholder: '',
|
||||
invalid: false,
|
||||
onCreate: undefined,
|
||||
isMulti: false,
|
||||
};
|
||||
|
||||
const Select = ({
|
||||
className,
|
||||
icon,
|
||||
value: propsValue,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
invalid,
|
||||
options,
|
||||
onChange,
|
||||
onCreate,
|
||||
isMulti,
|
||||
}) => {
|
||||
const [stateValue, setStateValue] = useState(defaultValue || (isMulti ? [] : null));
|
||||
const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const isControlled = propsValue !== undefined;
|
||||
const value = isControlled ? propsValue : stateValue;
|
||||
|
||||
const $selectRef = useRef();
|
||||
const $inputRef = useRef();
|
||||
|
||||
const activateDropdown = () => {
|
||||
if (isDropdownOpen) {
|
||||
$inputRef.current.focus();
|
||||
} else {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const deactivateDropdown = () => {
|
||||
setDropdownOpen(false);
|
||||
setSearchValue('');
|
||||
$selectRef.current.focus();
|
||||
};
|
||||
|
||||
useOnOutsideClick($selectRef, isDropdownOpen, deactivateDropdown);
|
||||
|
||||
const handleChange = newValue => {
|
||||
if (!isControlled) {
|
||||
setStateValue(newValue);
|
||||
}
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const removeOptionValue = optionValue => {
|
||||
handleChange(value.filter(val => val !== optionValue));
|
||||
};
|
||||
|
||||
const handleFocusedSelectKeydown = event => {
|
||||
if (isDropdownOpen) return;
|
||||
|
||||
if (event.keyCode === KeyCodes.enter) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (event.keyCode !== KeyCodes.escape && event.keyCode !== KeyCodes.tab && !event.shiftKey) {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const getOption = optionValue => options.find(option => option.value === optionValue);
|
||||
const getOptionLabel = optionValue => (getOption(optionValue) || { label: '' }).label;
|
||||
|
||||
const isValueEmpty = isMulti ? !value.length : !getOption(value);
|
||||
|
||||
const renderSingleValue = () => <ValueSingle>{getOptionLabel(value)}</ValueSingle>;
|
||||
|
||||
const renderMultiValue = () => (
|
||||
<ValueMulti>
|
||||
{value.map(optionValue => (
|
||||
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
|
||||
{getOptionLabel(optionValue)}
|
||||
<Icon type="close" />
|
||||
</ValueMultiItem>
|
||||
))}
|
||||
<AddMore>
|
||||
<Icon type="plus" />
|
||||
Add more
|
||||
</AddMore>
|
||||
</ValueMulti>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSelect
|
||||
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()}
|
||||
</ValueContainer>
|
||||
{isDropdownOpen && (
|
||||
<Dropdown
|
||||
value={value}
|
||||
isValueEmpty={isValueEmpty}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
$selectRef={$selectRef}
|
||||
$inputRef={$inputRef}
|
||||
deactivateDropdown={deactivateDropdown}
|
||||
options={options}
|
||||
onChange={handleChange}
|
||||
onCreate={onCreate}
|
||||
isMulti={isMulti}
|
||||
/>
|
||||
)}
|
||||
</StyledSelect>
|
||||
);
|
||||
};
|
||||
|
||||
Select.propTypes = propTypes;
|
||||
Select.defaultProps = defaultProps;
|
||||
|
||||
export default Select;
|
||||
219
client/src/shared/components/Spinner.jsx
Normal file
219
client/src/shared/components/Spinner.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { color as colors } from 'shared/utils/styles';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
color: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
size: 32,
|
||||
color: colors.textMedium,
|
||||
};
|
||||
|
||||
const Spinner = ({ className, size, color }) => (
|
||||
<span className={className}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
style={{ background: '0 0' }}
|
||||
>
|
||||
<g>
|
||||
<g transform="translate(80 50)">
|
||||
<circle r={8} fill={color}>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
begin="-0.875s"
|
||||
values="1 1;1 1"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
values="1;0"
|
||||
begin="-0.875s"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="rotate(45 -50.355 121.569)">
|
||||
<circle r={8} fill={color} fillOpacity=".875">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
begin="-0.75s"
|
||||
values="1 1;1 1"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
values="1;0"
|
||||
begin="-0.75s"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="rotate(90 -15 65)">
|
||||
<circle r={8} fill={color} fillOpacity=".75">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
begin="-0.625s"
|
||||
values="1 1;1 1"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
values="1;0"
|
||||
begin="-0.625s"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="rotate(135 -.355 41.569)">
|
||||
<circle r={8} fill={color} fillOpacity=".625">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
begin="-0.5s"
|
||||
values="1 1;1 1"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
values="1;0"
|
||||
begin="-0.5s"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="rotate(180 10 25)">
|
||||
<circle r={8} fill={color} fillOpacity=".5">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
begin="-0.375s"
|
||||
values="1 1;1 1"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
values="1;0"
|
||||
begin="-0.375s"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="rotate(-135 20.355 8.431)">
|
||||
<circle r={8} fill={color} fillOpacity=".375">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
begin="-0.25s"
|
||||
values="1 1;1 1"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
values="1;0"
|
||||
begin="-0.25s"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="rotate(-90 35 -15)">
|
||||
<circle r={8} fill={color} fillOpacity=".25">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
begin="-0.125s"
|
||||
values="1 1;1 1"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
values="1;0"
|
||||
begin="-0.125s"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g transform="rotate(-45 70.355 -71.569)">
|
||||
<circle r={8} fill={color} fillOpacity=".125">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
begin="0s"
|
||||
values="1 1;1 1"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="fill-opacity"
|
||||
keyTimes="0;1"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
values="1;0"
|
||||
begin="0s"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
Spinner.propTypes = propTypes;
|
||||
Spinner.defaultProps = defaultProps;
|
||||
|
||||
export default Spinner;
|
||||
23
client/src/shared/components/Textarea/Styles.js
Normal file
23
client/src/shared/components/Textarea/Styles.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export default styled.div`
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 13px 15px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.borderLight};
|
||||
box-shadow: inset 0 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
background: #fff;
|
||||
overflow-y: hidden;
|
||||
${font.regular}
|
||||
${font.size(14)}
|
||||
&:focus {
|
||||
border: 1px solid ${color.borderMedium};
|
||||
}
|
||||
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
|
||||
}
|
||||
`;
|
||||
36
client/src/shared/components/Textarea/index.jsx
Normal file
36
client/src/shared/components/Textarea/index.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TextareaAutoSize from 'react-textarea-autosize';
|
||||
|
||||
import StyledTextarea from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
invalid: PropTypes.bool,
|
||||
minRows: PropTypes.number,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
invalid: false,
|
||||
minRows: 2,
|
||||
value: undefined,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps }, ref) => (
|
||||
<StyledTextarea className={className} invalid={invalid}>
|
||||
<TextareaAutoSize
|
||||
{...textareaProps}
|
||||
onChange={event => onChange(event, event.target.value)}
|
||||
ref={ref}
|
||||
/>
|
||||
</StyledTextarea>
|
||||
));
|
||||
|
||||
Textarea.propTypes = propTypes;
|
||||
Textarea.defaultProps = defaultProps;
|
||||
|
||||
export default Textarea;
|
||||
12
client/src/shared/components/index.js
Normal file
12
client/src/shared/components/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Button } from './Button';
|
||||
export { default as ConfirmModal } from './ConfirmModal';
|
||||
export { default as DatePicker } from './DatePicker';
|
||||
export { default as Icon } from './Icon';
|
||||
export { default as Input } from './Input';
|
||||
export { default as Logo } from './Logo';
|
||||
export { default as Modal } from './Modal';
|
||||
export { default as PageLoader } from './PageLoader';
|
||||
export { default as Select } from './Select';
|
||||
export { default as Spinner } from './Spinner';
|
||||
export { default as Textarea } from './Textarea';
|
||||
Reference in New Issue
Block a user