Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions src/FlagToggleOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ConfigValue } from "@prefab-cloud/prefab-cloud-js";
import { PrefabContext, ProvidedContext } from "./types";

function FlagToggleOverlay({ children }: { children: React.ReactNode | undefined }) {
const parentContext: ProvidedContext = useContext(PrefabContext);
const [showOverlay, setShowOverlay] = useState(false);
const [flags, setFlags] = useState<{ key: string; value: ConfigValue }[]>([]);
const [overrides, setOverrides] = useState<Map<string, ConfigValue>>(new Map());

// we can't keep track of usage in a state variable,
// because evaluation calls from child component render methods would trigger an infinite render loop
const usageRef = useRef(new Map<string, ConfigValue>());

const addOverride = (key: string, value: ConfigValue) => {
setOverrides((prevOverrides) => {
const newOverrides = new Map(prevOverrides);
newOverrides.set(key, value);
return newOverrides;
});
};

const clearFlags = () => {
usageRef.current.clear();
};

const contextValue = useMemo(
() => ({
...parentContext,
get: (key: string) => {
if (overrides.has(key)) {
const result = overrides.get(key);
usageRef.current.set(key, result);
return result;
}
const result = parentContext.get(key);
usageRef.current.set(key, result);
return result;
},
isEnabled: (key: string) => {
if (overrides.has(key)) {
const result = overrides.get(key) as boolean;
usageRef.current.set(key, result);
return result;
}
const result = parentContext.isEnabled(key);
usageRef.current.set(key, result);
return result;
},
}),
[parentContext, overrides]
);

// changes to the data in usageRef don't trigger a render,
// so we need to poll for changes
useEffect(() => {
if (showOverlay) {
const interval = setInterval(() => {
setFlags(Array.from(usageRef.current.entries()).map(([key, value]) => ({ key, value })));
}, 1000);

return () => clearInterval(interval);
}

return () => {};
}, [showOverlay]);

// setup keyboard shortcut listener to toggle overlay
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === "f") {
setShowOverlay((prev) => !prev);
}
};
window.addEventListener("keydown", handleKeyDown);

return () => window.removeEventListener("keydown", handleKeyDown);
}, []);

// setup listeners for page changes
useEffect(() => {
const handlePopState = () => {
clearFlags();
};

const patchHistoryMethods = () => {
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;

window.history.pushState = function (
state: any,
unused: string,
url?: string | URL | null | undefined
) {
const result = originalPushState.apply(window.history, [state, unused, url]);
window.dispatchEvent(new Event("pushState"));
return result;
};

window.history.replaceState = function (
state: any,
unused: string,
url?: string | URL | null | undefined
) {
const result = originalReplaceState.apply(window.history, [state, unused, url]);
window.dispatchEvent(new Event("replaceState"));
return result;
};

window.addEventListener("pushState", handlePopState);
window.addEventListener("replaceState", handlePopState);

return () => {
window.removeEventListener("pushState", handlePopState);
window.removeEventListener("replaceState", handlePopState);
window.history.pushState = originalPushState;
window.history.replaceState = originalReplaceState;
};
};

const unpatchHistoryMethods = patchHistoryMethods();

window.addEventListener("popstate", handlePopState);

return () => {
window.removeEventListener("popstate", handlePopState);
unpatchHistoryMethods();
};
}, []);

if (!showOverlay)
return <PrefabContext.Provider value={contextValue}>{children}</PrefabContext.Provider>;

return (
<PrefabContext.Provider value={contextValue}>
{children}
<div
className="flag-toggle-overlay fixed bg-white border-2 rounded-xl flex flex-col"
style={{
bottom: "48px",
right: "48px",
borderColor: "#4352D1",
boxShadow: "0px 0px 35px rgba(67,82,209,0.35)",
overflow: "hidden",
}}
>
<div className="p-4" style={{ backgroundColor: "#4352D1" }}>
<svg
className="h-10 w-auto"
width="703"
height="191"
viewBox="0 0 703 191"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_1_79)">
<path
d="M0 20.77H60.69C106.69 20.77 113.97 51.81 113.97 72.67C113.97 93.53 103.84 122.33 63.34 122.33H51.94V166.29H0V20.77ZM57.23 86.51C67 86.51 71.88 82.24 71.88 73.69V72.88C71.88 64.53 67.2 60.87 57.43 60.87H51.93V86.52H57.22L57.23 86.51Z"
fill="white"
/>
<path
d="M120.73 20.77H177.63C220.98 20.77 232.13 48.52 232.13 69.82C232.13 91.12 223.79 101.16 210.76 108.29L240.68 166.3H193.31L170.72 120.1H167.67V166.3H120.74V20.77H120.73ZM173.77 86.11C184.35 86.11 189.04 81.84 189.04 73.29V72.48C189.04 64.54 184.36 61.08 174.18 61.08H167.67V86.12H173.78L173.77 86.11Z"
fill="white"
/>
<path
d="M245.48 20.77H340.16V62.09H295.42V74.51H328.56V111.96H295.42V124.99H343.22V166.31H245.49V20.77H245.48Z"
fill="white"
/>
<path
d="M673.41 90.0499C689.49 86.5899 699.46 76.4099 699.46 58.4999V57.6899C699.46 38.1499 687.45 19.4299 649.19 19.4299H555.61C574.51 56.5899 585.07 113.24 592.13 164.96H646.96C686.65 164.96 702.53 149.9 702.53 124.46V123.65C702.53 102.89 689.5 92.3099 673.42 90.0699L673.41 90.0499ZM625.99 54.2299H636.57C645.32 54.2299 648.58 57.6899 648.58 64.1999V65.0099C648.58 71.5199 645.12 74.3699 636.57 74.3699H625.99V54.2199V54.2299ZM649.19 118.14C649.19 125.47 644.92 129.95 635.76 129.95H625.99V105.73H635.15C645.33 105.73 649.19 110 649.19 117.33V118.14Z"
fill="white"
/>
<path
d="M454.05 81.2999H408.95V60.9499H459.52C467.1 34.6799 475.41 19.4299 475.41 19.4299H354.61V164.96H408.95V120.59H445.74C450.02 97.0199 454.05 81.3099 454.05 81.3099V81.2999Z"
fill="white"
/>
<path
d="M515.48 0C473.24 0 453.85 155.27 450.01 188.47C449.82 190.09 451.92 190.86 452.84 189.52C461.3 177.22 484.27 143.7 515.48 143.7C546.69 143.7 569.66 177.21 578.12 189.52C579.04 190.86 581.14 190.08 580.95 188.47C577.11 155.27 557.72 0 515.48 0ZM531.59 81.56H499.38V70.87C499.38 66.98 502.54 63.82 506.43 63.82H524.55C528.44 63.82 531.6 66.98 531.6 70.87V81.56H531.59Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_1_79">
<rect width="702.51" height="190.21" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<div className="p-8 flex flex-col font-sm">
<div className="font-bold text-lg">Feature Flags</div>
<ul>
{flags.map(({ key, value }) => {
const actualValue = overrides.has(key) ? overrides.get(key) : value;

return (
<li key={key} className="flex gap-4 mt-4">
{typeof actualValue === "boolean" ? (
<label className="inline-flex items-center cursor-pointer gap-2">
<input
type="checkbox"
value=""
className="sr-only peer"
checked={
overrides.has(key) ? (overrides.get(key) as boolean) : (value as boolean)
}
onChange={() => {
addOverride(key, overrides.has(key) ? !overrides.get(key) : !value);
}}
/>
<div className="after:ml-0.5 relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span className="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">
{key}
</span>
</label>
) : (
<>
<strong>{key}</strong>
<em>{actualValue?.toString()}</em>
</>
)}
</li>
);
})}
</ul>
</div>
</div>
</PrefabContext.Provider>
);
}

export default FlagToggleOverlay;
27 changes: 4 additions & 23 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,9 @@
import React, { PropsWithChildren } from "react";
import { prefab, ConfigValue, Context } from "@prefab-cloud/prefab-cloud-js";
import version from "./version";

type ContextValue = number | string | boolean;
type ContextAttributes = { [key: string]: Record<string, ContextValue> };

type ProvidedContext = {
get: (key: string) => any;
contextAttributes: ContextAttributes;
isEnabled: (key: string) => boolean;
loading: boolean;
prefab: typeof prefab;
keys: string[];
};

const defaultContext: ProvidedContext = {
get: (_: string) => undefined,
isEnabled: (_: string) => false,
keys: [],
loading: true,
contextAttributes: {},
prefab,
};

const PrefabContext = React.createContext(defaultContext);
import FlagToggleOverlay from "./FlagToggleOverlay";
import { ContextAttributes, PrefabContext, ProvidedContext } from "./types";
import version from "./version";

const usePrefab = () => React.useContext(PrefabContext);

Expand Down Expand Up @@ -168,6 +148,7 @@ function PrefabTestProvider({ config, children }: PropsWithChildren<TestProps>)
export {
PrefabProvider,
PrefabTestProvider,
FlagToggleOverlay,
usePrefab,
TestProps,
Props,
Expand Down
26 changes: 26 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createContext } from "react";
import { prefab } from "@prefab-cloud/prefab-cloud-js";

type ContextValue = number | string | boolean;

export type ContextAttributes = { [key: string]: Record<string, ContextValue> };

export type ProvidedContext = {
get: (key: string) => any;
contextAttributes: ContextAttributes;
isEnabled: (key: string) => boolean;
loading: boolean;
prefab: typeof prefab;
keys: string[];
};

const defaultContext: ProvidedContext = {
get: (_: string) => undefined,
isEnabled: (_: string) => false,
keys: [],
loading: true,
contextAttributes: {},
prefab,
};

export const PrefabContext = createContext(defaultContext);