diff --git a/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js b/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js new file mode 100644 index 00000000..3816ec85 --- /dev/null +++ b/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {Popup} from 'semantic-ui-react'; +/** + * Displays a placeholder for a candidate time slot when the Timeline is hovered. + */ +export default function CandidatePlaceholder({visible, left, width, time}) { + if (!visible) { + return null; + } + + return ( + + } + /> + ); +} + +CandidatePlaceholder.propTypes = { + visible: PropTypes.bool.isRequired, + width: PropTypes.number.isRequired, + left: PropTypes.number.isRequired, + time: PropTypes.string.isRequired, +}; diff --git a/newdle/client/src/components/creation/timeslots/CandidateSlot.js b/newdle/client/src/components/creation/timeslots/CandidateSlot.js index 55fbd0e9..7f1768e4 100644 --- a/newdle/client/src/components/creation/timeslots/CandidateSlot.js +++ b/newdle/client/src/components/creation/timeslots/CandidateSlot.js @@ -16,6 +16,7 @@ function SlotEditWidget({startTime, onChange, isValidTime, slot}) { e.stopPropagation()} content={ <> e.stopPropagation()} width={width} pos={pos} moreStyles={styles['candidate']} @@ -82,6 +86,8 @@ export default function CandidateSlot({ name="times circle outline" onClick={onDelete} className={`${styles['clickable']} ${styles['delete-btn']}`} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} /> ); @@ -102,6 +108,8 @@ CandidateSlot.propTypes = { endTime: PropTypes.string.isRequired, onDelete: PropTypes.func.isRequired, onChangeSlotTime: PropTypes.func.isRequired, + onMouseEnter: PropTypes.func.isRequired, + onMouseLeave: PropTypes.func.isRequired, isValidTime: PropTypes.func.isRequired, text: PropTypes.string, }; diff --git a/newdle/client/src/components/creation/timeslots/Timeline.js b/newdle/client/src/components/creation/timeslots/Timeline.js index ab53d5ea..da1f432b 100644 --- a/newdle/client/src/components/creation/timeslots/Timeline.js +++ b/newdle/client/src/components/creation/timeslots/Timeline.js @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useState, useRef} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {Trans, t, plural} from '@lingui/macro'; import _ from 'lodash'; @@ -19,6 +19,7 @@ import { import {hourRange, toMoment, getHourSpan, DEFAULT_TIME_FORMAT} from '../../../util/date'; import {useIsSmallScreen} from '../../../util/hooks'; import TimezonePicker from '../../common/TimezonePicker'; +import CandidatePlaceholder from './CandidatePlaceholder'; import CandidateSlot from './CandidateSlot'; import DurationPicker from './DurationPicker'; import TimelineHeader from './TimelineHeader'; @@ -164,30 +165,25 @@ function TimelineInput({minHour, maxHour}) { const duration = useSelector(getDuration); const date = useSelector(getCreationCalendarActiveDate); const candidates = useSelector(getTimeslotsForActiveDate); - const pastCandidates = useSelector(getPreviousDayTimeslots); const availability = useSelector(getParticipantAvailability); - const [_editing, setEditing] = useState(false); - const editing = _editing || !!candidates.length; const latestStartTime = useSelector(getNewTimeslotStartTime); const [timeslotTime, setTimeslotTime] = useState(latestStartTime); const [newTimeslotPopupOpen, setTimeslotPopupOpen] = useState(false); + const timelineRef = useRef(null); + // Indicates the position of the mouse in the timeline + const [candidatePlaceholder, setCandidatePlaceholder] = useState({ + visible: false, + time: '', + left: 0, + width: 0, + }); + // We don't want to show the tooltip when the mouse is hovering over a slot + const [isHoveringSlot, setIsHoveringSlot] = useState(false); useEffect(() => { setTimeslotTime(latestStartTime); }, [latestStartTime, candidates, duration]); - const handleStartEditing = () => { - setEditing(true); - setTimeslotPopupOpen(true); - }; - - const handleCopyClick = () => { - pastCandidates.forEach(time => { - dispatch(addTimeslot(date, time)); - }); - setEditing(true); - }; - const handlePopupClose = () => { setTimeslotPopupOpen(false); }; @@ -196,8 +192,9 @@ function TimelineInput({minHour, maxHour}) { dispatch(addTimeslot(date, time)); }; - const handleRemoveSlot = time => { + const handleRemoveSlot = (event, time) => { dispatch(removeTimeslot(date, time)); + setIsHoveringSlot(false); }; const handleUpdateSlot = (oldTime, newTime) => { @@ -205,91 +202,213 @@ function TimelineInput({minHour, maxHour}) { dispatch(addTimeslot(date, newTime)); }; + // Function to check if a time matches any existing timeslot times + const isTimeSlotTaken = time => { + return candidates.includes(time); + }; + + function calculatePlaceholderStart(e, minHour, maxHour) { + const timelineRect = timelineRef.current.getBoundingClientRect(); + const position = (e.clientX - timelineRect.left) / timelineRect.width; + const totalMinutes = (maxHour - minHour) * 60; + + let minutes = minHour * 60 + position * totalMinutes; + minutes = Math.floor(minutes / 15) * 15; + + if (position < 0) { + minutes = 0; + } + + return moment().startOf('day').add(minutes, 'minutes'); + } + + const handleMouseDown = e => { + const start = calculatePlaceholderStart(e, minHour, maxHour); + const formattedTime = start.format(DEFAULT_TIME_FORMAT); + if (!isTimeSlotTaken(formattedTime)) { + handleAddSlot(formattedTime); + } + }; + + /** + * Tracks the mouse movement in the timeline and updates the candidatePlaceholder state + * @param {Event} e + * @returns + */ + const handleTimelineMouseMove = e => { + if (isHoveringSlot) { + setCandidatePlaceholder(p => ({...p, visible: false})); + return; + } + + const start = calculatePlaceholderStart(e, minHour, maxHour); + const end = moment(start).add(duration, 'minutes'); + const time = start.format(DEFAULT_TIME_FORMAT); + + // Check if the time slot is already taken + if (isTimeSlotTaken(time)) { + setCandidatePlaceholder(p => ({...p, visible: false})); + return; + } + + setCandidatePlaceholder(p => ({ + ...p, + visible: true, + time, + left: calculatePosition(start, minHour, maxHour), + width: calculateWidth(start, end, minHour, maxHour), + })); + }; + + const handleTimelineMouseLeave = () => { + setCandidatePlaceholder(p => ({...p, visible: false})); + }; + const groupedCandidates = splitOverlappingCandidates(candidates, duration); - return editing ? ( -
-
- {groupedCandidates.map((rowCandidates, i) => ( -
- {rowCandidates.map(time => { - const slotProps = getCandidateSlotProps(time, duration, minHour, maxHour); - const participants = availability?.find(a => a.startDt === `${date}T${time}`); - return ( - !candidates.includes(time)} - onDelete={() => handleRemoveSlot(time)} - onChangeSlotTime={newStartTime => handleUpdateSlot(time, newStartTime)} - text={ - participants && - plural(participants.availableCount, { - 0: 'No participants registered', - one: '# participant registered', - other: '# participants registered', - }) - } - /> - ); - })} -
- ))} -
- - } - on="click" - position="bottom center" - onOpen={() => setTimeslotPopupOpen(true)} - onClose={handlePopupClose} - open={newTimeslotPopupOpen} - onKeyDown={evt => { - const canBeAdded = timeslotTime && !candidates.includes(timeslotTime); - if (evt.key === 'Enter' && canBeAdded) { - handleAddSlot(timeslotTime); - handlePopupClose(); - } + return ( +
+
{ + handleMouseDown(event); + handleTimelineMouseLeave(); }} - className={styles['timepicker-popup']} - content={ - <> - setTimeslotTime(time ? time.format(DEFAULT_TIME_FORMAT) : null)} - allowEmpty={false} - // keep the picker in the DOM tree of the surrounding element - getPopupContainer={node => node} - /> - - - } - /> + {rowCandidates.map(time => { + const slotProps = getCandidateSlotProps(time, duration, minHour, maxHour); + const participants = availability?.find(a => a.startDt === `${date}T${time}`); + return ( + !isTimeSlotTaken(time)} + onDelete={event => { + // Prevent the event from bubbling up to the parent div + event.stopPropagation(); + handleRemoveSlot(event, time); + }} + onMouseEnter={() => { + setIsHoveringSlot(true); + }} + onMouseLeave={() => { + setIsHoveringSlot(false); + }} + onChangeSlotTime={newStartTime => handleUpdateSlot(time, newStartTime)} + text={ + participants && + plural(participants.availableCount, { + 0: 'No participants registered', + one: '# participant registered', + other: '# participants registered', + }) + } + /> + ); + })} +
+ ))} +
+
e.stopPropagation()} className={styles['add-btn-wrapper']}> + e.stopPropagation()} + /> + } + on="click" + onMouseMove={e => { + e.stopPropagation(); + }} + position="bottom center" + onOpen={evt => { + // Prevent the event from bubbling up to the parent div + evt.stopPropagation(); + setTimeslotPopupOpen(true); + }} + onClose={handlePopupClose} + open={newTimeslotPopupOpen} + onKeyDown={evt => { + const canBeAdded = timeslotTime && !isTimeSlotTaken(timeslotTime); + if (evt.key === 'Enter' && canBeAdded) { + handleAddSlot(timeslotTime); + handlePopupClose(); + } + }} + className={styles['timepicker-popup']} + content={ +
e.stopPropagation()} + onMouseMove={e => { + e.stopPropagation(); + }} + > + setTimeslotTime(time ? time.format(DEFAULT_TIME_FORMAT) : null)} + onMouseMove={e => e.stopPropagation()} + allowEmpty={false} + // keep the picker in the DOM tree of the surrounding element + getPopupContainer={node => node} + /> + +
+ } + /> +
+
- ) : ( + ); +} + +TimelineInput.propTypes = { + minHour: PropTypes.number.isRequired, + maxHour: PropTypes.number.isRequired, +}; + +function ClickToAddTimeSlots({startEditing, copyTimeSlots}) { + const pastCandidates = useSelector(getPreviousDayTimeslots); + + return (
-
+
Click to add time slots
{pastCandidates && ( -
+
Copy time slots from previous day
@@ -298,25 +417,52 @@ function TimelineInput({minHour, maxHour}) { ); } -TimelineInput.propTypes = { - minHour: PropTypes.number.isRequired, - maxHour: PropTypes.number.isRequired, +ClickToAddTimeSlots.propTypes = { + startEditing: PropTypes.func.isRequired, + copyTimeSlots: PropTypes.func.isRequired, }; function TimelineContent({busySlots: allBusySlots, minHour, maxHour}) { + const dispatch = useDispatch(); + const [editing, setEditing] = useState(false); + const date = useSelector(getCreationCalendarActiveDate); + const pastCandidates = useSelector(getPreviousDayTimeslots); + const candidates = useSelector(getTimeslotsForActiveDate); + + const copyTimeSlots = () => { + pastCandidates.forEach(time => { + dispatch(addTimeslot(date, time)); + }); + setEditing(true); + }; + + if (!editing && candidates.length === 0) { + return ( + setEditing(true)} copyTimeSlots={copyTimeSlots} /> + ); + } + return ( -
- {allBusySlots.map(slot => ( - - ))} - {allBusySlots.map(({busySlots, participant}) => - busySlots.map(slot => { - const key = `${participant.email}-${slot.startTime}-${slot.endTime}`; - return ; - }) + <> +
+ {allBusySlots.map(slot => ( + + ))} + {allBusySlots.map(({busySlots, participant}) => + busySlots.map(slot => { + const key = `${participant.email}-${slot.startTime}-${slot.endTime}`; + return ; + }) + )} + +
+ {editing && candidates.length === 0 && ( +
+ + Click the timeline to add your first time slot +
)} - -
+ ); } @@ -352,7 +498,8 @@ export default function Timeline({date, availability, defaultMinHour, defaultMax duration, format, }; - setHourSpan(getHourSpan(input)); + const shouldSpanTwoDays = false; + setHourSpan(getHourSpan(input, shouldSpanTwoDays)); }, [candidates, defaultHourSpan, defaultMaxHour, defaultMinHour, duration]); const creationTimezone = localStorage.getItem('creationTimezone'); @@ -431,7 +578,7 @@ Timeline.propTypes = { }; Timeline.defaultProps = { - defaultMinHour: 8, + defaultMinHour: 0, defaultMaxHour: 24, hourStep: 2, }; diff --git a/newdle/client/src/components/creation/timeslots/Timeline.module.scss b/newdle/client/src/components/creation/timeslots/Timeline.module.scss index 3f918332..38498635 100644 --- a/newdle/client/src/components/creation/timeslots/Timeline.module.scss +++ b/newdle/client/src/components/creation/timeslots/Timeline.module.scss @@ -2,17 +2,19 @@ $row-height: 50px; $label-width: 180px; +$rows-border-width: 5px; .timeline { position: relative; margin: 4px; - @media screen and (min-width: 1200px) { - margin-left: $label-width; - } .timeline-title { display: flex; justify-content: space-between; + + @media screen and (min-width: 1200px) { + margin-left: $label-width; + } } .timeline-date { @@ -20,8 +22,8 @@ $label-width: 180px; } .timeline-hours { - margin-left: 30px; - margin-right: 10px; + margin-left: $rows-border-width; + margin-right: $rows-border-width; color: $grey; height: $row-height; position: relative; @@ -35,10 +37,6 @@ $label-width: 180px; } } - .timeline-slot-picker { - margin-left: 10px; - margin-right: 10px; - } @media screen and (min-width: 990px) { .timeline-slot-picker { //breakpoint happens at 990, if no width specified, the view is jumpy @@ -48,8 +46,9 @@ $label-width: 180px; } .timeline-rows { position: relative; - margin-left: 20px; - margin-right: 10px; + background-color: lighten($green, 27%); + border: $rows-border-width solid lighten($green, 22%); + .timeline-row { height: $row-height; display: flex; @@ -140,6 +139,7 @@ $label-width: 180px; z-index: 1; &.candidate { + box-sizing: border-box; background-color: $green; border: 1px solid darken($green, 4%); height: 40px; @@ -176,6 +176,11 @@ $label-width: 180px; margin-left: -5px; } + .add-first-text { + color: $grey; + margin-top: 10px; + } + .timeline-input { opacity: 0.6; display: flex; @@ -185,7 +190,7 @@ $label-width: 180px; border-radius: 3px; box-sizing: content-box; flex-basis: 100%; - margin-left: 5px; + cursor: pointer; .timeline-candidates { display: flex; @@ -211,10 +216,13 @@ $label-width: 180px; } &.edit { - background-color: lighten($green, 27%); - border: 5px solid lighten($green, 22%); - margin: -5px -40px; - padding: 10px; + padding-top: 10px; + padding-bottom: 10px; + + .add-btn-wrapper { + display: flex; + align-items: center; + } .add-btn { margin-left: auto; diff --git a/newdle/client/src/locales/es/messages.po b/newdle/client/src/locales/es/messages.po index 562a9567..8480a2ca 100644 --- a/newdle/client/src/locales/es/messages.po +++ b/newdle/client/src/locales/es/messages.po @@ -111,7 +111,11 @@ msgstr "" msgid "Choose your participants" msgstr "Elige a los participantes" -#: src/components/creation/timeslots/Timeline.js:289 +#: src/components/creation/timeslots/Timeline.js:462 +msgid "Click the timeline to add your first time slot" +msgstr "" + +#: src/components/creation/timeslots/Timeline.js:408 msgid "Click to add time slots" msgstr "" @@ -141,7 +145,7 @@ msgstr "" msgid "Copied!" msgstr "" -#: src/components/creation/timeslots/Timeline.js:294 +#: src/components/creation/timeslots/Timeline.js:413 msgid "Copy time slots from previous day" msgstr "" @@ -402,7 +406,7 @@ msgstr "" msgid "Please log in again to confirm your identity" msgstr "" -#: src/components/creation/timeslots/Timeline.js:373 +#: src/components/creation/timeslots/Timeline.js:520 msgid "Revert to the local timezone" msgstr "" @@ -519,11 +523,11 @@ msgstr "" msgid "Timeslots" msgstr "" -#: src/components/creation/timeslots/Timeline.js:381 +#: src/components/creation/timeslots/Timeline.js:528 msgid "Timezone" msgstr "" -#: src/components/creation/timeslots/Timeline.js:379 +#: src/components/creation/timeslots/Timeline.js:526 msgid "Timezone {revertIcon}" msgstr "" @@ -633,7 +637,7 @@ msgstr "" msgid "switch back to the <0>{defaultUserTz} timezone" msgstr "" -#: src/components/creation/timeslots/Timeline.js:227 +#: src/components/creation/timeslots/Timeline.js:316 msgid "{0, plural, =0 {No participants registered} one {# participant registered} other {# participants registered}}" msgstr "" diff --git a/newdle/client/src/util/date.js b/newdle/client/src/util/date.js index a671ee21..5b0a5a12 100644 --- a/newdle/client/src/util/date.js +++ b/newdle/client/src/util/date.js @@ -18,7 +18,7 @@ export function overlaps([start1, end1], [start2, end2]) { return start1.isBefore(end2) && start2.isBefore(end1); } -export function getHourSpan(input) { +export function getHourSpan(input, shouldSpanTwoDays = true) { const {timeSlots, defaultHourSpan, defaultMinHour, defaultMaxHour, duration, format} = input; const timeSlotsMoment = timeSlots.map(c => toMoment(c, format)); const minTimelineHour = Math.min(...timeSlotsMoment.map(timeSlot => timeSlot.hour())); @@ -34,7 +34,7 @@ export function getHourSpan(input) { timeSlot => !timeSlot.isSame(timeSlot.clone().add(duration, 'm'), 'day') ) !== undefined; let maxTimelineHour; - if (spansOverTwoDays) { + if (spansOverTwoDays && shouldSpanTwoDays) { maxTimelineHour = 24 + maxTimeline.hour(); } else { maxTimelineHour = maxTimeline.minutes() ? maxTimeline.hour() + 1 : maxTimeline.hour();