Skip to content

Commit f270dde

Browse files
committed
Create slots on Timeline click
1 parent 1633f31 commit f270dde

File tree

5 files changed

+276
-88
lines changed

5 files changed

+276
-88
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
/**
4+
* Displays a placeholder for a candidate time slot when the Timeline is hovered.
5+
*/
6+
export default function CandidatePlaceholder({xPosition, yPosition, height, widthPercent}) {
7+
return (
8+
<div
9+
style={{
10+
background: 'rgba(0, 0, 0, 0.3)',
11+
borderRadius: '3px',
12+
color: 'white',
13+
display: 'block',
14+
height: height,
15+
left: xPosition,
16+
padding: '4px',
17+
position: 'fixed',
18+
pointerEvents: 'none',
19+
top: yPosition,
20+
transform: 'translate(-50%, -100%)',
21+
width: `${widthPercent}%`,
22+
zIndex: 1000,
23+
}}
24+
/>
25+
);
26+
}
27+
28+
CandidatePlaceholder.propTypes = {
29+
height: PropTypes.number.isRequired,
30+
widthPercent: PropTypes.number.isRequired,
31+
xPosition: PropTypes.number.isRequired,
32+
yPosition: PropTypes.number.isRequired,
33+
};

newdle/client/src/components/creation/timeslots/CandidateSlot.js

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function SlotEditWidget({startTime, onChange, isValidTime, slot}) {
1616
<Popup
1717
className={styles['timepicker-popup']}
1818
on="click"
19+
onClick={e => e.stopPropagation()}
1920
content={
2021
<>
2122
<TimePicker
@@ -68,6 +69,7 @@ export default function CandidateSlot({
6869
}) {
6970
const slot = (
7071
<Slot
72+
onClick={e => e.stopPropagation()}
7173
width={width}
7274
pos={pos}
7375
moreStyles={styles['candidate']}

newdle/client/src/components/creation/timeslots/Timeline.js

+223-74
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import {hourRange, toMoment, getHourSpan, DEFAULT_TIME_FORMAT} from '../../../util/date';
2020
import {useIsSmallScreen} from '../../../util/hooks';
2121
import TimezonePicker from '../../common/TimezonePicker';
22+
import CandidatePlaceholder from './CandidatePlaceholder';
2223
import CandidateSlot from './CandidateSlot';
2324
import DurationPicker from './DurationPicker';
2425
import TimelineHeader from './TimelineHeader';
@@ -171,6 +172,17 @@ function TimelineInput({minHour, maxHour}) {
171172
const latestStartTime = useSelector(getNewTimeslotStartTime);
172173
const [timeslotTime, setTimeslotTime] = useState(latestStartTime);
173174
const [newTimeslotPopupOpen, setTimeslotPopupOpen] = useState(false);
175+
// Indicates the position of the mouse in the timeline
176+
const [candidatePlaceholder, setCandidatePlaceholder] = useState({
177+
visible: false,
178+
time: '',
179+
x: 0,
180+
y: 0,
181+
});
182+
// We don't want to show the tooltip when the mouse is hovering over a slot
183+
const [isHoveringSlot, setIsHoveringSlot] = useState(false);
184+
// const baseWidth = calculateWidth("00:00", duration, minHour, maxHour);
185+
const placeHolderSlot = getCandidateSlotProps('00:00', duration, minHour, maxHour);
174186

175187
useEffect(() => {
176188
setTimeslotTime(latestStartTime);
@@ -196,92 +208,228 @@ function TimelineInput({minHour, maxHour}) {
196208
dispatch(addTimeslot(date, time));
197209
};
198210

199-
const handleRemoveSlot = time => {
211+
const handleRemoveSlot = (event, time) => {
200212
dispatch(removeTimeslot(date, time));
213+
setIsHoveringSlot(false);
201214
};
202215

203216
const handleUpdateSlot = (oldTime, newTime) => {
204217
dispatch(removeTimeslot(date, oldTime));
205218
dispatch(addTimeslot(date, newTime));
206219
};
207220

221+
const handleMouseDown = e => {
222+
const parentRect = e.target.getBoundingClientRect();
223+
const totalMinutes = (maxHour - minHour) * 60;
224+
225+
// Get the parent rect start position
226+
const parentRectStart = parentRect.left;
227+
// Get the parent rect end position
228+
const parentRectEnd = parentRect.right;
229+
230+
const clickPositionRelative = (e.clientX - parentRectStart) / (parentRectEnd - parentRectStart);
231+
232+
let clickTimeRelative = clickPositionRelative * totalMinutes;
233+
234+
// Round clickTimeRelative to the nearest 15-minute interval
235+
clickTimeRelative = Math.round(clickTimeRelative / 15) * 15;
236+
237+
// Convert clickTimeRelative to a time format (HH:mm)
238+
const clickTimeRelativeTime = moment()
239+
.startOf('day')
240+
.add(clickTimeRelative, 'minutes')
241+
.format('HH:mm');
242+
243+
const canBeAdded = clickTimeRelativeTime && !candidates.includes(clickTimeRelativeTime);
244+
if (canBeAdded) {
245+
handleAddSlot(clickTimeRelativeTime);
246+
}
247+
};
248+
249+
/**
250+
* Tracks the mouse movement in the timeline and updates the candidatePlaceholder state
251+
* @param {Event} e
252+
* @returns
253+
*/
254+
const handleTimelineMouseMove = e => {
255+
if (isHoveringSlot) {
256+
setCandidatePlaceholder({visible: false});
257+
return;
258+
}
259+
const parentRect = e.target.getBoundingClientRect();
260+
const relativeMouseXPosition = e.clientX - parentRect.left;
261+
const relativeMouseYPosition = parentRect.top - e.clientY;
262+
263+
const totalMinutes = (maxHour - minHour) * 60; // Total minutes in the timeline
264+
let timeInMinutes = (relativeMouseXPosition / parentRect.width) * totalMinutes;
265+
// Round timeInMinutes to the nearest 15-minute interval
266+
timeInMinutes = Math.round(timeInMinutes / 15) * 15;
267+
const slotWidth = (placeHolderSlot.width / parentRect.width) * 100;
268+
269+
const hours = Math.floor(timeInMinutes / 60) + minHour;
270+
const minutes = Math.floor(timeInMinutes % 60);
271+
const time = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
272+
273+
const tempPlaceholder = {
274+
visible: true,
275+
time,
276+
x: relativeMouseXPosition,
277+
y: relativeMouseYPosition,
278+
clientX: e.clientX,
279+
clientY: parentRect.top + parentRect.height,
280+
height: parentRect.height,
281+
parentHeight: parentRect.height - 10,
282+
width: slotWidth,
283+
};
284+
285+
if (hours >= 0 && minutes >= 0) {
286+
setCandidatePlaceholder(tempPlaceholder);
287+
}
288+
};
289+
290+
const handleTimelineMouseLeave = () => {
291+
setCandidatePlaceholder({visible: false});
292+
};
293+
208294
const groupedCandidates = splitOverlappingCandidates(candidates, duration);
209295

210296
return editing ? (
211-
<div className={`${styles['timeline-input']} ${styles['edit']}`}>
212-
<div className={styles['timeline-candidates']}>
213-
{groupedCandidates.map((rowCandidates, i) => (
214-
<div className={styles['candidates-group']} key={i}>
215-
{rowCandidates.map(time => {
216-
const slotProps = getCandidateSlotProps(time, duration, minHour, maxHour);
217-
const participants = availability?.find(a => a.startDt === `${date}T${time}`);
218-
return (
219-
<CandidateSlot
220-
{...slotProps}
221-
key={time}
222-
isValidTime={time => !candidates.includes(time)}
223-
onDelete={() => handleRemoveSlot(time)}
224-
onChangeSlotTime={newStartTime => handleUpdateSlot(time, newStartTime)}
225-
text={
226-
participants &&
227-
plural(participants.availableCount, {
228-
0: 'No participants registered',
229-
one: '# participant registered',
230-
other: '# participants registered',
231-
})
232-
}
233-
/>
234-
);
235-
})}
297+
<Popup
298+
content={candidatePlaceholder.time}
299+
open={candidatePlaceholder.visible}
300+
popperModifiers={[
301+
{
302+
name: 'offset',
303+
enabled: true,
304+
options: {
305+
offset: [candidatePlaceholder.x, 0],
306+
},
307+
},
308+
]}
309+
trigger={
310+
<div
311+
className={`${styles['timeline-input']} ${styles['edit']}`}
312+
onClick={event => handleMouseDown(event)}
313+
onMouseMove={handleTimelineMouseMove}
314+
onMouseLeave={handleTimelineMouseLeave}
315+
>
316+
<div className={styles['timeline-candidates']}>
317+
{groupedCandidates.map((rowCandidates, i) => (
318+
<div
319+
className={styles['candidates-group']}
320+
key={i}
321+
onMouseEnter={() => {
322+
// Prevent the candidate placeholder from showing when hovering over a slot
323+
setIsHoveringSlot(true);
324+
}}
325+
onMouseLeave={() => {
326+
setIsHoveringSlot(false);
327+
}}
328+
>
329+
{rowCandidates.map(time => {
330+
const slotProps = getCandidateSlotProps(time, duration, minHour, maxHour);
331+
const participants = availability?.find(a => a.startDt === `${date}T${time}`);
332+
return (
333+
<CandidateSlot
334+
{...slotProps}
335+
key={time}
336+
isValidTime={time => !candidates.includes(time)}
337+
onDelete={event => {
338+
// Prevent the event from bubbling up to the parent div
339+
event.stopPropagation();
340+
handleRemoveSlot(event, time);
341+
}}
342+
onChangeSlotTime={newStartTime => handleUpdateSlot(time, newStartTime)}
343+
text={
344+
participants &&
345+
plural(participants.availableCount, {
346+
0: 'No participants registered',
347+
one: '# participant registered',
348+
other: '# participants registered',
349+
})
350+
}
351+
/>
352+
);
353+
})}
354+
</div>
355+
))}
356+
{candidatePlaceholder.visible && (
357+
<CandidatePlaceholder
358+
xPosition={candidatePlaceholder.clientX}
359+
yPosition={candidatePlaceholder.clientY}
360+
height={candidatePlaceholder.parentHeight}
361+
widthPercent={candidatePlaceholder.width}
362+
/>
363+
)}
236364
</div>
237-
))}
238-
</div>
239-
<Popup
240-
trigger={
241-
<Icon
242-
className={`${styles['clickable']} ${styles['add-btn']}`}
243-
name="plus circle"
244-
size="large"
245-
/>
246-
}
247-
on="click"
248-
position="bottom center"
249-
onOpen={() => setTimeslotPopupOpen(true)}
250-
onClose={handlePopupClose}
251-
open={newTimeslotPopupOpen}
252-
onKeyDown={evt => {
253-
const canBeAdded = timeslotTime && !candidates.includes(timeslotTime);
254-
if (evt.key === 'Enter' && canBeAdded) {
255-
handleAddSlot(timeslotTime);
256-
handlePopupClose();
257-
}
258-
}}
259-
className={styles['timepicker-popup']}
260-
content={
261-
<>
262-
<TimePicker
263-
showSecond={false}
264-
value={toMoment(timeslotTime, DEFAULT_TIME_FORMAT)}
265-
format={DEFAULT_TIME_FORMAT}
266-
onChange={time => setTimeslotTime(time ? time.format(DEFAULT_TIME_FORMAT) : null)}
267-
allowEmpty={false}
268-
// keep the picker in the DOM tree of the surrounding element
269-
getPopupContainer={node => node}
270-
/>
271-
<Button
272-
icon
273-
onClick={() => {
274-
handleAddSlot(timeslotTime);
275-
handlePopupClose();
365+
<div onMouseMove={e => e.stopPropagation()} className={styles['add-btn-wrapper']}>
366+
<Popup
367+
trigger={
368+
<Icon
369+
className={`${styles['clickable']} ${styles['add-btn']}`}
370+
name="plus circle"
371+
size="large"
372+
onMouseMove={e => e.stopPropagation()}
373+
/>
374+
}
375+
on="click"
376+
onMouseMove={e => {
377+
e.stopPropagation();
276378
}}
277-
disabled={!timeslotTime || candidates.includes(timeslotTime)}
278-
>
279-
<Icon name="check" />
280-
</Button>
281-
</>
282-
}
283-
/>
284-
</div>
379+
position="bottom center"
380+
onOpen={evt => {
381+
// Prevent the event from bubbling up to the parent div
382+
evt.stopPropagation();
383+
setTimeslotPopupOpen(true);
384+
}}
385+
onClose={handlePopupClose}
386+
open={newTimeslotPopupOpen}
387+
onKeyDown={evt => {
388+
const canBeAdded = timeslotTime && !candidates.includes(timeslotTime);
389+
if (evt.key === 'Enter' && canBeAdded) {
390+
handleAddSlot(timeslotTime);
391+
handlePopupClose();
392+
}
393+
}}
394+
className={styles['timepicker-popup']}
395+
content={
396+
<div
397+
// We need a div to attach events
398+
onClick={e => e.stopPropagation()}
399+
onMouseMove={e => {
400+
e.stopPropagation();
401+
}}
402+
>
403+
<TimePicker
404+
showSecond={false}
405+
value={toMoment(timeslotTime, DEFAULT_TIME_FORMAT)}
406+
format={DEFAULT_TIME_FORMAT}
407+
onChange={time =>
408+
setTimeslotTime(time ? time.format(DEFAULT_TIME_FORMAT) : null)
409+
}
410+
onMouseMove={e => e.stopPropagation()}
411+
allowEmpty={false}
412+
// keep the picker in the DOM tree of the surrounding element
413+
getPopupContainer={node => node}
414+
/>
415+
<Button
416+
icon
417+
onMouseMove={e => e.stopPropagation()}
418+
onClick={() => {
419+
handleAddSlot(timeslotTime);
420+
handlePopupClose();
421+
}}
422+
disabled={!timeslotTime || candidates.includes(timeslotTime)}
423+
>
424+
<Icon name="check" onMouseMove={e => e.stopPropagation()} />
425+
</Button>
426+
</div>
427+
}
428+
/>
429+
</div>
430+
</div>
431+
}
432+
/>
285433
) : (
286434
<div className={styles['timeline-input-wrapper']}>
287435
<div className={`${styles['timeline-input']} ${styles['msg']}`} onClick={handleStartEditing}>
@@ -316,6 +464,7 @@ function TimelineContent({busySlots: allBusySlots, minHour, maxHour}) {
316464
})
317465
)}
318466
<TimelineInput minHour={minHour} maxHour={maxHour} />
467+
{/* <TimelineDragAndDrop duration={duration} /> */}
319468
</div>
320469
);
321470
}
@@ -431,7 +580,7 @@ Timeline.propTypes = {
431580
};
432581

433582
Timeline.defaultProps = {
434-
defaultMinHour: 8,
583+
defaultMinHour: 0,
435584
defaultMaxHour: 24,
436585
hourStep: 2,
437586
};

0 commit comments

Comments
 (0)