Skip to content

Commit 67843ad

Browse files
authored
feat: admin feature flags v3 + assign/unassign (calcom#24428)
* Feature grouping * Add assign/unassign logic for teams feature flags * Fix types
1 parent 64297f0 commit 67843ad

10 files changed

Lines changed: 458 additions & 17 deletions

File tree

apps/web/public/static/locales/en/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,8 @@
11891189
"featured_categories": "Featured Categories",
11901190
"feature_flags": "Feature Flags",
11911191
"admin_flags_description": "Here you can toggle your Cal.com instance features.",
1192+
"feature_assigned_successfully": "Feature assigned successfully",
1193+
"feature_unassigned_successfully": "Feature unassigned successfully",
11921194
"popular_categories": "Popular Categories",
11931195
"number_apps_one": "{{count}} App",
11941196
"number_apps_other": "{{count}} Apps",
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
5+
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
6+
import { useLocale } from "@calcom/lib/hooks/useLocale";
7+
import { trpc } from "@calcom/trpc/react";
8+
import type { RouterOutputs } from "@calcom/trpc/react";
9+
import { Avatar } from "@calcom/ui/components/avatar";
10+
import { Button } from "@calcom/ui/components/button";
11+
import { Checkbox, TextField } from "@calcom/ui/components/form";
12+
import {
13+
Sheet,
14+
SheetContent,
15+
SheetHeader,
16+
SheetTitle,
17+
SheetBody,
18+
SheetFooter,
19+
} from "@calcom/ui/components/sheet";
20+
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
21+
import { showToast } from "@calcom/ui/components/toast";
22+
23+
type Flag = RouterOutputs["viewer"]["features"]["list"][number];
24+
25+
interface AssignFeatureSheetProps {
26+
flag: Flag;
27+
open: boolean;
28+
onOpenChange: (open: boolean) => void;
29+
}
30+
31+
export function AssignFeatureSheet({ flag, open, onOpenChange }: AssignFeatureSheetProps) {
32+
const { t } = useLocale();
33+
const utils = trpc.useUtils();
34+
const [searchTerm, setSearchTerm] = useState("");
35+
const debouncedSearchTerm = useDebounce(searchTerm, 300);
36+
37+
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
38+
trpc.viewer.admin.getTeamsForFeature.useInfiniteQuery(
39+
{
40+
featureId: flag.slug,
41+
limit: 20,
42+
searchTerm: debouncedSearchTerm || undefined,
43+
},
44+
{
45+
enabled: open,
46+
getNextPageParam: (lastPage) => lastPage.nextCursor,
47+
}
48+
);
49+
50+
const teams = data?.pages.flatMap((page) => page.teams) ?? [];
51+
52+
useEffect(() => {
53+
if (!open) {
54+
setSearchTerm("");
55+
}
56+
}, [open]);
57+
58+
const assignMutation = trpc.viewer.admin.assignFeatureToTeam.useMutation({
59+
onSuccess: () => {
60+
utils.viewer.admin.getTeamsForFeature.invalidate({ featureId: flag.slug });
61+
showToast(t("feature_assigned_successfully"), "success");
62+
},
63+
onError: (err) => {
64+
showToast(err.message, "error");
65+
},
66+
});
67+
68+
const unassignMutation = trpc.viewer.admin.unassignFeatureFromTeam.useMutation({
69+
onSuccess: () => {
70+
utils.viewer.admin.getTeamsForFeature.invalidate({ featureId: flag.slug });
71+
showToast(t("feature_unassigned_successfully"), "success");
72+
},
73+
onError: (err) => {
74+
showToast(err.message, "error");
75+
},
76+
});
77+
78+
const handleToggleTeam = (teamId: number, currentlyHasFeature: boolean) => {
79+
if (currentlyHasFeature) {
80+
unassignMutation.mutate({
81+
teamId,
82+
featureId: flag.slug,
83+
});
84+
} else {
85+
assignMutation.mutate({
86+
teamId,
87+
featureId: flag.slug,
88+
});
89+
}
90+
};
91+
92+
const handleClose = () => {
93+
onOpenChange(false);
94+
};
95+
96+
const isLoading = assignMutation.isPending || unassignMutation.isPending;
97+
98+
return (
99+
<Sheet open={open} onOpenChange={handleClose}>
100+
<SheetContent className="bg-muted">
101+
<SheetHeader>
102+
<SheetTitle>Assign: {flag.slug}</SheetTitle>
103+
</SheetHeader>
104+
<SheetBody>
105+
<div className="mb-4">
106+
<TextField
107+
type="text"
108+
placeholder={t("search")}
109+
value={searchTerm}
110+
onChange={(e) => setSearchTerm(e.target.value)}
111+
/>
112+
</div>
113+
{isPending ? (
114+
<SkeletonContainer>
115+
<div className="space-y-3">
116+
{[...Array(5)].map((_, i) => (
117+
<SkeletonText key={i} className="h-16 w-full" />
118+
))}
119+
</div>
120+
</SkeletonContainer>
121+
) : teams && teams.length > 0 ? (
122+
<>
123+
<div className="space-y-2">
124+
{teams.map((team) => (
125+
<button
126+
key={team.id}
127+
type="button"
128+
onClick={() => handleToggleTeam(team.id, team.hasFeature)}
129+
disabled={isLoading}
130+
className="bg-default border-subtle hover:bg-muted flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50">
131+
<div className="flex items-center gap-3">
132+
<div className="relative">
133+
{team.isOrganization ? (
134+
<div className="h-8 w-8 overflow-hidden rounded">
135+
{team.logoUrl ? (
136+
<img
137+
src={team.logoUrl}
138+
alt={team.name || ""}
139+
className="h-full w-full object-cover"
140+
/>
141+
) : (
142+
<div className="bg-emphasis text-default flex h-full w-full items-center justify-center text-xs font-semibold">
143+
{team.name?.charAt(0).toUpperCase()}
144+
</div>
145+
)}
146+
</div>
147+
) : (
148+
<Avatar size="sm" alt={team.name || ""} imageSrc={team.logoUrl} />
149+
)}
150+
{team.parent && team.parentId && (
151+
<div className="border-emphasis absolute -bottom-1 -right-1 h-4 w-4 overflow-hidden rounded border">
152+
{team.parent.logoUrl ? (
153+
<img
154+
src={team.parent.logoUrl}
155+
alt={team.parent.name || ""}
156+
className="h-full w-full object-cover"
157+
/>
158+
) : (
159+
<div className="bg-emphasis text-default flex h-full w-full items-center justify-center text-[8px] font-semibold">
160+
{team.parent.name?.charAt(0).toUpperCase()}
161+
</div>
162+
)}
163+
</div>
164+
)}
165+
</div>
166+
<div>
167+
<p className="text-emphasis text-sm font-medium">{team.name}</p>
168+
{team.slug && <p className="text-subtle text-xs">{team.slug}</p>}
169+
{team.parent && (
170+
<p className="text-subtle text-xs">
171+
{t("organization")}: {team.parent.name}
172+
</p>
173+
)}
174+
</div>
175+
</div>
176+
<Checkbox
177+
checked={team.hasFeature}
178+
disabled={isLoading}
179+
onCheckedChange={() => {}}
180+
/>
181+
</button>
182+
))}
183+
</div>
184+
{hasNextPage && (
185+
<div className="mt-4 flex justify-center">
186+
<Button
187+
color="secondary"
188+
onClick={() => fetchNextPage()}
189+
loading={isFetchingNextPage}
190+
disabled={isFetchingNextPage}>
191+
{t("load_more")}
192+
</Button>
193+
</div>
194+
)}
195+
</>
196+
) : (
197+
<p className="text-subtle text-center text-sm">{t("no_teams_found")}</p>
198+
)}
199+
</SheetBody>
200+
<SheetFooter>
201+
<Button color="secondary" onClick={handleClose}>
202+
{t("close")}
203+
</Button>
204+
</SheetFooter>
205+
</SheetContent>
206+
</Sheet>
207+
);
208+
}

packages/features/flags/components/FlagAdminList.tsx

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,69 @@
1+
import { useState } from "react";
2+
13
import { trpc } from "@calcom/trpc/react";
24
import type { RouterOutputs } from "@calcom/trpc/react";
35
import { Badge } from "@calcom/ui/components/badge";
6+
import { Button } from "@calcom/ui/components/button";
7+
import { PanelCard } from "@calcom/ui/components/card";
48
import { Switch } from "@calcom/ui/components/form";
59
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/components/list";
610
import { List } from "@calcom/ui/components/list";
711
import { showToast } from "@calcom/ui/components/toast";
812

13+
import { AssignFeatureSheet } from "./AssignFeatureSheet";
14+
915
export const FlagAdminList = () => {
1016
const [data] = trpc.viewer.features.list.useSuspenseQuery();
17+
const [selectedFlag, setSelectedFlag] = useState<Flag | null>(null);
18+
const [sheetOpen, setSheetOpen] = useState(false);
19+
20+
const groupedFlags = data.reduce((acc, flag) => {
21+
const type = flag.type || "OTHER";
22+
if (!acc[type]) {
23+
acc[type] = [];
24+
}
25+
acc[type].push(flag);
26+
return acc;
27+
}, {} as Record<string, typeof data>);
28+
29+
const sortedTypes = Object.keys(groupedFlags).sort();
30+
31+
const handleAssignClick = (flag: Flag) => {
32+
setSelectedFlag(flag);
33+
setSheetOpen(true);
34+
};
35+
1136
return (
12-
<List roundContainer noBorderTreatment>
13-
{data.map((flag) => (
14-
<ListItem key={flag.slug} rounded={false}>
15-
<div className="flex flex-1 flex-col">
16-
<ListItemTitle component="h3">
17-
{flag.slug}
18-
&nbsp;&nbsp;
19-
<Badge variant="green">{flag.type?.replace("_", " ")}</Badge>
20-
</ListItemTitle>
21-
<ListItemText component="p">{flag.description}</ListItemText>
22-
</div>
23-
<div className="flex py-2">
24-
<FlagToggle flag={flag} />
25-
</div>
26-
</ListItem>
27-
))}
28-
</List>
37+
<>
38+
<div className="space-y-4">
39+
{sortedTypes.map((type) => (
40+
<PanelCard key={type} title={type.replace(/_/g, " ")} collapsible defaultCollapsed={false}>
41+
<List roundContainer noBorderTreatment>
42+
{groupedFlags[type].map((flag: Flag, index: number) => (
43+
<ListItem key={flag.slug} rounded={index === 0 || index === groupedFlags[type].length - 1}>
44+
<div className="flex flex-1 flex-col">
45+
<ListItemTitle component="h3">{flag.slug}</ListItemTitle>
46+
<ListItemText component="p">{flag.description}</ListItemText>
47+
</div>
48+
<div className="flex items-center gap-2 py-2">
49+
<FlagToggle flag={flag} />
50+
<Button
51+
color="secondary"
52+
size="sm"
53+
variant="icon"
54+
onClick={() => handleAssignClick(flag)}
55+
StartIcon="users"></Button>
56+
</div>
57+
</ListItem>
58+
))}
59+
</List>
60+
</PanelCard>
61+
))}
62+
</div>
63+
{selectedFlag && (
64+
<AssignFeatureSheet flag={selectedFlag} open={sheetOpen} onOpenChange={setSheetOpen} />
65+
)}
66+
</>
2967
);
3068
};
3169

packages/trpc/server/routers/viewer/admin/_router.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { authedAdminProcedure } from "../../../procedures/authedProcedure";
22
import { router } from "../../../trpc";
3+
import { ZAdminAssignFeatureToTeamSchema } from "./assignFeatureToTeam.schema";
34
import { ZCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.schema";
5+
import { ZAdminGetTeamsForFeatureSchema } from "./getTeamsForFeature.schema";
46
import { ZListMembersSchema } from "./listPaginated.schema";
57
import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema";
68
import { ZAdminRemoveTwoFactor } from "./removeTwoFactor.schema";
79
import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema";
810
import { ZSetSMSLockState } from "./setSMSLockState.schema";
911
import { toggleFeatureFlag } from "./toggleFeatureFlag.procedure";
12+
import { ZAdminUnassignFeatureFromTeamSchema } from "./unassignFeatureFromTeam.schema";
1013
import { ZAdminVerifyWorkflowsSchema } from "./verifyWorkflows.schema";
1114
import { ZWhitelistUserWorkflows } from "./whitelistUserWorkflows.schema";
1215
import {
@@ -60,6 +63,24 @@ export const adminRouter = router({
6063
const { default: handler } = await import("./whitelistUserWorkflows.handler");
6164
return handler(opts);
6265
}),
66+
getTeamsForFeature: authedAdminProcedure
67+
.input(ZAdminGetTeamsForFeatureSchema)
68+
.query(async (opts) => {
69+
const { default: handler } = await import("./getTeamsForFeature.handler");
70+
return handler(opts);
71+
}),
72+
assignFeatureToTeam: authedAdminProcedure
73+
.input(ZAdminAssignFeatureToTeamSchema)
74+
.mutation(async (opts) => {
75+
const { default: handler } = await import("./assignFeatureToTeam.handler");
76+
return handler(opts);
77+
}),
78+
unassignFeatureFromTeam: authedAdminProcedure
79+
.input(ZAdminUnassignFeatureFromTeamSchema)
80+
.mutation(async (opts) => {
81+
const { default: handler } = await import("./unassignFeatureFromTeam.handler");
82+
return handler(opts);
83+
}),
6384
workspacePlatform: router({
6485
list: authedAdminProcedure.query(async () => {
6586
const { default: handler } = await import("./workspacePlatform/list.handler");
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { PrismaClient } from "@calcom/prisma";
2+
3+
import type { TrpcSessionUser } from "../../../types";
4+
import type { TAdminAssignFeatureToTeamSchema } from "./assignFeatureToTeam.schema";
5+
6+
type AssignFeatureOptions = {
7+
ctx: {
8+
user: NonNullable<TrpcSessionUser>;
9+
prisma: PrismaClient;
10+
};
11+
input: TAdminAssignFeatureToTeamSchema;
12+
};
13+
14+
export const assignFeatureToTeamHandler = async ({ ctx, input }: AssignFeatureOptions) => {
15+
const { prisma, user } = ctx;
16+
const { teamId, featureId } = input;
17+
18+
await prisma.teamFeatures.upsert({
19+
where: {
20+
teamId_featureId: {
21+
teamId,
22+
featureId,
23+
},
24+
},
25+
create: {
26+
teamId,
27+
featureId,
28+
assignedBy: `user:${user.id}`,
29+
},
30+
update: {},
31+
});
32+
33+
return { success: true };
34+
};
35+
36+
export default assignFeatureToTeamHandler;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { z } from "zod";
2+
3+
export const ZAdminAssignFeatureToTeamSchema = z.object({
4+
teamId: z.number(),
5+
featureId: z.string(),
6+
});
7+
8+
export type TAdminAssignFeatureToTeamSchema = z.infer<typeof ZAdminAssignFeatureToTeamSchema>;

0 commit comments

Comments
 (0)