2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge, List, ListItem, ListItemText, ListItemTitle, Switch, showToast } 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?.replace("_", " ")}</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.useUtils();
const mutation = trpc.viewer.admin.toggleFeatureFlag.useMutation({
onSuccess: () => {
showToast("Flags successfully updated", "success");
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,16 @@
/**
* Right now we only support boolean flags.
* Maybe later on we can add string variants or numeric ones
**/
export type AppFlags = {
"calendar-cache": boolean;
emails: boolean;
insights: boolean;
teams: boolean;
webhooks: boolean;
workflows: boolean;
organizations: boolean;
"email-verification": boolean;
"google-workspace-directory": boolean;
"disable-signup": boolean;
};

View File

@@ -0,0 +1,56 @@
import { createContext, useContext, createElement } 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 = Partial<AppFlags>;
/**
* Allows you to access the flags from context
*/
const FeatureContext = 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 = 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 createElement(FeatureContext.Provider, { value: props.value }, props.children);
}

View File

@@ -0,0 +1,12 @@
import type { AppFlags } from "@calcom/features/flags/config";
import { trpc } from "@calcom/trpc/react";
const initialData: Partial<AppFlags> = process.env.NEXT_PUBLIC_IS_E2E
? { organizations: true, teams: true }
: {};
export function useFlags(): Partial<AppFlags> {
const query = trpc.viewer.features.map.useQuery(undefined, {
initialData,
});
return query.data ?? {};
}

View File

@@ -0,0 +1,30 @@
import { Suspense } from "react";
import NoSSR from "@calcom/core/components/NoSSR";
import { Meta, SkeletonText, SkeletonContainer } from "@calcom/ui";
import { FlagAdminList } from "../components/FlagAdminList";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="divide-subtle mb-8 mt-6 space-y-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
export const FlagListingView = () => {
return (
<>
<Meta title="Feature Flags" description="Here you can toggle your Cal.com instance features." />
<NoSSR>
<Suspense fallback={<SkeletonLoader />}>
<FlagAdminList />
</Suspense>
</NoSSR>
</>
);
};

View File

@@ -0,0 +1,8 @@
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { getFeatureFlagMap } from "../utils";
export const map = publicProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
return getFeatureFlagMap(prisma);
});

View File

@@ -0,0 +1,15 @@
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { map } from "./procedures/map";
export const featureFlagRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
return prisma.feature.findMany({
orderBy: { slug: "asc" },
cacheStrategy: { swr: 300, ttl: 300 },
});
}),
map,
});

View File

@@ -0,0 +1,66 @@
import type { PrismaClient } from "@calcom/prisma";
import type { AppFlags } from "../config";
export async function getFeatureFlagMap(prisma: PrismaClient) {
const flags = await prisma.feature.findMany({
orderBy: { slug: "asc" },
cacheStrategy: { swr: 300, ttl: 300 },
});
return flags.reduce((acc, flag) => {
acc[flag.slug as keyof AppFlags] = flag.enabled;
return acc;
}, {} as Partial<AppFlags>);
}
interface CacheEntry {
value: boolean; // adapt to other supported value types in the future
expiry: number;
}
interface CacheOptions {
ttl: number; // time in ms
}
const featureFlagCache = new Map<keyof AppFlags, CacheEntry>();
const isExpired = (entry: CacheEntry): boolean => {
return Date.now() > entry.expiry;
};
export const getFeatureFlag = async (
prisma: PrismaClient,
slug: keyof AppFlags,
options: CacheOptions = { ttl: 5 * 60 * 1000 }
): Promise<boolean> => {
// pre-compute all app flags, each one will independelty reload it's own state after expiry.
if (featureFlagCache.size === 0) {
const flags = await prisma.feature.findMany({ orderBy: { slug: "asc" } });
flags.forEach((flag) => {
featureFlagCache.set(flag.slug as keyof AppFlags, {
value: flag.enabled,
expiry: Date.now() + options.ttl,
});
});
}
const cacheEntry = featureFlagCache.get(slug);
if (cacheEntry && !isExpired(cacheEntry)) {
return cacheEntry.value;
}
const flag = await prisma.feature.findUnique({
where: {
slug,
},
});
const isEnabled = Boolean(flag && flag.enabled);
const expiry = Date.now() + options.ttl;
featureFlagCache.set(slug, { value: isEnabled, expiry });
return isEnabled;
};