diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 09c57ee27b..73d1de5965 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1942,5 +1942,12 @@ "insights_user_filter": "User: {{userName}}", "insights_subtitle": "View booking insights across your events", "custom_plan": "Custom Plan", + "org_team_names_example": "e.g. Marketing Team", + "org_team_names_example_1": "e.g. Marketing Team", + "org_team_names_example_2": "e.g. Sales Team", + "org_team_names_example_3": "e.g. Design Team", + "org_team_names_example_4": "e.g. Engineering Team", + "org_team_names_example_5": "e.g. Data Analytics Team", + "org_max_team_warnings": "You will be able to add more teams later on.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx index 6c3f624393..299eeee559 100644 --- a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx +++ b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx @@ -1,40 +1,67 @@ -import { ArrowRight } from "lucide-react"; import { useRouter } from "next/router"; import { useState } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; import { z } from "zod"; +import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Button, showToast, TextField } from "@calcom/ui"; -import { Plus, X } from "@calcom/ui/components/icon"; +import { Plus, X, ArrowRight } from "@calcom/ui/components/icon"; const querySchema = z.object({ id: z.string().transform((val) => parseInt(val)), }); +const schema = z.object({ + teams: z + .array( + z.object({ + name: z.string().min(2, "org_team_name_min_2_chars").trim(), + }) + ) + .min(1, "At least one team is required"), +}); + export const AddNewTeamsForm = () => { const { t } = useLocale(); const router = useRouter(); const { id: orgId } = querySchema.parse(router.query); const [counter, setCounter] = useState(1); - const [inputValues, setInputValues] = useState([""]); + const { register, control, handleSubmit, formState, trigger, setValue, getValues } = useForm({ + defaultValues: { teams: [{ name: "" }] }, // Set initial values + resolver: async (data) => { + try { + const validatedData = await schema.parseAsync(data); + return { values: validatedData, errors: {} }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + values: { + teams: [], + }, + errors: error.formErrors.fieldErrors, + }; + } + return { values: {}, errors: { teams: { message: "Error validating input" } } }; + } + }, + }); + const { fields, append, remove } = useFieldArray({ + control, + name: "teams", + }); const handleCounterIncrease = () => { - setCounter((prevCounter) => prevCounter + 1); - setInputValues((prevInputValues) => [...prevInputValues, ""]); - }; - - const handleInputChange = (index: number, value: string) => { - const newInputValues = [...inputValues]; - newInputValues[index] = value; - setInputValues(newInputValues); + if (counter >= 0 && counter < 5) { + setCounter((prevCounter) => prevCounter + 1); + append({ name: "" }); + } }; const handleRemoveInput = (index: number) => { - const newInputValues = [...inputValues]; - newInputValues.splice(index, 1); - setInputValues(newInputValues); + remove(index); setCounter((prevCounter) => prevCounter - 1); }; @@ -42,6 +69,8 @@ export const AddNewTeamsForm = () => { async onSuccess(data) { if (data.duplicatedSlugs.length) { showToast(t("duplicated_slugs_warning", { slugs: data.duplicatedSlugs.join(", ") }), "warning"); + // Server will return array of duplicated slugs, so we need to wait for user to read the warning + // before pushing to next page setTimeout(() => { router.push(`/event-types`); }, 3000); @@ -54,54 +83,69 @@ export const AddNewTeamsForm = () => { }, }); + const handleInputChange = (index: number, event: any) => { + const { name, value } = event.target; + setValue(`teams.${index}.name`, value); + trigger(`teams.${index}.name`); + }; + + const handleFormSubmit = () => { + if (formState.isValid) { + const fields = getValues("teams"); + createTeamsMutation.mutate({ orgId, teamNames: fields.map((field) => field.name) }); + } + }; + return ( <> - {Array.from({ length: counter }, (_, index) => ( -
- handleInputChange(index, e.target.value)} - addOnClassname="bg-transparent p-0 border-l-0" - addOnSuffix={ - index > 0 && ( - - ) - } - /> -
- ))} - - +
+ {fields.map((field, index) => ( +
0 ? "mb-2" : "")} key={field.id}> + handleInputChange(index, e)} + addOnClassname="bg-transparent p-0 border-l-0" + className={index > 0 ? "mb-2" : ""} + placeholder={t(`org_team_names_example_${index + 1}`) || t("org_team_names_example")} + addOnSuffix={ + index > 0 && ( + + ) + } + minLength={2} + maxLength={63} + /> +
+ ))} + {counter === 5 &&

{t("org_max_team_warnings")}

} + {counter < 5 && ( + + )} + +
); }; diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index a8143b2f7b..4d340bb03a 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -16,31 +16,57 @@ type CreateTeamsOptions = { }; export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => { + // Get User From context + const user = ctx.user; + + if (!user) { + throw new NoUserError(); + } + const { teamNames, orgId } = input; + if (orgId !== user.organizationId) { + throw new NotAuthorizedError(); + } + + // Validate user membership role + const userMembershipRole = await prisma.membership.findFirst({ + where: { + userId: user.id, + teamId: orgId, + role: { + in: ["OWNER", "ADMIN"], + }, + // @TODO: not sure if this already setup earlier + // accepted: true, + }, + select: { + role: true, + }, + }); + + if (!userMembershipRole) { + throw new NotAuthorizedError(); + } + const organization = await prisma.team.findFirst({ where: { id: orgId }, select: { slug: true, metadata: true }, }); - const metadata = teamMetadataSchema.parse(organization?.metadata); - if (!metadata?.requestedSlug && !organization?.slug) - throw new TRPCError({ code: "BAD_REQUEST", message: "no_organization_slug" }); + if (!organization) throw new NoOrganizationError(); - const userMembership = await prisma.membership.findFirst({ - where: { - userId: ctx.user.id, - teamId: orgId, - }, - select: { - userId: true, - role: true, - }, - }); + const parseTeams = teamMetadataSchema.safeParse(organization?.metadata); - // TODO test this check works - if (!userMembership || userMembership.role !== MembershipRole.OWNER) - throw new TRPCError({ code: "BAD_REQUEST", message: "not_authorized" }); + if (!parseTeams.success) { + throw new InvalidMetadataError(); + } + + const metadata = parseTeams.success ? parseTeams.data : undefined; + + if (!metadata?.requestedSlug && !organization?.slug) { + throw new NoOrganizationSlugError(); + } const [teamSlugs, userSlugs] = await prisma.$transaction([ prisma.team.findMany({ where: { parentId: orgId }, select: { slug: true } }), @@ -51,11 +77,16 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => .flatMap((ts) => ts.slug ?? []) .concat(userSlugs.flatMap((us) => us.username ?? [])); - const duplicatedSlugs = existingSlugs.filter((slug) => teamNames.includes(slug)); + const duplicatedSlugs = existingSlugs.filter((slug) => + teamNames.map((item) => slugify(item)).includes(slug) + ); + if (duplicatedSlugs.length === teamNames.length) { + return { duplicatedSlugs }; + } await prisma.$transaction( teamNames.flatMap((name) => { - if (!duplicatedSlugs.includes(name)) { + if (!duplicatedSlugs.includes(slugify(name))) { return prisma.team.create({ data: { name, @@ -74,3 +105,33 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => return { duplicatedSlugs }; }; + +class NoUserError extends TRPCError { + constructor() { + super({ code: "BAD_REQUEST", message: "no_user" }); + } +} + +class NotAuthorizedError extends TRPCError { + constructor() { + super({ code: "FORBIDDEN", message: "not_authorized" }); + } +} + +class InvalidMetadataError extends TRPCError { + constructor() { + super({ code: "BAD_REQUEST", message: "invalid_organization_metadata" }); + } +} + +class NoOrganizationError extends TRPCError { + constructor() { + super({ code: "BAD_REQUEST", message: "no_organization_found" }); + } +} + +class NoOrganizationSlugError extends TRPCError { + constructor() { + super({ code: "BAD_REQUEST", message: "no_organization_slug" }); + } +} diff --git a/packages/ui/components/form/inputs/Input.tsx b/packages/ui/components/form/inputs/Input.tsx index 57678845a2..0b2f3e2073 100644 --- a/packages/ui/components/form/inputs/Input.tsx +++ b/packages/ui/components/form/inputs/Input.tsx @@ -12,7 +12,7 @@ import { Eye, EyeOff, X, Search } from "../../icon"; import { HintsOrErrors } from "./HintOrErrors"; import { Label } from "./Label"; -type InputProps = JSX.IntrinsicElements["input"] & { isFullWidth?: boolean; isStandaloneField?: boolean }; +type InputProps = JSX.IntrinsicElements["input"] & { isFullWidth?: boolean }; export const Input = forwardRef(function Input( { isFullWidth = true, ...props }, @@ -140,7 +140,6 @@ export const InputField = forwardRef(function type={type} placeholder={placeholder} isFullWidth={inputIsFullWidth} - isStandaloneField={false} className={classNames( className, "disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed",