-
Notifications
You must be signed in to change notification settings - Fork 73
New issue
Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? # to your account
Use mouse click to add a new time slot #495
Conversation
76cb1ad
to
f270dde
Compare
Thanks again for working on this! Couple more ideas for improvements:
|
f270dde
to
b5119af
Compare
These glitches should be fixed on the last version, which also has the steps for the candidate placeholder and a help message. |
Much better! Since we now display all hours 0-24, the timeline feels a bit too small- you have to be more precise with your mouse because there's a only a few pixels between each step. We have some space on the left so I think we could use it for the timeline. My other issue is with having the placeholder as Here's what I have in mind: Diffdiff --git a/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js b/newdle/client/src/components/creation/timeslots/CandidatePlaceholder.js
index d7a4e19..3816ec8 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 (
- <div
- style={{
- background: 'rgba(0, 0, 0, 0.3)',
- borderRadius: '3px',
- color: 'white',
- display: 'block',
- height: height,
- left: xPosition,
- padding: '4px',
- position: 'fixed',
- pointerEvents: 'none',
- top: yPosition,
- transform: 'translate(-50%, -100%)',
- width: `${widthPercent}%`,
- zIndex: 1000,
- }}
+ <Popup
+ content={time}
+ open={true}
+ position="top center"
+ trigger={
+ <div
+ style={{
+ boxSizing: 'border-box',
+ position: 'absolute',
+ left: `${left}%`,
+ width: `${width}%`,
+ top: 5,
+ height: 'calc(100% - 10px)',
+ zIndex: 1000,
+ background: 'rgba(0, 0, 0, 0.2)',
+ borderRadius: '3px',
+ display: 'block',
+ pointerEvents: 'none',
+ }}
+ />
+ }
/>
);
}
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 f7c7468..9b7f32c 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,220 +232,152 @@ 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 ? (
- <Popup
- content={candidatePlaceholder.time}
- open={candidatePlaceholder.visible}
- popperModifiers={[
- {
- name: 'offset',
- enabled: true,
- options: {
- offset: [candidatePlaceholder.tooltipX + candidatePlaceholder.tooltipMarginLeft, 0],
- },
- },
- ]}
- trigger={
- <div>
- <div
- className={`${styles['timeline-input']} ${styles['edit']}`}
- onClick={event => {
- handleMouseDown(event);
- handleTimelineMouseLeave();
- }}
- onMouseMove={handleTimelineMouseMove}
- onMouseLeave={handleTimelineMouseLeave}
- >
- <div className={styles['timeline-candidates']}>
- {groupedCandidates.map((rowCandidates, i) => (
- <div
- className={styles['candidates-group']}
- key={i}
- onMouseEnter={() => {
- // 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 (
- <CandidateSlot
- {...slotProps}
- key={time}
- isValidTime={time => !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',
- })
- }
- />
- );
- })}
- </div>
- ))}
- {candidatePlaceholder.visible && (
- <CandidatePlaceholder
- xPosition={candidatePlaceholder.candidateX}
- yPosition={candidatePlaceholder.candidateY}
- height={candidatePlaceholder.candidateHeight}
- widthPercent={candidatePlaceholder.width}
- />
- )}
- </div>
- <div onMouseMove={e => e.stopPropagation()} className={styles['add-btn-wrapper']}>
- <Popup
- trigger={
- <Icon
- className={`${styles['clickable']} ${styles['add-btn']}`}
- name="plus circle"
- size="large"
- onMouseMove={e => e.stopPropagation()}
+ return (
+ <div>
+ <div
+ className={`${styles['timeline-input']} ${styles['edit']}`}
+ onClick={event => {
+ handleMouseDown(event);
+ handleTimelineMouseLeave();
+ }}
+ onMouseMove={handleTimelineMouseMove}
+ onMouseLeave={handleTimelineMouseLeave}
+ >
+ <CandidatePlaceholder {...candidatePlaceholder} />
+ <div className={styles['timeline-candidates']}>
+ {groupedCandidates.map((rowCandidates, i) => (
+ <div
+ className={styles['candidates-group']}
+ key={i}
+ onMouseEnter={() => {
+ // 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 (
+ <CandidateSlot
+ {...slotProps}
+ key={time}
+ isValidTime={time => !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"
+ );
+ })}
+ </div>
+ ))}
+ </div>
+ <div onMouseMove={e => e.stopPropagation()} className={styles['add-btn-wrapper']}>
+ <Popup
+ trigger={
+ <Icon
+ className={`${styles['clickable']} ${styles['add-btn']}`}
+ name="plus circle"
+ size="large"
+ onMouseMove={e => 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={
+ <div
+ // We need a div to attach events
+ onClick={e => 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) {
+ >
+ <TimePicker
+ showSecond={false}
+ value={toMoment(timeslotTime, DEFAULT_TIME_FORMAT)}
+ format={DEFAULT_TIME_FORMAT}
+ onChange={time => 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}
+ />
+ <Button
+ icon
+ onMouseMove={e => e.stopPropagation()}
+ onClick={() => {
handleAddSlot(timeslotTime);
handlePopupClose();
- }
- }}
- className={styles['timepicker-popup']}
- content={
- <div
- // We need a div to attach events
- onClick={e => e.stopPropagation()}
- onMouseMove={e => {
- e.stopPropagation();
- }}
- >
- <TimePicker
- showSecond={false}
- value={toMoment(timeslotTime, DEFAULT_TIME_FORMAT)}
- format={DEFAULT_TIME_FORMAT}
- onChange={time =>
- 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}
- />
- <Button
- icon
- onMouseMove={e => e.stopPropagation()}
- onClick={() => {
- handleAddSlot(timeslotTime);
- handlePopupClose();
- }}
- disabled={!timeslotTime || isTimeSlotTaken(timeslotTime)}
- >
- <Icon name="check" onMouseMove={e => e.stopPropagation()} />
- </Button>
- </div>
- }
- />
- </div>
- </div>
- {candidates.length === 0 && (
- <div className={styles['add-first-text']}>
- <Icon name="mouse pointer" />
- <Trans>Click the timeline to add your first time slot</Trans>
- </div>
- )}
+ }}
+ disabled={!timeslotTime || isTimeSlotTaken(timeslotTime)}
+ >
+ <Icon name="check" onMouseMove={e => e.stopPropagation()} />
+ </Button>
+ </div>
+ }
+ />
</div>
- }
- />
- ) : (
- <div className={styles['timeline-input-wrapper']}>
- <div className={`${styles['timeline-input']} ${styles['msg']}`} onClick={handleStartEditing}>
- <Icon name="plus circle" size="large" />
- <Trans>Click to add time slots</Trans>
</div>
- {pastCandidates && (
- <div className={`${styles['timeline-input']} ${styles['msg']}`} onClick={handleCopyClick}>
- <Icon name="copy" size="large" />
- <Trans>Copy time slots from previous day</Trans>
- </div>
- )}
</div>
);
}
@@ -492,23 +387,74 @@ TimelineInput.propTypes = {
maxHour: PropTypes.number.isRequired,
};
-function TimelineContent({busySlots: allBusySlots, minHour, maxHour}) {
+function ClickToAddTimeSlots({startEditing, copyTimeSlots}) {
+ const pastCandidates = useSelector(getPreviousDayTimeslots);
+
return (
- <div className={styles['timeline-rows']}>
- {allBusySlots.map(slot => (
- <TimelineRow {...slot} key={slot.participant.email} />
- ))}
- {allBusySlots.map(({busySlots, participant}) =>
- busySlots.map(slot => {
- const key = `${participant.email}-${slot.startTime}-${slot.endTime}`;
- return <BusyColumn {...slot} key={key} />;
- })
+ <div className={styles['timeline-input-wrapper']}>
+ <div className={`${styles['timeline-input']} ${styles['msg']}`} onClick={startEditing}>
+ <Icon name="plus circle" size="large" />
+ <Trans>Click to add time slots</Trans>
+ </div>
+ {pastCandidates && (
+ <div className={`${styles['timeline-input']} ${styles['msg']}`} onClick={copyTimeSlots}>
+ <Icon name="copy" size="large" />
+ <Trans>Copy time slots from previous day</Trans>
+ </div>
)}
- <TimelineInput minHour={minHour} maxHour={maxHour} />
</div>
);
}
+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 (
+ <ClickToAddTimeSlots startEditing={() => setEditing(true)} copyTimeSlots={copyTimeSlots} />
+ );
+ }
+
+ return (
+ <>
+ <div className={styles['timeline-rows']}>
+ {allBusySlots.map(slot => (
+ <TimelineRow {...slot} key={slot.participant.email} />
+ ))}
+ {allBusySlots.map(({busySlots, participant}) =>
+ busySlots.map(slot => {
+ const key = `${participant.email}-${slot.startTime}-${slot.endTime}`;
+ return <BusyColumn {...slot} key={key} />;
+ })
+ )}
+ <TimelineInput minHour={minHour} maxHour={maxHour} />
+ </div>
+ {editing && candidates.length === 0 && (
+ <div className={styles['add-first-text']}>
+ <Icon name="mouse pointer" />
+ <Trans>Click the timeline to add your first time slot</Trans>
+ </div>
+ )}
+ </>
+ );
+}
+
TimelineContent.propTypes = {
busySlots: PropTypes.array.isRequired,
minHour: PropTypes.number.isRequired,
diff --git a/newdle/client/src/components/creation/timeslots/Timeline.module.scss b/newdle/client/src/components/creation/timeslots/Timeline.module.scss
index 8ee51ba..3849863 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; |
4d16658
to
b546167
Compare
Refactor candidate placeholder Co-authored-by: Tomas Roun <tomas.roun@cern.ch> Fix tooltips and hide placeholder when x button is hovered Do not expand 2 days if end of slot is next day
b546167
to
4819972
Compare
@renefs is there anything missing in this PR? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
works fine now! :)
I think it should be fine :) |
OK will merge later today (leaving for lunch now and don't want to trigger a deployment right before going AFK) |
This issue resolves #490. Continues #494 PR.
Screen.Recording.2024-10-22.at.09.28.58.mov