@colisweb/rescript-toolkit 5.47.2 → 5.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/locale/fr.json CHANGED
@@ -164,6 +164,11 @@
164
164
  "defaultMessage": "Doit être un entier positif",
165
165
  "message": "Doit être un entier positif"
166
166
  },
167
+ {
168
+ "id": "_87223ce4",
169
+ "defaultMessage": "Réinitialiser l'échelle",
170
+ "message": "Réinitialiser l'échelle"
171
+ },
167
172
  {
168
173
  "id": "_8b33a5ae",
169
174
  "defaultMessage": "Votre identifiant",
@@ -199,6 +204,11 @@
199
204
  "defaultMessage": "Lundi",
200
205
  "message": "Lundi"
201
206
  },
207
+ {
208
+ "id": "_a37c0ff0",
209
+ "defaultMessage": "Augmenter l'échelle",
210
+ "message": "Augmenter l'échelle"
211
+ },
202
212
  {
203
213
  "id": "_a7e73ea4",
204
214
  "defaultMessage": "Déconnexion",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colisweb/rescript-toolkit",
3
- "version": "5.47.2",
3
+ "version": "5.48.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "clean": "rescript clean",
@@ -47,3 +47,4 @@ module PasswordRulesNotice = Toolkit__Ui_PasswordRulesNotice
47
47
  module SectionCard = Toolkit__Ui_SectionCard
48
48
  module SuspenseImage = Toolkit__Ui_SuspenseImage
49
49
  module Sheet = Toolkit__Ui_Sheet
50
+ module Timeline = Toolkit__Ui_Timeline
@@ -0,0 +1,593 @@
1
+ open ReactIntl
2
+
3
+ let formatToPx = (number: float) => `${number->Float.toString}px`
4
+
5
+ let markerWidth = 24.
6
+ let padding = 24.
7
+ type measuringScale = {
8
+ startHour: float,
9
+ endHour: float,
10
+ displayedHours: float,
11
+ hours: array<int>,
12
+ containerWidth: float,
13
+ // INFO:
14
+ // This is calculated with the container width and the number of displayed hours
15
+ hourSizePx: float,
16
+ minuteSizePx: float,
17
+ }
18
+
19
+ let calculateMeasuringScale = (~containerWidth, ~startHour: float, ~endHour): measuringScale => {
20
+ // INFO: We must add 1 to the displayedHours value in order to take into account the last hour visibility.
21
+ let displayedHours = endHour -. startHour +. 1.
22
+ let hourSizePx = (containerWidth -. padding *. 2.) /. displayedHours
23
+ // Based on the hour size, we can calculte the width of 1 minute duration
24
+ let minuteSizePx = hourSizePx /. 60.
25
+ let hours = []
26
+
27
+ for h in startHour->Float.toInt to endHour->Float.toInt {
28
+ hours->Js.Array2.push(h)->ignore
29
+ }
30
+
31
+ {
32
+ startHour,
33
+ endHour,
34
+ hours,
35
+ displayedHours,
36
+ containerWidth,
37
+ hourSizePx,
38
+ minuteSizePx,
39
+ }
40
+ }
41
+ type state = {
42
+ // Required to reset the value
43
+ initialContainerWidth: float,
44
+ measuringScale: measuringScale,
45
+ }
46
+
47
+ type timelinePointExtras = {
48
+ className?: string,
49
+ content?: React.element,
50
+ popoverContent?: React.element,
51
+ popoverContentOptions?: Radix.Popover.Content.options,
52
+ }
53
+ type timelinePoint = {
54
+ start: Js.Date.t,
55
+ end: Js.Date.t,
56
+ extras?: timelinePointExtras,
57
+ }
58
+
59
+ type marker =
60
+ | Cluster(array<timelinePoint>)
61
+ | Point(timelinePoint)
62
+
63
+ type timlineInfos = {content: React.element, className?: string}
64
+
65
+ type timeline<'data> = {
66
+ points: array<timelinePoint>,
67
+ id: string,
68
+ infos?: timlineInfos,
69
+ }
70
+
71
+ type markerOffset = {
72
+ offsetStart: float,
73
+ offsetEnd: float,
74
+ width: float,
75
+ }
76
+
77
+ let getMarkerOffset = (~marker: marker, ~measuringScale: measuringScale) => {
78
+ let {startHour} = measuringScale
79
+ // INFO:
80
+ // The task is at the beginning of the day, so the index will be 0
81
+ // This index will be multiplied by the hourSize to give it the right position
82
+ // Ex: taskHour is 10h00, startHour is 8h, and the hourSize is 100px
83
+ // (10-8) * 100 = 200 -> the marker will have 200px of offset
84
+
85
+ let calculateOffset = (date: Js.Date.t) => {
86
+ let (startTaskHour, startTaskMinutes) = (date->Js.Date.getHours, date->Js.Date.getMinutes)
87
+ let startHourIndex = startTaskHour -. startHour
88
+ let hourPx = (startHourIndex *. measuringScale.hourSizePx)->Js.Math.floor_float
89
+ let minutesPx = startTaskMinutes *. measuringScale.minuteSizePx
90
+
91
+ hourPx +. minutesPx +. padding
92
+ }
93
+
94
+ switch marker {
95
+ | Cluster(tasks) => {
96
+ let firstTask = tasks[0]->Option.getExn
97
+ let lastTask = tasks->Array.tailExn
98
+
99
+ let offsetStart = calculateOffset(firstTask.start)
100
+ let offsetEnd = calculateOffset(lastTask.end)
101
+
102
+ {
103
+ offsetStart,
104
+ offsetEnd,
105
+ width: offsetEnd -. offsetStart,
106
+ }
107
+ }
108
+ | Point(task) => {
109
+ let offsetStart = calculateOffset(task.start)
110
+ let offsetEnd = calculateOffset(task.end)
111
+
112
+ // Can be drawn like this:
113
+ // ----[offsetStart][offsetWidth][offsetEnd]----
114
+ {
115
+ offsetStart,
116
+ offsetEnd,
117
+ width: Js.Math.max_float(markerWidth, offsetEnd -. offsetStart),
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ module HoursRuler = {
124
+ @react.component
125
+ let make = (~setMeasuringScale, ~state: state) => {
126
+ let currentHour = Js.Date.make()->DateFns.getHours
127
+ let {initialContainerWidth, measuringScale: {containerWidth, startHour, endHour}} = state
128
+ let scroll = Toolkit__Hooks.useScrollPosition()
129
+
130
+ let handleClick = (i, event) => {
131
+ event->JsxEventC.Mouse.preventDefault
132
+
133
+ let newScaling = calculateMeasuringScale(
134
+ ~containerWidth=containerWidth +. 1500.,
135
+ ~startHour,
136
+ ~endHour,
137
+ )
138
+ setMeasuringScale(_ => {
139
+ Some({
140
+ initialContainerWidth,
141
+ measuringScale: newScaling,
142
+ })
143
+ })
144
+ Js.Global.setTimeout(() => {
145
+ ReactDOM.querySelector("#timeline-container")->Option.forEach(div => {
146
+ (div->Obj.magic)["scroll"](. {
147
+ "left": i->Int.toFloat *. newScaling.hourSizePx -. initialContainerWidth /. 2.,
148
+ "behavior": "smooth",
149
+ })
150
+ })
151
+ }, 16)->ignore
152
+ }
153
+
154
+ <section
155
+ className={cx([
156
+ "flex flex-row bg-neutral-100 pb-3 h-8 px-[--padding] absolute top-[--top] z-30",
157
+ ])}
158
+ style={Obj.magic({
159
+ "--padding": padding->formatToPx,
160
+ "--top": (scroll.y < 230. ? 0. : scroll.y -. 230.)->formatToPx,
161
+ })}>
162
+ {state.measuringScale.hours
163
+ ->Array.mapWithIndex((i, h) => {
164
+ <React.Fragment key={`timline-hour-${h->Int.toString}`}>
165
+ <div
166
+ className={"h-8 flex-shrink-0 text-sm text-neutral-700 font-mono w-[--size] transition-all duration-250 ease-linear relative"}
167
+ style={Obj.magic({
168
+ "--size": state.measuringScale.hourSizePx->formatToPx,
169
+ })}>
170
+ <span
171
+ onClick={handleClick(i)}
172
+ onContextMenu={handleClick(i)}
173
+ className={cx([
174
+ "absolute right-full transform translate-x-1/2 z-20 rounded py-0.5 cursor-pointer",
175
+ currentHour === h
176
+ ? "bg-primary-600 font-semibold text-white px-1"
177
+ : "bg-neutral-100 !rounded-none",
178
+ ])}>
179
+ {`${(h >= 24 ? h - 24 : h)->Int.toString}h`->React.string}
180
+ </span>
181
+ </div>
182
+ <span
183
+ className="absolute top-0 w-px h-full border-l border-dashed border-neutral-400 ml-[--offsetX]"
184
+ style={Obj.magic({
185
+ "--offsetX": (state.measuringScale.hourSizePx *.
186
+ (h->Int.toFloat -. state.measuringScale.startHour))->formatToPx,
187
+ })}
188
+ />
189
+ </React.Fragment>
190
+ })
191
+ ->React.array}
192
+ </section>
193
+ }
194
+ }
195
+
196
+ module CurrentTimeIndicator = {
197
+ @react.component
198
+ let make = (~measuringScale: measuringScale) => {
199
+ let now = Js.Date.make()
200
+
201
+ let offset = {
202
+ let size =
203
+ (now->DateFns.getHours->Int.toFloat -. measuringScale.startHour) *.
204
+ measuringScale.hourSizePx +. padding
205
+ size +. 2. +. now->Js.Date.getMinutes *. measuringScale.minuteSizePx
206
+ }
207
+
208
+ <section
209
+ className={"w-1 h-full bg-primary-600 absolute left-[--offset-x] z-10 opacity-70"}
210
+ style={Obj.magic({
211
+ "--offset-x": offset->formatToPx,
212
+ })}
213
+ />
214
+ }
215
+ }
216
+
217
+ module Timeline = {
218
+ module Marker = {
219
+ @react.component
220
+ let make = (
221
+ ~marker: marker,
222
+ ~clusterClassName="",
223
+ ~markerClassName="",
224
+ ~measuringScale,
225
+ ~setMeasuringScale,
226
+ ) => {
227
+ let markerOffset = getMarkerOffset(~marker, ~measuringScale)
228
+
229
+ <div
230
+ onClick={_ => {
231
+ switch marker {
232
+ | Cluster(tasks) =>
233
+ setMeasuringScale(m => {
234
+ switch m {
235
+ | Some({
236
+ initialContainerWidth,
237
+ measuringScale: {containerWidth, startHour, endHour},
238
+ }) => {
239
+ let newScaling =
240
+ tasks->Array.length->Int.toFloat *.
241
+ measuringScale.hourSizePx *.
242
+ measuringScale.displayedHours
243
+
244
+ let newMeasuringScale = calculateMeasuringScale(
245
+ ~containerWidth=containerWidth +. newScaling,
246
+ ~startHour,
247
+ ~endHour,
248
+ )
249
+
250
+ let markerOffset = getMarkerOffset(~marker, ~measuringScale=newMeasuringScale)
251
+
252
+ Js.Global.setTimeout(() => {
253
+ ReactDOM.querySelector("#timeline-container")->Option.forEach(
254
+ div => {
255
+ (div->Obj.magic)["scroll"](. {
256
+ "left": markerOffset.offsetStart -. initialContainerWidth /. 2.,
257
+ "behavior": "smooth",
258
+ })
259
+ },
260
+ )
261
+ }, 150)->ignore
262
+
263
+ Some({
264
+ initialContainerWidth,
265
+ measuringScale: newMeasuringScale,
266
+ })
267
+ }
268
+
269
+ | _ => m
270
+ }
271
+ })
272
+ | Point(_) => ()
273
+ }
274
+ }}
275
+ style={{
276
+ "--x-offset": `${(markerOffset.offsetStart +. padding)->Float.toString}px`,
277
+ "--markerWidth": (markerOffset.width < 1. ? markerWidth : markerOffset.width)->formatToPx,
278
+ "--markerHeight": switch marker {
279
+ | Cluster(_) => markerWidth +. 10.
280
+ | Point(_) => markerWidth
281
+ }->formatToPx,
282
+ }->Obj.magic}
283
+ className={cx([
284
+ "flex-shrink-0 w-[--markerWidth] h-[--markerHeight] inline-flex justify-center items-center rounded-full absolute ml-[--x-offset] transition-all duration-150 ease-linear bg-white border-2 border-neutral-600",
285
+ switch marker {
286
+ | Cluster(_) => `${clusterClassName}`
287
+ | Point(p) =>
288
+ `${markerClassName} ${p.extras
289
+ ->Option.flatMap(v => v.className)
290
+ ->Option.getWithDefault("")}`
291
+ },
292
+ ])}>
293
+ {switch marker {
294
+ | Cluster(p) => p->Array.length->React.int
295
+ | Point({extras}) =>
296
+ switch extras.popoverContent {
297
+ | None =>
298
+ <div
299
+ className={"w-full h-[--markerHeight] cursor-pointer"}
300
+ style={{
301
+ "--markerHeight": switch marker {
302
+ | Cluster(_) => markerWidth +. 10.
303
+ | Point(_) => markerWidth
304
+ }->formatToPx,
305
+ }->Obj.magic}>
306
+ {extras.content->Option.getWithDefault(React.null)}
307
+ </div>
308
+ | Some(c) =>
309
+ <Toolkit__Ui_Popover
310
+ triggerAsChild={true}
311
+ trigger={<div
312
+ className={"w-full h-[--markerHeight] cursor-pointer"}
313
+ style={{
314
+ "--markerHeight": switch marker {
315
+ | Cluster(_) => markerWidth +. 10.
316
+ | Point(_) => markerWidth
317
+ }->formatToPx,
318
+ }->Obj.magic}>
319
+ {extras.content->Option.getWithDefault(React.null)}
320
+ </div>}
321
+ contentOptions=?{extras.popoverContentOptions}
322
+ content={c}
323
+ />
324
+ }
325
+ | Point(_) => React.null
326
+ }}
327
+ </div>
328
+ }
329
+ }
330
+ @react.component
331
+ let make = (~timeline: timeline<'a>, ~measuringScale, ~setMeasuringScale) => {
332
+ // INFO: handle the need of cluster if there are too many tasks in 20 min
333
+ let markers =
334
+ timeline.points
335
+ ->Array.reduce(Js.Dict.empty(), (acc, task) => {
336
+ let taskHour = task.start->DateFns.getHours
337
+ let taskMinutes = task.start->DateFns.getMinutes
338
+ let taskMinutes = switch taskMinutes {
339
+ | taskMinutes if taskMinutes >= 40 => 40
340
+ | taskMinutes if taskMinutes >= 20 => 20
341
+ | _ => 0
342
+ }
343
+ // INFO: We group tasks per 20 minutes
344
+ let key = `${taskHour->Int.toString}-${taskMinutes->Int.toString}`
345
+ acc->Js.Dict.set(
346
+ key,
347
+ acc
348
+ ->Js.Dict.get(key)
349
+ ->Option.getWithDefault([])
350
+ ->Array.concat([task]),
351
+ )
352
+ acc
353
+ })
354
+ ->Js.Dict.values
355
+ ->Array.map(tasksByPeriod => {
356
+ let maximumTasksAllowed = measuringScale.hourSizePx /. 3. /. markerWidth
357
+
358
+ if tasksByPeriod->Array.length->Int.toFloat > maximumTasksAllowed {
359
+ [Cluster(tasksByPeriod->Lodash.sortBy(v => v.start))]
360
+ } else {
361
+ tasksByPeriod->Array.map(t => Point(t))
362
+ }
363
+ })
364
+ ->Lodash.flatten
365
+ ->Lodash.sortBy(marker => {
366
+ switch marker {
367
+ | Point({start}) => start
368
+ | Cluster(tasks) => (tasks->Array.getUnsafe(0)).start
369
+ }
370
+ })
371
+
372
+ let lastMarkerOffset = getMarkerOffset(
373
+ ~marker=markers->Toolkit__Primitives.Array.tailExn,
374
+ ~measuringScale,
375
+ )
376
+
377
+ <div className="h-[44px]">
378
+ <div
379
+ className={"bg-white rounded z-20 w-[--width] @container"}
380
+ style={Obj.magic({
381
+ "--width": (lastMarkerOffset.offsetEnd +. padding *. 2.)->formatToPx,
382
+ })}>
383
+ <div
384
+ className={cx([
385
+ "flex flex-row items-center py-2 h-10 rounded border-2 border-neutral-400",
386
+ ])}>
387
+ <div
388
+ className={"h-0.5 bg-neutral-300 absolute w-[--width] ml-[--offsetX] "}
389
+ style={Obj.magic({
390
+ "--width": lastMarkerOffset.offsetEnd->formatToPx,
391
+ "--offsetX": (padding /. 2.)->formatToPx,
392
+ })}
393
+ />
394
+ {markers
395
+ ->Array.mapWithIndex((i, marker) => {
396
+ <Marker
397
+ key={`${timeline.id}-task-${i->Int.toString}`} marker measuringScale setMeasuringScale
398
+ />
399
+ })
400
+ ->React.array}
401
+ </div>
402
+ </div>
403
+ </div>
404
+ }
405
+ }
406
+
407
+ @react.component
408
+ let make = (
409
+ ~timelines: array<timeline<'a>>,
410
+ ~infosContainerClassName="",
411
+ ~displayCurrentTime=false,
412
+ ~centerOnMount=false,
413
+ ) => {
414
+ let containerRef = React.useRef(Js.Nullable.null)
415
+ let (measuringScale, setMeasuringScale) = React.useState((): option<state> => None)
416
+
417
+ React.useEffect(() => {
418
+ let getContainerWidth = _ => {
419
+ containerRef.current
420
+ ->Js.Nullable.toOption
421
+ ->Option.forEach(element => {
422
+ let containerWidth = (element->Browser.DomElement.getBoundingClientRect).width
423
+ let startHour = ref(8.)
424
+ let endHour = ref(23.)
425
+
426
+ timelines->Array.forEachWithIndex(
427
+ (i, timeline) => {
428
+ let sortedTasks = timeline.points->Lodash.sortBy(p => p.start)
429
+
430
+ switch sortedTasks[0] {
431
+ | Some({start}) => {
432
+ let h = start->DateFns.getHours->Int.toFloat
433
+
434
+ // INFO: We have to set the first route to be truely content based
435
+ if i === 0 {
436
+ startHour.contents = h
437
+ } else if h < startHour.contents {
438
+ startHour.contents = h
439
+ }
440
+ }
441
+ | _ => ()
442
+ }
443
+ switch sortedTasks->Toolkit__Primitives.Array.tail {
444
+ | Some({end}) => {
445
+ let h = end->DateFns.getHours->Int.toFloat
446
+
447
+ // INFO: We have to set the first route to be truely content based
448
+ if i === 0 {
449
+ endHour.contents = h
450
+ } else if h > endHour.contents {
451
+ endHour.contents = h
452
+ }
453
+ }
454
+ | _ => ()
455
+ }
456
+ },
457
+ )
458
+
459
+ let measuringScale = calculateMeasuringScale(
460
+ ~containerWidth=containerWidth +. 600.,
461
+ ~startHour=startHour.contents,
462
+ ~endHour=endHour.contents,
463
+ )
464
+
465
+ setMeasuringScale(
466
+ _ => Some({
467
+ initialContainerWidth: containerWidth,
468
+ measuringScale,
469
+ }),
470
+ )
471
+
472
+ if centerOnMount {
473
+ Js.Global.setTimeout(
474
+ () => {
475
+ let hourIndex = (endHour.contents -. startHour.contents) /. 2. +. 1.
476
+ let middleHourSection = hourIndex *. measuringScale.hourSizePx -. containerWidth /. 2.
477
+
478
+ ReactDOM.querySelector("#timeline-container")->Option.forEach(
479
+ div => {
480
+ (div->Obj.magic)["scroll"](. {
481
+ "left": middleHourSection,
482
+ "behavior": "smooth",
483
+ })
484
+ },
485
+ )
486
+ },
487
+ 100,
488
+ )->ignore
489
+ }
490
+ })
491
+ }
492
+
493
+ if !(containerRef.current->Js.Nullable.isNullable) {
494
+ getContainerWidth()
495
+ }
496
+
497
+ Browser.Window.addEventListener("resize", getContainerWidth)
498
+
499
+ Some(
500
+ () => {
501
+ Browser.Window.removeEventListener("resize", getContainerWidth)
502
+ },
503
+ )
504
+ }, (containerRef.current->Js.Nullable.isNullable, timelines->Array.length, centerOnMount))
505
+
506
+ <section className={"flex flex-row"}>
507
+ <div>
508
+ {measuringScale->Option.mapWithDefault(React.null, state => {
509
+ <section className={"flex items-center justify-end gap-2 mr-2"}>
510
+ <Toolkit__Ui_Tooltip
511
+ color={Black}
512
+ side={Top}
513
+ tooltipContent={<FormattedMessage defaultMessage={"Augmenter l'échelle"} />}
514
+ triggerContent={<Toolkit__Ui_Button
515
+ className={"!px-2"}
516
+ onClick={_ =>
517
+ setMeasuringScale(_ => {
518
+ Some({
519
+ initialContainerWidth: state.initialContainerWidth,
520
+ measuringScale: calculateMeasuringScale(
521
+ ~containerWidth=state.measuringScale.containerWidth +. 1500.,
522
+ ~startHour=state.measuringScale.startHour,
523
+ ~endHour=state.measuringScale.endHour,
524
+ ),
525
+ })
526
+ })}>
527
+ <ReactIcons.FaSearchPlus />
528
+ </Toolkit__Ui_Button>}
529
+ />
530
+ {state.measuringScale.containerWidth !== state.initialContainerWidth
531
+ ? <Toolkit__Ui_Tooltip
532
+ color={Black}
533
+ side={Top}
534
+ tooltipContent={<FormattedMessage defaultMessage={"Réinitialiser l'échelle"} />}
535
+ triggerContent={<Toolkit__Ui_Button
536
+ className={"!px-2"}
537
+ onClick={_ =>
538
+ setMeasuringScale(_ => {
539
+ Some({
540
+ initialContainerWidth: state.initialContainerWidth,
541
+ measuringScale: calculateMeasuringScale(
542
+ ~containerWidth=state.initialContainerWidth,
543
+ ~startHour=state.measuringScale.startHour,
544
+ ~endHour=state.measuringScale.endHour,
545
+ ),
546
+ })
547
+ })}>
548
+ <ReactIcons.MdUndo />
549
+ </Toolkit__Ui_Button>}
550
+ />
551
+ : React.null}
552
+ </section>
553
+ })}
554
+ <div className={cx(["min-w-32 pt-1 flex flex-col gap-1", infosContainerClassName])}>
555
+ {timelines
556
+ ->Array.map(timeline =>
557
+ timeline.infos->Option.mapWithDefault(
558
+ <div key={`${timeline.id}info`} className="h-10" />,
559
+ infos => {
560
+ <div
561
+ key={`${timeline.id}info`}
562
+ className={cx([
563
+ "bg-white border-2 h-10 flex flex-row items-center justify-between px-1",
564
+ infos.className->Option.getWithDefault(""),
565
+ ])}>
566
+ {infos.content}
567
+ </div>
568
+ },
569
+ )
570
+ )
571
+ ->React.array}
572
+ </div>
573
+ </div>
574
+ <section
575
+ id={"timeline-container"}
576
+ className={"flex flex-col gap-4 relative bg-neutral-100 pb-4 overflow-y-scroll z-10 w-full"}
577
+ ref={ReactDOM.Ref.domRef(containerRef)}>
578
+ {measuringScale->Option.mapWithDefault(React.null, ({measuringScale} as state) => {
579
+ <React.Fragment>
580
+ <HoursRuler setMeasuringScale state />
581
+ {displayCurrentTime ? <CurrentTimeIndicator measuringScale /> : React.null}
582
+ <div className={"mt-10"}>
583
+ {timelines
584
+ ->Array.map(timeline =>
585
+ <Timeline key={timeline.id} timeline measuringScale setMeasuringScale />
586
+ )
587
+ ->React.array}
588
+ </div>
589
+ </React.Fragment>
590
+ })}
591
+ </section>
592
+ </section>
593
+ }