diff --git a/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js b/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js index d7a4e19b..3816ec85 100644 --- a/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js +++ b/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js @@ -1,33 +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({xPosition, yPosition, height, widthPercent}) { +export default function CandidatePlaceholder({visible, left, width, time}) { + if (!visible) { + return null; + } + return ( -
+ } /> ); } CandidatePlaceholder.propTypes = { - height: PropTypes.number.isRequired, - widthPercent: PropTypes.number.isRequired, - xPosition: PropTypes.number.isRequired, - yPosition: PropTypes.number.isRequired, + 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/Timeline.js b/newdle/client/src/components/creation/timeslots/Timeline.js index f7c74680..9b7f32c4 100644 --- a/newdle/client/src/components/creation/timeslots/Timeline.js +++ b/newdle/client/src/components/creation/timeslots/Timeline.js @@ -56,6 +56,17 @@ function calculatePosition(start, minHour, maxHour) { return position < 100 ? position : 100 - OVERFLOW_WIDTH; } +function calculatePlaceholderStart(e, minHour, maxHour) { + const timelineRect = e.target.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; + + return moment().startOf('day').add(minutes, 'minutes'); +} + function getSlotProps(startTime, endTime, minHour, maxHour) { const start = toMoment(startTime, DEFAULT_TIME_FORMAT); const end = toMoment(endTime, DEFAULT_TIME_FORMAT); @@ -165,10 +176,7 @@ 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); @@ -176,41 +184,16 @@ function TimelineInput({minHour, maxHour}) { const [candidatePlaceholder, setCandidatePlaceholder] = useState({ visible: false, time: '', - x: 0, - y: 0, + 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); - const placeHolderSlot = getCandidateSlotProps('00:00', duration, minHour, maxHour); - - useEffect(() => { - const handleScroll = () => { - setCandidatePlaceholder({visible: false}); - }; - - window.addEventListener('scroll', handleScroll); - - return () => { - window.removeEventListener('scroll', handleScroll); - }; - }, []); 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); }; @@ -235,30 +218,10 @@ function TimelineInput({minHour, maxHour}) { }; const handleMouseDown = e => { - const parentRect = e.target.getBoundingClientRect(); - const totalMinutes = (maxHour - minHour) * 60; - - // Get the parent rect start position - const parentRectStart = parentRect.left; - // Get the parent rect end position - const parentRectEnd = parentRect.right; - - const clickPositionRelative = (e.clientX - parentRectStart) / (parentRectEnd - parentRectStart); - - let clickTimeRelative = clickPositionRelative * totalMinutes; - - // Round clickTimeRelative to the nearest 15-minute interval - clickTimeRelative = Math.round(clickTimeRelative / 15) * 15; - - // Convert clickTimeRelative to a time format (HH:mm) - const clickTimeRelativeTime = moment() - .startOf('day') - .add(clickTimeRelative, 'minutes') - .format('HH:mm'); - - const canBeAdded = clickTimeRelativeTime && !isTimeSlotTaken(clickTimeRelativeTime); - if (canBeAdded) { - handleAddSlot(clickTimeRelativeTime); + const start = calculatePlaceholderStart(e, minHour, maxHour); + const formattedTime = start.format(DEFAULT_TIME_FORMAT); + if (!isTimeSlotTaken(formattedTime)) { + handleAddSlot(formattedTime); } }; @@ -269,216 +232,172 @@ function TimelineInput({minHour, maxHour}) { */ const handleTimelineMouseMove = e => { if (isHoveringSlot) { - setCandidatePlaceholder({visible: false}); + setCandidatePlaceholder(p => ({...p, visible: false})); return; } - const timelineRect = e.target.getBoundingClientRect(); - const relativeMouseXPosition = e.clientX - timelineRect.left; - - const totalMinutes = (maxHour - minHour) * 60; // Total minutes in the timeline - let timeInMinutes = (relativeMouseXPosition / timelineRect.width) * totalMinutes; - // Round timeInMinutes to the nearest 15-minute interval - timeInMinutes = Math.round(timeInMinutes / 15) * 15; - const slotWidth = (placeHolderSlot.width / timelineRect.width) * 100; - const hours = Math.floor(timeInMinutes / 60) + minHour; - const minutes = Math.floor(timeInMinutes % 60); - const time = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; - - if (time === candidatePlaceholder.time) { - 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({visible: false}); + setCandidatePlaceholder(p => ({...p, visible: false})); return; } - const timelineVerticalPadding = 16; - const timelineVerticalPaddingBottom = 5; - const candidatePlaceholderPaddingLeft = 5; - const tooltipMarginLeft = -20; - const candidatePlaceholderMarginLeft = 5; - - const tempPlaceholder = { + setCandidatePlaceholder(p => ({ + ...p, visible: true, time, - candidateX: e.clientX + candidatePlaceholderMarginLeft, - candidateY: timelineRect.top + timelineRect.height - timelineVerticalPaddingBottom, - candidateHeight: timelineRect.height - timelineVerticalPadding, - tooltipMarginLeft: tooltipMarginLeft, - tooltipX: relativeMouseXPosition + candidatePlaceholderPaddingLeft, - width: slotWidth, - }; - - if (hours >= 0 && minutes >= 0) { - setCandidatePlaceholder(tempPlaceholder); - } + left: calculatePosition(start, minHour, maxHour), + width: calculateWidth(start, end, minHour, maxHour), + })); }; const handleTimelineMouseLeave = () => { - setCandidatePlaceholder({visible: false}); + setCandidatePlaceholder(p => ({...p, visible: false})); }; const groupedCandidates = splitOverlappingCandidates(candidates, duration); - return editing ? ( - -
{ - handleMouseDown(event); - handleTimelineMouseLeave(); - }} - onMouseMove={handleTimelineMouseMove} - onMouseLeave={handleTimelineMouseLeave} - > -
- {groupedCandidates.map((rowCandidates, i) => ( -
{ - // Prevent the candidate placeholder from showing when hovering over a slot - setIsHoveringSlot(true); - }} - onMouseLeave={() => { - setIsHoveringSlot(false); - }} - > - {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); - }} - onChangeSlotTime={newStartTime => handleUpdateSlot(time, newStartTime)} - text={ - participants && - plural(participants.availableCount, { - 0: 'No participants registered', - one: '# participant registered', - other: '# participants registered', - }) - } - /> - ); - })} -
- ))} - {candidatePlaceholder.visible && ( - - )} -
-
e.stopPropagation()} className={styles['add-btn-wrapper']}> - e.stopPropagation()} + return ( +
+
{ + handleMouseDown(event); + handleTimelineMouseLeave(); + }} + onMouseMove={handleTimelineMouseMove} + onMouseLeave={handleTimelineMouseLeave} + > + +
+ {groupedCandidates.map((rowCandidates, i) => ( +
{ + // Prevent the candidate placeholder from showing when hovering over a slot + setIsHoveringSlot(true); + }} + onMouseLeave={() => { + setIsHoveringSlot(false); + }} + > + {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); + }} + onChangeSlotTime={newStartTime => handleUpdateSlot(time, newStartTime)} + text={ + participants && + plural(participants.availableCount, { + 0: 'No participants registered', + one: '# participant registered', + other: '# participants registered', + }) + } /> - } - on="click" + ); + })} +
+ ))} +
+
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(); }} - 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) { + > + 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} + /> + -
- } - /> -
-
- {candidates.length === 0 && ( -
- - Click the timeline to add your first time slot -
- )} + }} + disabled={!timeslotTime || isTimeSlotTaken(timeslotTime)} + > + e.stopPropagation()} /> + +
+ } + />
- } - /> - ) : ( +
+
+ ); +} + +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
@@ -487,25 +406,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 +
)} - -
+ ); } diff --git a/newdle/client/src/components/creation/timeslots/Timeline.module.scss b/newdle/client/src/components/creation/timeslots/Timeline.module.scss index 8ee51ba0..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; @@ -44,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; @@ -136,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; @@ -212,9 +216,8 @@ $label-width: 180px; } &.edit { - background-color: lighten($green, 27%); - border: 5px solid lighten($green, 22%); - padding: 10px; + padding-top: 10px; + padding-bottom: 10px; .add-btn-wrapper { display: flex; diff --git a/newdle/client/src/locales/es/messages.po b/newdle/client/src/locales/es/messages.po index dd65d6d5..08325e27 100644 --- a/newdle/client/src/locales/es/messages.po +++ b/newdle/client/src/locales/es/messages.po @@ -111,11 +111,11 @@ msgstr "" msgid "Choose your participants" msgstr "Elige a los participantes" -#: src/components/creation/timeslots/Timeline.js:468 +#: src/components/creation/timeslots/Timeline.js:451 msgid "Click the timeline to add your first time slot" msgstr "" -#: src/components/creation/timeslots/Timeline.js:478 +#: src/components/creation/timeslots/Timeline.js:397 msgid "Click to add time slots" msgstr "" @@ -145,7 +145,7 @@ msgstr "" msgid "Copied!" msgstr "" -#: src/components/creation/timeslots/Timeline.js:483 +#: src/components/creation/timeslots/Timeline.js:402 msgid "Copy time slots from previous day" msgstr "" @@ -406,7 +406,7 @@ msgstr "" msgid "Please log in again to confirm your identity" msgstr "" -#: src/components/creation/timeslots/Timeline.js:562 +#: src/components/creation/timeslots/Timeline.js:508 msgid "Revert to the local timezone" msgstr "" @@ -523,11 +523,11 @@ msgstr "" msgid "Timeslots" msgstr "" -#: src/components/creation/timeslots/Timeline.js:570 +#: src/components/creation/timeslots/Timeline.js:516 msgid "Timezone" msgstr "" -#: src/components/creation/timeslots/Timeline.js:568 +#: src/components/creation/timeslots/Timeline.js:514 msgid "Timezone {revertIcon}" msgstr "" @@ -637,7 +637,7 @@ msgstr "" msgid "switch back to the <0>{defaultUserTz} timezone" msgstr "" -#: src/components/creation/timeslots/Timeline.js:379 +#: src/components/creation/timeslots/Timeline.js:305 msgid "{0, plural, =0 {No participants registered} one {# participant registered} other {# participants registered}}" msgstr "" diff --git a/newdle/core/webargs.py b/newdle/core/webargs.py index 1d051d37..b0645400 100644 --- a/newdle/core/webargs.py +++ b/newdle/core/webargs.py @@ -14,14 +14,14 @@ def _strip_whitespace(value): return value -class WhitspaceStrippingFlaskParser(flaskparser.FlaskParser): +class WhitespaceStrippingFlaskParser(flaskparser.FlaskParser): def pre_load(self, location_data, *, schema, req, location): if location in ('query', 'form', 'json'): return _strip_whitespace(location_data) return location_data -parser = WhitspaceStrippingFlaskParser(unknown=EXCLUDE) +parser = WhitespaceStrippingFlaskParser(unknown=EXCLUDE) @parser.error_handler