Skip to content
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

Merged
merged 21 commits into from
Mar 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/web/lib/app-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>
Expand Down
9 changes: 9 additions & 0 deletions apps/web/pages/settings/admin/flags.tsx
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;
10 changes: 9 additions & 1 deletion packages/emails/templates/_base-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>[];
Expand All @@ -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"));
}
Comment on lines +35 to +40
Copy link
Member Author

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

if (process.env.NEXT_PUBLIC_IS_E2E) {
global.E2E_EMAILS = global.E2E_EMAILS || [];
global.E2E_EMAILS.push(this.getNodeMailerPayload());
Expand Down
49 changes: 49 additions & 0 deletions packages/features/flags/components/FlagAdminList.tsx
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();
Copy link
Member Author

Choose a reason for hiding this comment

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

Choose a reason for hiding this comment

The 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 });
}}
/>
);
};
11 changes: 11 additions & 0 deletions packages/features/flags/config.ts
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 = {
Copy link
Member Author

Choose a reason for hiding this comment

The 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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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

};
56 changes: 56 additions & 0 deletions packages/features/flags/context/provider.ts
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);
Copy link
Member Author

Choose a reason for hiding this comment

The 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);
}
6 changes: 6 additions & 0 deletions packages/features/flags/hooks/index.ts
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;
}
17 changes: 17 additions & 0 deletions packages/features/flags/pages/flag-listing-view.tsx
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." />
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suspense!

</>
);
};
28 changes: 28 additions & 0 deletions packages/features/flags/server/router.ts
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
Copy link
Member Author

Choose a reason for hiding this comment

The 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
Copy link
Member Author

Choose a reason for hiding this comment

The 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 },
});
}),
});
13 changes: 13 additions & 0 deletions packages/features/flags/server/utils.ts
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) {
Copy link
Member Author

Choose a reason for hiding this comment

The 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);
}
1 change: 1 addition & 0 deletions packages/features/settings/layouts/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
40 changes: 22 additions & 18 deletions packages/features/shell/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,46 +23,47 @@ 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";
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,
FiDownload,
FiExternalLink,
FiFileText,
FiGrid,
FiHelpCircle,
FiLink,
FiLogOut,
FiMap,
FiMoon,
FiMoreHorizontal,
FiFileText,
FiZap,
FiMoreVertical,
FiSettings,
FiBarChart,
FiArrowRight,
FiArrowLeft,
FiSlack,
FiUsers,
FiZap
} from "@calcom/ui/components/icon";

import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider";
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/features/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const base = require("@calcom/config/tailwind-preset");
Copy link
Member Author

Choose a reason for hiding this comment

The 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}"],
};
1 change: 1 addition & 0 deletions packages/lib/isKeyInObject.ts
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;
Copy link
Member Author

Choose a reason for hiding this comment

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