-
Notifications
You must be signed in to change notification settings - Fork 6.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adds basic global feature flags #7459
Changes from all commits
5354c42
b525c28
9eed811
e9d0064
8b5d336
2a90f36
e8c8e26
abf008d
72365fe
e5818d4
5f117ad
83159d3
5893e9a
b74565e
8f51630
58c9fde
b84b148
f7561eb
06d0309
0764677
9767f0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view"; | ||
|
||
import { getLayout } from "@components/auth/layouts/AdminLayout"; | ||
|
||
const FlagsPage = () => <FlagListingView />; | ||
|
||
FlagsPage.getLayout = getLayout; | ||
|
||
export default FlagsPage; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import { trpc } from "@calcom/trpc/react"; | ||
import type { RouterOutputs } from "@calcom/trpc/react"; | ||
import { Badge, List, ListItem, ListItemText, ListItemTitle, Switch } from "@calcom/ui"; | ||
|
||
export const FlagAdminList = () => { | ||
const [data] = trpc.viewer.features.list.useSuspenseQuery(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using new suspense queries |
||
return ( | ||
<List roundContainer noBorderTreatment> | ||
{data.map((flag) => ( | ||
<ListItem key={flag.slug} rounded={false}> | ||
<div className="flex flex-1 flex-col"> | ||
<ListItemTitle component="h3"> | ||
{flag.slug} | ||
| ||
<Badge variant="green">{flag.type}</Badge> | ||
</ListItemTitle> | ||
<ListItemText component="p">{flag.description}</ListItemText> | ||
</div> | ||
<div className="flex py-2"> | ||
<FlagToggle flag={flag} /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self contained toggle to enable/disable features |
||
</div> | ||
</ListItem> | ||
))} | ||
</List> | ||
); | ||
}; | ||
|
||
type Flag = RouterOutputs["viewer"]["features"]["list"][number]; | ||
|
||
const FlagToggle = (props: { flag: Flag }) => { | ||
const { | ||
flag: { slug, enabled }, | ||
} = props; | ||
const utils = trpc.useContext(); | ||
const mutation = trpc.viewer.features.toggle.useMutation({ | ||
onSuccess: () => { | ||
utils.viewer.features.list.invalidate(); | ||
utils.viewer.features.map.invalidate(); | ||
}, | ||
}); | ||
return ( | ||
<Switch | ||
defaultChecked={enabled} | ||
onCheckedChange={(checked) => { | ||
mutation.mutate({ slug, enabled: checked }); | ||
}} | ||
/> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
/** | ||
* Right now we only support boolean flags. | ||
* Maybe later on we can add string variants or numeric ones | ||
**/ | ||
export type AppFlags = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we cannot type DB slugs, we need to keep a record here for type safety. |
||
emails: boolean; | ||
teams: boolean; | ||
webhooks: boolean; | ||
workflows: boolean; | ||
"booking-page-v2": boolean; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we improve this to be v2-booking-page? As mentioned in a example down below v2-workflows |
||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import * as React from "react"; | ||
|
||
import type { AppFlags } from "../config"; | ||
|
||
/** | ||
* Generic Feature Flags | ||
* | ||
* Entries consist of the feature flag name as the key and the resolved variant's value as the value. | ||
*/ | ||
export type Flags = AppFlags; | ||
|
||
/** | ||
* Allows you to access the flags from context | ||
*/ | ||
const FeatureContext = React.createContext<Flags | null>(null); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So we can fetch once, use everywhere they're needed. |
||
|
||
/** | ||
* Accesses the evaluated flags from context. | ||
* | ||
* You need to render a <FeatureProvider /> further up to be able to use | ||
* this component. | ||
*/ | ||
export function useFlagMap() { | ||
const flagMapContext = React.useContext(FeatureContext); | ||
if (flagMapContext === null) throw new Error("Error: useFlagMap was used outside of FeatureProvider."); | ||
return flagMapContext as Flags; | ||
} | ||
|
||
/** | ||
* If you want to be able to access the flags from context using `useFlagMap()`, | ||
* you can render the FeatureProvider at the top of your Next.js pages, like so: | ||
* | ||
* ```ts | ||
* import { useFlags } from "@calcom/features/flags/hooks/useFlag" | ||
* import { FeatureProvider, useFlagMap } from @calcom/features/flags/context/provider" | ||
* | ||
* export default function YourPage () { | ||
* const flags = useFlags() | ||
* | ||
* return ( | ||
* <FeatureProvider value={flags}> | ||
* <YourOwnComponent /> | ||
* </FeatureProvider> | ||
* ) | ||
* } | ||
* ``` | ||
* | ||
* You can then call `useFlagMap()` to access your `flagMap` from within | ||
* `YourOwnComponent` or further down. | ||
* | ||
* _Note that it's generally better to explicitly pass your flags down as props, | ||
* so you might not need this at all._ | ||
*/ | ||
export function FeatureProvider<F extends Flags>(props: { value: F; children: React.ReactNode }) { | ||
return React.createElement(FeatureContext.Provider, { value: props.value }, props.children); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { trpc } from "@calcom/trpc/react"; | ||
|
||
export function useFlags() { | ||
const query = trpc.viewer.features.map.useQuery(undefined, { initialData: {} }); | ||
return query.data; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { Suspense } from "react"; | ||
|
||
import { Meta } from "@calcom/ui"; | ||
import { FiLoader } from "@calcom/ui/components/icon"; | ||
|
||
import { FlagAdminList } from "../components/FlagAdminList"; | ||
|
||
export const FlagListingView = () => { | ||
return ( | ||
<> | ||
<Meta title="Feature Flags" description="Here you can toggle your Cal.com instance features." /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIT: translation |
||
<Suspense fallback={<FiLoader />}> | ||
<FlagAdminList /> | ||
</Suspense> | ||
Comment on lines
+12
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suspense! |
||
</> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import { z } from "zod"; | ||
|
||
import { authedAdminProcedure, publicProcedure, router } from "@calcom/trpc/server/trpc"; | ||
|
||
import { getFeatureFlagMap } from "./utils"; | ||
|
||
export const featureFlagRouter = router({ | ||
list: publicProcedure.query(async ({ ctx }) => { | ||
const { prisma } = ctx; | ||
return prisma.feature.findMany({ | ||
orderBy: { slug: "asc" }, | ||
}); | ||
}), | ||
Comment on lines
+8
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For admin listing |
||
map: publicProcedure.query(async ({ ctx }) => { | ||
const { prisma } = ctx; | ||
return getFeatureFlagMap(prisma); | ||
}), | ||
Comment on lines
+14
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To use in context |
||
toggle: authedAdminProcedure | ||
.input(z.object({ slug: z.string(), enabled: z.boolean() })) | ||
.mutation(({ ctx, input }) => { | ||
const { prisma, user } = ctx; | ||
const { slug, enabled } = input; | ||
return prisma.feature.update({ | ||
where: { slug }, | ||
data: { enabled, updatedBy: user.id }, | ||
}); | ||
}), | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import type { PrismaClient } from "@prisma/client"; | ||
|
||
import type { AppFlags } from "../config"; | ||
|
||
export async function getFeatureFlagMap(prisma: PrismaClient) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To be reused on server/trpc calls. |
||
const flags = await prisma.feature.findMany({ | ||
orderBy: { slug: "asc" }, | ||
}); | ||
return flags.reduce<AppFlags>((acc, flag) => { | ||
acc[flag.slug as keyof AppFlags] = flag.enabled; | ||
return acc; | ||
}, {} as AppFlags); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
const base = require("@calcom/config/tailwind-preset"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Se we can use tailwind auto-complete in features. |
||
|
||
module.exports = { | ||
...base, | ||
content: [...base.content, "/**/*.{js,ts,jsx,tsx}"], | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export const isKeyInObject = <T extends object>(k: PropertyKey, o: T): k is keyof T => k in o; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So we can ensure a key exists in a object on both runtime and type land. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
-- CreateEnum | ||
CREATE TYPE "FeatureType" AS ENUM ('RELEASE', 'EXPERIMENT', 'OPERATIONAL', 'KILL_SWITCH', 'PERMISSION'); | ||
|
||
-- CreateTable | ||
CREATE TABLE "Feature" ( | ||
"slug" TEXT NOT NULL, | ||
"enabled" BOOLEAN NOT NULL DEFAULT false, | ||
"description" TEXT, | ||
"type" "FeatureType" DEFAULT 'RELEASE', | ||
"stale" BOOLEAN DEFAULT false, | ||
"lastUsedAt" TIMESTAMP(3), | ||
"createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, | ||
"updatedAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, | ||
"updatedBy" INTEGER, | ||
|
||
CONSTRAINT "Feature_pkey" PRIMARY KEY ("slug") | ||
); | ||
|
||
-- CreateIndex | ||
CREATE UNIQUE INDEX "Feature_slug_key" ON "Feature"("slug"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adds basic email kill switch as example and in case it's needed