feat: org team app install (#15704)
* feat: remove dropdown from app-store and redirect to new-app-install-flow * removed extra code * fix: account select loading issue * removed getPaymentCredential (unused) * fix: only redirect after the app has been added to all the event-types * remove overflowIndicatorStyles * refactor getUserAdminTeams * send teamId instead of id * seperate locations component * for conferencing apps skip select account page * refactor InstallAppButtonChild * installing conferencing apps shows locations dropdown in configure step * send location data to the handler * send location data to the handler * add the newly installed to the locations dropdown (prefillLocation) * fix: type errors * fix: handle es-lint errors * fix: app is added again on submit * only add app if not already added * fix: type erros * filter out managed events for now * fix: show installed count badge * remove 2 toast message * feat: added tests for conferencing apps * fix: loading indicator * Update apps/web/pages/apps/installation/[[...step]].tsx Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> * Update apps/web/pages/apps/installation/[[...step]].tsx Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> * move `getUserAdminTeams` to `UserRepository` * fix: zoom and jelly not redirecting to the new app install flow * fix: typo * add `installableOnTeams` prop * split `configureStepCard` into smaller components * fix: step count * fix: show loading indicator until the new page is loaded * fix: organizer_default_conferencing_app not visible in a team * Introduce QueryClient to app card tests * fix: typo * add installableOnTeams prop * fix: tests * seated events shouldn't have multiple locations #15174 * fix: o365 callback * fix: office365_video not redirecting to event-types step * Revert "fix: o365 callback" This reverts commit bba841035ea34f8c31201b64c77221b8d8c3e626. * add TEAM_SELECT_STEP * add apps to orgs and their sub-teams initial commit * undo team select step * small ui fix * fix: wrong step numbers * fix: dont allow app installation without cretendialId * fix: don't show acme team as it cannot have any events * refactor useAddAppMutation * removed console.log * added comment * added comment * move locationOptions from getServerSideProps a trpc query * fix: failing tests * Update conferencingApps.e2e.ts * refactor useAddAppMutation * refactor useAddAppMutation 2 * fix: test failing * fix: unit test * Revert "fix: unit test" This reverts commit 6d74032211d094478c6d7cf9aedbce696dfb768d. * fix: failing test * Increase test timeout for conferencing app tests * fix: write separate tests for each conferencing app to prevent hitting 6000 ms timeout * improved tests naming * fix: correct message and translation key #15657 * fix: write separate tests for each analytics app to prevent hitting 6000 ms timeout * fix: analytics apps test * attempt to fix failing tests * fix typo * refactor * update: replace text-gray with text-stuble - works with light mode too * update: use userRepository.getUserAdminTeams * Merge branch 'main' into feat/org-team-app-install-2 * fix: after merge conflict from app router migration * remove consoles --------- Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
co-authored by
Joe Au-Yeung
Omar López
Joe Au-Yeung
Udit Takkar
parent
f86a5db843
commit
d27ccd6f44
+1
-1
@@ -8,4 +8,4 @@ export class CreateOrganizationAttributeOptionInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
readonly slug!: string;
|
||||
}
|
||||
}
|
||||
|
||||
+30
-11
@@ -3,34 +3,53 @@ import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs
|
||||
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributesService {
|
||||
constructor(private readonly organizationAttributesRepository: OrganizationAttributesRepository) {}
|
||||
|
||||
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
|
||||
const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(organizationId, data);
|
||||
const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(
|
||||
organizationId,
|
||||
data
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(organizationId, attributeId);
|
||||
const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
|
||||
const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(organizationId, skip, take);
|
||||
const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(
|
||||
organizationId,
|
||||
skip,
|
||||
take
|
||||
);
|
||||
return attributes;
|
||||
}
|
||||
|
||||
async updateOrganizationAttribute(organizationId: number, attributeId: string, data: UpdateOrganizationAttributeInput) {
|
||||
const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(organizationId, attributeId, data);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(organizationId, attributeId);
|
||||
async updateOrganizationAttribute(
|
||||
organizationId: number,
|
||||
attributeId: string,
|
||||
data: UpdateOrganizationAttributeInput
|
||||
) {
|
||||
const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId,
|
||||
data
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
|
||||
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
|
||||
const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(
|
||||
organizationId,
|
||||
attributeId
|
||||
);
|
||||
return attribute;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,27 @@ import React, { useState } from "react";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { Avatar, StepCard } from "@calcom/ui";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { StepCard } from "@calcom/ui";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
import type { TTeams } from "~/apps/installation/[[...step]]/step-view";
|
||||
|
||||
export type PersonalAccountProps = Pick<User, "id" | "avatarUrl" | "name"> & { alreadyInstalled: boolean };
|
||||
|
||||
type AccountStepCardProps = {
|
||||
teams?: TTeams;
|
||||
personalAccount: PersonalAccountProps;
|
||||
onSelect: (id?: number) => void;
|
||||
loading: boolean;
|
||||
installableOnTeams: boolean;
|
||||
};
|
||||
|
||||
type AccountSelectorProps = {
|
||||
avatar?: string;
|
||||
name: string;
|
||||
alreadyInstalled: boolean;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
loading: boolean;
|
||||
testId: string;
|
||||
};
|
||||
@@ -29,13 +42,13 @@ const AccountSelector: FC<AccountSelectorProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1 transition",
|
||||
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
|
||||
(alreadyInstalled || loading) && "cursor-not-allowed",
|
||||
selected && loading && "bg-muted animate-pulse"
|
||||
)}
|
||||
data-testid={testId}
|
||||
onClick={() => {
|
||||
if (!alreadyInstalled && !loading) {
|
||||
if (!alreadyInstalled && !loading && onClick) {
|
||||
setSelected(true);
|
||||
onClick();
|
||||
}
|
||||
@@ -45,28 +58,14 @@ const AccountSelector: FC<AccountSelectorProps> = ({
|
||||
imageSrc={getPlaceholderAvatar(avatar, name)} // if no image, use default avatar
|
||||
size="sm"
|
||||
/>
|
||||
<div className="text-md pt-0.5 font-medium text-gray-500">
|
||||
<div className="text-md text-subtle pt-0.5 font-medium">
|
||||
{name}
|
||||
{alreadyInstalled ? <span className="ml-1 text-sm text-gray-400">{t("already_installed")}</span> : ""}
|
||||
{alreadyInstalled ? <span className="text-subtle ml-2 text-sm">({t("already_installed")})</span> : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PersonalAccountProps = Pick<User, "id" | "avatarUrl" | "name"> & { alreadyInstalled: boolean };
|
||||
|
||||
export type TeamsProp = (Pick<Team, "id" | "name" | "logoUrl"> & {
|
||||
alreadyInstalled: boolean;
|
||||
})[];
|
||||
|
||||
type AccountStepCardProps = {
|
||||
teams?: TeamsProp;
|
||||
personalAccount: PersonalAccountProps;
|
||||
onSelect: (id?: number) => void;
|
||||
loading: boolean;
|
||||
installableOnTeams: boolean;
|
||||
};
|
||||
|
||||
export const AccountsStepCard: FC<AccountStepCardProps> = ({
|
||||
teams,
|
||||
personalAccount,
|
||||
@@ -77,7 +76,7 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<StepCard>
|
||||
<div className="text-sm font-medium text-gray-400">{t("install_app_on")}</div>
|
||||
<div className="text-subtle text-sm font-medium">{t("install_app_on")}</div>
|
||||
<div className={classNames("mt-2 flex flex-col gap-2 ")}>
|
||||
<AccountSelector
|
||||
testId="install-app-button-personal"
|
||||
|
||||
@@ -12,12 +12,12 @@ import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolv
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import { Button, Form, Icon } from "@calcom/ui";
|
||||
import { Button, Form, Icon, Avatar } from "@calcom/ui";
|
||||
|
||||
import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper";
|
||||
import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings";
|
||||
|
||||
import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view";
|
||||
import type { TEventType, TEventTypesForm, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
|
||||
|
||||
export type TFormType = {
|
||||
id: number;
|
||||
@@ -35,7 +35,7 @@ export type ConfigureStepCardProps = {
|
||||
loading?: boolean;
|
||||
isConferencing: boolean;
|
||||
formPortalRef: React.RefObject<HTMLDivElement>;
|
||||
eventTypes: TEventType[] | undefined;
|
||||
eventTypeGroups: TEventTypeGroup[];
|
||||
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
||||
handleSetUpLater: () => void;
|
||||
};
|
||||
@@ -56,6 +56,7 @@ type EventTypeAppSettingsFormProps = Pick<
|
||||
locations?: LocationObject[];
|
||||
}) => void;
|
||||
};
|
||||
type TUpdatedEventTypesStatus = { id: number; updated: boolean }[][];
|
||||
|
||||
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
|
||||
function EventTypeAppSettingsForm(props, ref) {
|
||||
@@ -116,82 +117,139 @@ const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSetti
|
||||
}
|
||||
);
|
||||
|
||||
export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
||||
loading,
|
||||
formPortalRef,
|
||||
eventTypes,
|
||||
setConfigureStep,
|
||||
handleSetUpLater,
|
||||
const EventTypeGroup = ({
|
||||
groupIndex,
|
||||
eventTypeGroups,
|
||||
setUpdatedEventTypesStatus,
|
||||
submitRefs,
|
||||
...props
|
||||
}: ConfigureStepCardProps & {
|
||||
groupIndex: number;
|
||||
setUpdatedEventTypesStatus: Dispatch<SetStateAction<TUpdatedEventTypesStatus>>;
|
||||
submitRefs: Array<React.RefObject<HTMLButtonElement>>;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control, getValues } = useFormContext<TEventTypesForm>();
|
||||
const { control } = useFormContext<TEventTypesForm>();
|
||||
const { fields, update } = useFieldArray({
|
||||
control,
|
||||
name: "eventTypes",
|
||||
name: `eventTypeGroups.${groupIndex}.eventTypes`,
|
||||
keyName: "fieldId",
|
||||
});
|
||||
return (
|
||||
<div className="ml-2 flex flex-col space-y-6">
|
||||
{fields.map(
|
||||
(field, index) =>
|
||||
field.selected && (
|
||||
<EventTypeAppSettingsForm
|
||||
key={field.fieldId}
|
||||
eventType={field}
|
||||
loading={props.loading}
|
||||
handleDelete={() => {
|
||||
const eventTypeDb = eventTypeGroups[groupIndex].eventTypes?.find(
|
||||
(eventType) => eventType.id == field.id
|
||||
);
|
||||
update(index, {
|
||||
...field,
|
||||
selected: false,
|
||||
metadata: eventTypeDb?.metadata,
|
||||
bookingFields: eventTypeDb?.bookingFields,
|
||||
...(eventTypeDb?.locations && { locations: eventTypeDb.locations }),
|
||||
});
|
||||
|
||||
const submitRefs = useRef<Array<React.RefObject<HTMLButtonElement>>>([]);
|
||||
submitRefs.current = fields.map(
|
||||
(_ref, index) => (submitRefs.current[index] = React.createRef<HTMLButtonElement>())
|
||||
setUpdatedEventTypesStatus((prev) => {
|
||||
const res = [...prev];
|
||||
res[groupIndex] = res[groupIndex].filter((item) => !(item.id === field.id));
|
||||
if (!res.some((item) => item.length > 0)) {
|
||||
props.setConfigureStep(false);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
update(index, { ...field, ...data });
|
||||
setUpdatedEventTypesStatus((prev) => {
|
||||
const res = [...prev];
|
||||
res[groupIndex] = res[groupIndex].map((item) =>
|
||||
item.id === field.id ? { ...item, updated: true } : item
|
||||
);
|
||||
return res;
|
||||
});
|
||||
}}
|
||||
ref={submitRefs[index]}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
|
||||
const { loading, formPortalRef, handleSetUpLater } = props;
|
||||
const { t } = useLocale();
|
||||
const { control, watch } = useFormContext<TEventTypesForm>();
|
||||
const { fields } = useFieldArray({
|
||||
control,
|
||||
name: "eventTypeGroups",
|
||||
keyName: "fieldId",
|
||||
});
|
||||
const eventTypeGroups = watch("eventTypeGroups");
|
||||
const submitRefs = useRef<Array<Array<React.RefObject<HTMLButtonElement>>>>([]);
|
||||
|
||||
submitRefs.current = eventTypeGroups.reduce(
|
||||
(arr: Array<Array<React.RefObject<HTMLButtonElement>>>, field) => {
|
||||
const res = field.eventTypes
|
||||
.filter((eventType) => eventType.selected)
|
||||
.map((_ref) => React.createRef<HTMLButtonElement>());
|
||||
return [...arr, res];
|
||||
},
|
||||
[]
|
||||
);
|
||||
const mainForSubmitRef = useRef<HTMLButtonElement>(null);
|
||||
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState(
|
||||
fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false }))
|
||||
);
|
||||
const [submit, setSubmit] = useState(false);
|
||||
const allUpdated = updatedEventTypesStatus.every((item) => item.updated);
|
||||
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState<TUpdatedEventTypesStatus>(
|
||||
eventTypeGroups.reduce((arr: Array<{ id: number; updated: boolean }[]>, field) => {
|
||||
const selectedEventTypes = field.eventTypes
|
||||
.filter((eventType) => eventType.selected)
|
||||
.map((eventType) => ({ id: eventType.id as number, updated: false }));
|
||||
|
||||
useEffect(() => {
|
||||
setUpdatedEventTypesStatus((prev) =>
|
||||
prev.filter((state) => fields.some((field) => field.id === state.id && field.selected))
|
||||
);
|
||||
if (!fields.some((field) => field.selected)) {
|
||||
setConfigureStep(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fields]);
|
||||
return [...arr, selectedEventTypes];
|
||||
}, [])
|
||||
);
|
||||
|
||||
const [submit, setSubmit] = useState(false);
|
||||
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
|
||||
|
||||
useEffect(() => {
|
||||
if (submit && allUpdated && mainForSubmitRef.current) {
|
||||
mainForSubmitRef.current?.click();
|
||||
setSubmit(false);
|
||||
}
|
||||
}, [submit, allUpdated, getValues, mainForSubmitRef]);
|
||||
}, [submit, allUpdated, mainForSubmitRef]);
|
||||
|
||||
return (
|
||||
formPortalRef?.current &&
|
||||
createPortal(
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
field.selected && (
|
||||
<EventTypeAppSettingsForm
|
||||
key={field.fieldId}
|
||||
eventType={field}
|
||||
loading={loading}
|
||||
handleDelete={() => {
|
||||
const eventMetadataDb = eventTypes?.find(
|
||||
(eventType) => eventType.id == field.id
|
||||
)?.metadata;
|
||||
update(index, { ...field, selected: false, metadata: eventMetadataDb });
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
update(index, { ...field, ...data });
|
||||
setUpdatedEventTypesStatus((prev) =>
|
||||
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
|
||||
);
|
||||
}}
|
||||
ref={submitRefs.current[index]}
|
||||
{...props}
|
||||
{fields.map((group, groupIndex) => (
|
||||
<div key={group.fieldId}>
|
||||
{eventTypeGroups[groupIndex].eventTypes.some((eventType) => eventType.selected === true) && (
|
||||
<div className="mb-2 mt-4 flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={group.image} // if no image, use default avatar
|
||||
size="md"
|
||||
className="inline-flex justify-center"
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-subtle block">{group.slug}</p>
|
||||
</div>
|
||||
)}
|
||||
<EventTypeGroup
|
||||
groupIndex={groupIndex}
|
||||
setUpdatedEventTypesStatus={setUpdatedEventTypesStatus}
|
||||
submitRefs={submitRefs.current[groupIndex]}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
|
||||
Save
|
||||
</button>
|
||||
@@ -200,7 +258,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
||||
type="button"
|
||||
data-testid="configure-step-save"
|
||||
onClick={() => {
|
||||
submitRefs.current.reverse().map((ref) => ref.current?.click());
|
||||
submitRefs.current.map((group) => group?.map((ref) => ref.current?.click()));
|
||||
setSubmit(true);
|
||||
}}
|
||||
loading={loading}>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { ScrollableArea, Badge, Button } from "@calcom/ui";
|
||||
import { ScrollableArea, Badge, Button, Avatar } from "@calcom/ui";
|
||||
|
||||
import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view";
|
||||
import type { TEventType, TEventTypesForm, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
|
||||
|
||||
type EventTypesCardProps = {
|
||||
userName: string;
|
||||
@@ -15,61 +15,7 @@ type EventTypesCardProps = {
|
||||
handleSetUpLater: () => void;
|
||||
};
|
||||
|
||||
export const EventTypesStepCard: FC<EventTypesCardProps> = ({
|
||||
setConfigureStep,
|
||||
userName,
|
||||
handleSetUpLater,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control } = useFormContext<TEventTypesForm>();
|
||||
const { fields, update } = useFieldArray({
|
||||
control,
|
||||
name: "eventTypes",
|
||||
keyName: "fieldId",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:border-subtle bg-default mt-10 border dark:bg-black sm:rounded-md">
|
||||
<ScrollableArea className="rounded-md">
|
||||
<ul className="border-subtle max-h-97 !static w-full divide-y">
|
||||
{fields.map((field, index) => (
|
||||
<EventTypeCard
|
||||
handleSelect={() => update(index, { ...field, selected: !field.selected })}
|
||||
userName={userName}
|
||||
key={field.fieldId}
|
||||
{...field}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollableArea>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="text-md mt-6 w-full justify-center"
|
||||
data-testid="save-event-types"
|
||||
onClick={() => {
|
||||
setConfigureStep(true);
|
||||
}}
|
||||
disabled={!fields.some((field) => field.selected === true)}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
data-testid="set-up-later"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleSetUpLater();
|
||||
}}
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{t("set_up_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
type EventTypeGroupProps = { groupIndex: number; userName: string } & TEventTypeGroup;
|
||||
|
||||
type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void };
|
||||
|
||||
@@ -95,7 +41,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
return (
|
||||
<div
|
||||
data-testid={`select-event-type-${id}`}
|
||||
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3 transition"
|
||||
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
|
||||
onClick={() => handleSelect()}>
|
||||
<input
|
||||
id={`${id}`}
|
||||
@@ -129,3 +75,98 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const EventTypeGroup: FC<EventTypeGroupProps> = ({ groupIndex, userName, ...props }) => {
|
||||
const { control } = useFormContext<TEventTypesForm>();
|
||||
const { fields, update } = useFieldArray({
|
||||
control,
|
||||
name: `eventTypeGroups.${groupIndex}.eventTypes`,
|
||||
keyName: "fieldId",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="mb-2 flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={props.image} // if no image, use default avatar
|
||||
size="md"
|
||||
className="mt-1 inline-flex justify-center"
|
||||
/>
|
||||
<p className="block pl-2 text-sm">{props.slug}</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:border-subtle bg-default border dark:bg-black sm:rounded-md">
|
||||
<ScrollableArea className="rounded-md">
|
||||
<ul className="border-subtle max-h-97 !static w-full divide-y">
|
||||
{fields.length > 0 ? (
|
||||
fields.map((field, index) => (
|
||||
<EventTypeCard
|
||||
key={`${field.fieldId}`}
|
||||
handleSelect={() => {
|
||||
update(index, { ...field, selected: !field.selected });
|
||||
}}
|
||||
userName={userName}
|
||||
{...field}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-subtle bg-muted w-full p-2 text-center text-sm">Team has no Events</div>
|
||||
)}
|
||||
</ul>
|
||||
</ScrollableArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTypesStepCard: FC<EventTypesCardProps> = ({
|
||||
setConfigureStep,
|
||||
userName,
|
||||
handleSetUpLater,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control, watch } = useFormContext<TEventTypesForm>();
|
||||
const { fields } = useFieldArray({
|
||||
control,
|
||||
name: "eventTypeGroups",
|
||||
keyName: "fieldId",
|
||||
});
|
||||
|
||||
const eventTypeGroups = watch("eventTypeGroups") || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{fields.map(
|
||||
(field, index) =>
|
||||
!field.isOrganisation && (
|
||||
<EventTypeGroup key={field.fieldId} groupIndex={index} userName={userName} {...field} />
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="text-md mt-6 w-full justify-center"
|
||||
data-testid="save-event-types"
|
||||
onClick={() => {
|
||||
setConfigureStep(true);
|
||||
}}
|
||||
disabled={
|
||||
!eventTypeGroups.some((field) => field.eventTypes.some((eventType) => eventType.selected === true))
|
||||
}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
data-testid="set-up-later"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleSetUpLater();
|
||||
}}
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{t("set_up_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -15,13 +14,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
let appStore, userAdminTeams: UserAdminTeams;
|
||||
let appStore, userAdminTeamsIds: number[];
|
||||
if (session?.user?.id) {
|
||||
userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
|
||||
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams);
|
||||
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
|
||||
userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
|
||||
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeamsIds);
|
||||
} else {
|
||||
appStore = await getAppRegistry();
|
||||
userAdminTeams = [];
|
||||
userAdminTeamsIds = [];
|
||||
}
|
||||
|
||||
const categoryQuery = appStore.map(({ categories }) => ({
|
||||
@@ -45,7 +45,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
userAdminTeams,
|
||||
userAdminTeams: userAdminTeamsIds,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import { z } from "zod";
|
||||
@@ -10,134 +11,171 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { STEPS } from "~/apps/installation/[[...step]]/constants";
|
||||
import type { OnboardingPageProps, TEventType } from "~/apps/installation/[[...step]]/step-view";
|
||||
|
||||
// Redirect Error map to give context on edge cases, this is for the devs, never shown to users
|
||||
const ERROR_MESSAGES = {
|
||||
appNotFound: "App not found",
|
||||
userNotAuthed: "User is not logged in",
|
||||
userNotFound: "User from session not found",
|
||||
appNotExtendsEventType: "App does not extend EventTypes",
|
||||
userNotInTeam: "User is not in provided team",
|
||||
} as const;
|
||||
import type { OnboardingPageProps, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
|
||||
|
||||
const getUser = async (userId: number) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
avatarUrl: true,
|
||||
name: true,
|
||||
username: true,
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
parent: {
|
||||
select: {
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const userAdminTeams = await UserRepository.getUserAdminTeams(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(ERROR_MESSAGES.userNotFound);
|
||||
if (!userAdminTeams?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const teams = user.teams.map(({ team }) => ({
|
||||
let teams = userAdminTeams.teams.map(({ team }) => ({
|
||||
...team,
|
||||
logoUrl: team.parent
|
||||
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
|
||||
: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
}));
|
||||
|
||||
const orgTeam = teams.find((team) => team.isOrganization === true);
|
||||
if (orgTeam?.id) {
|
||||
teams = teams.filter((team) => team?.parent?.id !== orgTeam.id);
|
||||
}
|
||||
return {
|
||||
...user,
|
||||
...userAdminTeams,
|
||||
teams,
|
||||
};
|
||||
};
|
||||
|
||||
const getOrgSubTeams = async (parentId: number) => {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
parentId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
isOrganization: true,
|
||||
parent: {
|
||||
select: {
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return teams.map((team) => ({
|
||||
...team,
|
||||
logoUrl: team.parent
|
||||
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
|
||||
: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
}));
|
||||
};
|
||||
|
||||
const getAppBySlug = async (appSlug: string) => {
|
||||
const app = await prisma.app.findUnique({
|
||||
where: { slug: appSlug, enabled: true },
|
||||
select: { slug: true, keys: true, enabled: true, dirName: true },
|
||||
});
|
||||
if (!app) throw new Error(ERROR_MESSAGES.appNotFound);
|
||||
return app;
|
||||
};
|
||||
|
||||
const getEventTypes = async (userId: number, teamId?: number) => {
|
||||
const eventTypes = (
|
||||
await prisma.eventType.findMany({
|
||||
const getEventTypes = async (userId: number, teamIds?: number[]) => {
|
||||
const eventTypeSelect: Prisma.EventTypeSelect = {
|
||||
id: true,
|
||||
description: true,
|
||||
durationLimits: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
title: true,
|
||||
position: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
team: { select: { slug: true } },
|
||||
schedulingType: true,
|
||||
teamId: true,
|
||||
users: { select: { username: true } },
|
||||
seatsPerTimeSlot: true,
|
||||
slug: true,
|
||||
locations: true,
|
||||
userId: true,
|
||||
destinationCalendar: true,
|
||||
bookingFields: true,
|
||||
};
|
||||
let eventTypeGroups: TEventTypeGroup[] | null = [];
|
||||
|
||||
if (teamIds && teamIds.length > 0) {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamIds,
|
||||
},
|
||||
isOrganization: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
description: true,
|
||||
durationLimits: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
title: true,
|
||||
position: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
team: { select: { slug: true } },
|
||||
schedulingType: true,
|
||||
teamId: true,
|
||||
users: { select: { username: true } },
|
||||
seatsPerTimeSlot: true,
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
slug: true,
|
||||
locations: true,
|
||||
userId: true,
|
||||
destinationCalendar: true,
|
||||
bookingFields: true,
|
||||
isOrganization: true,
|
||||
eventTypes: {
|
||||
select: eventTypeSelect,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* filter out managed events for now
|
||||
* @todo: can install apps to managed event types
|
||||
*/
|
||||
where: teamId ? { teamId } : { userId, parent: null, teamId: null },
|
||||
})
|
||||
).sort((eventTypeA, eventTypeB) => {
|
||||
return eventTypeB.position - eventTypeA.position;
|
||||
});
|
||||
});
|
||||
eventTypeGroups = teams.map((team) => ({
|
||||
teamId: team.id,
|
||||
slug: team.slug,
|
||||
name: team.name,
|
||||
isOrganisation: team.isOrganization,
|
||||
image: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
eventTypes: team.eventTypes
|
||||
.map((item) => ({
|
||||
...item,
|
||||
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
|
||||
selected: false,
|
||||
locations: item.locations as unknown as LocationObject[],
|
||||
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
|
||||
}))
|
||||
.sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position),
|
||||
}));
|
||||
} else {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
teamId: null,
|
||||
},
|
||||
select: eventTypeSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (eventTypes.length === 0) {
|
||||
return [];
|
||||
if (user) {
|
||||
eventTypeGroups.push({
|
||||
userId: user.id,
|
||||
slug: user.username,
|
||||
name: user.name,
|
||||
image: getPlaceholderAvatar(user.avatarUrl, user.name),
|
||||
eventTypes: user.eventTypes
|
||||
.map((item) => ({
|
||||
...item,
|
||||
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${
|
||||
item.slug
|
||||
}`,
|
||||
selected: false,
|
||||
locations: item.locations as unknown as LocationObject[],
|
||||
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
|
||||
}))
|
||||
.sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return eventTypes.map((item) => ({
|
||||
...item,
|
||||
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
|
||||
selected: false,
|
||||
locations: item.locations as unknown as LocationObject[],
|
||||
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
|
||||
}));
|
||||
return eventTypeGroups;
|
||||
};
|
||||
|
||||
const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => {
|
||||
@@ -161,136 +199,128 @@ const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: n
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
try {
|
||||
let eventTypes: TEventType[] | null = null;
|
||||
const { req, query, params } = context;
|
||||
const stepsEnum = z.enum(STEPS);
|
||||
const parsedAppSlug = z.coerce.string().parse(query?.slug);
|
||||
const parsedStepParam = z.coerce.string().parse(params?.step);
|
||||
const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId);
|
||||
const _ = stepsEnum.parse(parsedStepParam);
|
||||
const session = await getServerSession({ req });
|
||||
const locale = await getLocale(context.req);
|
||||
const app = await getAppBySlug(parsedAppSlug);
|
||||
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
|
||||
const extendsEventType = appMetadata?.extendsFeature === "EventType";
|
||||
const { req, res, query, params } = context;
|
||||
let eventTypeGroups: TEventTypeGroup[] | null = null;
|
||||
let isOrg = false;
|
||||
const stepsEnum = z.enum(STEPS);
|
||||
const parsedAppSlug = z.coerce.string().parse(query?.slug);
|
||||
const parsedStepParam = z.coerce.string().parse(params?.step);
|
||||
const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId);
|
||||
const _ = stepsEnum.parse(parsedStepParam);
|
||||
const session = await getServerSession({ req, res });
|
||||
if (!session?.user?.id) return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
const locale = await getLocale(context.req);
|
||||
const app = await getAppBySlug(parsedAppSlug);
|
||||
if (!app) return { redirect: { permanent: false, destination: "/apps" } };
|
||||
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
|
||||
const extendsEventType = appMetadata?.extendsFeature === "EventType";
|
||||
|
||||
const isConferencing = isConferencingApp(appMetadata.categories);
|
||||
const showEventTypesStep = extendsEventType || isConferencing;
|
||||
console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep);
|
||||
const isConferencing = isConferencingApp(appMetadata.categories);
|
||||
const showEventTypesStep = extendsEventType || isConferencing;
|
||||
|
||||
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) return { redirect: { permanent: false, destination: "/apps" } };
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
let userTeams = user.teams;
|
||||
const hasTeams = Boolean(userTeams.length);
|
||||
|
||||
const userTeams = user.teams;
|
||||
const hasTeams = Boolean(userTeams.length);
|
||||
|
||||
const appInstalls = await getAppInstallsBySlug(
|
||||
parsedAppSlug,
|
||||
user.id,
|
||||
userTeams.map(({ id }) => id)
|
||||
);
|
||||
|
||||
if (parsedTeamIdParam) {
|
||||
const isUserMemberOfTeam = userTeams.some((team) => team.id === parsedTeamIdParam);
|
||||
if (!isUserMemberOfTeam) {
|
||||
throw new Error(ERROR_MESSAGES.userNotInTeam);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
|
||||
if (!showEventTypesStep) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
|
||||
if (isConferencing) {
|
||||
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
eventTypeId: null,
|
||||
},
|
||||
});
|
||||
for (let index = 0; index < eventTypes.length; index++) {
|
||||
let eventType = eventTypes[index];
|
||||
if (!eventType.destinationCalendar) {
|
||||
eventType = { ...eventType, destinationCalendar };
|
||||
}
|
||||
eventTypes[index] = eventType;
|
||||
}
|
||||
}
|
||||
|
||||
if (eventTypes.length === 0) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const personalAccount = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
|
||||
};
|
||||
|
||||
const teamsWithIsAppInstalled = hasTeams
|
||||
? userTeams.map((team) => ({
|
||||
...team,
|
||||
alreadyInstalled: appInstalls.some(
|
||||
(install) => Boolean(install.teamId) && install.teamId === team.id
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
let credentialId = null;
|
||||
if (parsedTeamIdParam) {
|
||||
credentialId =
|
||||
appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null;
|
||||
} else {
|
||||
credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null;
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
app,
|
||||
appMetadata,
|
||||
showEventTypesStep,
|
||||
step: parsedStepParam,
|
||||
teams: teamsWithIsAppInstalled,
|
||||
personalAccount,
|
||||
eventTypes,
|
||||
teamId: parsedTeamIdParam ?? null,
|
||||
userName: user.username,
|
||||
credentialId,
|
||||
isConferencing,
|
||||
// conferencing apps dont support team install
|
||||
installableOnTeams: !isConferencing,
|
||||
} as OnboardingPageProps,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
if (parsedTeamIdParam) {
|
||||
const currentTeam = userTeams.find((team) => team.id === parsedTeamIdParam);
|
||||
if (!currentTeam?.id) {
|
||||
return { redirect: { permanent: false, destination: "/apps" } };
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
switch (err.message) {
|
||||
case ERROR_MESSAGES.userNotAuthed:
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
case ERROR_MESSAGES.userNotFound:
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
default:
|
||||
return { redirect: { permanent: false, destination: "/apps" } };
|
||||
}
|
||||
} else {
|
||||
return { redirect: { permanent: false, destination: "/apps" } };
|
||||
if (currentTeam.isOrganization) {
|
||||
const subTeams = await getOrgSubTeams(parsedTeamIdParam);
|
||||
userTeams = [...userTeams, ...subTeams];
|
||||
isOrg = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
|
||||
if (!showEventTypesStep) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (isOrg) {
|
||||
const teamIds = userTeams.map((item) => item.id);
|
||||
eventTypeGroups = await getEventTypes(user.id, teamIds);
|
||||
} else if (parsedTeamIdParam) {
|
||||
eventTypeGroups = await getEventTypes(user.id, [parsedTeamIdParam]);
|
||||
} else {
|
||||
eventTypeGroups = await getEventTypes(user.id);
|
||||
}
|
||||
if (isConferencing && eventTypeGroups) {
|
||||
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
eventTypeId: null,
|
||||
},
|
||||
});
|
||||
|
||||
eventTypeGroups.forEach((group) => {
|
||||
group.eventTypes = group.eventTypes.map((eventType) => {
|
||||
if (!eventType.destinationCalendar) {
|
||||
return { ...eventType, destinationCalendar };
|
||||
}
|
||||
return eventType;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const appInstalls = await getAppInstallsBySlug(
|
||||
parsedAppSlug,
|
||||
user.id,
|
||||
userTeams.map(({ id }) => id)
|
||||
);
|
||||
|
||||
const personalAccount = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
|
||||
};
|
||||
|
||||
const teamsWithIsAppInstalled = hasTeams
|
||||
? userTeams.map((team) => ({
|
||||
...team,
|
||||
alreadyInstalled: appInstalls.some(
|
||||
(install) => Boolean(install.teamId) && install.teamId === team.id
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
let credentialId = null;
|
||||
if (parsedTeamIdParam) {
|
||||
credentialId = appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null;
|
||||
} else {
|
||||
credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null;
|
||||
}
|
||||
// dont allow app installation without cretendialId
|
||||
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP && !credentialId) {
|
||||
return { redirect: { permanent: false, destination: "/apps" } };
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
app,
|
||||
appMetadata,
|
||||
showEventTypesStep,
|
||||
step: parsedStepParam,
|
||||
teams: teamsWithIsAppInstalled,
|
||||
personalAccount,
|
||||
eventTypeGroups,
|
||||
teamId: parsedTeamIdParam ?? null,
|
||||
userName: user.username,
|
||||
credentialId,
|
||||
isConferencing,
|
||||
isOrg,
|
||||
// conferencing apps dont support team install
|
||||
installableOnTeams: !isConferencing,
|
||||
} as OnboardingPageProps,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Team } from "@calcom/prisma/client";
|
||||
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -24,7 +25,7 @@ import { Form, Steps, showToast } from "@calcom/ui";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import type { PersonalAccountProps, TeamsProp } from "@components/apps/installation/AccountsStepCard";
|
||||
import type { PersonalAccountProps } from "@components/apps/installation/AccountsStepCard";
|
||||
import { AccountsStepCard } from "@components/apps/installation/AccountsStepCard";
|
||||
import { ConfigureStepCard } from "@components/apps/installation/ConfigureStepCard";
|
||||
import { EventTypesStepCard } from "@components/apps/installation/EventTypesStepCard";
|
||||
@@ -42,10 +43,20 @@ export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
|
||||
bookingFields?: LocationFormValues["bookingFields"];
|
||||
};
|
||||
|
||||
export type TEventTypesForm = {
|
||||
export type TEventTypeGroup = {
|
||||
teamId?: number;
|
||||
userId?: number | null;
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
image: string;
|
||||
isOrganisation?: boolean;
|
||||
eventTypes: TEventType[];
|
||||
};
|
||||
|
||||
export type TEventTypesForm = {
|
||||
eventTypeGroups: TEventTypeGroup[];
|
||||
};
|
||||
|
||||
type StepType = (typeof STEPS)[number];
|
||||
|
||||
type StepObj = Record<
|
||||
@@ -57,17 +68,22 @@ type StepObj = Record<
|
||||
}
|
||||
>;
|
||||
|
||||
export type TTeams = (Pick<Team, "id" | "name" | "logoUrl" | "isOrganization"> & {
|
||||
alreadyInstalled: boolean;
|
||||
})[];
|
||||
|
||||
export type OnboardingPageProps = {
|
||||
appMetadata: AppMeta;
|
||||
step: StepType;
|
||||
teams?: TeamsProp;
|
||||
teams?: TTeams;
|
||||
personalAccount: PersonalAccountProps;
|
||||
eventTypes?: TEventType[];
|
||||
eventTypeGroups?: TEventTypeGroup[];
|
||||
userName: string;
|
||||
credentialId?: number;
|
||||
showEventTypesStep: boolean;
|
||||
isConferencing: boolean;
|
||||
installableOnTeams: boolean;
|
||||
isOrg: boolean;
|
||||
};
|
||||
|
||||
type TUpdateObject = {
|
||||
@@ -82,7 +98,7 @@ const OnboardingPage = ({
|
||||
teams,
|
||||
personalAccount,
|
||||
appMetadata,
|
||||
eventTypes,
|
||||
eventTypeGroups,
|
||||
userName,
|
||||
credentialId,
|
||||
showEventTypesStep,
|
||||
@@ -133,7 +149,7 @@ const OnboardingPage = ({
|
||||
|
||||
const formMethods = useForm<TEventTypesForm>({
|
||||
defaultValues: {
|
||||
eventTypes,
|
||||
eventTypeGroups,
|
||||
},
|
||||
});
|
||||
const mutation = useAddAppMutation(null, {
|
||||
@@ -147,9 +163,9 @@ const OnboardingPage = ({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
eventTypes && formMethods.setValue("eventTypes", eventTypes);
|
||||
eventTypeGroups && formMethods.setValue("eventTypeGroups", eventTypeGroups);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventTypes]);
|
||||
}, [eventTypeGroups]);
|
||||
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
@@ -222,34 +238,38 @@ const OnboardingPage = ({
|
||||
form={formMethods}
|
||||
id="outer-event-type-form"
|
||||
handleSubmit={async (values) => {
|
||||
const mutationPromises = values?.eventTypes
|
||||
.filter((eventType) => eventType.selected)
|
||||
.map((value: TEventType) => {
|
||||
// Prevent two payment apps to be enabled
|
||||
// Ok to cast type here because this metadata will be updated as the event type metadata
|
||||
if (
|
||||
checkForMultiplePaymentApps(value.metadata as z.infer<typeof EventTypeMetaDataSchema>)
|
||||
)
|
||||
throw new Error(t("event_setup_multiple_payment_apps_error"));
|
||||
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
|
||||
throw new Error(t("seats_and_no_show_fee_error"));
|
||||
}
|
||||
let updateObject: TUpdateObject = { id: value.id };
|
||||
if (isConferencing) {
|
||||
updateObject = {
|
||||
...updateObject,
|
||||
locations: value.locations,
|
||||
bookingFields: value.bookingFields ? value.bookingFields : undefined,
|
||||
};
|
||||
} else {
|
||||
updateObject = {
|
||||
...updateObject,
|
||||
metadata: value.metadata,
|
||||
};
|
||||
}
|
||||
let mutationPromises: ReturnType<typeof updateMutation.mutateAsync>[] = [];
|
||||
for (const group of values.eventTypeGroups) {
|
||||
const promises = group.eventTypes
|
||||
.filter((eventType) => eventType.selected)
|
||||
.map((value: TEventType) => {
|
||||
// Prevent two payment apps to be enabled
|
||||
// Ok to cast type here because this metadata will be updated as the event type metadata
|
||||
if (
|
||||
checkForMultiplePaymentApps(value.metadata as z.infer<typeof EventTypeMetaDataSchema>)
|
||||
)
|
||||
throw new Error(t("event_setup_multiple_payment_apps_error"));
|
||||
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
|
||||
throw new Error(t("seats_and_no_show_fee_error"));
|
||||
}
|
||||
let updateObject: TUpdateObject = { id: value.id };
|
||||
if (isConferencing) {
|
||||
updateObject = {
|
||||
...updateObject,
|
||||
locations: value.locations,
|
||||
bookingFields: value.bookingFields ? value.bookingFields : undefined,
|
||||
};
|
||||
} else {
|
||||
updateObject = {
|
||||
...updateObject,
|
||||
metadata: value.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
return updateMutation.mutateAsync(updateObject);
|
||||
});
|
||||
return updateMutation.mutateAsync(updateObject);
|
||||
});
|
||||
mutationPromises = [...mutationPromises, ...promises];
|
||||
}
|
||||
try {
|
||||
await Promise.all(mutationPromises);
|
||||
router.push("/event-types");
|
||||
@@ -272,28 +292,30 @@ const OnboardingPage = ({
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
|
||||
eventTypes &&
|
||||
Boolean(eventTypes?.length) && (
|
||||
eventTypeGroups &&
|
||||
Boolean(eventTypeGroups?.length) && (
|
||||
<EventTypesStepCard
|
||||
setConfigureStep={setConfigureStep}
|
||||
userName={userName}
|
||||
handleSetUpLater={handleSetUpLater}
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && (
|
||||
<ConfigureStepCard
|
||||
slug={appMetadata.slug}
|
||||
categories={appMetadata.categories}
|
||||
credentialId={credentialId}
|
||||
userName={userName}
|
||||
loading={updateMutation.isPending}
|
||||
formPortalRef={formPortalRef}
|
||||
setConfigureStep={setConfigureStep}
|
||||
eventTypes={eventTypes}
|
||||
handleSetUpLater={handleSetUpLater}
|
||||
isConferencing={isConferencing}
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
|
||||
formPortalRef.current &&
|
||||
eventTypeGroups && (
|
||||
<ConfigureStepCard
|
||||
slug={appMetadata.slug}
|
||||
categories={appMetadata.categories}
|
||||
credentialId={credentialId}
|
||||
userName={userName}
|
||||
loading={updateMutation.isPending}
|
||||
formPortalRef={formPortalRef}
|
||||
setConfigureStep={setConfigureStep}
|
||||
eventTypeGroups={eventTypeGroups}
|
||||
handleSetUpLater={handleSetUpLater}
|
||||
isConferencing={isConferencing}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@ test.describe("check non-oAuth link-based conferencing apps using the new flow",
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const eventTypes = await user.getUserEventsAsOwner();
|
||||
const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 2);
|
||||
const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 1);
|
||||
await appsPage.installConferencingAppNewFlow(app, eventTypeIds);
|
||||
await appsPage.verifyConferencingAppNew(app, eventTypeIds);
|
||||
});
|
||||
|
||||
@@ -73,8 +73,7 @@ export function createAppsFixture(page: Page) {
|
||||
},
|
||||
|
||||
installConferencingAppNewFlow: async (app: TApp, eventTypeIds: number[]) => {
|
||||
await page.goto("apps/categories/conferencing");
|
||||
await page.getByTestId(`app-store-app-card-${app.slug}`).click();
|
||||
await page.goto(`apps/${app.slug}`);
|
||||
await page.getByTestId("install-app-button").click();
|
||||
await page.waitForURL(`apps/installation/event-types?slug=${app.slug}`);
|
||||
|
||||
@@ -82,6 +81,7 @@ export function createAppsFixture(page: Page) {
|
||||
await page.click(`[data-testid="select-event-type-${id}"]`);
|
||||
}
|
||||
await page.click(`[data-testid="save-event-types"]`);
|
||||
page.waitForLoadState("networkidle");
|
||||
|
||||
for (let eindex = 0; eindex < eventTypeIds.length; eindex++) {
|
||||
if (!app.organizerInputPlaceholder) continue;
|
||||
|
||||
@@ -265,7 +265,7 @@ test.describe("Stripe integration skip true", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Stripe integration with the new app install flow skip flase", () => {
|
||||
test.describe("Stripe integration with the new app install flow skip false", () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
|
||||
|
||||
|
||||
@@ -29,20 +29,7 @@ const createOAuthAppCredential = async (
|
||||
|
||||
if (state?.teamId) {
|
||||
// Check that the user belongs to the team
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: state.teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId: req.session?.user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true, members: { select: { userId: true } } },
|
||||
});
|
||||
|
||||
if (!team) throw new Error("User does not belong to the team");
|
||||
await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId });
|
||||
|
||||
return await prisma.credential.create({
|
||||
data: {
|
||||
@@ -54,8 +41,6 @@ const createOAuthAppCredential = async (
|
||||
});
|
||||
}
|
||||
|
||||
await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId });
|
||||
|
||||
return await prisma.credential.create({
|
||||
data: {
|
||||
type: appData.type,
|
||||
|
||||
@@ -11,7 +11,8 @@ export const throwIfNotHaveAdminAccessToTeam = async ({
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
const teamsUserHasAdminAccessFor = await UserRepository.getUserAdminTeams(userId);
|
||||
const userAdminTeams = await UserRepository.getUserAdminTeams(userId);
|
||||
const teamsUserHasAdminAccessFor = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
|
||||
const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((id) => id === teamId);
|
||||
|
||||
if (!hasAdminAccessToTeam) {
|
||||
|
||||
@@ -67,8 +67,8 @@ function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutatio
|
||||
const state: IntegrationOAuthCallbackState = {
|
||||
onErrorReturnTo,
|
||||
fromApp: true,
|
||||
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
|
||||
...(teamId && { teamId }),
|
||||
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
|
||||
...(returnTo && { returnTo }),
|
||||
...(defaultInstall && { defaultInstall }),
|
||||
};
|
||||
|
||||
@@ -516,7 +516,58 @@ export class UserRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getUserAdminTeams(userId: number) {
|
||||
return prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
avatarUrl: true,
|
||||
name: true,
|
||||
username: true,
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
OR: [
|
||||
{
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
{
|
||||
team: {
|
||||
parent: {
|
||||
members: {
|
||||
some: {
|
||||
id: userId,
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
isOrganization: true,
|
||||
parent: {
|
||||
select: {
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
static async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
const membershipQuery = {
|
||||
members: {
|
||||
@@ -543,29 +594,6 @@ export class UserRepository {
|
||||
return !!teams.length;
|
||||
}
|
||||
|
||||
static async getUserAdminTeams(userId: number): Promise<number[]> {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
select: { teamId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamIds = [];
|
||||
for (const team of user?.teams || []) {
|
||||
teamIds.push(team.teamId);
|
||||
}
|
||||
return teamIds;
|
||||
}
|
||||
|
||||
static async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
|
||||
return await prisma.user.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -15,6 +15,7 @@ type AppCredentialsByTypeOptions = {
|
||||
export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentialsByTypeOptions) => {
|
||||
const { user } = ctx;
|
||||
const userAdminTeams = await UserRepository.getUserAdminTeams(ctx.user.id);
|
||||
const userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
@@ -22,7 +23,7 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials
|
||||
{ userId: user.id },
|
||||
{
|
||||
teamId: {
|
||||
in: userAdminTeams,
|
||||
in: userAdminTeamsIds,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -34,6 +35,6 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials
|
||||
// return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id);
|
||||
return {
|
||||
credentials,
|
||||
userAdminTeams,
|
||||
userAdminTeams: userAdminTeamsIds,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user