Adds basic global feature flags (#7459)

* Adds basic feature flag model

* Create router.ts

* WIP

* WIP

* WIP

* WIP

* Emails kill switch

* Adds missing migrations

* Type fix

* Cleanup

* Revert

* Cleanup

* Fixes migration

* Update packages/features/flags/server/utils.ts

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
Omar López 2023-03-24 17:59:04 -07:00 committed by GitHub
parent 20022e2cdd
commit 69491d5700
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 316 additions and 22 deletions

View File

@ -10,6 +10,8 @@ import type { ComponentProps, ReactNode } from "react";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import { trpc } from "@calcom/trpc/react";
import { MetaProvider } from "@calcom/ui";
@ -58,6 +60,11 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
return <I18nextAdapter {...passedProps} />;
};
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
const flags = useFlags();
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
}
const AppProviders = (props: AppPropsWithChildren) => {
const session = trpc.viewer.public.session.useQuery().data;
// No need to have intercom on public pages - Good for Page Performance
@ -85,7 +92,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
storageKey={storageKey}
forcedTheme={forcedTheme}
attribute="class">
<MetaProvider>{props.children}</MetaProvider>
<FeatureFlagsProvider>
<MetaProvider>{props.children}</MetaProvider>
</FeatureFlagsProvider>
</ThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>

View File

@ -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;

View File

@ -4,8 +4,10 @@ import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import prisma from "@calcom/prisma";
declare let global: {
E2E_EMAILS?: Record<string, unknown>[];
@ -29,7 +31,13 @@ export default class BaseEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
return {};
}
public sendEmail() {
public async sendEmail() {
const featureFlags = await getFeatureFlagMap(prisma);
/** If email kill switch exists and is active, we prevent emails being sent. */
if (featureFlags.emails) {
console.warn("Skipped Sending Email due to active Kill Switch");
return new Promise((r) => r("Skipped Sending Email due to active Kill Switch"));
}
if (process.env.NEXT_PUBLIC_IS_E2E) {
global.E2E_EMAILS = global.E2E_EMAILS || [];
global.E2E_EMAILS.push(this.getNodeMailerPayload());

View File

@ -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();
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}
&nbsp;&nbsp;
<Badge variant="green">{flag.type}</Badge>
</ListItemTitle>
<ListItemText component="p">{flag.description}</ListItemText>
</div>
<div className="flex py-2">
<FlagToggle flag={flag} />
</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 });
}}
/>
);
};

View File

@ -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 = {
emails: boolean;
teams: boolean;
webhooks: boolean;
workflows: boolean;
"booking-page-v2": boolean;
};

View File

@ -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);
/**
* 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);
}

View File

@ -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;
}

View File

@ -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." />
<Suspense fallback={<FiLoader />}>
<FlagAdminList />
</Suspense>
</>
);
};

View File

@ -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" },
});
}),
map: publicProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
return getFeatureFlagMap(prisma);
}),
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 },
});
}),
});

View File

@ -0,0 +1,13 @@
import type { PrismaClient } from "@prisma/client";
import type { AppFlags } from "../config";
export async function getFeatureFlagMap(prisma: PrismaClient) {
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);
}

View File

@ -83,6 +83,7 @@ const tabs: VerticalTabItemProps[] = [
icon: FiLock,
children: [
//
{ name: "features", href: "/settings/admin/flags" },
{ name: "license", href: "/auth/setup?step=1" },
{ name: "impersonation", href: "/settings/admin/impersonation" },
{ name: "apps", href: "/settings/admin/apps/calendar" },

View File

@ -13,6 +13,7 @@ import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookin
import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner";
import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem";
import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { KBarContent, KBarRoot, KBarTrigger } from "@calcom/features/kbar/Kbar";
import TimezoneChangeDialog from "@calcom/features/settings/TimezoneChangeDialog";
import { Tips } from "@calcom/features/tips";
@ -22,6 +23,7 @@ import classNames from "@calcom/lib/classNames";
import { APP_NAME, DESKTOP_APP_LINK, JOIN_SLACK, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { SVGComponent } from "@calcom/types/SVGComponent";
@ -29,39 +31,39 @@ import {
Button,
Credits,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
ErrorBoundary,
Logo,
HeadSeo,
showToast,
Logo,
SkeletonText,
showToast,
} from "@calcom/ui";
import {
FiMoreVertical,
FiMoon,
FiExternalLink,
FiLink,
FiSlack,
FiMap,
FiHelpCircle,
FiDownload,
FiLogOut,
FiArrowLeft,
FiArrowRight,
FiBarChart,
FiCalendar,
FiClock,
FiUsers,
FiGrid,
FiMoreHorizontal,
FiDownload,
FiExternalLink,
FiFileText,
FiZap,
FiGrid,
FiHelpCircle,
FiLink,
FiLogOut,
FiMap,
FiMoon,
FiMoreHorizontal,
FiMoreVertical,
FiSettings,
FiBarChart,
FiArrowRight,
FiArrowLeft,
FiSlack,
FiUsers,
FiZap
} from "@calcom/ui/components/icon";
import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider";
@ -584,6 +586,8 @@ function useShouldDisplayNavigationItem(item: NavigationItemType) {
trpc: {},
}
);
const flags = useFlagMap();
if (isKeyInObject(item.name, flags)) return flags[item.name];
return !requiredCredentialNavigationItems.includes(item.name) || routingForms?.isInstalled;
}

View File

@ -0,0 +1,6 @@
const base = require("@calcom/config/tailwind-preset");
module.exports = {
...base,
content: [...base.content, "/**/*.{js,ts,jsx,tsx}"],
};

View File

@ -0,0 +1 @@
export const isKeyInObject = <T extends object>(k: PropertyKey, o: T): k is keyof T => k in o;

View File

@ -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");

View File

@ -0,0 +1,28 @@
-- Insert initial feature flags with their default values
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'emails',
false,
'Enable to prevent any emails being send',
'KILL_SWITCH'
),
(
'workflows',
true,
'Enable workflows for this instance',
'OPERATIONAL'
),
(
'teams',
true,
'Enable teams for this instance',
'OPERATIONAL'
),
(
'webhooks',
true,
'Enable webhooks for this instance',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;

View File

@ -686,3 +686,28 @@ model VerifiedNumber {
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
phoneNumber String
}
model Feature {
// The feature slug, ex: 'v2-workflows'
slug String @id @unique
// If the feature is currently enabled
enabled Boolean @default(false)
// A short description of the feature
description String?
// The type of feature flag
type FeatureType? @default(RELEASE)
// If the flag is considered stale
stale Boolean? @default(false)
lastUsedAt DateTime?
createdAt DateTime? @default(now())
updatedAt DateTime? @default(now()) @updatedAt
updatedBy Int?
}
enum FeatureType {
RELEASE
EXPERIMENT
OPERATIONAL
KILL_SWITCH
PERMISSION
}

View File

@ -1,3 +1,4 @@
import type { NextPageContext } from "next/types";
import superjson from "superjson";
import { httpLink } from "../client/links/httpLink";
@ -15,7 +16,7 @@ import type { AppRouter } from "../server/routers/_app";
* A set of strongly-typed React hooks from your `AppRouter` type signature with `createTRPCReact`.
* @link https://trpc.io/docs/v10/react#2-create-trpc-hooks
*/
export const trpc = createTRPCNext<AppRouter>({
export const trpc = createTRPCNext<AppRouter, NextPageContext, "ExperimentalSuspense">({
config() {
const url =
typeof window !== "undefined"

View File

@ -23,6 +23,7 @@ import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml";
import { featureFlagRouter } from "@calcom/features/flags/server/router";
import { insightsRouter } from "@calcom/features/insights/server/trpc-router";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
@ -1278,6 +1279,7 @@ export const viewerRouter = mergeRouters(
// After that there would just one merge call here for all the apps.
appRoutingForms: app_RoutingForms,
eth: ethRouter,
features: featureFlagRouter,
appsRouter,
})
);