diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a873e6234f..eaf846a4f7 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1189,6 +1189,8 @@ "featured_categories": "Featured Categories", "feature_flags": "Feature Flags", "admin_flags_description": "Here you can toggle your Cal.com instance features.", + "feature_assigned_successfully": "Feature assigned successfully", + "feature_unassigned_successfully": "Feature unassigned successfully", "popular_categories": "Popular Categories", "number_apps_one": "{{count}} App", "number_apps_other": "{{count}} Apps", diff --git a/packages/features/flags/components/AssignFeatureSheet.tsx b/packages/features/flags/components/AssignFeatureSheet.tsx new file mode 100644 index 0000000000..cae631010d --- /dev/null +++ b/packages/features/flags/components/AssignFeatureSheet.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useState, useEffect } from "react"; + +import { useDebounce } from "@calcom/lib/hooks/useDebounce"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Avatar } from "@calcom/ui/components/avatar"; +import { Button } from "@calcom/ui/components/button"; +import { Checkbox, TextField } from "@calcom/ui/components/form"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetBody, + SheetFooter, +} from "@calcom/ui/components/sheet"; +import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; +import { showToast } from "@calcom/ui/components/toast"; + +type Flag = RouterOutputs["viewer"]["features"]["list"][number]; + +interface AssignFeatureSheetProps { + flag: Flag; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AssignFeatureSheet({ flag, open, onOpenChange }: AssignFeatureSheetProps) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = + trpc.viewer.admin.getTeamsForFeature.useInfiniteQuery( + { + featureId: flag.slug, + limit: 20, + searchTerm: debouncedSearchTerm || undefined, + }, + { + enabled: open, + getNextPageParam: (lastPage) => lastPage.nextCursor, + } + ); + + const teams = data?.pages.flatMap((page) => page.teams) ?? []; + + useEffect(() => { + if (!open) { + setSearchTerm(""); + } + }, [open]); + + const assignMutation = trpc.viewer.admin.assignFeatureToTeam.useMutation({ + onSuccess: () => { + utils.viewer.admin.getTeamsForFeature.invalidate({ featureId: flag.slug }); + showToast(t("feature_assigned_successfully"), "success"); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const unassignMutation = trpc.viewer.admin.unassignFeatureFromTeam.useMutation({ + onSuccess: () => { + utils.viewer.admin.getTeamsForFeature.invalidate({ featureId: flag.slug }); + showToast(t("feature_unassigned_successfully"), "success"); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const handleToggleTeam = (teamId: number, currentlyHasFeature: boolean) => { + if (currentlyHasFeature) { + unassignMutation.mutate({ + teamId, + featureId: flag.slug, + }); + } else { + assignMutation.mutate({ + teamId, + featureId: flag.slug, + }); + } + }; + + const handleClose = () => { + onOpenChange(false); + }; + + const isLoading = assignMutation.isPending || unassignMutation.isPending; + + return ( + + + + Assign: {flag.slug} + + +
+ setSearchTerm(e.target.value)} + /> +
+ {isPending ? ( + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+ ) : teams && teams.length > 0 ? ( + <> +
+ {teams.map((team) => ( + + ))} +
+ {hasNextPage && ( +
+ +
+ )} + + ) : ( +

{t("no_teams_found")}

+ )} +
+ + + +
+
+ ); +} diff --git a/packages/features/flags/components/FlagAdminList.tsx b/packages/features/flags/components/FlagAdminList.tsx index ffb90ba32a..86339e89dc 100644 --- a/packages/features/flags/components/FlagAdminList.tsx +++ b/packages/features/flags/components/FlagAdminList.tsx @@ -1,31 +1,69 @@ +import { useState } from "react"; + import { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { PanelCard } from "@calcom/ui/components/card"; import { Switch } from "@calcom/ui/components/form"; import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/components/list"; import { List } from "@calcom/ui/components/list"; import { showToast } from "@calcom/ui/components/toast"; +import { AssignFeatureSheet } from "./AssignFeatureSheet"; + export const FlagAdminList = () => { const [data] = trpc.viewer.features.list.useSuspenseQuery(); + const [selectedFlag, setSelectedFlag] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + + const groupedFlags = data.reduce((acc, flag) => { + const type = flag.type || "OTHER"; + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(flag); + return acc; + }, {} as Record); + + const sortedTypes = Object.keys(groupedFlags).sort(); + + const handleAssignClick = (flag: Flag) => { + setSelectedFlag(flag); + setSheetOpen(true); + }; + return ( - - {data.map((flag) => ( - -
- - {flag.slug} -    - {flag.type?.replace("_", " ")} - - {flag.description} -
-
- -
-
- ))} -
+ <> +
+ {sortedTypes.map((type) => ( + + + {groupedFlags[type].map((flag: Flag, index: number) => ( + +
+ {flag.slug} + {flag.description} +
+
+ + +
+
+ ))} +
+
+ ))} +
+ {selectedFlag && ( + + )} + ); }; diff --git a/packages/trpc/server/routers/viewer/admin/_router.ts b/packages/trpc/server/routers/viewer/admin/_router.ts index 49580eb947..b2c145727b 100644 --- a/packages/trpc/server/routers/viewer/admin/_router.ts +++ b/packages/trpc/server/routers/viewer/admin/_router.ts @@ -1,12 +1,15 @@ import { authedAdminProcedure } from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; +import { ZAdminAssignFeatureToTeamSchema } from "./assignFeatureToTeam.schema"; import { ZCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.schema"; +import { ZAdminGetTeamsForFeatureSchema } from "./getTeamsForFeature.schema"; import { ZListMembersSchema } from "./listPaginated.schema"; import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema"; import { ZAdminRemoveTwoFactor } from "./removeTwoFactor.schema"; import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema"; import { ZSetSMSLockState } from "./setSMSLockState.schema"; import { toggleFeatureFlag } from "./toggleFeatureFlag.procedure"; +import { ZAdminUnassignFeatureFromTeamSchema } from "./unassignFeatureFromTeam.schema"; import { ZAdminVerifyWorkflowsSchema } from "./verifyWorkflows.schema"; import { ZWhitelistUserWorkflows } from "./whitelistUserWorkflows.schema"; import { @@ -60,6 +63,24 @@ export const adminRouter = router({ const { default: handler } = await import("./whitelistUserWorkflows.handler"); return handler(opts); }), + getTeamsForFeature: authedAdminProcedure + .input(ZAdminGetTeamsForFeatureSchema) + .query(async (opts) => { + const { default: handler } = await import("./getTeamsForFeature.handler"); + return handler(opts); + }), + assignFeatureToTeam: authedAdminProcedure + .input(ZAdminAssignFeatureToTeamSchema) + .mutation(async (opts) => { + const { default: handler } = await import("./assignFeatureToTeam.handler"); + return handler(opts); + }), + unassignFeatureFromTeam: authedAdminProcedure + .input(ZAdminUnassignFeatureFromTeamSchema) + .mutation(async (opts) => { + const { default: handler } = await import("./unassignFeatureFromTeam.handler"); + return handler(opts); + }), workspacePlatform: router({ list: authedAdminProcedure.query(async () => { const { default: handler } = await import("./workspacePlatform/list.handler"); diff --git a/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts b/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts new file mode 100644 index 0000000000..910a3d7390 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts @@ -0,0 +1,36 @@ +import type { PrismaClient } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TAdminAssignFeatureToTeamSchema } from "./assignFeatureToTeam.schema"; + +type AssignFeatureOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TAdminAssignFeatureToTeamSchema; +}; + +export const assignFeatureToTeamHandler = async ({ ctx, input }: AssignFeatureOptions) => { + const { prisma, user } = ctx; + const { teamId, featureId } = input; + + await prisma.teamFeatures.upsert({ + where: { + teamId_featureId: { + teamId, + featureId, + }, + }, + create: { + teamId, + featureId, + assignedBy: `user:${user.id}`, + }, + update: {}, + }); + + return { success: true }; +}; + +export default assignFeatureToTeamHandler; diff --git a/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.schema.ts b/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.schema.ts new file mode 100644 index 0000000000..ee008c9c1e --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZAdminAssignFeatureToTeamSchema = z.object({ + teamId: z.number(), + featureId: z.string(), +}); + +export type TAdminAssignFeatureToTeamSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.handler.ts b/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.handler.ts new file mode 100644 index 0000000000..3021764591 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.handler.ts @@ -0,0 +1,80 @@ +import type { PrismaClient } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TAdminGetTeamsForFeatureSchema } from "./getTeamsForFeature.schema"; + +type GetTeamsOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TAdminGetTeamsForFeatureSchema; +}; + +export const getTeamsForFeatureHandler = async ({ ctx, input }: GetTeamsOptions) => { + const { prisma } = ctx; + const { featureId, limit = 10, cursor, searchTerm } = input; + + const whereClause = { + isPlatform: false, + ...(searchTerm + ? { + OR: [ + { name: { contains: searchTerm, mode: "insensitive" as const } }, + { slug: { contains: searchTerm, mode: "insensitive" as const } }, + ], + } + : {}), + }; + + const [teams, assignedTeams] = await Promise.all([ + prisma.team.findMany({ + where: whereClause, + select: { + id: true, + name: true, + slug: true, + logoUrl: true, + parentId: true, + isOrganization: true, + parent: { + select: { + name: true, + logoUrl: true, + }, + }, + }, + orderBy: { + name: "asc", + }, + take: limit + 1, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + }), + prisma.teamFeatures.findMany({ + where: { + featureId, + }, + select: { + teamId: true, + }, + }), + ]); + + const assignedTeamIds = new Set(assignedTeams.map((tf) => tf.teamId)); + + let nextCursor: typeof cursor | undefined = undefined; + if (teams.length > limit) { + const nextItem = teams.pop(); + nextCursor = nextItem?.id; + } + + return { + teams: teams.map((team) => ({ + ...team, + hasFeature: assignedTeamIds.has(team.id), + })), + nextCursor, + }; +}; + +export default getTeamsForFeatureHandler; diff --git a/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.schema.ts b/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.schema.ts new file mode 100644 index 0000000000..3c18eb94da --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ZAdminGetTeamsForFeatureSchema = z.object({ + featureId: z.string(), + limit: z.number().min(1).max(100).default(10), + cursor: z.number().nullish(), + searchTerm: z.string().optional(), +}); + +export type TAdminGetTeamsForFeatureSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.handler.ts b/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.handler.ts new file mode 100644 index 0000000000..5b4eeb802d --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.handler.ts @@ -0,0 +1,30 @@ +import type { PrismaClient } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TAdminUnassignFeatureFromTeamSchema } from "./unassignFeatureFromTeam.schema"; + +type UnassignFeatureOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TAdminUnassignFeatureFromTeamSchema; +}; + +export const unassignFeatureFromTeamHandler = async ({ ctx, input }: UnassignFeatureOptions) => { + const { prisma } = ctx; + const { teamId, featureId } = input; + + await prisma.teamFeatures.delete({ + where: { + teamId_featureId: { + teamId, + featureId, + }, + }, + }); + + return { success: true }; +}; + +export default unassignFeatureFromTeamHandler; diff --git a/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.schema.ts b/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.schema.ts new file mode 100644 index 0000000000..db68a55725 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZAdminUnassignFeatureFromTeamSchema = z.object({ + teamId: z.number(), + featureId: z.string(), +}); + +export type TAdminUnassignFeatureFromTeamSchema = z.infer;