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:
parent
20022e2cdd
commit
69491d5700
|
@ -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">
|
||||
<FeatureFlagsProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
|
|
|
@ -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;
|
|
@ -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());
|
||||
|
|
|
@ -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}
|
||||
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 },
|
||||
});
|
||||
}),
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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" },
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
const base = require("@calcom/config/tailwind-preset");
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
content: [...base.content, "/**/*.{js,ts,jsx,tsx}"],
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export const isKeyInObject = <T extends object>(k: PropertyKey, o: T): k is keyof T => k in o;
|
|
@ -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");
|
|
@ -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;
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue
Block a user