Calendar Weekly Scheduler (#5653)
* storybook v2 init * Merge config into storybook vite build * Remove path * Storybook config tweaks * Added styles and settings for storybook v2, and started working on button documentation and examples. * Badges + flex wrap on mobile * Breadcrumbs+button+avatar * Checkbox * Input + moving files around * WIP table * WIP table grid * Replaced imports for new components. * Added first steps for varianttable. * Small alignment fix. * Custom Args Table - With scrollbar * Adding table to components that need it + darkmode * Add intro * Fix types * Remove V1 storybook and replace with V2 * Fix badge type error * Fixed storybook dependencies * Added cover image to storybook * Remove vita from ts config, we dont use vite. * Fixed button import. * Explained postcss pseudo plugin. * Fixed badge import. * Add Avatar Stories * ButtonGroup Stories * Fixed imports * Add checkbox stories * Inital state plannning * Inital state combined with passed in props * Start of UI work * Able to change dates? * Add dynamic hour props * Get grid system setup correctly * Show events on grid * Weird sizing issue but events placed correctly gridstart * CAL styled calendar event component * availability WIP ish * Blocking days! + Block days < today * Kinda working time line * Rename grid stop + formatting * Handle sorting events if required. * Add util for getting startDate bassed on weekday * Remove event stories for now * Implement gridstops per hour to be dyamic * New CSS Grid + offsetbased positoning * Fix weird Z-Index issues on hover * Implement blocklist again with new format * Side by side events working - styling needs work * New design of overlap * Overlapping? Working :O * Cleanup * WIP hover state * Werid border issue * fix translate issue * Kinda working with overflow * Fix overflow * Progressive date blocking * Cleanup * Fix double render of blocked list * WIP mobile implementaiton * Trying to fix CSS * Extract CSS to styles.css to allow media queries * Improve documentation - allow args to be changed in storybook * Fix hover showing even if disabled * WIP cols auto approach * Merge blocking dates * Fix zindex * Fix hover position * Fix Z-Index issues on hover and blocking events * Re add onclick handler * Fix overlapping blocking dates * Fix scaling for datevalues columns * Date values closer to DS * Blocked List Tidy up * Storybook + file tidy up * Little tidy up * Fix offsets * Remove event hover * Fix random bg-red-500 * Fix import * FIx blocking cells appearing above start Date * Fix truncation * Fix border overlap * Overlap a little nicer * Condtional 80% sizing * Nitpicks * Fix today height and top breaking * Add text left to time stamp * Support string dates * Add shalow to reduce re-renders * Rename to Calendar instead of scheduler * Fix 3 overlapping events * Fix merge type error * Fix destructuring * NITS * Move to features package Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl> Co-authored-by: zomars <zomars@me.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
2078f12a85
commit
8fd5d6b5b5
|
@ -4,6 +4,7 @@ module.exports = {
|
|||
stories: [
|
||||
"../intro.stories.mdx",
|
||||
"../../../packages/ui/components/**/*.stories.mdx",
|
||||
"../../../packages/features/**/*.stories.mdx",
|
||||
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
|
||||
],
|
||||
addons: [
|
||||
|
@ -50,6 +51,21 @@ module.exports = {
|
|||
vm: false,
|
||||
zlib: false,
|
||||
};
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
"style-loader",
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: true, // Enable modules to help you using className
|
||||
},
|
||||
},
|
||||
],
|
||||
include: path.resolve(__dirname, "../src"),
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -15,7 +15,11 @@ export const parameters = {
|
|||
},
|
||||
};
|
||||
|
||||
addDecorator((storyFn) => <I18nextProvider i18n={i18n}>{storyFn()}</I18nextProvider>);
|
||||
addDecorator((storyFn) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<div style={{ margin: "2rem" }}>{storyFn()}</div>
|
||||
</I18nextProvider>
|
||||
));
|
||||
|
||||
window.getEmbedNamespace = () => {
|
||||
const url = new URL(document.URL);
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import "../../../packages/ui/styles/shared-globals.css"
|
||||
@import "../../../packages/ui/styles/shared-globals.css"
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
@import url("../../../packages/features/calendars/weeklyview/styles/styles.css");
|
||||
|
||||
.sbdocs {
|
||||
font-family: 'Inter var' !important;
|
||||
padding: 0!important;
|
||||
|
@ -36,7 +38,7 @@
|
|||
|
||||
/** Docs table **/
|
||||
.custom-args-wrapper{
|
||||
max-height: 600px;
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
margin-bottom: 1rem;
|
||||
|
|
|
@ -30,6 +30,7 @@ import customParseFormat from "dayjs/plugin/customParseFormat";
|
|||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import isToday from "dayjs/plugin/isToday";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import minmax from "dayjs/plugin/minMax";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import timeZone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
|
@ -44,6 +45,7 @@ dayjs.extend(relativeTime);
|
|||
dayjs.extend(timeZone);
|
||||
dayjs.extend(toArray);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(minmax);
|
||||
|
||||
export type Dayjs = dayjs.Dayjs;
|
||||
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import { TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
import { CalendarEvent } from "./types/events";
|
||||
|
||||
const startDate = dayjs().set("hour", 11).set("minute", 0);
|
||||
|
||||
export const events: CalendarEvent[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Event 1",
|
||||
start: startDate.add(10, "minutes").toDate(),
|
||||
end: startDate.add(45, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Event 2",
|
||||
start: startDate.add(1, "day").toDate(),
|
||||
end: startDate.add(1, "day").add(30, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Event 3",
|
||||
start: startDate.add(2, "day").toDate(),
|
||||
end: startDate.add(2, "day").add(60, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Event 4",
|
||||
start: startDate.add(3, "day").toDate(),
|
||||
end: startDate.add(3, "day").add(2, "hour").add(30, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Event 4 Overlap",
|
||||
start: startDate.add(3, "day").add(30, "minutes").toDate(),
|
||||
end: startDate.add(3, "day").add(2, "hour").add(45, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Event 1 Overlap",
|
||||
start: startDate.toDate(),
|
||||
end: startDate.add(30, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Event 1 Overlap Two",
|
||||
start: startDate.toDate(),
|
||||
end: startDate.add(30, "minutes").toDate(),
|
||||
allDay: false,
|
||||
source: "Booking",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
];
|
||||
|
||||
export const blockingDates: TimeRange[] = [
|
||||
{
|
||||
start: startDate.add(1, "day").hour(10).toDate(),
|
||||
end: startDate.add(1, "day").hour(13).toDate(),
|
||||
},
|
||||
];
|
|
@ -0,0 +1,133 @@
|
|||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { useCalendarStore } from "../state/store";
|
||||
import "../styles/styles.css";
|
||||
import { CalendarComponentProps } from "../types/state";
|
||||
import { getDaysBetweenDates, getHoursToDisplay } from "../utils";
|
||||
import { DateValues } from "./DateValues";
|
||||
import { BlockedList } from "./blocking/BlockedList";
|
||||
import { EmptyCell } from "./event/Empty";
|
||||
import { EventList } from "./event/EventList";
|
||||
import { SchedulerColumns } from "./grid";
|
||||
import { SchedulerHeading } from "./heading/SchedulerHeading";
|
||||
import { HorizontalLines } from "./horizontalLines";
|
||||
import { VeritcalLines } from "./verticalLines";
|
||||
|
||||
export function Calendar(props: CalendarComponentProps) {
|
||||
const container = useRef<HTMLDivElement | null>(null);
|
||||
const containerNav = useRef<HTMLDivElement | null>(null);
|
||||
const containerOffset = useRef<HTMLDivElement | null>(null);
|
||||
const initalState = useCalendarStore((state) => state.initState);
|
||||
|
||||
const startDate = useCalendarStore((state) => state.startDate);
|
||||
const endDate = useCalendarStore((state) => state.endDate);
|
||||
const startHour = useCalendarStore((state) => state.startHour || 0);
|
||||
const endHour = useCalendarStore((state) => state.endHour || 23);
|
||||
const usersCellsStopsPerHour = useCalendarStore((state) => state.gridCellsPerHour || 4);
|
||||
|
||||
const days = useMemo(() => getDaysBetweenDates(startDate, endDate), [startDate, endDate]);
|
||||
|
||||
const hours = useMemo(() => getHoursToDisplay(startHour || 0, endHour || 23), [startHour, endHour]);
|
||||
|
||||
const numberOfGridStopsPerDay = hours.length * usersCellsStopsPerHour;
|
||||
|
||||
// Initalise State on inital mount
|
||||
useEffect(() => {
|
||||
initalState(props);
|
||||
}, [props, initalState]);
|
||||
|
||||
return (
|
||||
<MobileNotSupported>
|
||||
<div
|
||||
className="scheduler-wrapper flex h-full w-full flex-col overflow-y-scroll"
|
||||
style={
|
||||
{ "--one-minute-height": `calc(1.75rem/(60/${usersCellsStopsPerHour}))` } as React.CSSProperties // This can't live in the css file because it's a dynamic value and css variable gets super
|
||||
}>
|
||||
<SchedulerHeading />
|
||||
<div ref={container} className="relative isolate flex flex-auto flex-col bg-white">
|
||||
<div
|
||||
style={{ width: "165%" }}
|
||||
className="flex max-w-full flex-none flex-col sm:max-w-none md:max-w-full">
|
||||
<DateValues containerNavRef={containerNav} days={days} />
|
||||
{/* TODO: Implement this at a later date. */}
|
||||
{/* <CurrentTime
|
||||
containerNavRef={containerNav}
|
||||
containerOffsetRef={containerOffset}
|
||||
containerRef={container}
|
||||
/> */}
|
||||
<div className="flex flex-auto">
|
||||
<div className="sticky left-0 z-10 w-14 flex-none bg-white ring-1 ring-gray-100" />
|
||||
<div className="grid flex-auto grid-cols-1 grid-rows-1 ">
|
||||
<HorizontalLines
|
||||
hours={hours}
|
||||
numberOfGridStopsPerCell={usersCellsStopsPerHour}
|
||||
containerOffsetRef={containerOffset}
|
||||
/>
|
||||
<VeritcalLines days={days} />
|
||||
|
||||
{/* Empty Cells */}
|
||||
<SchedulerColumns
|
||||
zIndex={50}
|
||||
offsetHeight={containerOffset.current?.offsetHeight}
|
||||
gridStopsPerDay={numberOfGridStopsPerDay}>
|
||||
<>
|
||||
{[...Array(days.length)].map((_, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
gridRow: `2 / span ${numberOfGridStopsPerDay}`,
|
||||
position: "relative",
|
||||
}}>
|
||||
{/* While startDate < endDate: */}
|
||||
{[...Array(numberOfGridStopsPerDay)].map((_, j) => {
|
||||
const key = `${i}-${j}`;
|
||||
return (
|
||||
<EmptyCell
|
||||
key={key}
|
||||
day={days[i].toDate()}
|
||||
gridCellIdx={j}
|
||||
totalGridCells={numberOfGridStopsPerDay}
|
||||
selectionLength={endHour - startHour}
|
||||
startHour={startHour}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
</SchedulerColumns>
|
||||
|
||||
<SchedulerColumns
|
||||
offsetHeight={containerOffset.current?.offsetHeight}
|
||||
gridStopsPerDay={numberOfGridStopsPerDay}>
|
||||
{/*Loop over events per day */}
|
||||
{days.map((day, i) => {
|
||||
return (
|
||||
<li key={day.toISOString()} className="relative" style={{ gridColumnStart: i + 1 }}>
|
||||
<EventList day={day} />
|
||||
<BlockedList day={day} containerRef={container} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</SchedulerColumns>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileNotSupported>
|
||||
);
|
||||
}
|
||||
|
||||
/** @todo Will be removed once we have mobile support */
|
||||
const MobileNotSupported = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col items-center justify-center sm:hidden">
|
||||
<h1 className="text-2xl font-bold">Mobile not supported yet </h1>
|
||||
<p className="text-gray-500">Please use a desktop browser to view this page</p>
|
||||
</div>
|
||||
<div className="hidden sm:block">{children}</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
import React from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
type Props = {
|
||||
days: dayjs.Dayjs[];
|
||||
containerNavRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function DateValues({ days, containerNavRef }: Props) {
|
||||
return (
|
||||
<div
|
||||
ref={containerNavRef}
|
||||
className="sticky top-0 z-30 flex-none border-b border-b-gray-300 bg-white sm:pr-8">
|
||||
<div className="flex text-sm leading-6 text-gray-500 sm:hidden" data-dayslength={days.length}>
|
||||
{days.map((day) => {
|
||||
const isToday = dayjs().isSame(day, "day");
|
||||
return (
|
||||
<button
|
||||
key={day.toString()}
|
||||
type="button"
|
||||
className="flex flex-1 flex-col items-center pt-2 pb-3">
|
||||
{day.format("dd")}{" "}
|
||||
<span
|
||||
className={classNames(
|
||||
"mt-1 flex h-8 w-8 items-center justify-center font-semibold text-gray-900",
|
||||
isToday && "rounded-full bg-gray-900 text-white"
|
||||
)}>
|
||||
{day.format("D")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="-mr-px hidden auto-cols-fr text-sm leading-6 text-gray-500 sm:flex ">
|
||||
<div className="col-end-1 w-14" />
|
||||
{days.map((day) => {
|
||||
const isToday = dayjs().isSame(day, "day");
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
className={classNames("flex flex-1 items-center justify-center py-3", isToday && "font-bold")}>
|
||||
<span>
|
||||
{day.format("ddd")}{" "}
|
||||
<span
|
||||
className={classNames(
|
||||
"items-center justify-center p-1",
|
||||
isToday && "rounded-full bg-gray-900 text-white"
|
||||
)}>
|
||||
{day.format("DD")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
import { useMemo } from "react";
|
||||
import shallow from "zustand/shallow";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import { useCalendarStore } from "../../state/store";
|
||||
import { BlockedTimeCell } from "./BlockedTimeCell";
|
||||
|
||||
type Props = {
|
||||
day: dayjs.Dayjs;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
function roundX(x: number, roundBy: number) {
|
||||
return Math.round(x / roundBy) * roundBy;
|
||||
}
|
||||
|
||||
type BlockedDayProps = {
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
day: dayjs.Dayjs;
|
||||
};
|
||||
|
||||
function BlockedBeforeToday({ day, startHour, endHour }: BlockedDayProps) {
|
||||
return (
|
||||
<>
|
||||
{day.isBefore(dayjs(), "day") && (
|
||||
<div
|
||||
key={day.format("YYYY-MM-DD")}
|
||||
className="absolute z-40 w-full"
|
||||
style={{
|
||||
top: `var(--one-minute-height)`,
|
||||
zIndex: 60,
|
||||
height: `calc(${(endHour + 1 - startHour) * 60} * var(--one-minute-height))`, // Add 1 to endHour to include the last hour that we add to display the last vertical line
|
||||
}}>
|
||||
<BlockedTimeCell />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
function BlockedToday({
|
||||
day,
|
||||
startHour,
|
||||
gridCellsPerHour,
|
||||
endHour,
|
||||
}: BlockedDayProps & { gridCellsPerHour: number }) {
|
||||
const dayStart = useMemo(() => day.startOf("day").hour(startHour), [day, startHour]);
|
||||
const dayEnd = useMemo(() => day.startOf("day").hour(endHour), [day, endHour]);
|
||||
const dayEndInMinutes = useMemo(() => dayEnd.diff(dayStart, "minutes"), [dayEnd, dayStart]);
|
||||
let nowComparedToDayStart = useMemo(() => dayjs().diff(dayStart, "minutes"), [dayStart]);
|
||||
|
||||
if (nowComparedToDayStart > dayEndInMinutes) nowComparedToDayStart = dayEndInMinutes;
|
||||
|
||||
return (
|
||||
<>
|
||||
{day.isToday() && (
|
||||
<div
|
||||
key={day.format("YYYY-MM-DD")}
|
||||
className="absolute z-40 w-full"
|
||||
style={{
|
||||
top: `var(--one-minute-height)`, // Still need this as this var takes into consideration the offset of the "AllDayEvents" bar
|
||||
zIndex: 60,
|
||||
height: `calc(${roundX(
|
||||
nowComparedToDayStart,
|
||||
60 / gridCellsPerHour
|
||||
)} * var(--one-minute-height) - 2px)`, // We minus the border width to make it 🧹
|
||||
}}>
|
||||
<BlockedTimeCell />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlockedList({ day }: Props) {
|
||||
const { startHour, blockingDates, endHour, gridCellsPerHour } = useCalendarStore(
|
||||
(state) => ({
|
||||
startHour: state.startHour || 0,
|
||||
endHour: state.endHour || 23,
|
||||
blockingDates: state.blockingDates,
|
||||
gridCellsPerHour: state.gridCellsPerHour || 4,
|
||||
}),
|
||||
shallow
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BlockedBeforeToday day={day} startHour={startHour} endHour={endHour} />
|
||||
<BlockedToday gridCellsPerHour={gridCellsPerHour} day={day} startHour={startHour} endHour={endHour} />
|
||||
{blockingDates &&
|
||||
blockingDates.map((event, i) => {
|
||||
const dayStart = day.startOf("day").hour(startHour);
|
||||
const blockingStart = dayjs(event.start);
|
||||
const eventEnd = dayjs(event.end);
|
||||
|
||||
const eventStart = dayStart.isAfter(blockingStart) ? dayStart : blockingStart;
|
||||
|
||||
if (!eventStart.isSame(day, "day")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (eventStart.isBefore(dayjs())) {
|
||||
if (eventEnd.isBefore(dayjs())) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const eventDuration = eventEnd.diff(eventStart, "minutes");
|
||||
|
||||
const eventStartHour = eventStart.hour();
|
||||
const eventStartDiff = (eventStartHour - (startHour || 0)) * 60;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${eventStart.toISOString()}-${i}`}
|
||||
className="absolute w-full"
|
||||
style={{
|
||||
zIndex: 60,
|
||||
top: `calc(${eventStartDiff}*var(--one-minute-height))`,
|
||||
height: `calc(${eventDuration}*var(--one-minute-height))`,
|
||||
}}>
|
||||
<BlockedTimeCell />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { classNames } from "@calcom/lib";
|
||||
|
||||
export function BlockedTimeCell() {
|
||||
return (
|
||||
<div
|
||||
className={classNames("group absolute inset-0 flex h-full flex-col hover:cursor-not-allowed")}
|
||||
style={{
|
||||
backgroundColor: "#D1D5DB",
|
||||
opacity: 0.2,
|
||||
background:
|
||||
"repeating-linear-gradient( -45deg, #E5E7EB, #E5E7EB 4.5px, #D1D5DB 4.5px, #D1D5DB 22.5px )",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
|
||||
|
||||
import {
|
||||
Examples,
|
||||
Example,
|
||||
Note,
|
||||
Title,
|
||||
CustomArgsTable,
|
||||
VariantsTable,
|
||||
VariantRow,
|
||||
} from "@calcom/storybook/components";
|
||||
|
||||
import { events, blockingDates } from "../_storybookData";
|
||||
import "../styles/styles.css";
|
||||
import { CalendarEvent } from "../types/events";
|
||||
import { Calendar } from "./Calendar";
|
||||
|
||||
<Meta title="UI/Calendar" component={Calendar} />
|
||||
|
||||
<Title title="Calendar" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
|
||||
|
||||
## Props
|
||||
|
||||
The Args Table below shows you a breakdown of what props can be passed into the Calendar component. All props should have a desciption to make it self explanitory to see what is going on.
|
||||
|
||||
<CustomArgsTable of={Calendar} />
|
||||
|
||||
## Example
|
||||
|
||||
There will be a few examples of how to use the Calendar component to show different usecases.
|
||||
|
||||
export const Template = (args) => <Calendar {...args} />;
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Customising Start Hour and EndHour"
|
||||
argTypes={{
|
||||
startHour: {
|
||||
control: { type: "number", min: 0, max: 23, step: 1 },
|
||||
},
|
||||
endHour: {
|
||||
control: { type: "number", min: 0, max: 23, step: 1 },
|
||||
},
|
||||
}}
|
||||
args={{
|
||||
sortEvents: true,
|
||||
startHour: 8,
|
||||
endHour: 20,
|
||||
events: events,
|
||||
hoverEventDuration: 0,
|
||||
blockingDates: blockingDates,
|
||||
}}>
|
||||
{Template.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
<Canvas>
|
||||
<Story name="Onclick Handlers">
|
||||
<Calendar
|
||||
startHour={8}
|
||||
endHour={17}
|
||||
events={events}
|
||||
onEventClick={(e) => alert(e.title)}
|
||||
onEmptyCellClick={(date) => alert(date.toString())}
|
||||
sortEvents
|
||||
hoverEventDuration={30}
|
||||
/>
|
||||
</Story>
|
||||
</Canvas>
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import { useCalendarStore } from "../../state/store";
|
||||
|
||||
type Props = {
|
||||
containerNavRef: React.RefObject<HTMLDivElement>;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
containerOffsetRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function CurrentTime({ containerOffsetRef }: Props) {
|
||||
const [currentTimePos, setCurrentTimePos] = useState<number>(0);
|
||||
const { startHour, endHour } = useCalendarStore((state) => ({
|
||||
startHour: state.startHour || 0,
|
||||
endHour: state.endHour || 23,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
// Set the container scroll position based on the current time.
|
||||
let currentMinute = new Date().getHours() * 60;
|
||||
currentMinute = currentMinute + new Date().getMinutes();
|
||||
|
||||
if (containerOffsetRef.current) {
|
||||
const totalHours = endHour - startHour;
|
||||
const currentTimePos = currentMinute;
|
||||
setCurrentTimePos(currentTimePos);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startHour, endHour]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-40 ml-3 flex h-1 items-center justify-center border-gray-800"
|
||||
aria-hidden="true"
|
||||
style={{ top: `calc(${currentTimePos}*var(--one-minute-height))`, zIndex: 100 }}>
|
||||
{dayjs().format("HH:mm")}
|
||||
<div className="ml-1 h-3 w-1 bg-gray-800" />
|
||||
<div className="h-1 w-full bg-gray-800" />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import shallow from "zustand/shallow";
|
||||
|
||||
import { useCalendarStore } from "../../state/store";
|
||||
import { gridCellToDateTime, GridCellToDateProps } from "../../utils";
|
||||
|
||||
export function EmptyCell(props: GridCellToDateProps) {
|
||||
const { onEmptyCellClick, hoverEventDuration } = useCalendarStore(
|
||||
(state) => ({
|
||||
onEmptyCellClick: state.onEmptyCellClick,
|
||||
hoverEventDuration: state.hoverEventDuration,
|
||||
}),
|
||||
shallow
|
||||
);
|
||||
|
||||
const cellToDate = gridCellToDateTime({
|
||||
day: props.day,
|
||||
gridCellIdx: props.gridCellIdx,
|
||||
totalGridCells: props.totalGridCells,
|
||||
selectionLength: props.selectionLength,
|
||||
startHour: props.startHour,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group w-full"
|
||||
style={{ height: "1.75rem", overflow: "visible" }}
|
||||
onClick={() => onEmptyCellClick && onEmptyCellClick(cellToDate.toDate())}>
|
||||
{hoverEventDuration !== 0 && (
|
||||
<div
|
||||
className="opacity-4 absolute inset-x-1 hidden rounded-[4px] border-[1px] border-gray-900 bg-gray-100
|
||||
py-1
|
||||
px-[6px] text-xs font-semibold leading-5 text-gray-900 hover:bg-gray-200 group-hover:block group-hover:cursor-pointer"
|
||||
style={{
|
||||
height: `calc(${hoverEventDuration}*var(--one-minute-height))`,
|
||||
zIndex: 49,
|
||||
width: "90%",
|
||||
}}>
|
||||
<div className="overflow-ellipsis leading-4">{cellToDate.format("HH:mm")}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import { CalendarEvent } from "../../types/events";
|
||||
|
||||
type EventProps = {
|
||||
event: CalendarEvent;
|
||||
currentlySelectedEventId?: number;
|
||||
eventDuration: number;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function Event({
|
||||
event,
|
||||
currentlySelectedEventId,
|
||||
eventDuration,
|
||||
disabled,
|
||||
onEventClick,
|
||||
}: EventProps) {
|
||||
const selected = currentlySelectedEventId === event.id;
|
||||
|
||||
const Component = onEventClick ? "button" : "div";
|
||||
|
||||
return (
|
||||
<Component
|
||||
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
|
||||
className={classNames(
|
||||
"group flex h-full w-full flex-col overflow-y-auto rounded-[4px] py-1 px-[6px] text-xs font-semibold leading-5 ",
|
||||
event.status === "ACCEPTED" &&
|
||||
!selected &&
|
||||
"border-[1px] border-gray-900 bg-gray-100 text-gray-900 hover:bg-gray-200",
|
||||
event.status === "PENDING" &&
|
||||
!selected &&
|
||||
"border-[1px] border-dashed border-gray-900 bg-white text-gray-900",
|
||||
selected && "border-[1px] border-transparent bg-gray-900 text-white",
|
||||
disabled ? "hover:cursor-default" : "hover:cursor-pointer"
|
||||
)}>
|
||||
<div className="w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4">
|
||||
{event.title}
|
||||
</div>
|
||||
{eventDuration >= 30 && (
|
||||
<p className="text-left text-[10px] leading-none text-gray-500">
|
||||
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
|
||||
</p>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
import shallow from "zustand/shallow";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import { useCalendarStore } from "../../state/store";
|
||||
import { Event } from "./Event";
|
||||
|
||||
type Props = {
|
||||
day: dayjs.Dayjs;
|
||||
};
|
||||
|
||||
export function EventList({ day }: Props) {
|
||||
const { startHour, events, eventOnClick } = useCalendarStore(
|
||||
(state) => ({
|
||||
startHour: state.startHour,
|
||||
events: state.events,
|
||||
eventOnClick: state.onEventClick,
|
||||
}),
|
||||
shallow
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{events
|
||||
.filter((event) => {
|
||||
return dayjs(event.start).isSame(day, "day") && !event.allDay; // Filter all events that are not allDay and that are on the current day
|
||||
})
|
||||
.map((event, idx, eventsArray) => {
|
||||
let width = 90;
|
||||
let marginLeft: string | number = 0;
|
||||
let right = 0;
|
||||
let zIndex = 61;
|
||||
|
||||
const eventStart = dayjs(event.start);
|
||||
const eventEnd = dayjs(event.end);
|
||||
|
||||
const eventDuration = eventEnd.diff(eventStart, "minutes");
|
||||
|
||||
const eventStartHour = eventStart.hour();
|
||||
const eventStartDiff = (eventStartHour - (startHour || 0)) * 60 + eventStart.minute();
|
||||
const nextEvent = eventsArray[idx + 1];
|
||||
const prevEvent = eventsArray[idx - 1];
|
||||
|
||||
// Check for overlapping events since this is sorted it should just work.
|
||||
if (nextEvent) {
|
||||
const nextEventStart = dayjs(nextEvent.start);
|
||||
const nextEventEnd = dayjs(nextEvent.end);
|
||||
// check if next event starts before this event ends
|
||||
if (nextEventStart.isBefore(eventEnd)) {
|
||||
// figure out which event has the longest duration
|
||||
const nextEventDuration = nextEventEnd.diff(nextEventStart, "minutes");
|
||||
if (nextEventDuration > eventDuration) {
|
||||
zIndex = 65;
|
||||
|
||||
marginLeft = "auto";
|
||||
// 8 looks like a really random number but we need to take into account the bordersize on the event.
|
||||
// Logically it should be 5% but this causes a bit of a overhang which we don't want.
|
||||
right = 8;
|
||||
width = width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextEventStart.isSame(eventStart)) {
|
||||
zIndex = 66;
|
||||
|
||||
marginLeft = "auto";
|
||||
right = 8;
|
||||
width = width / 2;
|
||||
}
|
||||
} else if (prevEvent) {
|
||||
const prevEventStart = dayjs(prevEvent.start);
|
||||
const prevEventEnd = dayjs(prevEvent.end);
|
||||
// check if next event starts before this event ends
|
||||
if (prevEventEnd.isAfter(eventStart)) {
|
||||
// figure out which event has the longest duration
|
||||
const prevEventDuration = prevEventEnd.diff(prevEventStart, "minutes");
|
||||
if (prevEventDuration > eventDuration) {
|
||||
zIndex = 65;
|
||||
marginLeft = "auto";
|
||||
right = 8;
|
||||
width = width / 2;
|
||||
if (eventDuration >= 30) {
|
||||
width = 80;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${event.id}-${eventStart.toISOString()}`}
|
||||
className="absolute inset-x-1 "
|
||||
style={{
|
||||
marginLeft,
|
||||
zIndex,
|
||||
right: `calc(${right}% - 1px)`,
|
||||
width: `${width}%`,
|
||||
top: `calc(${eventStartDiff}*var(--one-minute-height))`,
|
||||
height: `calc(${eventDuration}*var(--one-minute-height))`,
|
||||
}}>
|
||||
<Event event={event} eventDuration={eventDuration} onEventClick={eventOnClick} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { Event } from "./Event";
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
offsetHeight: number | undefined;
|
||||
gridStopsPerDay: number;
|
||||
children: React.ReactNode;
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
export function SchedulerColumns({ offsetHeight, gridStopsPerDay, children, zIndex }: Props) {
|
||||
return (
|
||||
<ol
|
||||
className="scheduler-grid-row-template col-start-1 col-end-2 row-start-1 grid auto-cols-auto sm:pr-8"
|
||||
style={{ marginTop: offsetHeight || "var(--gridDefaultSize)", zIndex }}
|
||||
data-gridstopsperday={gridStopsPerDay}>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import { Icon, Button, ButtonGroup } from "@calcom/ui";
|
||||
|
||||
import { useCalendarStore } from "../../state/store";
|
||||
|
||||
export function SchedulerHeading() {
|
||||
const { startDate, endDate, handleDateChange } = useCalendarStore((state) => ({
|
||||
startDate: dayjs(state.startDate),
|
||||
endDate: dayjs(state.endDate),
|
||||
handleDateChange: state.handleDateChange,
|
||||
}));
|
||||
|
||||
return (
|
||||
<header className="flex flex-none flex-col justify-between py-4 sm:flex-row sm:items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
{startDate.format("MMM DD")}-{endDate.format("DD")}
|
||||
<span className="text-gray-500">,{startDate.format("YYYY")}</span>
|
||||
</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* TODO: Renable when we have daily/mobile support */}
|
||||
{/* <ToggleGroup
|
||||
options={[
|
||||
{ label: "Daily", value: "day", disabled: false },
|
||||
{ label: "Weekly", value: "week", disabled: isSm },
|
||||
]}
|
||||
defaultValue={view === "day" ? "day" : "week"}
|
||||
/> */}
|
||||
|
||||
<ButtonGroup combined>
|
||||
{/* TODO: i18n label with correct view */}
|
||||
<Button
|
||||
StartIcon={Icon.FiChevronLeft}
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Previous Week"
|
||||
onClick={() => {
|
||||
handleDateChange("DECREMENT");
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
StartIcon={Icon.FiChevronRight}
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Next Week"
|
||||
onClick={() => {
|
||||
handleDateChange("INCREMENT");
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { SchedulerHeading } from "./SchedulerHeading";
|
|
@ -0,0 +1,39 @@
|
|||
import { useId } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
export const HorizontalLines = ({
|
||||
hours,
|
||||
numberOfGridStopsPerCell,
|
||||
containerOffsetRef,
|
||||
}: {
|
||||
hours: dayjs.Dayjs[];
|
||||
numberOfGridStopsPerCell: number;
|
||||
containerOffsetRef: React.RefObject<HTMLDivElement>;
|
||||
}) => {
|
||||
const finalHour = hours[hours.length - 1].add(1, "hour").format("h A");
|
||||
const id = useId();
|
||||
return (
|
||||
<div
|
||||
className=" col-start-1 col-end-2 row-start-1 grid divide-y divide-gray-300"
|
||||
style={{
|
||||
gridTemplateRows: `repeat(${hours.length}, minmax(${1.75 * numberOfGridStopsPerCell}rem,1fr)`,
|
||||
}}>
|
||||
<div className="row-end-1 h-7 " ref={containerOffsetRef} />
|
||||
{hours.map((hour) => (
|
||||
<>
|
||||
<div key={`${id}-${hour.get("hour")}`}>
|
||||
<div className="sticky left-0 z-20 -mt-2.5 -ml-14 w-14 pr-2 text-right text-xs leading-5 text-gray-400">
|
||||
{hour.format("h A")}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
<div key={`${id}-${finalHour}`}>
|
||||
<div className="sticky left-0 z-20 -mt-2.5 -ml-14 w-14 pr-2 text-right text-xs leading-5 text-gray-400">
|
||||
{finalHour}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { Calendar } from "./Calendar";
|
|
@ -0,0 +1,19 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
|
||||
return (
|
||||
<div
|
||||
className="col-start-1 col-end-2 row-start-1 grid auto-cols-auto grid-rows-1 divide-x divide-gray-300
|
||||
sm:pr-8">
|
||||
{days.map((_, i) => (
|
||||
<div
|
||||
key={`Key_vertical_${i}`}
|
||||
className="row-span-full"
|
||||
style={{
|
||||
gridColumnStart: i + 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { Calendar } from "./components/Calendar";
|
|
@ -0,0 +1,81 @@
|
|||
import create from "zustand";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import {
|
||||
CalendarComponentProps,
|
||||
CalendarPublicActions,
|
||||
CalendarState,
|
||||
CalendarStoreProps,
|
||||
} from "../types/state";
|
||||
import { mergeOverlappingDateRanges, weekdayDates } from "../utils";
|
||||
|
||||
const defaultState: CalendarComponentProps = {
|
||||
view: "week",
|
||||
startDate: weekdayDates(0, new Date()).startDate,
|
||||
endDate: weekdayDates(0, new Date()).endDate,
|
||||
events: [],
|
||||
startHour: 0,
|
||||
endHour: 23,
|
||||
gridCellsPerHour: 4,
|
||||
};
|
||||
|
||||
export const useCalendarStore = create<CalendarStoreProps>((set) => ({
|
||||
...defaultState,
|
||||
setView: (view: CalendarComponentProps["view"]) => set({ view }),
|
||||
setStartDate: (startDate: CalendarComponentProps["startDate"]) => set({ startDate }),
|
||||
setEndDate: (endDate: CalendarComponentProps["endDate"]) => set({ endDate }),
|
||||
setEvents: (events: CalendarComponentProps["events"]) => set({ events }),
|
||||
// This looks a bit odd but init state only overrides the public props + actions as we don't want to override our internal state
|
||||
initState: (state: CalendarState & CalendarPublicActions) => {
|
||||
// Handle sorting of events if required
|
||||
let events = state.events;
|
||||
|
||||
if (state.sortEvents) {
|
||||
events = state.events.sort(
|
||||
(a, b) => dayjs(a.start).get("milliseconds") - dayjs(b.start).get("milliseconds")
|
||||
);
|
||||
}
|
||||
const blockingDates = mergeOverlappingDateRanges(state.blockingDates || []); // We merge overlapping dates so we don't get duplicate blocking "Cells" in the UI
|
||||
|
||||
set({
|
||||
...state,
|
||||
blockingDates,
|
||||
events,
|
||||
});
|
||||
},
|
||||
setSelectedEvent: (event) => set({ selectedEvent: event }),
|
||||
handleDateChange: (payload) =>
|
||||
set((state) => {
|
||||
const { startDate, endDate } = state;
|
||||
if (payload === "INCREMENT") {
|
||||
const newStartDate = dayjs(startDate).add(1, state.view).toDate();
|
||||
const newEndDate = dayjs(endDate).add(1, state.view).toDate();
|
||||
|
||||
// Do nothing if
|
||||
if (
|
||||
(state.minDate && newStartDate < state.minDate) ||
|
||||
(state.maxDate && newEndDate > state.maxDate)
|
||||
) {
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
// We call this callback if we have it -> Allows you to change your state outside of the component
|
||||
state.onDateChange && state.onDateChange(newStartDate, newEndDate);
|
||||
return {
|
||||
startDate: newStartDate,
|
||||
endDate: newEndDate,
|
||||
};
|
||||
}
|
||||
const newStartDate = dayjs(startDate).subtract(1, state.view).toDate();
|
||||
const newEndDate = dayjs(endDate).subtract(1, state.view).toDate();
|
||||
state.onDateChange && state.onDateChange(newStartDate, newEndDate);
|
||||
return {
|
||||
startDate: newStartDate,
|
||||
endDate: newEndDate,
|
||||
};
|
||||
}),
|
||||
}));
|
|
@ -0,0 +1,17 @@
|
|||
.scheduler-wrapper {
|
||||
--gridDefaultSize: 1.75rem;
|
||||
--hoursInDay: 24;
|
||||
--minuteInHour:60;
|
||||
--gridMobileSize: 100vw;
|
||||
}
|
||||
|
||||
.scheduler-grid-row-template{
|
||||
grid-template-rows: var(--gridMobileSize) repeat(attr(data-gridstoperperday number), var(--gridMobileSize)) auto;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.scheduler-grid-row-template {
|
||||
grid-template-rows: var(--gridDefaultSize) repeat(attr(data-gridstoperperday number), var(--gridDefaultSize)) auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { BookingStatus } from "@calcom/prisma/client";
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: number;
|
||||
title: string;
|
||||
start: Date | string; // You can pass in a string from DB since we use dayjs for the dates.
|
||||
end: Date;
|
||||
allDay?: boolean;
|
||||
source?: string;
|
||||
status?: BookingStatus;
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import { TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
import { CalendarEvent } from "./events";
|
||||
|
||||
export type View = "month" | "week" | "day";
|
||||
export type Hours =
|
||||
| 0
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 13
|
||||
| 14
|
||||
| 15
|
||||
| 16
|
||||
| 17
|
||||
| 18
|
||||
| 19
|
||||
| 20
|
||||
| 21
|
||||
| 22
|
||||
| 23;
|
||||
|
||||
// These will be on eventHandlers - e.g. do more actions on view change if required
|
||||
export type CalendarPublicActions = {
|
||||
onViewChange?: (view: View) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onEventContextMenu?: (event: CalendarEvent) => void;
|
||||
onEmptyCellClick?: (date: Date) => void;
|
||||
onDateChange?: (startDate: Date, endDate?: Date) => void;
|
||||
};
|
||||
|
||||
// We have private actions here that we want to be available in state but not as component props.
|
||||
export type CalendarPrivateActions = {
|
||||
/** initState is used to init the state from public props -> Doesn't override internal state */
|
||||
initState: (state: CalendarState & CalendarPublicActions) => void;
|
||||
setView: (view: CalendarComponentProps["view"]) => void;
|
||||
setStartDate: (startDate: CalendarComponentProps["startDate"]) => void;
|
||||
setEndDate: (endDate: CalendarComponentProps["endDate"]) => void;
|
||||
setEvents: (events: CalendarComponentProps["events"]) => void;
|
||||
selectedEvent?: CalendarEvent;
|
||||
setSelectedEvent: (event: CalendarEvent) => void;
|
||||
handleDateChange: (payload: "INCREMENT" | "DECREMENT") => void;
|
||||
};
|
||||
|
||||
export type CalendarState = {
|
||||
/** @NotImplemented This in future will change the view to be daily/weekly/monthly DAY/WEEK are supported currently however WEEK is the most adv.*/
|
||||
view?: View;
|
||||
startDate: Date;
|
||||
/** By default we just dynamically create endDate from the viewType */
|
||||
endDate: Date;
|
||||
/**
|
||||
* Please enter events already SORTED. This is required to setup tab index correctly.
|
||||
* @Note Ideally you should pass in a sorted array from the DB however, pass the prop `sortEvents` if this is not possible and we will sort this for you..
|
||||
*/
|
||||
events: CalendarEvent[];
|
||||
/** Any time ranges passed in here will display as blocked on the users calendar. Note: Anything < than the current date automatically gets blocked. */
|
||||
blockingDates?: TimeRange[];
|
||||
/** Loading will only expect events to be loading. */
|
||||
loading?: boolean;
|
||||
/** Disables all actions on Events*/
|
||||
eventsDisabled?: boolean;
|
||||
/** If you don't want the date to be scrollable past a certian date */
|
||||
minDate?: Date;
|
||||
/** If you don't want the date to be scrollable past a certain date */
|
||||
maxDate?: Date;
|
||||
/**
|
||||
* Defined the time your calendar will start at
|
||||
* @default 0
|
||||
*/
|
||||
startHour?: Hours;
|
||||
/**
|
||||
* Defined the time your calendar will end at
|
||||
* @default 23
|
||||
*/
|
||||
endHour?: Hours;
|
||||
/** Toggle the ability to scroll to currentTime */
|
||||
scrollToCurrentTime?: boolean;
|
||||
/** Toggle the ability show the current time on the calendar
|
||||
* @NotImplemented
|
||||
*/
|
||||
showCurrentTimeLine?: boolean;
|
||||
/**
|
||||
* This indicates the number of grid stops that are available per hour. 4 -> Grid set to 15 minutes.
|
||||
* @NotImplemented
|
||||
* @default 4
|
||||
*/
|
||||
gridCellsPerHour?: number;
|
||||
/**
|
||||
* Sets the duration on the hover event. In minutes.
|
||||
* @Note set to 0 to disable any hover actions.
|
||||
*/
|
||||
hoverEventDuration?: number;
|
||||
/**
|
||||
* If passed in we will sort the events internally.
|
||||
* @Note It is recommended to sort the events before passing them into the scheduler - e.g. On DB level.
|
||||
*/
|
||||
sortEvents?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarComponentProps = CalendarPublicActions & CalendarState;
|
||||
|
||||
export type CalendarStoreProps = CalendarComponentProps & CalendarPrivateActions;
|
|
@ -0,0 +1,92 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import { TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
// By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
|
||||
export function weekdayDates(weekStart = 0, startDate: Date, length = 6) {
|
||||
const tmpStartDate = startDate;
|
||||
while (tmpStartDate.getDay() !== weekStart) {
|
||||
tmpStartDate.setDate(tmpStartDate.getDate() - 1);
|
||||
}
|
||||
return {
|
||||
startDate: tmpStartDate,
|
||||
endDate: new Date(tmpStartDate.getTime() + length * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
export type GridCellToDateProps = {
|
||||
day: Date;
|
||||
gridCellIdx: number;
|
||||
totalGridCells: number;
|
||||
selectionLength: number;
|
||||
startHour: number;
|
||||
};
|
||||
|
||||
export function gridCellToDateTime({
|
||||
day,
|
||||
gridCellIdx,
|
||||
totalGridCells,
|
||||
selectionLength,
|
||||
startHour,
|
||||
}: GridCellToDateProps) {
|
||||
// endHour - startHour = selectionLength
|
||||
const minutesInSelection = (selectionLength + 1) * 60;
|
||||
const minutesPerCell = minutesInSelection / totalGridCells;
|
||||
const minutesIntoSelection = minutesPerCell * gridCellIdx;
|
||||
|
||||
// Add startHour since we use StartOfDay for day props. This could be improved by changing the getDaysBetweenDates function
|
||||
// To handle the startHour+endHour
|
||||
const cellDateTime = dayjs(day).add(minutesIntoSelection, "minutes").add(startHour, "hours");
|
||||
return cellDateTime;
|
||||
}
|
||||
|
||||
export function getDaysBetweenDates(dateFrom: Date, dateTo: Date) {
|
||||
const dates = []; // this is as dayjs date
|
||||
let startDate = dayjs(dateFrom).utc().hour(0).minute(0).second(0).millisecond(0);
|
||||
dates.push(startDate);
|
||||
const endDate = dayjs(dateTo).utc().hour(0).minute(0).second(0).millisecond(0);
|
||||
while (startDate.isBefore(endDate)) {
|
||||
dates.push(startDate.add(1, "day"));
|
||||
startDate = startDate.add(1, "day");
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
export function getHoursToDisplay(startHour: number, endHour: number) {
|
||||
const dates = []; // this is as dayjs date
|
||||
let startDate = dayjs("1970-01-01").utc().hour(startHour);
|
||||
dates.push(startDate);
|
||||
const endDate = dayjs("1970-01-01").utc().hour(endHour);
|
||||
while (startDate.isBefore(endDate)) {
|
||||
dates.push(startDate.add(1, "hour"));
|
||||
startDate = startDate.add(1, "hour");
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
export function mergeOverlappingDateRanges(dateRanges: TimeRange[]) {
|
||||
//Sort the date ranges by start date
|
||||
dateRanges.sort((a, b) => {
|
||||
return a.start.getTime() - b.start.getTime();
|
||||
});
|
||||
//Create a new array to hold the merged date ranges
|
||||
const mergedDateRanges = [];
|
||||
//Loop through the date ranges
|
||||
for (let i = 0; i < dateRanges.length; i++) {
|
||||
//If the merged date ranges array is empty, add the first date range
|
||||
if (mergedDateRanges.length === 0) {
|
||||
mergedDateRanges.push(dateRanges[i]);
|
||||
} else {
|
||||
//Get the last date range in the merged date ranges array
|
||||
const lastMergedDateRange = mergedDateRanges[mergedDateRanges.length - 1];
|
||||
//Get the current date range
|
||||
const currentDateRange = dateRanges[i];
|
||||
//If the last merged date range overlaps with the current date range, merge them
|
||||
if (lastMergedDateRange.end.getTime() >= currentDateRange.start.getTime()) {
|
||||
lastMergedDateRange.end = currentDateRange.end;
|
||||
} else {
|
||||
//Otherwise, add the current date range to the merged date ranges array
|
||||
mergedDateRanges.push(currentDateRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedDateRanges;
|
||||
}
|
|
@ -8,6 +8,10 @@
|
|||
"dependencies": {
|
||||
"@lexical/react": "^0.5.0",
|
||||
"dompurify": "^2.4.1",
|
||||
"lexical": "^0.5.0"
|
||||
"lexical": "^0.5.0",
|
||||
"zustand": "^4.1.4",
|
||||
"@calcom/ui": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/dayjs": "*"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { classNames } from "@calcom/lib";
|
|||
export const ToggleGroupItem = () => <div>hi</div>;
|
||||
|
||||
interface ToggleGroupProps extends Omit<RadixToggleGroup.ToggleGroupSingleProps, "type"> {
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string; label: string; disabled?: boolean }[];
|
||||
isFullWidth?: boolean;
|
||||
}
|
||||
|
||||
|
@ -37,10 +37,14 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T
|
|||
/>
|
||||
{options.map((option) => (
|
||||
<RadixToggleGroup.Item
|
||||
disabled={option.disabled}
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={classNames(
|
||||
"relative rounded-[4px] px-3 py-1 text-sm dark:text-neutral-200 [&[aria-checked='false']]:hover:font-medium",
|
||||
"relative rounded-[4px] px-3 py-1 text-sm",
|
||||
option.disabled
|
||||
? "text-gray-400 hover:cursor-not-allowed dark:text-neutral-100"
|
||||
: "dark:text-neutral-200 [&[aria-checked='false']]:hover:font-medium",
|
||||
isFullWidth && "w-full"
|
||||
)}
|
||||
ref={(node) => {
|
||||
|
|
Loading…
Reference in New Issue
Block a user