first commit
This commit is contained in:
50
calcom/packages/features/flags/components/FlagAdminList.tsx
Normal file
50
calcom/packages/features/flags/components/FlagAdminList.tsx
Normal 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}
|
||||
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
16
calcom/packages/features/flags/config.ts
Normal file
16
calcom/packages/features/flags/config.ts
Normal 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;
|
||||
};
|
||||
56
calcom/packages/features/flags/context/provider.ts
Normal file
56
calcom/packages/features/flags/context/provider.ts
Normal 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);
|
||||
}
|
||||
12
calcom/packages/features/flags/hooks/index.ts
Normal file
12
calcom/packages/features/flags/hooks/index.ts
Normal 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 ?? {};
|
||||
}
|
||||
30
calcom/packages/features/flags/pages/flag-listing-view.tsx
Normal file
30
calcom/packages/features/flags/pages/flag-listing-view.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
8
calcom/packages/features/flags/server/procedures/map.ts
Normal file
8
calcom/packages/features/flags/server/procedures/map.ts
Normal 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);
|
||||
});
|
||||
15
calcom/packages/features/flags/server/router.ts
Normal file
15
calcom/packages/features/flags/server/router.ts
Normal 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,
|
||||
});
|
||||
66
calcom/packages/features/flags/server/utils.ts
Normal file
66
calcom/packages/features/flags/server/utils.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user