Merge https://github.com/123om123/cal.com into minimum-booking-notice-will-allow-hours-and-days
This commit is contained in:
commit
6eb2a3f567
50
README.md
50
README.md
|
@ -354,56 +354,6 @@ following
|
|||
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
|
||||
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
|
||||
|
||||
### Obtaining Slack Client ID and Secret and Signing Secret
|
||||
|
||||
To test this you will need to create a Slack app for yourself on [their apps website](https://api.slack.com/apps).
|
||||
|
||||
Copy and paste the app manifest below into the setting on your slack app. Be sure to replace `YOUR_DOMAIN` with your own domain or your proxy host if you're testing locally.
|
||||
|
||||
<details>
|
||||
<summary>App Manifest</summary>
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Cal.com Slack
|
||||
features:
|
||||
bot_user:
|
||||
display_name: Cal.com Slack
|
||||
always_online: false
|
||||
slash_commands:
|
||||
- command: /create-event
|
||||
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
|
||||
description: Create an event within Cal!
|
||||
should_escape: false
|
||||
- command: /today
|
||||
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
|
||||
description: View all your bookings for today
|
||||
should_escape: false
|
||||
oauth_config:
|
||||
redirect_urls:
|
||||
- https://YOUR_DOMAIN/api/integrations/slackmessaging/callback
|
||||
scopes:
|
||||
bot:
|
||||
- chat:write
|
||||
- commands
|
||||
- chat:write.public
|
||||
settings:
|
||||
interactivity:
|
||||
is_enabled: true
|
||||
request_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
|
||||
message_menu_options_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Add the integration as normal - slack app - add. Follow the oauth flow to add it to a server.
|
||||
|
||||
Next make sure you have your app running `yarn dx`. Then in the slack chat type one of these commands: `/create-event` or `/today`
|
||||
|
||||
> NOTE: Next you will need to setup a proxy server like [ngrok](https://ngrok.com/) to allow your local host machine to be hosted on a public https server.
|
||||
|
||||
### Obtaining Zoom Client ID and Secret
|
||||
|
||||
|
|
|
@ -801,245 +801,247 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||
{embed.title}
|
||||
</h3>
|
||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full items-center text-base font-medium text-neutral-900">
|
||||
<div>
|
||||
{embedType === "inline"
|
||||
? "Inline Embed Customization"
|
||||
: embedType === "floating-popup"
|
||||
? "Floating Popup Customization"
|
||||
: "Element Click Customization"}
|
||||
</div>
|
||||
<Icon.FiChevronRight
|
||||
className={`${
|
||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="text-sm">
|
||||
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
||||
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
||||
<div className="text-sm">Embed Window Sizing</div>
|
||||
<div className="justify-left flex items-center">
|
||||
<TextField
|
||||
name="width"
|
||||
labelProps={{ className: "hidden" }}
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
const width = e.target.value || "100%";
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
width,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>W</InputLeading>}
|
||||
/>
|
||||
<span className="p-2">×</span>
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
name="height"
|
||||
value={previewState.inline.height}
|
||||
required
|
||||
onChange={(e) => {
|
||||
const height = e.target.value || "100%";
|
||||
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
height,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>H</InputLeading>}
|
||||
/>
|
||||
<div className="max-h-97 flex flex-col overflow-y-auto">
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full items-center text-base font-medium text-neutral-900">
|
||||
<div>
|
||||
{embedType === "inline"
|
||||
? "Inline Embed Customization"
|
||||
: embedType === "floating-popup"
|
||||
? "Floating Popup Customization"
|
||||
: "Element Click Customization"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2 text-sm">Button Text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
name="buttonText"
|
||||
labelProps={{ className: "hidden" }}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonText: e.target.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue="Book my Cal"
|
||||
required
|
||||
<Icon.FiChevronRight
|
||||
className={`${
|
||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-start",
|
||||
embedType === "floating-popup" ? "space-x-2" : "hidden"
|
||||
)}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
hideButtonIcon: !checked,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm">Display Calendar Icon Button</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2">Position of Button</div>
|
||||
<Select
|
||||
onChange={(position) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonPosition: position?.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue={FloatingPopupPositionOptions[0]}
|
||||
options={FloatingPopupPositionOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Button Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="text-sm">
|
||||
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
||||
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
||||
<div className="text-sm">Embed Window Sizing</div>
|
||||
<div className="justify-left flex items-center">
|
||||
<TextField
|
||||
name="width"
|
||||
labelProps={{ className: "hidden" }}
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
const width = e.target.value || "100%";
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
width,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>W</InputLeading>}
|
||||
/>
|
||||
<span className="p-2">×</span>
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
name="height"
|
||||
value={previewState.inline.height}
|
||||
required
|
||||
onChange={(e) => {
|
||||
const height = e.target.value || "100%";
|
||||
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
height,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>H</InputLeading>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2 text-sm">Button Text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
name="buttonText"
|
||||
labelProps={{ className: "hidden" }}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
buttonText: e.target.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue="Book my Cal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Text Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-start",
|
||||
embedType === "floating-popup" ? "space-x-2" : "hidden"
|
||||
)}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
hideButtonIcon: !checked,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm">Display Calendar Icon Button</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
<div className="mt-4 font-medium">
|
||||
<Collapsible
|
||||
open={isBookingCustomizationOpen}
|
||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger className="flex w-full" type="button">
|
||||
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
|
||||
<Icon.FiChevronRight
|
||||
className={`${
|
||||
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<Label className="">
|
||||
<div className="mb-2">Theme</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2">Position of Button</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
onChange={(position) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
theme: option.value,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonPosition: position?.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}
|
||||
defaultValue={FloatingPopupPositionOptions[0]}
|
||||
options={FloatingPopupPositionOptions}
|
||||
/>
|
||||
</Label>
|
||||
{[
|
||||
{ name: "brandColor", title: "Brand Color" },
|
||||
// { name: "lightColor", title: "Light Color" },
|
||||
// { name: "lighterColor", title: "Lighter Color" },
|
||||
// { name: "lightestColor", title: "Lightest Color" },
|
||||
// { name: "highlightColor", title: "Highlight Color" },
|
||||
// { name: "medianColor", title: "Median Color" },
|
||||
].map((palette) => (
|
||||
<Label key={palette.name} className="pb-4">
|
||||
<div className="mb-2 pt-2">{palette.title}</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
[palette.name as keyof typeof previewState["palette"]]: color,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Button Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Text Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
<div className="mt-4 font-medium">
|
||||
<Collapsible
|
||||
open={isBookingCustomizationOpen}
|
||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger className="flex w-full" type="button">
|
||||
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
|
||||
<Icon.FiChevronRight
|
||||
className={`${
|
||||
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<Label className="">
|
||||
<div className="mb-2">Theme</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
theme: option.value,
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}
|
||||
/>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{[
|
||||
{ name: "brandColor", title: "Brand Color" },
|
||||
// { name: "lightColor", title: "Light Color" },
|
||||
// { name: "lighterColor", title: "Lighter Color" },
|
||||
// { name: "lightestColor", title: "Lightest Color" },
|
||||
// { name: "highlightColor", title: "Highlight Color" },
|
||||
// { name: "medianColor", title: "Median Color" },
|
||||
].map((palette) => (
|
||||
<Label key={palette.name} className="pb-4">
|
||||
<div className="mb-2 pt-2">{palette.title}</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
[palette.name as keyof typeof previewState["palette"]]: color,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-2/3 flex-col p-8">
|
||||
|
|
|
@ -14,7 +14,7 @@ import { Icon } from "@calcom/ui";
|
|||
import { Checkbox, Button, TextField, Label } from "@calcom/ui/components";
|
||||
import { CustomInputItem, Dialog, DialogContent, SettingsToggle, showToast, Tooltip } from "@calcom/ui/v2";
|
||||
|
||||
import CustomInputTypeForm from "@components/v2/eventtype/CustomInputTypeForm";
|
||||
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
||||
|
||||
const generateHashedLink = (id: number) => {
|
||||
const translator = short();
|
|
@ -16,7 +16,7 @@ import { Select, Skeleton } from "@calcom/ui/v2";
|
|||
|
||||
import { slugify } from "@lib/slugify";
|
||||
|
||||
import { EditLocationDialog } from "@components/v2/eventtype/EditLocationDialog";
|
||||
import { EditLocationDialog } from "@components/eventtype/EditLocationDialog";
|
||||
|
||||
type OptionTypeBase = {
|
||||
label: string;
|
|
@ -1,13 +1,12 @@
|
|||
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui/v2";
|
||||
|
||||
/** @deprecated Use `apps/web/components/v2/eventtype/SkeletonLoader.tsx` */
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-4 flex items-center">
|
||||
<SkeletonAvatar width="8" height="8" />
|
||||
<div className="space-y-1">
|
||||
<SkeletonAvatar className="h-8 w-8" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
</div>
|
||||
|
@ -28,17 +27,17 @@ function SkeletonItem() {
|
|||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div>
|
||||
<SkeletonText width="32" height="5" />
|
||||
<SkeletonText className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="">
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon.FiClock className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText width="12" height="4" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon.FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText width="16" height="4" />
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui/v2";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-4 flex items-center">
|
||||
<SkeletonAvatar className="h-8 w-8" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-neutral-200 rounded-md border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div>
|
||||
<SkeletonText className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="">
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon.FiClock className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon.FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
|
@ -28,16 +28,16 @@ import { getSession } from "@lib/auth";
|
|||
import { HttpError } from "@lib/core/http/error";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { AvailabilityTab } from "@components/v2/eventtype/AvailabilityTab";
|
||||
import { AvailabilityTab } from "@components/eventtype/AvailabilityTab";
|
||||
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings
|
||||
import { EventAdvancedTab } from "@components/v2/eventtype/EventAdvancedTab";
|
||||
import { EventAppsTab } from "@components/v2/eventtype/EventAppsTab";
|
||||
import { EventLimitsTab } from "@components/v2/eventtype/EventLimitsTab";
|
||||
import { EventRecurringTab } from "@components/v2/eventtype/EventRecurringTab";
|
||||
import { EventSetupTab } from "@components/v2/eventtype/EventSetupTab";
|
||||
import { EventTeamTab } from "@components/v2/eventtype/EventTeamTab";
|
||||
import { EventTypeSingleLayout } from "@components/v2/eventtype/EventTypeSingleLayout";
|
||||
import EventWorkflowsTab from "@components/v2/eventtype/EventWorkfowsTab";
|
||||
import { EventAdvancedTab } from "@components/eventtype/EventAdvancedTab";
|
||||
import { EventAppsTab } from "@components/eventtype/EventAppsTab";
|
||||
import { EventLimitsTab } from "@components/eventtype/EventLimitsTab";
|
||||
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
|
||||
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
|
||||
import { EventTeamTab } from "@components/eventtype/EventTeamTab";
|
||||
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
||||
import EventWorkflowsTab from "@components/eventtype/EventWorkfowsTab";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
|
|
|
@ -28,9 +28,9 @@ import { withQuery } from "@lib/QueryCell";
|
|||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import SkeletonLoader from "@components/v2/eventtype/SkeletonLoader";
|
||||
|
||||
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
|
|
|
@ -85,7 +85,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
//TODO: Use zodSchema to verify it instead of using Type Assertion
|
||||
locations: privacyFilteredLocations(eventTypeRaw.locations as LocationObject[]),
|
||||
locations: privacyFilteredLocations((eventTypeRaw.locations || []) as LocationObject[]),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
};
|
||||
|
||||
|
|
|
@ -35,16 +35,16 @@ test.describe("Wipe my Cal App Test", () => {
|
|||
await bookings.create(pro.id, pro.username, eventType.id, {});
|
||||
await pro.login();
|
||||
await page.goto("/bookings/upcoming");
|
||||
|
||||
await expect(page.locator("data-testid=wipe-today-button")).toBeVisible();
|
||||
|
||||
const $openBookingCount = await page.locator('[data-testid="bookings"] > *').count();
|
||||
await expect($openBookingCount).toBe(3);
|
||||
expect($openBookingCount).toBe(3);
|
||||
|
||||
await page.locator("data-testid=wipe-today-button").click();
|
||||
await page.locator("data-testid=send_request").click();
|
||||
|
||||
const $openBookings = await page.locator('[data-testid="bookings"]');
|
||||
// Don't await send_request click, otherwise mutation can possibly occur before observer is attached
|
||||
page.locator("data-testid=send_request").click();
|
||||
const $openBookings = page.locator('[data-testid="bookings"]');
|
||||
await $openBookings.evaluate((ul) => {
|
||||
return new Promise<void>((resolve) =>
|
||||
new window.MutationObserver(() => {
|
||||
|
|
|
@ -126,7 +126,6 @@
|
|||
"webhook_updated_successfully": "Webhook updated successfully!",
|
||||
"webhook_removed_successfully": "Webhook removed successfully!",
|
||||
"payload_template": "Payload Template",
|
||||
"payload":"Payload",
|
||||
"dismiss": "Dismiss",
|
||||
"no_data_yet": "No data yet",
|
||||
"ping_test": "Ping test",
|
||||
|
@ -782,7 +781,7 @@
|
|||
"analytics": "Analytics",
|
||||
"empty_installed_apps_headline": "No apps installed",
|
||||
"empty_installed_apps_description": "Apps enable you to enhance your workflow and improve your scheduling life significantly.",
|
||||
"empty_installed_apps_button": "Explore the App Store or Install from below apps",
|
||||
"empty_installed_apps_button": "Browse App Store",
|
||||
"manage_your_connected_apps": "Manage your installed apps or change settings",
|
||||
"browse_apps": "Browse Apps",
|
||||
"features": "Features",
|
||||
|
@ -1345,5 +1344,9 @@
|
|||
"attendee_email_info": "The person booking's email",
|
||||
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
|
||||
"choose_common_schedule_team_event": "Choose a common schedule",
|
||||
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule."
|
||||
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
|
||||
"test_routing_form": "Test Routing Form",
|
||||
"test_preview": "Test Preview",
|
||||
"route_to": "Route to",
|
||||
"test_preview_description": "Test your routing form without submitting any data"
|
||||
}
|
||||
|
|
|
@ -20,7 +20,6 @@ export const InstallAppButtonMap = {
|
|||
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
|
||||
office365video: dynamic(() => import("./office365video/components/InstallAppButton")),
|
||||
riverside: dynamic(() => import("./riverside/components/InstallAppButton")),
|
||||
slackmessaging: dynamic(() => import("./slackmessaging/components/InstallAppButton")),
|
||||
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
|
||||
vital: dynamic(() => import("./vital/components/InstallAppButton")),
|
||||
whereby: dynamic(() => import("./whereby/components/InstallAppButton")),
|
||||
|
|
|
@ -32,7 +32,6 @@ import { metadata as raycast_meta } from "./raycast/_metadata";
|
|||
import { metadata as riverside_meta } from "./riverside/_metadata";
|
||||
import { metadata as sendgridothercalendar_meta } from "./sendgridothercalendar/_metadata";
|
||||
import { metadata as sirius_video_meta } from "./sirius_video/_metadata";
|
||||
import { metadata as slackmessaging_meta } from "./slackmessaging/_metadata";
|
||||
import { metadata as stripepayment_meta } from "./stripepayment/_metadata";
|
||||
import { metadata as tandemvideo_meta } from "./tandemvideo/_metadata";
|
||||
import { metadata as typeform_meta } from "./typeform/_metadata";
|
||||
|
@ -74,7 +73,6 @@ export const appStoreMetadata = {
|
|||
riverside: riverside_meta,
|
||||
sendgridothercalendar: sendgridothercalendar_meta,
|
||||
sirius_video: sirius_video_meta,
|
||||
slackmessaging: slackmessaging_meta,
|
||||
stripepayment: stripepayment_meta,
|
||||
tandemvideo: tandemvideo_meta,
|
||||
typeform: typeform_meta,
|
||||
|
|
|
@ -31,7 +31,6 @@ export const apiHandlers = {
|
|||
riverside: import("./riverside/api"),
|
||||
sendgridothercalendar: import("./sendgridothercalendar/api"),
|
||||
sirius_video: import("./sirius_video/api"),
|
||||
slackmessaging: import("./slackmessaging/api"),
|
||||
stripepayment: import("./stripepayment/api"),
|
||||
tandemvideo: import("./tandemvideo/api"),
|
||||
typeform: import("./typeform/api"),
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
|
||||
import { SerializableForm, Response } from "../types/types";
|
||||
|
||||
type Props = {
|
||||
form: SerializableForm<App_RoutingForms_Form>;
|
||||
response: Response;
|
||||
setResponse: Dispatch<SetStateAction<Response>>;
|
||||
};
|
||||
|
||||
export default function FormInputFields(props: Props) {
|
||||
const { form, response, setResponse } = props;
|
||||
|
||||
const queryBuilderConfig = getQueryBuilderConfig(form);
|
||||
|
||||
return (
|
||||
<>
|
||||
{form.fields?.map((field) => {
|
||||
const widget = queryBuilderConfig.widgets[field.type];
|
||||
if (!("factory" in widget)) {
|
||||
return null;
|
||||
}
|
||||
const Component = widget.factory;
|
||||
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div key={field.id} className="mb-4 block flex-col sm:flex ">
|
||||
<div className="min-w-48 mb-2 flex-grow">
|
||||
<label
|
||||
id="slug-label"
|
||||
htmlFor="slug"
|
||||
className="flex text-sm font-medium text-neutral-700 dark:text-white">
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex rounded-sm">
|
||||
<Component
|
||||
value={response[field.id]?.value}
|
||||
// required property isn't accepted by query-builder types
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/* @ts-ignore */
|
||||
required={!!field.required}
|
||||
listValues={options}
|
||||
data-testid="field"
|
||||
setValue={(value) => {
|
||||
setResponse((response) => {
|
||||
response = response || {};
|
||||
return {
|
||||
...response,
|
||||
[field.id]: {
|
||||
label: field.label,
|
||||
value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,11 +1,12 @@
|
|||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, UseFormReturn, Controller } from "react-hook-form";
|
||||
|
||||
import useApp from "@calcom/lib/hooks/useApp";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Dialog, DialogContent, DialogClose, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Button, ButtonGroup } from "@calcom/ui/components";
|
||||
import { Form, TextAreaField, TextField } from "@calcom/ui/components/form";
|
||||
import { showToast, DropdownMenuSeparator, Tooltip, VerticalDivider } from "@calcom/ui/v2";
|
||||
|
@ -14,8 +15,12 @@ import SettingsToggle from "@calcom/ui/v2/core/SettingsToggle";
|
|||
import { ShellMain } from "@calcom/ui/v2/core/Shell";
|
||||
import Banner from "@calcom/ui/v2/core/banner";
|
||||
|
||||
import { processRoute } from "../lib/processRoute";
|
||||
import { RoutingPages } from "../pages/route-builder/[...appPages]";
|
||||
import { SerializableForm } from "../types/types";
|
||||
import { Response, Route } from "../types/types";
|
||||
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
|
||||
import FormInputFields from "./FormInputFields";
|
||||
import RoutingNavBar from "./RoutingNavBar";
|
||||
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
@ -190,6 +195,15 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
|||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
|
||||
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
|
||||
const [response, setResponse] = useState<Response>({});
|
||||
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
|
||||
|
||||
function testRouting() {
|
||||
const action = processRoute({ form, response });
|
||||
setDecidedAction(action);
|
||||
}
|
||||
|
||||
const hookForm = useForm({
|
||||
defaultValues: form,
|
||||
});
|
||||
|
@ -210,76 +224,151 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
|||
},
|
||||
});
|
||||
return (
|
||||
<Form
|
||||
form={hookForm}
|
||||
handleSubmit={(data) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
mutation.mutate({
|
||||
...data,
|
||||
});
|
||||
}}>
|
||||
<FormActionsProvider appUrl={appUrl}>
|
||||
<Meta title={form.name} description={form.description || ""} />
|
||||
<ShellMain
|
||||
heading={form.name}
|
||||
subtitle={form.description || ""}
|
||||
backPath={`/${appUrl}/forms`}
|
||||
CTA={<Actions form={form} mutation={mutation} />}>
|
||||
<div className="-mx-4 px-4 sm:px-6 md:-mx-8 md:px-8">
|
||||
<div className="flex flex-col items-center md:flex-row md:items-start">
|
||||
<div className="lg:min-w-72 lg:max-w-72 mb-6 md:mr-6">
|
||||
<TextField
|
||||
type="text"
|
||||
containerClassName="mb-6"
|
||||
placeholder="Title"
|
||||
{...hookForm.register("name")}
|
||||
/>
|
||||
<TextAreaField
|
||||
rows={3}
|
||||
id="description"
|
||||
data-testid="description"
|
||||
placeholder="Form Description"
|
||||
{...hookForm.register("description")}
|
||||
defaultValue={form.description || ""}
|
||||
/>
|
||||
<>
|
||||
<Form
|
||||
form={hookForm}
|
||||
handleSubmit={(data) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
mutation.mutate({
|
||||
...data,
|
||||
});
|
||||
}}>
|
||||
<FormActionsProvider appUrl={appUrl}>
|
||||
<Meta title={form.name} description={form.description || ""} />
|
||||
<ShellMain
|
||||
heading={form.name}
|
||||
subtitle={form.description || ""}
|
||||
backPath={`/${appUrl}/forms`}
|
||||
CTA={<Actions form={form} mutation={mutation} />}>
|
||||
<div className="-mx-4 px-4 sm:px-6 md:-mx-8 md:px-8">
|
||||
<div className="flex flex-col items-center md:flex-row md:items-start">
|
||||
<div className="lg:min-w-72 lg:max-w-72 mb-6 md:mr-6">
|
||||
<TextField
|
||||
type="text"
|
||||
containerClassName="mb-6"
|
||||
placeholder="Title"
|
||||
{...hookForm.register("name")}
|
||||
/>
|
||||
<TextAreaField
|
||||
rows={3}
|
||||
id="description"
|
||||
data-testid="description"
|
||||
placeholder="Form Description"
|
||||
{...hookForm.register("description")}
|
||||
defaultValue={form.description || ""}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<Controller
|
||||
name="settings.emailOwnerOnSubmission"
|
||||
control={hookForm.control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("routing_forms_send_email_owner")}
|
||||
description={t("routing_forms_send_email_owner_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(val) => onChange(val)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
<Controller
|
||||
name="settings.emailOwnerOnSubmission"
|
||||
control={hookForm.control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("routing_forms_send_email_owner")}
|
||||
description={t("routing_forms_send_email_owner_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(val) => onChange(val)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button color="secondary" onClick={() => setIsTestPreviewOpen(true)}>
|
||||
{t("test_preview")}
|
||||
</Button>
|
||||
</div>
|
||||
{!form._count?.responses && (
|
||||
<Banner
|
||||
className="mt-6"
|
||||
variant="neutral"
|
||||
title="No Responses yet"
|
||||
description="Wait for some time for responses to be collected. You can go and submit the form yourself as well."
|
||||
Icon={Icon.FiInfo}
|
||||
onDismiss={() => console.log("dismissed")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full rounded-md border border-gray-200 p-8">
|
||||
<RoutingNavBar appUrl={appUrl} form={form} />
|
||||
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
|
||||
</div>
|
||||
{!form._count?.responses && (
|
||||
<Banner
|
||||
className="mt-6"
|
||||
variant="neutral"
|
||||
title="No Responses yet"
|
||||
description="Wait for some time for responses to be collected. You can go and submit the form yourself as well."
|
||||
Icon={Icon.FiInfo}
|
||||
onDismiss={() => console.log("dismissed")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full rounded-md border border-gray-200 p-8">
|
||||
<RoutingNavBar appUrl={appUrl} form={form} />
|
||||
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</ShellMain>
|
||||
</FormActionsProvider>
|
||||
</Form>
|
||||
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
|
||||
<div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
testRouting();
|
||||
}}>
|
||||
<div className="px-1">
|
||||
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
|
||||
</div>
|
||||
<div>
|
||||
{decidedAction && (
|
||||
<div className="mt-5 rounded-md bg-gray-100 p-3">
|
||||
<div className="font-bold ">{t("route_to")}:</div>
|
||||
<div className="mt-2">
|
||||
{RoutingPages.map((page) => {
|
||||
if (page.value === decidedAction.type) {
|
||||
return <>{page.label}</>;
|
||||
}
|
||||
})}
|
||||
:{" "}
|
||||
{decidedAction.type === "customPageMessage" ? (
|
||||
<span className="text-gray-700">{decidedAction.value}</span>
|
||||
) : decidedAction.type === "externalRedirectUrl" ? (
|
||||
<span className="text-gray-700 underline">
|
||||
<a
|
||||
target="_blank"
|
||||
href={
|
||||
decidedAction.value.includes("https://") ||
|
||||
decidedAction.value.includes("http://")
|
||||
? decidedAction.value
|
||||
: `http://${decidedAction.value}`
|
||||
}
|
||||
rel="noreferrer">
|
||||
{decidedAction.value}
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-700 underline">
|
||||
<a target="_blank" href={`/${decidedAction.value}`} rel="noreferrer">
|
||||
{decidedAction.value}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setIsTestPreviewOpen(false);
|
||||
setDecidedAction(null);
|
||||
setResponse({});
|
||||
}}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">{t("Test Routing")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</ShellMain>
|
||||
</FormActionsProvider>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -104,7 +104,6 @@ const MultiSelectWidget = ({
|
|||
return (
|
||||
<Select
|
||||
className="dark:border-darkgray-300 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
|
||||
menuPosition="fixed"
|
||||
onChange={(items) => {
|
||||
setValue(items?.map((item) => item.value));
|
||||
}}
|
||||
|
@ -138,7 +137,6 @@ function SelectWidget({
|
|||
return (
|
||||
<Select
|
||||
className="data-testid-select dark:border-darkgray-300 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
|
||||
menuPosition="fixed"
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
|
|
|
@ -128,6 +128,21 @@ type SerializableRoute = Pick<Route, "id" | "action"> & {
|
|||
isFallback?: Route["isFallback"];
|
||||
};
|
||||
|
||||
export const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
|
||||
{
|
||||
label: "Custom Page",
|
||||
value: "customPageMessage",
|
||||
},
|
||||
{
|
||||
label: "External Redirect",
|
||||
value: "externalRedirectUrl",
|
||||
},
|
||||
{
|
||||
label: "Event Redirect",
|
||||
value: "eventTypeRedirectUrl",
|
||||
},
|
||||
];
|
||||
|
||||
const Route = ({
|
||||
route,
|
||||
routes,
|
||||
|
@ -146,20 +161,7 @@ const Route = ({
|
|||
moveDown?: { fn: () => void; check: () => boolean } | null;
|
||||
}) => {
|
||||
const index = routes.indexOf(route);
|
||||
const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
|
||||
{
|
||||
label: "Custom Page",
|
||||
value: "customPageMessage",
|
||||
},
|
||||
{
|
||||
label: "External Redirect",
|
||||
value: "externalRedirectUrl",
|
||||
},
|
||||
{
|
||||
label: "Event Redirect",
|
||||
value: "eventTypeRedirectUrl",
|
||||
},
|
||||
];
|
||||
|
||||
const { data: eventTypesByGroup } = trpc.useQuery(["viewer.eventTypes"]);
|
||||
|
||||
const eventOptions: { label: string; value: string }[] = [];
|
||||
|
|
|
@ -18,10 +18,10 @@ import showToast from "@calcom/ui/v2/core/notifications";
|
|||
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
|
||||
import FormInputFields from "../../components/FormInputFields";
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
import { processRoute } from "../../lib/processRoute";
|
||||
import { Response, Route } from "../../types/types";
|
||||
import { getQueryBuilderConfig } from "../route-builder/[...appPages]";
|
||||
|
||||
function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
|
||||
|
@ -89,8 +89,6 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
|||
|
||||
const [response, setResponse] = useState<Response>({});
|
||||
|
||||
const queryBuilderConfig = getQueryBuilderConfig(form);
|
||||
|
||||
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
onSubmit(response);
|
||||
|
@ -124,57 +122,7 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
|||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{form.fields?.map((field) => {
|
||||
const widget = queryBuilderConfig.widgets[field.type];
|
||||
if (!("factory" in widget)) {
|
||||
return null;
|
||||
}
|
||||
const Component = widget.factory;
|
||||
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div key={field.id} className="mb-4 block flex-col sm:flex ">
|
||||
<div className="min-w-48 mb-2 flex-grow">
|
||||
<label
|
||||
id="slug-label"
|
||||
htmlFor="slug"
|
||||
className="flex text-sm font-medium text-neutral-700 dark:text-white">
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex rounded-sm">
|
||||
<Component
|
||||
value={response[field.id]?.value}
|
||||
// required property isn't accepted by query-builder types
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/* @ts-ignore */
|
||||
required={!!field.required}
|
||||
listValues={options}
|
||||
data-testid="field"
|
||||
setValue={(value) => {
|
||||
setResponse((response) => {
|
||||
response = response || {};
|
||||
return {
|
||||
...response,
|
||||
[field.id]: {
|
||||
label: field.label,
|
||||
value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<FormInputFields form={form} response={response} setResponse={setResponse} />
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast dark:border-transparent"
|
||||
|
|
|
@ -16,7 +16,6 @@ import * as larkcalendar from "./larkcalendar";
|
|||
import * as office365calendar from "./office365calendar";
|
||||
import * as office365video from "./office365video";
|
||||
import * as sendgridothercalendar from "./sendgridothercalendar";
|
||||
import * as slackmessaging from "./slackmessaging";
|
||||
import * as stripepayment from "./stripepayment";
|
||||
import * as tandemvideo from "./tandemvideo";
|
||||
import * as vital from "./vital";
|
||||
|
@ -39,7 +38,6 @@ const appStore = {
|
|||
office365calendar,
|
||||
office365video,
|
||||
sendgridothercalendar,
|
||||
slackmessaging,
|
||||
stripepayment,
|
||||
tandemvideo,
|
||||
vital,
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
items:
|
||||
- /api/app-store/slackmessaging/1.jpg
|
||||
- /api/app-store/slackmessaging/2.jpg
|
||||
- /api/app-store/slackmessaging/3.jpg
|
||||
---
|
||||
|
||||
Slack is a proprietary business communication platform that includes many IRC (internet relay chat) features - these include channels, private groups, direct messaging and more. Users are able to send pictures, and videos as well as even hop on calls with others using the paid version. Slack is available via desktop app, web browser or mobile app. The Cal.com Slack App can be used to display your links, upcoming bookings or to create events. Use it with your team members in a Slack, in a Community Slack or even with external contributors in a Slack Connect Channel.
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type { AppMeta } from "@calcom/types/App";
|
||||
|
||||
import _package from "./package.json";
|
||||
|
||||
export const metadata = {
|
||||
name: "Slack App",
|
||||
description: _package.description,
|
||||
category: "messaging",
|
||||
imageSrc: "/apps/slack.svg",
|
||||
logo: "/apps/slack.svg",
|
||||
publisher: "Cal.com",
|
||||
rating: 5,
|
||||
reviews: 69,
|
||||
slug: "slack",
|
||||
title: "Slack App",
|
||||
trending: true,
|
||||
// DB has type slack_app. It is an inconsistency
|
||||
type: "slack_messaging",
|
||||
url: "https://slack.com/",
|
||||
variant: "conferencing",
|
||||
verified: true,
|
||||
email: "help@cal.com",
|
||||
} as AppMeta;
|
||||
|
||||
export default metadata;
|
|
@ -1,40 +0,0 @@
|
|||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSlackAppKeys } from "../lib/utils";
|
||||
|
||||
const scopes = ["commands", "users:read", "users:read.email", "chat:write", "chat:write.public"];
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
if (!req.session?.user?.id) {
|
||||
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
|
||||
}
|
||||
|
||||
const { client_id } = await getSlackAppKeys();
|
||||
// Get user
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const params = {
|
||||
client_id,
|
||||
scope: scopes.join(","),
|
||||
};
|
||||
const query = stringify(params);
|
||||
const url = `https://slack.com/oauth/v2/authorize?${query}&user_`;
|
||||
// const url =
|
||||
// "https://slack.com/oauth/v2/authorize?client_id=3194129032064.3178385871204&scope=chat:write,commands&user_scope=";
|
||||
return { url };
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
|
@ -1,61 +0,0 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { stringify } from "querystring";
|
||||
import { z } from "zod";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import { getSlackAppKeys } from "../lib/utils";
|
||||
|
||||
const callbackQuerySchema = z.object({
|
||||
code: z.string().min(1),
|
||||
});
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session?.user?.id) {
|
||||
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
|
||||
}
|
||||
|
||||
// Get user
|
||||
const parsedCallbackQuery = callbackQuerySchema.safeParse(req.query);
|
||||
|
||||
if (!parsedCallbackQuery.success) {
|
||||
return res.redirect("/apps/installed"); // Redirect to where the user was if they cancel the signup or if the oauth fails
|
||||
}
|
||||
|
||||
const { code } = parsedCallbackQuery.data;
|
||||
const { client_id, client_secret } = await getSlackAppKeys();
|
||||
|
||||
const query = {
|
||||
client_secret,
|
||||
client_id,
|
||||
code,
|
||||
};
|
||||
const params = stringify(query);
|
||||
const url = `https://slack.com/api/oauth.v2.access?${params}`;
|
||||
const result = await fetch(url);
|
||||
const responseBody = await result.json();
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: req.session.user.id,
|
||||
},
|
||||
data: {
|
||||
credentials: {
|
||||
create: {
|
||||
type: "slack_app",
|
||||
key: responseBody,
|
||||
appId: "slack",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "slack" }));
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
|
@ -1,39 +0,0 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { showCreateEventMessage, showTodayMessage } from "../lib";
|
||||
import showLinksMessage from "../lib/showLinksMessage";
|
||||
|
||||
export enum SlackAppCommands {
|
||||
CREATE_EVENT = "create-event",
|
||||
TODAY = "today",
|
||||
LINKS = "links",
|
||||
}
|
||||
|
||||
const commandHandlerBodySchema = z.object({
|
||||
command: z.string().min(1),
|
||||
user_id: z.string(),
|
||||
trigger_id: z.string(),
|
||||
channel_id: z.string().optional(),
|
||||
});
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = commandHandlerBodySchema.parse(req.body);
|
||||
const command = body.command.split("/").pop();
|
||||
switch (command) {
|
||||
case SlackAppCommands.CREATE_EVENT:
|
||||
return await showCreateEventMessage(req, res);
|
||||
case SlackAppCommands.TODAY:
|
||||
return await showTodayMessage(req, res);
|
||||
case SlackAppCommands.LINKS:
|
||||
return await showLinksMessage(req, res);
|
||||
default:
|
||||
return res.status(404).json({ message: `Command not found` });
|
||||
}
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
POST: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
|
@ -1,4 +0,0 @@
|
|||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
export { default as commandHandler } from "./commandHandler";
|
||||
export { default as interactiveHandler } from "./interactiveHandler";
|
|
@ -1,21 +0,0 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import createEvent from "../lib/actions/createEvent";
|
||||
|
||||
enum InteractionEvents {
|
||||
CREATE_EVENT = "cal.event.create",
|
||||
}
|
||||
|
||||
export default async function interactiveHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") {
|
||||
const payload = JSON.parse(req.body.payload);
|
||||
const actions = payload.view.callback_id;
|
||||
switch (actions) {
|
||||
case InteractionEvents.CREATE_EVENT:
|
||||
await createEvent(req, res);
|
||||
default:
|
||||
return res.status(200).end(); // Techincally an invalid request but we don't want to return an throw an error to slack - 200 just does nothing
|
||||
}
|
||||
}
|
||||
return res.status(200).end(); // Send 200 if we dont have a case for the action_id
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import type { InstallAppButtonProps } from "@calcom/app-store/types";
|
||||
|
||||
import useAddAppMutation from "../../_utils/useAddAppMutation";
|
||||
|
||||
export default function InstallAppButton(props: InstallAppButtonProps) {
|
||||
const mutation = useAddAppMutation("slack_messaging");
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.render({
|
||||
onClick() {
|
||||
mutation.mutate("");
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default as InstallAppButton } from "./InstallAppButton";
|
|
@ -1,2 +0,0 @@
|
|||
export * as api from "./api";
|
||||
export { default } from "./_metadata";
|
|
@ -1,9 +0,0 @@
|
|||
export const WhereCredsEqualsId = (userId: string) => ({
|
||||
where: {
|
||||
type: "slack_app",
|
||||
key: {
|
||||
path: ["authed_user", "id"],
|
||||
equals: userId,
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,119 +0,0 @@
|
|||
import { WebClient } from "@slack/web-api";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import db from "@calcom/prisma";
|
||||
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { WhereCredsEqualsId } from "../WhereCredsEqualsID";
|
||||
import { getUserEmail } from "../utils";
|
||||
|
||||
export default async function createEvent(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {
|
||||
user,
|
||||
view: {
|
||||
state: { values },
|
||||
id: view_id,
|
||||
},
|
||||
response_url,
|
||||
} = JSON.parse(req.body.payload);
|
||||
// This is a mess I have no idea why slack makes getting infomation this hard.
|
||||
const {
|
||||
eventName: {
|
||||
event_name: { value: selected_name },
|
||||
},
|
||||
eventType: {
|
||||
"create.event.type": {
|
||||
selected_option: { value: selected_event_id },
|
||||
},
|
||||
},
|
||||
selectedUsers: {
|
||||
invite_users: { selected_users },
|
||||
},
|
||||
eventDate: {
|
||||
event_date: { selected_date },
|
||||
},
|
||||
eventTime: {
|
||||
event_start_time: { selected_time },
|
||||
},
|
||||
} = values;
|
||||
|
||||
// Im sure this query can be made more efficient... The JSON filtering wouldnt work when doing it directly on user.
|
||||
const foundUser = await db.credential
|
||||
.findFirstOrThrow({
|
||||
...WhereCredsEqualsId(user.id),
|
||||
})
|
||||
.user({
|
||||
select: {
|
||||
username: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
id: parseInt(selected_event_id),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
length: true,
|
||||
locations: true,
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
...WhereCredsEqualsId(user.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const SlackCredentialsSchema = z.object({
|
||||
access_token: z.string(),
|
||||
});
|
||||
|
||||
const slackCredentials = SlackCredentialsSchema.parse(foundUser?.credentials[0].key); // Only one slack credential for user
|
||||
|
||||
const access_token = slackCredentials?.access_token;
|
||||
// https://api.slack.com/authentication/best-practices#verifying since we verify the request is coming from slack we can store the access_token in the DB.
|
||||
const client = new WebClient(access_token);
|
||||
// This could get a bit weird as there is a 3 second limit until the post times ou
|
||||
|
||||
// Compute all users that have been selected and get their email.
|
||||
const invitedGuestsEmails = selected_users.map((userId: string) => getUserEmail(client, userId));
|
||||
|
||||
const startDate = dayjs(`${selected_date} ${selected_time}`, "YYYY-MM-DD HH:mm");
|
||||
|
||||
const PostData: BookingCreateBody = {
|
||||
start: dayjs(startDate).format(),
|
||||
end: dayjs(startDate)
|
||||
.add(foundUser?.eventTypes[0]?.length ?? 0, "minute")
|
||||
.format(),
|
||||
eventTypeId: foundUser?.eventTypes[0]?.id ?? 0,
|
||||
user: foundUser?.username ?? "",
|
||||
email: foundUser?.email ?? "",
|
||||
name: foundUser?.username ?? "",
|
||||
guests: await Promise.all(invitedGuestsEmails),
|
||||
location: DailyLocationType, // Defaulting to daily video to make this a bit more usefull than in-person
|
||||
timeZone: foundUser?.timeZone ?? "",
|
||||
language: foundUser?.locale ?? "en",
|
||||
customInputs: [{ label: "", value: "" }],
|
||||
metadata: {},
|
||||
notes: "This event was created with slack.",
|
||||
};
|
||||
|
||||
const response = await fetch(`${WEBAPP_URL}/api/book/event`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(PostData),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const body = await response.json();
|
||||
client.chat.postMessage({
|
||||
token: access_token,
|
||||
channel: user.id,
|
||||
text: body.errorCode ? `Error: ${body.errorCode}` : "Booking has been created.",
|
||||
});
|
||||
return res.status(200).send("");
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export { default as showCreateEventMessage } from "./showCreateEventMessage";
|
||||
export { default as showTodayMessage } from "./showTodayMessage";
|
||||
export * as utils from "./utils";
|
|
@ -1,41 +0,0 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
|
||||
import slackVerify from "./slackVerify";
|
||||
import { CreateEventModal, NoUserMessage } from "./views";
|
||||
|
||||
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body;
|
||||
await slackVerify(req, res);
|
||||
|
||||
const data = await prisma.credential.findFirst({
|
||||
...WhereCredsEqualsId(body.user_id),
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
eventTypes: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return res.status(200).json(NoUserMessage);
|
||||
const slackCredentials = data?.key; // Only one slack credential for user
|
||||
const access_token = (slackCredentials as Prisma.JsonObject)?.access_token as string;
|
||||
const slackClient = new WebClient(access_token);
|
||||
await slackClient.views.open({
|
||||
trigger_id: body.trigger_id,
|
||||
view: CreateEventModal(data),
|
||||
});
|
||||
return res.status(200).end();
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
|
||||
import slackVerify from "./slackVerify";
|
||||
import { NoUserMessage } from "./views";
|
||||
import ShowLinks from "./views/ShowLinks";
|
||||
|
||||
export default async function showLinksMessage(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body;
|
||||
await slackVerify(req, res);
|
||||
|
||||
const data = await prisma.credential.findFirst({
|
||||
...WhereCredsEqualsId(body.user_id),
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
hidden: false,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return res.status(200).json(NoUserMessage);
|
||||
const slackCredentials = data?.key; // Only one slack credential for user
|
||||
const access_token = (slackCredentials as Prisma.JsonObject)?.access_token as string;
|
||||
const slackClient = new WebClient(access_token);
|
||||
const blocks = JSON.parse(ShowLinks(data.user?.eventTypes, data.user?.username ?? "")).blocks;
|
||||
|
||||
slackClient.chat.postMessage({
|
||||
channel: body.channel_id,
|
||||
text: `${data.user?.username}'s Cal.com Links`,
|
||||
blocks,
|
||||
});
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import { BookingStatus } from "@prisma/client";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
|
||||
import slackVerify from "./slackVerify";
|
||||
import { NoUserMessage, TodayMessage } from "./views";
|
||||
|
||||
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = req.body;
|
||||
await slackVerify(req, res);
|
||||
const foundUser = await prisma.credential.findFirst({
|
||||
...WhereCredsEqualsId(body.user_id),
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundUser) res.status(200).json(NoUserMessage);
|
||||
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: foundUser?.userId,
|
||||
},
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: foundUser?.user?.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [
|
||||
{
|
||||
endTime: { gte: dayjs().startOf("day").toDate(), lte: dayjs().endOf("day").toDate() },
|
||||
AND: [
|
||||
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
|
||||
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json(TodayMessage(bookings));
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import { createHmac } from "crypto";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import { getSlackAppKeys } from "./utils";
|
||||
|
||||
export default async function slackVerify(req: NextApiRequest, res: NextApiResponse) {
|
||||
const timeStamp = req.headers["x-slack-request-timestamp"] as string; // Always returns a string and not a string[]
|
||||
const slackSignature = req.headers["x-slack-signature"] as string;
|
||||
const currentTime = dayjs().unix();
|
||||
const { signing_secret: signingSecret } = await getSlackAppKeys();
|
||||
const [version, hash] = slackSignature.split("=");
|
||||
|
||||
if (!timeStamp) {
|
||||
return res.status(400).json({ message: "Missing X-Slack-Request-Timestamp header" });
|
||||
}
|
||||
|
||||
if (!signingSecret) {
|
||||
return res.status(400).json({ message: "Missing Slack's signing_secret" });
|
||||
}
|
||||
|
||||
if (Math.abs(currentTime - parseInt(timeStamp)) > 60 * 5) {
|
||||
return res.status(400).json({ message: "Request is too old" });
|
||||
}
|
||||
|
||||
const hmac = createHmac("sha256", signingSecret);
|
||||
|
||||
hmac.update(`${version}:${timeStamp}:${stringify(req.body)}`);
|
||||
|
||||
const signed_sig = hmac.digest("hex");
|
||||
console.log({ signed_sig, hash, match: signed_sig === hash });
|
||||
if (signed_sig !== hash) {
|
||||
throw new Error("Hashes do not match ");
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import { WebClient } from "@slack/web-api";
|
||||
import { z } from "zod";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
|
||||
export const getUserEmail = async (client: WebClient, userId: string) =>
|
||||
(await client.users.info({ user: userId })).user?.profile?.email;
|
||||
|
||||
const slackAppKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
signing_secret: z.string(),
|
||||
});
|
||||
|
||||
export const getSlackAppKeys = async () => {
|
||||
const appKeys = await getAppKeysFromSlug("slack");
|
||||
return slackAppKeysSchema.parse(appKeys);
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
import { Blocks, Message } from "slack-block-builder";
|
||||
|
||||
const BookingSuccess = () => {
|
||||
return Message()
|
||||
.blocks(Blocks.Section({ text: `Your booking has been created!` }))
|
||||
.buildToObject();
|
||||
};
|
||||
|
||||
export default BookingSuccess;
|
|
@ -1,53 +0,0 @@
|
|||
import { Credential } from "@prisma/client";
|
||||
import { Bits, Blocks, Elements, Modal, setIfTruthy } from "slack-block-builder";
|
||||
|
||||
const CreateEventModal = (
|
||||
data:
|
||||
| (Credential & {
|
||||
user: {
|
||||
username: string | null;
|
||||
eventTypes: {
|
||||
id: number;
|
||||
title: string;
|
||||
}[];
|
||||
} | null;
|
||||
})
|
||||
| null,
|
||||
invalidInput = false
|
||||
) => {
|
||||
return Modal({ title: "Create Booking", submit: "Create", callbackId: "cal.event.create" })
|
||||
.blocks(
|
||||
Blocks.Section({ text: `Hey there, *${data?.user?.username}!*` }),
|
||||
Blocks.Divider(),
|
||||
Blocks.Input({ label: "Your Name", blockId: "eventName" }).element(
|
||||
Elements.TextInput({ placeholder: "Event Name" }).actionId("event_name")
|
||||
),
|
||||
Blocks.Input({ label: "Which event would you like to create?", blockId: "eventType" }).element(
|
||||
Elements.StaticSelect({ placeholder: "Which event would you like to create?" })
|
||||
.actionId("create.event.type")
|
||||
.options(
|
||||
data?.user?.eventTypes.map((item: any) =>
|
||||
Bits.Option({ text: item.title ?? "No Name", value: item.id.toString() })
|
||||
)
|
||||
)
|
||||
), // This doesnt need to reach out to the server when the user changes the selection
|
||||
Blocks.Input({
|
||||
label: "Who would you like to invite to your event?",
|
||||
blockId: "selectedUsers",
|
||||
}).element(
|
||||
Elements.UserMultiSelect({ placeholder: "Who would you like to invite to your event?" }).actionId(
|
||||
"invite_users"
|
||||
)
|
||||
),
|
||||
Blocks.Input({ label: "When would this event be?", blockId: "eventDate" }).element(
|
||||
Elements.DatePicker({ placeholder: "Select Date" }).actionId("event_date")
|
||||
),
|
||||
Blocks.Input({ label: "What time would you like to start?", blockId: "eventTime" }).element(
|
||||
Elements.TimePicker({ placeholder: "Select Time" }).actionId("event_start_time")
|
||||
), // TODO: We could in future validate if the time is in the future or if busy at point - Didnt see much point as this gets validated when you submit. Could be better UX
|
||||
setIfTruthy(invalidInput, [Blocks.Section({ text: "Please fill in all the fields" })])
|
||||
)
|
||||
.buildToObject();
|
||||
};
|
||||
|
||||
export default CreateEventModal;
|
|
@ -1,20 +0,0 @@
|
|||
import { Message, Blocks, Elements } from "slack-block-builder";
|
||||
|
||||
import { BASE_URL } from "@calcom/lib/constants";
|
||||
|
||||
const NoUserMessage = () => {
|
||||
return Message()
|
||||
.blocks(
|
||||
Blocks.Section({ text: "This slack account is not linked with a cal.com account" }),
|
||||
Blocks.Actions().elements(
|
||||
Elements.Button({ text: "Cancel", actionId: "cancel" }).danger(),
|
||||
Elements.Button({
|
||||
text: "Connect",
|
||||
actionId: "open.connect.link",
|
||||
url: `${BASE_URL}/apps/installed`,
|
||||
}).primary()
|
||||
)
|
||||
)
|
||||
.buildToJSON();
|
||||
};
|
||||
export default NoUserMessage;
|
|
@ -1,31 +0,0 @@
|
|||
import { Blocks, Elements, Message } from "slack-block-builder";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
interface IEventTypes {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ShowLinks = (eventLinks: IEventTypes[] | undefined, username: string) => {
|
||||
if (eventLinks?.length === 0 || !eventLinks) {
|
||||
return Message()
|
||||
.blocks(Blocks.Section({ text: "You do not have any links." }))
|
||||
.asUser()
|
||||
.buildToJSON();
|
||||
}
|
||||
return Message()
|
||||
.blocks(
|
||||
Blocks.Section({ text: `${username}'s Cal.com Links` }),
|
||||
Blocks.Divider(),
|
||||
eventLinks.map((links) =>
|
||||
Blocks.Section({
|
||||
text: `${links.title} | ${WEBAPP_URL}/${username}/${links.slug}`,
|
||||
}).accessory(Elements.Button({ text: "Open", url: `${WEBAPP_URL}/${username}/${links.slug}` }))
|
||||
)
|
||||
)
|
||||
.buildToJSON();
|
||||
};
|
||||
|
||||
export default ShowLinks;
|
|
@ -1,27 +0,0 @@
|
|||
import { Booking } from "@prisma/client";
|
||||
import { Blocks, Elements, Message } from "slack-block-builder";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
const TodayMessage = (bookings: Booking[]) => {
|
||||
if (bookings.length === 0) {
|
||||
return Message()
|
||||
.blocks(Blocks.Section({ text: "You do not have any bookings for today." }))
|
||||
.asUser()
|
||||
.buildToObject();
|
||||
}
|
||||
return Message()
|
||||
.blocks(
|
||||
Blocks.Section({ text: `Todays Bookings.` }),
|
||||
Blocks.Divider(),
|
||||
bookings.map((booking) =>
|
||||
Blocks.Section({
|
||||
text: `${booking.title} | ${dayjs(booking.startTime).format("HH:mm")}`,
|
||||
}).accessory(Elements.Button({ text: "Cancel", url: `${WEBAPP_URL}/cancel/${booking.uid}` }))
|
||||
)
|
||||
)
|
||||
.buildToObject();
|
||||
};
|
||||
|
||||
export default TodayMessage;
|
|
@ -1,3 +0,0 @@
|
|||
export { default as CreateEventModal } from "./CreateEventModal";
|
||||
export { default as TodayMessage } from "./TodayMessage";
|
||||
export { default as NoUserMessage } from "./NoUser";
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/slackmessaging",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"description": "A business communication platform that includes persistent chat rooms (channels), private groups and direct messaging.",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@slack/web-api": "^6.7.2",
|
||||
"slack-block-builder": "^2.6.0",
|
||||
"zod": "^3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 203 KiB |
Binary file not shown.
Before Width: | Height: | Size: 278 KiB |
Binary file not shown.
Before Width: | Height: | Size: 132 KiB |
|
@ -1,31 +0,0 @@
|
|||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 270 270" style="enable-background:new 0 0 270 270;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#E01E5A;}
|
||||
.st1{fill:#36C5F0;}
|
||||
.st2{fill:#2EB67D;}
|
||||
.st3{fill:#ECB22E;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M99.4,151.2c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9h12.9V151.2z"/>
|
||||
<path class="st0" d="M105.9,151.2c0-7.1,5.8-12.9,12.9-12.9s12.9,5.8,12.9,12.9v32.3c0,7.1-5.8,12.9-12.9,12.9
|
||||
s-12.9-5.8-12.9-12.9V151.2z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st1" d="M118.8,99.4c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9s12.9,5.8,12.9,12.9v12.9H118.8z"/>
|
||||
<path class="st1" d="M118.8,105.9c7.1,0,12.9,5.8,12.9,12.9s-5.8,12.9-12.9,12.9H86.5c-7.1,0-12.9-5.8-12.9-12.9
|
||||
s5.8-12.9,12.9-12.9H118.8z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st2" d="M170.6,118.8c0-7.1,5.8-12.9,12.9-12.9c7.1,0,12.9,5.8,12.9,12.9s-5.8,12.9-12.9,12.9h-12.9V118.8z"/>
|
||||
<path class="st2" d="M164.1,118.8c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9V86.5c0-7.1,5.8-12.9,12.9-12.9
|
||||
c7.1,0,12.9,5.8,12.9,12.9V118.8z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M151.2,170.6c7.1,0,12.9,5.8,12.9,12.9c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9v-12.9H151.2z"/>
|
||||
<path class="st3" d="M151.2,164.1c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9h32.3c7.1,0,12.9,5.8,12.9,12.9
|
||||
c0,7.1-5.8,12.9-12.9,12.9H151.2z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -2,13 +2,13 @@ import { WebhookTriggerEvents } from "@prisma/client";
|
|||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, Label, TextArea, TextField } from "@calcom/ui/components/form";
|
||||
import Switch from "@calcom/ui/v2/core/Switch";
|
||||
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
|
||||
import Select from "@calcom/ui/v2/core/form/select";
|
||||
|
||||
import customTemplate, { hasTemplateIntegration } from "../lib/integrationTemplate";
|
||||
|
@ -217,28 +217,24 @@ const WebhookForm = (props: {
|
|||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="font-sm mt-8 text-gray-900">
|
||||
<>{t("payload")}</>
|
||||
<>{t("payload_template")}</>
|
||||
</Label>
|
||||
<div className="flex rounded-md border">
|
||||
<div
|
||||
className={classNames(
|
||||
"px-1/2 w-1/2 rounded-md py-2.5 text-center font-medium text-gray-900",
|
||||
!useCustomTemplate && "bg-gray-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
setUseCustomTemplate(false);
|
||||
formMethods.setValue("payloadTemplate", undefined);
|
||||
}}>
|
||||
<p>{t("default")}</p>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"px-1/2 w-1/2 rounded-md py-2.5 text-center font-medium text-gray-900",
|
||||
useCustomTemplate && "bg-gray-200"
|
||||
)}
|
||||
onClick={() => setUseCustomTemplate(true)}>
|
||||
<p>{t("custom")}</p>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<ToggleGroup
|
||||
onValueChange={(val) => {
|
||||
if (val === "default") {
|
||||
setUseCustomTemplate(false);
|
||||
formMethods.setValue("payloadTemplate", undefined);
|
||||
} else {
|
||||
setUseCustomTemplate(true);
|
||||
}
|
||||
}}
|
||||
defaultValue={value ? "custom" : "default"}
|
||||
options={[
|
||||
{ value: "default", label: t("default") },
|
||||
{ value: "custom", label: t("custom") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{useCustomTemplate && (
|
||||
<TextArea
|
||||
|
|
|
@ -266,14 +266,7 @@ export default async function main() {
|
|||
|
||||
// Web3 apps
|
||||
await createApp("huddle01", "huddle01video", ["web3", "video"], "huddle01_video");
|
||||
// Messaging apps
|
||||
if (process.env.SLACK_CLIENT_ID && process.env.SLACK_CLIENT_SECRET && process.env.SLACK_SIGNING_SECRET) {
|
||||
await createApp("slack", "slackmessaging", ["messaging"], "slack_messaging", {
|
||||
client_id: process.env.SLACK_CLIENT_ID,
|
||||
client_secret: process.env.SLACK_CLIENT_SECRET,
|
||||
signing_secret: process.env.SLACK_SIGNING_SECRET,
|
||||
});
|
||||
}
|
||||
|
||||
// Payment apps
|
||||
if (
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
|
|
|
@ -69,7 +69,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
|
|||
<DialogPrimitive.Content
|
||||
{...props}
|
||||
className={classNames(
|
||||
"min-w-[360px] rounded bg-white text-left shadow-xl focus-visible:outline-none sm:w-full sm:align-middle",
|
||||
"h-auto max-h-[inherit] min-w-[360px] rounded bg-white text-left shadow-xl focus-visible:outline-none sm:w-full sm:align-middle",
|
||||
props.size == "xl"
|
||||
? "p-0.5 sm:max-w-[98vw]"
|
||||
: props.size == "lg"
|
||||
|
|
Loading…
Reference in New Issue
Block a user