-
Notifications
You must be signed in to change notification settings - Fork 73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in 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 <[email protected]> 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