Skip to content

Commit

Permalink
feat: Routing form submitted but no booking - Salesforce actions (#18616
Browse files Browse the repository at this point in the history
)

* Add booking incomplete actions table

* Add incomplete booking page tab

* Add `getincompleteBookingSettings` trpc endpoints

* Add incomplete booking page

* Abstract enabled apps array

* Handle no enabled credentials and no actions

* Add enabled field to incomplete booking action db record

* Add new write record entries

* UI add separation between switch and inputs

* Fix typo

* clean up

* Add saveIncompleteBookingSettings endpoint

* Save incomplete booking settings

* Fix language around when to write to field

* Add `credentialId` to action record

* Choose which credential to assign to action

* Save credential to action

* Revert "Save credential to action"

This reverts commit ba6a1c8.

* Revert "Choose which credential to assign to action"

This reverts commit 968f6e5.

* Revert "Add `credentialId` to action record"

This reverts commit 579f9ff.

* Add credentialId to action record - rewrite migration file

* Revert "Add credentialId to action record - rewrite migration file"

This reverts commit 2843a92.

* Revert "Add booking incomplete actions table"

This reverts commit 7ec75be.

* Revert "Add enabled field to incomplete booking action db record"

This reverts commit d279a1d.

* Write migration in single commit

* Rename table

* Rename table - remove underscores

* Remove credential relationship

* Type fix - changing table name

* Fix table name

* Change writeToRecordObject to object

* Salesforce add incomplete booking, write to record

* Add incomplete booking actions to `triggerFormSubmittedNoEventWebhooks`

* Remove console.log

* Type fixes

* Type fixes

* Iterate if incompleteBookingActions

* Choose which credential to assign to action

* Save credential to action

* Fix getServerSideProp changes

* Type fix

* Type fix

* Type fix

* Type fix

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
  • Loading branch information
joeauyeung and emrysal authored Jan 14, 2025
1 parent 7a8b9ed commit 66b3e73
Show file tree
Hide file tree
Showing 19 changed files with 756 additions and 21 deletions.
1 change: 1 addition & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2926,5 +2926,6 @@
"managed_users": "Managed Users",
"managed_users_description": "See all the managed users created by your OAuth client",
"select_oAuth_client": "Select Oauth Client",
"on_every_instance": "On every instance",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
4 changes: 4 additions & 0 deletions packages/app-store/routing-forms/components/RoutingNavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export default function RoutingNavBar({
target: "_blank",
href: `${appUrl}/reporting/${form?.id}`,
},
{
name: "Incomplete Booking",
href: `${appUrl}/incomplete-booking/${form?.id}`,
},
];
return (
<div className="mb-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const enabledIncompleteBookingApps = ["salesforce"];
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { z } from "zod";

import { routingFormIncompleteBookingDataSchema as salesforceRoutingFormIncompleteBookingDataSchema } from "@calcom/app-store/salesforce/zod";
import { IncompleteBookingActionType } from "@calcom/prisma/enums";

const incompleteBookingActionDataSchemas: Record<IncompleteBookingActionType, z.ZodType<any>> = {
[IncompleteBookingActionType.SALESFORCE]: salesforceRoutingFormIncompleteBookingDataSchema,
};

export default incompleteBookingActionDataSchemas;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { App_RoutingForms_IncompleteBookingActions } from "@prisma/client";

import { incompleteBookingAction as salesforceIncompleteBookingAction } from "@calcom/app-store/salesforce/lib/routingForm/incompleteBookingAction";
import { IncompleteBookingActionType } from "@calcom/prisma/enums";

const incompleteBookingActionFunctions: Record<
IncompleteBookingActionType,
(action: App_RoutingForms_IncompleteBookingActions, email: string) => void
> = {
[IncompleteBookingActionType.SALESFORCE]: salesforceIncompleteBookingAction,
};

export default incompleteBookingActionFunctions;
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export const routingServerSidePropsConfig: Record<string, AppGetServerSideProps>
"route-builder": getServerSidePropsSingleForm,
"routing-link": getServerSidePropsRoutingLink,
reporting: getServerSidePropsSingleForm,
"incomplete-booking": getServerSidePropsSingleForm,
};
2 changes: 2 additions & 0 deletions packages/app-store/routing-forms/pages/app-routing.config.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//TODO: Generate this file automatically so that like in Next.js file based routing can work automatically
import * as formEdit from "./form-edit/[...appPages]";
import * as forms from "./forms/[...appPages]";
import * as IncompleteBooking from "./incomplete-booking/[...appPages]";
import * as LayoutHandler from "./layout-handler/[...appPages]";
import * as Reporting from "./reporting/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
Expand All @@ -13,6 +14,7 @@ const routingConfig = {
"routing-link": RoutingLink,
reporting: Reporting,
layoutHandler: LayoutHandler,
"incomplete-booking": IncompleteBooking,
};

export default routingConfig;
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
import { useState, useEffect } from "react";
import type z from "zod";

import { WhenToWriteToRecord, SalesforceFieldType } from "@calcom/app-store/salesforce/lib/enums";
import type { writeToRecordDataSchema as salesforceWriteToRecordDataSchema } from "@calcom/app-store/salesforce/zod";
import { routingFormIncompleteBookingDataSchema as salesforceRoutingFormIncompleteBookingDataSchema } from "@calcom/app-store/salesforce/zod";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { IncompleteBookingActionType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Switch, InputField, Button, Select, showToast } from "@calcom/ui";

import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
} from "../../components/SingleForm";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import { enabledIncompleteBookingApps } from "../../lib/enabledIncompleteBookingApps";

function Page({ form }: { form: RoutingFormWithResponseCount }) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.appRoutingForms.getIncompleteBookingSettings.useQuery({
formId: form.id,
});

const mutation = trpc.viewer.appRoutingForms.saveIncompleteBookingSettings.useMutation({
onSuccess: () => {
showToast(t("success"), "success");
},
onError: (error) => {
showToast(t(`error: ${error.message}`), "error");
},
});

const [salesforceWriteToRecordObject, setSalesforceWriteToRecordObject] = useState<
z.infer<typeof salesforceWriteToRecordDataSchema>
>({});

// Handle just Salesforce for now but need to expand this to other apps
const [salesforceActionEnabled, setSalesforceActionEnabled] = useState<boolean>(false);

const fieldTypeOptions = [{ label: t("text"), value: SalesforceFieldType.TEXT }];

const [selectedFieldType, setSelectedFieldType] = useState(fieldTypeOptions[0]);

const whenToWriteToRecordOptions = [
{ label: t("on_every_instance"), value: WhenToWriteToRecord.EVERY_BOOKING },
{ label: t("only_if_field_is_empty"), value: WhenToWriteToRecord.FIELD_EMPTY },
];

const [selectedWhenToWrite, setSelectedWhenToWrite] = useState(whenToWriteToRecordOptions[0]);

const [newSalesforceAction, setNewSalesforceAction] = useState({
field: "",
fieldType: selectedFieldType.value,
value: "",
whenToWrite: WhenToWriteToRecord.FIELD_EMPTY,
});

const credentialOptions = data?.credentials.map((credential) => ({
label: credential.team?.name,
value: credential.id,
}));

const [selectedCredential, setSelectedCredential] = useState(
Array.isArray(credentialOptions) ? credentialOptions[0] : null
);

useEffect(() => {
const salesforceAction = data?.incompleteBookingActions.find(
(action) => action.actionType === IncompleteBookingActionType.SALESFORCE
);

if (salesforceAction) {
setSalesforceActionEnabled(salesforceAction.enabled);

const parsedSalesforceActionData = salesforceRoutingFormIncompleteBookingDataSchema.safeParse(
salesforceAction.data
);
if (parsedSalesforceActionData.success) {
setSalesforceWriteToRecordObject(parsedSalesforceActionData.data?.writeToRecordObject ?? {});
}

setSelectedCredential(
credentialOptions
? credentialOptions.find((option) => option.value === salesforceAction?.credentialId) ??
selectedCredential
: selectedCredential
);
}
}, [data]);

if (isLoading) {
return <div>Loading...</div>;
}

// Check to see if the user has any compatible credentials
if (
!data?.credentials.some((credential) => enabledIncompleteBookingApps.includes(credential?.appId ?? ""))
) {
return <div>No apps installed that support this feature</div>;
}

return (
<>
<div className="bg-default border-subtle rounded-md border p-8">
<div>
<Switch
labelOnLeading
label="Write to Salesforce contact/lead record"
checked={salesforceActionEnabled}
onCheckedChange={(checked) => {
setSalesforceActionEnabled(checked);
}}
/>
</div>

{salesforceActionEnabled ? (
<>
<hr className="mt-4 border" />

{form.team && (
<>
<div className="mt-2">
<p>Credential to use</p>
<Select
options={credentialOptions}
value={selectedCredential}
onChange={(option) => {
if (!option) {
return;
}
setSelectedCredential(option);
}}
/>
</div>

<hr className="mt-4 border" />
</>
)}

<div className="mt-2">
<div className="grid grid-cols-5 gap-4">
<div>{t("field_name")}</div>
<div>{t("field_type")}</div>
<div>{t("value")}</div>
<div>{t("when_to_write")}</div>
</div>
<div>
{Object.keys(salesforceWriteToRecordObject).map((key) => {
const action =
salesforceWriteToRecordObject[key as keyof typeof salesforceWriteToRecordObject];
return (
<div className="mt-2 grid grid-cols-5 gap-4" key={key}>
<div>
<InputField value={key} readOnly />
</div>
<div>
<Select
value={fieldTypeOptions.find((option) => option.value === action.fieldType)}
isDisabled={true}
/>
</div>
<div>
<InputField value={action.value} readOnly />
</div>
<div>
<Select
value={whenToWriteToRecordOptions.find(
(option) => option.value === action.whenToWrite
)}
isDisabled={true}
/>
</div>
<div>
<Button
StartIcon="trash"
variant="icon"
color="destructive"
onClick={() => {
const newActions = { ...salesforceWriteToRecordObject };
delete newActions[key];
setSalesforceWriteToRecordObject(newActions);
}}
/>
</div>
</div>
);
})}
<div className="mt-2 grid grid-cols-5 gap-4">
<div>
<InputField
value={newSalesforceAction.field}
onChange={(e) =>
setNewSalesforceAction({
...newSalesforceAction,
field: e.target.value,
})
}
/>
</div>
<div>
<Select
options={fieldTypeOptions}
value={selectedFieldType}
onChange={(e) => {
if (e) {
setSelectedFieldType(e);
setNewSalesforceAction({
...newSalesforceAction,
fieldType: e.value,
});
}
}}
/>
</div>
<div>
<InputField
value={newSalesforceAction.value}
onChange={(e) =>
setNewSalesforceAction({
...newSalesforceAction,
value: e.target.value,
})
}
/>
</div>
<div>
<Select
options={whenToWriteToRecordOptions}
value={selectedWhenToWrite}
onChange={(e) => {
if (e) {
setSelectedWhenToWrite(e);
setNewSalesforceAction({
...newSalesforceAction,
whenToWrite: e.value,
});
}
}}
/>
</div>
</div>
</div>
<Button
className="mt-2"
size="sm"
disabled={
!(
newSalesforceAction.field &&
newSalesforceAction.fieldType &&
newSalesforceAction.value &&
newSalesforceAction.whenToWrite
)
}
onClick={() => {
if (Object.keys(salesforceWriteToRecordObject).includes(newSalesforceAction.field.trim())) {
showToast("Field already exists", "error");
return;
}

setSalesforceWriteToRecordObject({
...salesforceWriteToRecordObject,
[newSalesforceAction.field]: {
fieldType: newSalesforceAction.fieldType,
value: newSalesforceAction.value,
whenToWrite: newSalesforceAction.whenToWrite,
},
});

setNewSalesforceAction({
field: "",
fieldType: selectedFieldType.value,
value: "",
whenToWrite: WhenToWriteToRecord.FIELD_EMPTY,
});
}}>
{t("add_new_field")}
</Button>
</div>
</>
) : null}
</div>
<div className="mt-2 flex justify-end">
<Button
size="sm"
disabled={mutation.isPending}
onClick={() => {
mutation.mutate({
formId: form.id,
data: {
writeToRecordObject: salesforceWriteToRecordObject,
},
actionType: IncompleteBookingActionType.SALESFORCE,
enabled: salesforceActionEnabled,
credentialId: selectedCredential?.value ?? data.credentials[0].id,
});
}}>
{t("save")}
</Button>
</div>
</>
);
}

export default function IncompleteBookingPage({
form,
appUrl,
enrichedWithUserProfileForm,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
Page={Page}
/>
);
}

IncompleteBookingPage.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
};

export { getServerSideProps };
Loading

0 comments on commit 66b3e73

Please sign in to comment.