diff --git a/.eslintrc.js b/.eslintrc.js index dda1fed..6162820 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -37,7 +37,7 @@ module.exports = { 'explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', 'jsx-a11y/no-autofocus': 'off', - 'no-console': 2, + 'no-console': 1, }, settings: { react: { diff --git a/src/components/AddEntryButton/AddEntryButton.tsx b/src/components/AddEntryButton/AddEntryButton.tsx index d877cc3..be5259b 100644 --- a/src/components/AddEntryButton/AddEntryButton.tsx +++ b/src/components/AddEntryButton/AddEntryButton.tsx @@ -14,7 +14,7 @@ const AddEntryButton = ({ onClick, errors }: AddEntryButtonProps) => { onClick()} - disabled={disabled} + isDisabled={disabled} disabledTooltip={disabledMessage} > Add diff --git a/src/components/EntryList/EntryList.tsx b/src/components/EntryList/EntryList.tsx index 957edab..2f063a0 100644 --- a/src/components/EntryList/EntryList.tsx +++ b/src/components/EntryList/EntryList.tsx @@ -1,11 +1,13 @@ import { palette, SPACING_SMALL } from 'config' -import { ChangeEvent } from 'react' +import { ChangeEvent, useState } from 'react' import { BsArrowLeftCircle, BsArrowRightCircle, BsExclamationOctagon, } from 'react-icons/bs' +import { TimeEntry } from 'services' import { Button } from 'shared/components' +import { EditEntryContext } from 'shared/utils' import { ControlsWrapper, DateField, @@ -20,6 +22,7 @@ import TimeSumup from './TimeSumup' type ChangeEv = ChangeEvent const EntryList = () => { + const [editing, setEditing] = useState(null) const { entriesFromDay, labels, @@ -32,45 +35,47 @@ const EntryList = () => { const onDateChange = (e: ChangeEv) => setTargetDate(e.target.value) return ( - - - Selected date: - - setDate(-1)} - color="primary" - variant="outlined" - margin={SPACING_SMALL} - > - - - - setDate(1)} - color="primary" - variant="outlined" - margin={SPACING_SMALL} - > - - - - - - - {entriesFromDay.map(item => ( - - ))} - {entriesFromDay.length === 0 && ( - - No time entries - - - )} - - + + + + Selected date: + + setDate(-1)} + color="primary" + variant="outlined" + margin={SPACING_SMALL} + > + + + + setDate(1)} + color="primary" + variant="outlined" + margin={SPACING_SMALL} + > + + + + + + + {entriesFromDay.map(item => ( + + ))} + {entriesFromDay.length === 0 && ( + + No time entries + + + )} + + + ) } diff --git a/src/components/EntryList/ListItem/ListItem.tsx b/src/components/EntryList/ListItem/ListItem.tsx index d3c87d2..674c277 100644 --- a/src/components/EntryList/ListItem/ListItem.tsx +++ b/src/components/EntryList/ListItem/ListItem.tsx @@ -1,15 +1,16 @@ -import { SPACING_SMALL } from 'config' -import { useContext } from 'react' -import { BsFillPencilFill, BsFillTrashFill } from 'react-icons/bs' -import { DB, TimeEntry } from 'services' -import { Button, TextArea } from 'shared/components' +import { DISABLED_ENTRY_LIST_ITEMS_TEXT, SPACING_SMALL } from 'config' +import { BsCheckLg, BsFillPencilFill, BsFillTrashFill } from 'react-icons/bs' +import { TimeEntry } from 'services' +import { Button, TextArea, Tooltip } from 'shared/components' import { Label } from 'shared/types' -import { EntryListContext } from 'shared/utils' import { EntryTimeField, Labels } from '../../index' import { ActionsWrapper, Item } from './ListItem.style' -import { calculateTimeEntry, getSelectedLabels } from './ListItem.utils' - -const db = new DB() +import { + calculateTimeEntry, + getSelectedLabels, + useDeleteEntry, + useEdit, +} from './ListItem.utils' interface ListItemProps extends TimeEntry { labels: Label[] @@ -23,25 +24,34 @@ const ListItem = ({ labels, id, }: ListItemProps) => { - const { setUpdateEntryList } = useContext(EntryListContext) || {} - const deleteEntry = () => { - db.deleteTimeEntry(id) - setUpdateEntryList?.(true) - } + const deleteEntry = useDeleteEntry(id) + const { disabled, toggleEditing } = useEdit(id) return ( - - - + + + + + + + + + Scaled time: {calculateTimeEntry(entryTimeHours, entryTimeMinutes)} @@ -54,8 +64,12 @@ const ListItem = ({ > - - + + {disabled ? : } diff --git a/src/components/EntryList/ListItem/ListItem.utils.ts b/src/components/EntryList/ListItem/ListItem.utils.ts index 32b9a59..4aacb0f 100644 --- a/src/components/EntryList/ListItem/ListItem.utils.ts +++ b/src/components/EntryList/ListItem/ListItem.utils.ts @@ -1,5 +1,10 @@ import { TIME_MULTIPLY_RATIO } from 'config' -import { Label } from 'shared/types' +import { useContext } from 'react' +import { DB } from 'services' +import { ID, Label } from 'shared/types' +import { EditEntryContext, EntryListContext } from 'shared/utils' + +const db = new DB() export const calculateTimeEntry = (hours: number, minutes: number) => { const scaledTime = Math.round(minutes / TIME_MULTIPLY_RATIO) @@ -19,3 +24,23 @@ export const getSelectedLabels = ( return filteredLabels } + +export const useEdit = (id: ID) => { + const { setEditing, editing } = useContext(EditEntryContext) || {} + const toggleEditing = () => { + setEditing?.(id) + if (editing === id) setEditing?.(null) + } + const disabled = id !== editing + + return { toggleEditing, disabled } +} + +export const useDeleteEntry = (id: ID) => { + const { setUpdateEntryList } = useContext(EntryListContext) || {} + + return () => { + db.deleteTimeEntry(id) + setUpdateEntryList?.(true) + } +} diff --git a/src/components/EntryTimeField/EntryTimeField.style.ts b/src/components/EntryTimeField/EntryTimeField.style.ts index 97b7d7f..63516d4 100644 --- a/src/components/EntryTimeField/EntryTimeField.style.ts +++ b/src/components/EntryTimeField/EntryTimeField.style.ts @@ -1,8 +1,17 @@ +import { + palette, + SPACING_REGULAR, + TIME_FIELD_MARGIN, + TIME_FIELD_WIDTH, +} from 'config' +import { darken } from 'polished' +import { + Input as BaseInput, + InputProps as BaseInputProps, +} from 'shared/components' import styled from 'styled-components' -import { SPACING_REGULAR, TIME_FIELD_MARGIN, TIME_FIELD_WIDTH } from 'config' -import { Input } from 'shared/components' -export const TimeField = styled(Input)({ +export const TimeField = styled(BaseInput)({ width: TIME_FIELD_WIDTH, margin: `0 ${TIME_FIELD_MARGIN}px`, }) @@ -12,3 +21,16 @@ export const Form = styled('form')({ justifyContent: 'space-between', gap: SPACING_REGULAR, }) + +interface InputProps extends BaseInputProps { + disabled?: boolean +} +export const Input = styled(BaseInput)(({ disabled }) => ({ + ...(disabled && { + cursor: 'not-allowed', + '&:hover': { + backgroundColor: palette.disabled, + borderColor: darken(0.2, palette.disabled), + }, + }), +})) diff --git a/src/components/EntryTimeField/EntryTimeField.tsx b/src/components/EntryTimeField/EntryTimeField.tsx index 46940f9..0ea44dc 100644 --- a/src/components/EntryTimeField/EntryTimeField.tsx +++ b/src/components/EntryTimeField/EntryTimeField.tsx @@ -1,14 +1,14 @@ -import { Input } from 'shared/components' import { setTimeFunc } from 'shared/types' import { HOURS_LIMIT, MINUTES_LIMIT } from '../../config' import { useEntryTimeField } from './EntryTime.utils' -import { Form } from './EntryTimeField.style' +import { Form, Input } from './EntryTimeField.style' interface EntryTimeFieldProps { hours: number minutes: number setHours?: setTimeFunc setMinutes?: setTimeFunc + disabled?: boolean } const EntryTimeField = ({ @@ -16,6 +16,7 @@ const EntryTimeField = ({ minutes, setHours, setMinutes, + disabled = false, }: EntryTimeFieldProps) => { const { error, handleChange } = useEntryTimeField() @@ -31,6 +32,7 @@ const EntryTimeField = ({ max={HOURS_LIMIT} error={error.hours} width={100} + disabled={disabled} /> handleChange(e.target.value, MINUTES_LIMIT, setMinutes)} error={error.minutes} width={100} + disabled={disabled} /> ) diff --git a/src/components/Labels/AddNewLabel.tsx b/src/components/Labels/AddNewLabel.tsx index aca23bf..900ec4b 100644 --- a/src/components/Labels/AddNewLabel.tsx +++ b/src/components/Labels/AddNewLabel.tsx @@ -25,10 +25,11 @@ export const AddNewLabel = ({ onAdd }: AddNewLabelProps) => { setLabelName(e.target.value) const onKeyPress = (e: React.KeyboardEvent) => { + if (!onAdd) return if (e.charCode !== 13) return db.addNewLabel(labelName) setInitiated(false) - onAdd && onAdd(true) + onAdd(true) setLabelName('') } diff --git a/src/components/Labels/Labels.style.ts b/src/components/Labels/Labels.style.ts index 9b6e629..af8314d 100644 --- a/src/components/Labels/Labels.style.ts +++ b/src/components/Labels/Labels.style.ts @@ -9,6 +9,7 @@ import { transition, typography, } from 'config' +import { darken } from 'polished' import { LabelProps } from 'shared/types' import styled from 'styled-components' @@ -38,8 +39,24 @@ export const StyledLabel = styled('button')(({ active }) => ({ }, })) -export const LabelWrapper = styled('div')({ - display: 'flex', - width: LABEL_WRAPPER_WIDTH, - flexWrap: 'wrap', -}) +interface LabelWrapperProps { + disabled: boolean +} +export const LabelWrapper = styled('div')( + ({ disabled }) => ({ + display: 'flex', + width: LABEL_WRAPPER_WIDTH, + flexWrap: 'wrap', + + ...(disabled && { + '& button:hover': { + backgroundColor: palette.disabled, + borderColor: darken(0.2, palette.disabled), + cursor: 'not-allowed', + }, + '& button:last-of-type': { + display: 'none', + }, + }), + }) +) diff --git a/src/components/Labels/Labels.tsx b/src/components/Labels/Labels.tsx index 099106b..1ae691b 100644 --- a/src/components/Labels/Labels.tsx +++ b/src/components/Labels/Labels.tsx @@ -8,9 +8,15 @@ interface LabelsProps { labels?: Label[] onClick?: (id: ID) => void selectedLabels?: ID[] + disabled?: boolean } -const Labels = ({ labels, onClick, selectedLabels }: LabelsProps) => { +const Labels = ({ + labels, + onClick, + selectedLabels, + disabled = false, +}: LabelsProps) => { const ctx = useContext(LabelsContext) const isLabelSelected = useCallback( @@ -19,17 +25,18 @@ const Labels = ({ labels, onClick, selectedLabels }: LabelsProps) => { ) return ( - + {labels?.map(({ id, name }) => ( onClick && onClick(id)} + onClick={() => !disabled && onClick?.(id)} active={isLabelSelected(id)} /> ))} - + + ) } diff --git a/src/config/content.ts b/src/config/content.ts new file mode 100644 index 0000000..7761fa1 --- /dev/null +++ b/src/config/content.ts @@ -0,0 +1 @@ +export const DISABLED_ENTRY_LIST_ITEMS_TEXT = 'To edit entry use edit button' diff --git a/src/config/index.ts b/src/config/index.ts index 65dbfe3..09d8fd5 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -6,3 +6,4 @@ export { zIndex } from './zIndex' export * from './config' export * from './DBSchema' export * from './UI' +export * from './content' diff --git a/src/config/palette.ts b/src/config/palette.ts index 05a500f..ca9bb16 100644 --- a/src/config/palette.ts +++ b/src/config/palette.ts @@ -13,6 +13,7 @@ export const palette = { accent: '#607D8B', }, divider: '#BDBDBD', + disabled: '#d3d3d3', shadows: { box0: '0 3px 3px rgb(0 0 0 / 11%), 0 3px 3px rgb(0 0 0 / 5%)', box1: '0 3px 5px rgb(0 0 0 / 20%), 0 3px 5px rgb(0 0 0 / 5%)', diff --git a/src/config/transition.ts b/src/config/transition.ts index ee268d5..a18cdf6 100644 --- a/src/config/transition.ts +++ b/src/config/transition.ts @@ -3,5 +3,6 @@ export const transition = { instant: 100, fast: 200, medium: 400, + long: 1000, }, } diff --git a/src/shared/components/Button/Button.style.ts b/src/shared/components/Button/Button.style.ts index 13efd73..862b459 100644 --- a/src/shared/components/Button/Button.style.ts +++ b/src/shared/components/Button/Button.style.ts @@ -5,7 +5,6 @@ import { SPACING_SMALL, transition, typography, - zIndex, } from 'config' import { lighten } from 'polished' import styled, { CSSProperties } from 'styled-components' @@ -118,32 +117,3 @@ export const BaseButton = styled('button')( }, }) ) -interface ButtonWrapperProps { - position: { - x: number - y: number - } -} -export const ButtonWrapper = styled('div')( - ({ position }) => ({ - span: { - opacity: 0, - position: 'absolute', - transition: `opacity ease-in-out ${transition.time.fast}ms`, - backgroundColor: palette.text.secondary, - color: palette.text.text, - padding: `${SPACING_SMALL}px ${SPACING_MID}px`, - borderRadius: DEFAULT_BORDER_RADIUS, - margin: SPACING_SMALL, - width: 'auto', - height: 'inherit', - left: 0, - top: 0, - transform: `translate(${position.x}px, ${position.y}px)`, - }, - 'button:hover ~ span': { - opacity: 1, - zIndex: zIndex.max, - }, - }) -) diff --git a/src/shared/components/Button/Button.tsx b/src/shared/components/Button/Button.tsx index f1b687c..4ffcd9e 100644 --- a/src/shared/components/Button/Button.tsx +++ b/src/shared/components/Button/Button.tsx @@ -1,43 +1,19 @@ -import { MouseEvent, PropsWithChildren, useRef, useState } from 'react' -import { useThrottle } from 'rooks' -import { BaseButton, ButtonWrapper } from './Button.style' +import { PropsWithChildren } from 'react' +import Tooltip from '../Tooltip' +import { BaseButton } from './Button.style' import { ButtonProps } from './Button.types' -type MouseMoveEvent = - | MouseEvent - | MouseEvent> - -const MOUSE_OFFSET = 10 - const Button = ({ - disabled, disabledTooltip, + isDisabled = false, ...props }: PropsWithChildren) => { - const buttonRef = useRef(null) - const tooltipRef = useRef(null) - const [position, setPosition] = useState({ x: 0, y: 0 }) - const [throttledSetPosition] = useThrottle(setPosition, 30) - - const onMouseMove = (e: MouseMoveEvent) => - throttledSetPosition({ - x: e.clientX - (tooltipRef.current?.offsetWidth || 0), - y: e.clientY - (tooltipRef.current?.offsetHeight || 0) - MOUSE_OFFSET, - }) - return ( - - + + {props.children} - - {disabled && {disabledTooltip}} - + ) } diff --git a/src/shared/components/Input/index.ts b/src/shared/components/Input/index.ts index aa97178..823777c 100644 --- a/src/shared/components/Input/index.ts +++ b/src/shared/components/Input/index.ts @@ -1 +1,2 @@ export { default } from './Input' +export type { InputProps } from './Input' diff --git a/src/shared/components/TextArea/TextArea.styled.ts b/src/shared/components/TextArea/TextArea.styled.ts index 4eb628f..c938428 100644 --- a/src/shared/components/TextArea/TextArea.styled.ts +++ b/src/shared/components/TextArea/TextArea.styled.ts @@ -1,4 +1,4 @@ -import { palette } from 'config' +import { palette, transition } from 'config' import { darken, lighten } from 'polished' import { Color } from 'shared/types' import { getColor } from 'shared/utils' @@ -12,10 +12,11 @@ export interface TextAreaProps { error?: string width?: Size height?: Size + disabled?: boolean } export const TextArea = styled('textarea')( - ({ resizable, color, error, width, height }) => ({ + ({ resizable, color, error, width, height, disabled = false }) => ({ width: width ? width : 'inherit', height: height ? height : 'inherit', resize: resizable ? 'both' : 'none', @@ -27,5 +28,14 @@ export const TextArea = styled('textarea')( boxShadow: palette.shadows.box1, backgroundColor: lighten(0.4, getColor(color)), caretColor: darken(0.5, getColor(color)), + transition: `all ease-in-out ${transition.time.fast}ms`, + cursor: disabled ? 'not-allowed' : 'default', + + '&: hover': { + ...(disabled && { + backgroundColor: getColor(color, undefined, disabled), + borderColor: darken(0.2, getColor(color, undefined, disabled)), + }), + }, }) ) diff --git a/src/shared/components/TextArea/TextArea.tsx b/src/shared/components/TextArea/TextArea.tsx index 1a05914..a5c8515 100644 --- a/src/shared/components/TextArea/TextArea.tsx +++ b/src/shared/components/TextArea/TextArea.tsx @@ -11,6 +11,7 @@ export interface TextAreaProps extends BaseTextAreaProps { } const TextArea = (props: TextAreaProps) => { return ( + // TODO: add tooltip here based on a props <> {props.error} diff --git a/src/shared/components/Tooltip/Tooltip.style.ts b/src/shared/components/Tooltip/Tooltip.style.ts new file mode 100644 index 0000000..a45d668 --- /dev/null +++ b/src/shared/components/Tooltip/Tooltip.style.ts @@ -0,0 +1,36 @@ +import { + DEFAULT_BORDER_RADIUS, + palette, + SPACING_MID, + SPACING_SMALL, + transition, + zIndex, +} from 'config' +import { Position } from 'shared/types' +import styled from 'styled-components' + +export const TooltipWrapper = styled('div')({ + '&:hover span': { + opacity: 1, + }, +}) + +interface BaseTooltipProps { + position: Position +} +export const BaseTooltip = styled('span')(({ position }) => ({ + position: 'fixed', + left: 0, + top: 0, + opacity: 0, + transform: `translate(${position.x}px, ${position.y}px)`, + transition: `opacity ease-in-out ${transition.time.long}ms, transform linear ${transition.time.instant}ms`, + backgroundColor: palette.text.secondary, + color: palette.text.text, + padding: `${SPACING_SMALL}px ${SPACING_MID}px ${SPACING_MID}px`, + borderRadius: DEFAULT_BORDER_RADIUS, + margin: SPACING_SMALL, + clipPath: + 'polygon(0% 0%, 100% 0%, 100% 70%, 56% 70%, 50% 100%, 40% 70%, 0 70%)', + zIndex: zIndex.max, +})) diff --git a/src/shared/components/Tooltip/Tooltip.tsx b/src/shared/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..0baed79 --- /dev/null +++ b/src/shared/components/Tooltip/Tooltip.tsx @@ -0,0 +1,46 @@ +import { MouseEvent, PropsWithChildren, useRef, useState } from 'react' +import { useThrottle } from 'rooks' +import { BaseTooltip, TooltipWrapper } from './Tooltip.style' + +const MOUSE_OFFSET = 15 + +type MouseMoveEvent = MouseEvent + +interface TooltipProps { + show?: boolean + text?: string +} +const Tooltip = ({ + children, + text, + show = true, +}: PropsWithChildren) => { + //TODO: 1) if tooltip run over the x direction of the screen it should be moved + const wrapperRef = useRef(null) + const tooltipRef = useRef(null) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [throttledSetPosition] = useThrottle(setPosition, 35) + + const onMouseMoveHandler = (e: MouseMoveEvent) => { + throttledSetPosition({ + x: + e.clientX - + (tooltipRef.current?.offsetWidth || 0) / 2 - + MOUSE_OFFSET / 2, + y: e.clientY - (tooltipRef.current?.offsetHeight || 0) - MOUSE_OFFSET, + }) + } + + return ( + + {show && ( + + {text} + + )} + {children} + + ) +} + +export default Tooltip diff --git a/src/shared/components/Tooltip/index.ts b/src/shared/components/Tooltip/index.ts new file mode 100644 index 0000000..2f430db --- /dev/null +++ b/src/shared/components/Tooltip/index.ts @@ -0,0 +1 @@ +export { default } from './Tooltip' diff --git a/src/shared/components/index.ts b/src/shared/components/index.ts index 6c3bece..a7d029d 100644 --- a/src/shared/components/index.ts +++ b/src/shared/components/index.ts @@ -5,4 +5,6 @@ export { default as Modal } from './Modal' export { default as Collapse } from './Collapse' export { default as Button } from './Button' export { default as Input } from './Input' +export type { InputProps } from './Input' export { default as TextArea } from './TextArea' +export { default as Tooltip } from './Tooltip' diff --git a/src/shared/types/props.ts b/src/shared/types/props.ts index 5da802c..9719884 100644 --- a/src/shared/types/props.ts +++ b/src/shared/types/props.ts @@ -14,3 +14,8 @@ export interface TimeEntryErrors { timeEntryDescription?: string timeEntry?: string } + +export interface Position { + x: T + y: T +} diff --git a/src/shared/utils/context.ts b/src/shared/utils/context.ts index 1ba51f9..5a70e83 100644 --- a/src/shared/utils/context.ts +++ b/src/shared/utils/context.ts @@ -1,11 +1,24 @@ import { createContext, Dispatch, SetStateAction } from 'react' +import { TimeEntry } from 'services' -export const EntryListContext = createContext<{ +interface EntryListContextProps { updateEntryList: boolean setUpdateEntryList: Dispatch> -} | null>(null) +} +export const EntryListContext = createContext( + null +) -export const LabelsContext = createContext<{ +interface LabelsContextProps { updateLabels: boolean setUpdateLabels: Dispatch> -} | null>(null) +} +export const LabelsContext = createContext(null) + +interface EditEntryContextProps { + editing: TimeEntry['id'] | null + setEditing: Dispatch> +} +export const EditEntryContext = createContext( + null +) diff --git a/src/shared/utils/helpers.ts b/src/shared/utils/helpers.ts index b357b7f..55a7195 100644 --- a/src/shared/utils/helpers.ts +++ b/src/shared/utils/helpers.ts @@ -9,8 +9,9 @@ export const getScaledMinutes = (minutes: number) => export const daysInMonth = (month: number, year: number) => new Date(year, month, 0).getDate() -export const getColor = (color: Color, error?: string) => { +export const getColor = (color: Color, error?: string, disabled?: boolean) => { if (error) return palette.error + if (disabled) return palette.disabled switch (color) { case 'primary': {