Skip to content

Commit

Permalink
WIP v5
Browse files Browse the repository at this point in the history
  • Loading branch information
garbles committed Apr 7, 2022
1 parent a641486 commit f4a5dd2
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 57 deletions.
6 changes: 3 additions & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
preset: "ts-jest",
testEnvironment: "jsdom",
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@types/node": "^17.0.23",
"jest": "^27.5.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"ts-jest": "^27.1.4",
"typescript": "^4.6.3"
},
Expand Down
148 changes: 140 additions & 8 deletions src/__test__/create-flags.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { render } from "@testing-library/react";
import React from "react";
import { render, screen } from "@testing-library/react";
import { createFlags } from "../create-flags";
import { Backend } from "../types";

type Flags = {
a: boolean;
b: boolean;
a: number;
b: string;
c: boolean;
d: boolean;
e: {
Expand All @@ -15,11 +17,141 @@ type Flags = {
i: number;
};

test("mounts", () => {
const { useFlag, FlagsProvider } = createFlags<Flags>();
const { useFlag, FlagsProvider } = createFlags<Flags>();

const App = () => {
const b = useFlag(["b"]);
const g = useFlag(["e", "f", "g"]);
const AppWithoutContext = (props: { a: number; b: string; g: boolean }) => {
const a = useFlag("a", props.a);
const b = useFlag(["b"], props.b);
const g = useFlag(["e", "f", "g"], props.g);

return <div role="main">{JSON.stringify({ a, b, g })}</div>;
};

const App = (props: { backend: Backend<Flags>; defaults: { a: number; b: string; g: boolean } }) => {
const defaults = props.defaults ?? {};

return (
<FlagsProvider backend={props.backend}>
<AppWithoutContext {...defaults} />
</FlagsProvider>
);
};

const getData = () => JSON.parse(screen.getByRole("main").textContent || "");

const silenceConsole = () => {
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
return () => spy.mockRestore();
};

test("when the background always returns the same thing", () => {
const data: any = {
a: 0,
b: "goodbye",
e: {
f: {
g: true,
},
},
};

const staticBackground: Backend<Flags> = {
name: "static",
get(keys: any) {
const [first, ...rest] = keys;
let result = data[first];

for (const key of rest) {
result = result[key];
}

return result;
},
};

render(<App backend={staticBackground} defaults={{ a: 0, b: "", g: false }} />);

expect(getData()).toEqual({ a: 0, b: "goodbye", g: true });
});

test("works with a backend that does nothing", () => {
const nullBackground: Backend<Flags> = {
name: "null",
get() {
return null;
},
};

const defaults = { a: 1, b: "hello", g: false };

render(<App backend={nullBackground} defaults={defaults} />);

expect(getData()).toEqual(defaults);
});

test("throws with a context", () => {
const restore = silenceConsole();

expect(() => render(<AppWithoutContext {...{ a: 2, b: "", g: false }} />)).toThrowError(
new Error('Calling `useFlag("a", 2)` requires that the application is wrapped in a `<FlagsProvider />`')
);

restore();
});

test("throws when you don't provide a default value", () => {
const restore = silenceConsole();

const nullBackground: Backend<Flags> = {
name: "null",
get() {
return null;
},
};

expect(() =>
render(
<App
backend={nullBackground}
// @ts-expect-error
defaults={{}}
/>
)
).toThrowError(
new Error('Calling `useFlag("a", undefined)` requires that you provide a default value that matches the type of the flag.')
);

restore();
});

test("throws when the flag won't be a scalar", () => {
const restore = silenceConsole();

const whoopsieBackground: Backend<Flags> = {
name: "whoopsie",
// @ts-expect-error
get() {
return { oof: 10 };
},
};

expect(() => render(<App backend={whoopsieBackground} defaults={{ a: 3, b: "", g: false }} />)).toThrowError(
new Error('Calling `useFlag("a", 3)` requires that the result is a boolean, number or string. Instead returned {"oof":10}.')
);

restore();
});

test("returns the default value if the return type doesn't match the default value type", () => {
const whoopsieBackground: Backend<Flags> = {
name: "whoopsie",
// @ts-expect-error
get() {
return "whoopsie";
},
};

render(<App backend={whoopsieBackground} defaults={{ a: 4, b: "", g: false }} />);

expect(getData()).toEqual({ a: 4, b: "whoopsie", g: false });
});
21 changes: 11 additions & 10 deletions src/__test__/types.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { KeyPaths, ShallowKeys } from "../types";
import { createFlags } from "../create-flags";
import { KeyPaths, ShallowKeys } from "../types";

type Something = {
a: boolean;
Expand Down Expand Up @@ -42,9 +42,7 @@ it("useFlag", () => {
const { useFlag } = createFlags<Something>();

function App() {
useFlag("a");

useFlag(["a"]);
useFlag("a", false);

useFlag(["a"], false);

Expand Down Expand Up @@ -77,25 +75,28 @@ it("useFlag", () => {
it("Flag", () => {
const { Flag } = createFlags<Something>();

<Flag keyPath={"a"} render={(a) => <div>{a === true}</div>} />;
<Flag keyPath={["a"]} defaultValue={false} render={(a) => <div>{a === true}</div>} />;

// @ts-expect-error
<Flag keyPath={"a"} render={(a) => <div>{a === "1"}</div>} />;
<Flag keyPath={["a"]} defaultValue={false} render={(a) => <div>{a === "1"}</div>} />;

// @ts-expect-error
<Flag keyPath={["a"]} render={(a) => <div>{a === "1"}</div>} />;
<Flag keyPath={["a"]} defaultValue={false} render={(a) => <div>{a === "1"}</div>} />;

// @ts-expect-error
<Flag keyPath={"b"} render={(b) => null} />;
<Flag keyPath={["b"]} render={(b) => null} />;

// @ts-expect-error
<Flag keyPath={["b"]} render={(b) => null} />;

// @ts-expect-error
<Flag keyPath={["b", "c"]} render={(c) => null} />;

<Flag keyPath={["b", "c", "e"]} render={(d) => <div>{d === "haha"}</div>} />;
<Flag keyPath={["b", "c", "e"]} defaultValue={"1"} render={(d) => <div>{d === "haha"}</div>} />;

// @ts-expect-error
<Flag keyPath={["b", "c", "e"]} defaultValue={"2"} render={(d) => <div>{d === true}</div>} />;

// @ts-expect-error
<Flag keyPath={["b", "c", "e"]} render={(d) => <div>{d === true}</div>} />;
<Flag keyPath={["b", "c", "e"]} defaultValue={false} render={(d) => <div>{d === "true"}</div>} />;
});
89 changes: 59 additions & 30 deletions src/create-flags.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import React from "react";
import { Backend, Flags, KeyPaths, GetValueFromKeyPath, ShallowKeys } from "./types";
import { Backend, Flags, FlagScalar, GetValueFromKeyPath, KeyPaths, ShallowKeys } from "./types";

const MISSING_CONTEXT = Symbol();
const NOOP = () => null;

const isScalar = (value: any): value is string | number | boolean => {
const type = typeof value;
return type === "string" || type === "number" || type === "boolean";
const isFlagScalar = (value: any): value is FlagScalar => {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
};

export const createFlags = <F extends Flags>() => {
Expand All @@ -18,64 +17,94 @@ export const createFlags = <F extends Flags>() => {

type ShallowFlagProps<K extends ShallowKeys<F>> = {
keyPath: K;
defaultValue: F[K];
render(value: F[K]): React.ReactNode;
fallback?(): React.ReactNode;
};

type KeyPathFlagProps<KP extends KeyPaths<F>> = {
keyPath: KP;
defaultValue: GetValueFromKeyPath<F, KP>;
render(value: GetValueFromKeyPath<F, KP>): React.ReactNode;
fallback?(): React.ReactNode;
};

const calleeStr = (keyPath: string[], defaultValue: any, format: "hook" | "component") => () => {
const keyPathStr = JSON.stringify(keyPath);
const defaultValueStr = JSON.stringify(defaultValue);

return format == "hook"
? `useFlag(${keyPathStr}, ${defaultValueStr})`
: `<Flag keyPath=${keyPathStr} defaultValue=${defaultValueStr} ... />`;
};

const Context = React.createContext<B | typeof MISSING_CONTEXT>(MISSING_CONTEXT);
Context.displayName = "Flag";

const FlagsProvider: React.FC<ProviderProps> = ({ backend, children }) => {
return <Context.Provider value={backend}>{children}</Context.Provider>;
};

function Flag<K extends ShallowKeys<F>>(props: ShallowFlagProps<K>): JSX.Element;
function Flag<KP extends KeyPaths<F>>(props: KeyPathFlagProps<KP>): JSX.Element;
function Flag({ keyPath, render, fallback }: any): JSX.Element {
fallback ??= NOOP;
const internalUseFlag = (keyPath: string | string[], defaultValue: any, displayCallee: () => string) => {
const keyPath_ = (Array.isArray(keyPath) ? keyPath : [keyPath]) as KeyPaths<F>;

const flag = useFlag(keyPath);
return flag ? render(flag) : fallback();
}
if (defaultValue === undefined) {
throw new Error(`Calling \`${displayCallee()}\` requires that you provide a default value that matches the type of the flag.`);
}

function useFlag<K extends ShallowKeys<F>>(keyPath: K, defaultValue?: F[K]): F[K];
function useFlag<KP extends KeyPaths<F>>(keyPath: KP, defaultValue?: GetValueFromKeyPath<F, KP>): GetValueFromKeyPath<F, KP>;
function useFlag(keyPath: string | string[], defaultValue: any) {
const keyPath_ = (Array.isArray(keyPath) ? keyPath : [keyPath]) as KeyPaths<F>;
const expectedType = typeof defaultValue;

const backend = React.useContext(Context);

if (backend === MISSING_CONTEXT) {
throw new Error("Calling `useFlag()`, or `<Flag />` requires that the application is wrapped in a `<FlagsProvider />`");
throw new Error(`Calling \`${displayCallee()}\` requires that the application is wrapped in a \`<FlagsProvider />\``);
}

if (backend.has(keyPath_)) {
const result = backend.get(keyPath_) as GetValueFromKeyPath<F, any>;
let result = backend.get(keyPath_);

if (!isScalar(result)) {
throw new Error(`Flag "${keyPath_.join(".")}" is not a boolean, number or string`);
}
if (result === undefined && process.env.NODE_ENV === "development") {
console.warn(`\`${displayCallee()}\` does not return anything from backend "${backend.name}".`);
}

return result;
} else {
if (defaultValue === undefined) {
throw new Error(
`Flag "${keyPath_.join(".")}" is missing from backend "${backend.name}" and does not have an assigned default value`
);
}
result ??= defaultValue;

if (process.env.NODE_ENV !== "production") {
console.warn(`Flag "${keyPath_.join(".")}" is missing from backend "${backend.name}"`);
if (!isFlagScalar(result)) {
throw new Error(
`Calling \`${displayCallee()}\` requires that the result is a boolean, number or string. Instead returned ${JSON.stringify(
result
)}.`
);
}

if (typeof result !== expectedType) {
if (process.env.NODE_ENV === "development") {
console.warn(
`Expected result of \`${displayCallee()}\` to be a ${expectedType} (based on the default value of ${JSON.stringify(
defaultValue
)}). Instead returned ${JSON.stringify(result)}. Falling back to default value.`
);
}

return defaultValue;
}

return result;
};

function Flag<K extends ShallowKeys<F>>(props: ShallowFlagProps<K>): JSX.Element;
function Flag<KP extends KeyPaths<F>>(props: KeyPathFlagProps<KP>): JSX.Element;
function Flag({ keyPath, defaultValue, render, fallback }: any): JSX.Element {
fallback ??= NOOP;

const flag = internalUseFlag(keyPath, defaultValue, calleeStr(keyPath, defaultValue, "component"));

return flag === false ? fallback() : render(flag);
}

function useFlag<K extends ShallowKeys<F>>(keyPath: K, defaultValue: F[K]): F[K];
function useFlag<KP extends KeyPaths<F>>(keyPath: KP, defaultValue: GetValueFromKeyPath<F, KP>): GetValueFromKeyPath<F, KP>;
function useFlag(keyPath: any, defaultValue: any) {
return internalUseFlag(keyPath, defaultValue, calleeStr(keyPath, defaultValue, "hook"));
}

return {
Expand Down
13 changes: 8 additions & 5 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
type Scalar = string | number | boolean;
type FlagScalar = string | number | boolean;

export type Flags = Record<string, Scalar | Flags>;
export type Flags = Record<string, FlagScalar | Flags>;

export type KeyPaths<T> = {
[Key in keyof T & string]: T[Key] extends object ? [Key, ...KeyPaths<T[Key]>] : [Key];
Expand All @@ -10,10 +10,13 @@ export type ShallowKeys<T> = {
[Key in keyof T & string]: T[Key] extends object ? never : Key;
}[keyof T & string];

export type GetValueFromKeyPath<T, KP extends KeyPaths<T>> = KP extends [infer K, ...infer Rest] ? GetValueFromKeyPath<T[K], Rest> : T;
export type GetValueFromKeyPath<T, KP extends KeyPaths<T>> = KP extends [infer K, ...infer Rest]
? GetValueFromKeyPath<T[K], Rest>
: T extends FlagScalar
? T
: never;

export type Backend<F> = {
name: string;
has<KP extends KeyPaths<F>>(keyPath: KP): boolean;
get<KP extends KeyPaths<F>>(keyPath: KP): KeyPathValue<F, KP>;
get<KP extends KeyPaths<F>>(keyPath: KP): GetValueFromKeyPath<F, KP> | null;
};
Loading

0 comments on commit f4a5dd2

Please sign in to comment.