@arnosaine/is
Version:
Feature Flags, Roles and Permissions-based rendering, A/B Testing, Experimental Features, and more in React.
170 lines (140 loc) • 4.3 kB
text/typescript
import type * as RemixNode from "@remix-run/node";
import type * as RemixReact from "@remix-run/react";
import { curry } from "lodash-es";
import { ReactNode } from "react";
import type * as ReactRouter from "react-router";
import { useMatches, useRouteLoaderData } from "react-router";
import {
Boolean,
Flatten,
HasUnknownKeys,
Merge,
Never,
NonBoolean,
Unflatten,
Writeable,
} from "./utils.js";
export * from "./utils.js";
// Workaround for typing unknown property (condition) names, e.g.
// role names as boolean props.
type HandleUnknownKeys<Conditions> = HasUnknownKeys<Conditions> extends true
? any // Has unknown properties
: Conditions; // Only known properties
export interface ElementProps {
children?: ReactNode;
fallback?: ReactNode;
}
export type Condition<Value> =
| Flatten<Boolean<Value> | NonBoolean<Writeable<Value>>>
| NonBoolean<Flatten<Value>>[]
| Unflatten<NonBoolean<Writeable<Value>>>;
export type Conditions<Values> = Partial<{
[P in keyof Values]: Readonly<Condition<Values[P]>>;
}> &
Never<ElementProps>;
export type Values<Value = unknown> = {
[key: string]: Value;
} & Never<ElementProps>;
export type Loader<Values> = (
args: ExtendedDataFunctionArgs
) => Values | Promise<Values>;
export type DataFunctionArgs =
| ReactRouter.ActionFunctionArgs
| ReactRouter.LoaderFunctionArgs
| ReactRouter.ClientActionFunctionArgs
| ReactRouter.ClientLoaderFunctionArgs
| RemixNode.ActionFunctionArgs
| RemixNode.LoaderFunctionArgs
| RemixReact.ClientActionFunctionArgs
| RemixReact.ClientLoaderFunctionArgs;
export type ExtendedDataFunctionArgs = DataFunctionArgs & {
serverAction: any;
serverLoader: any;
context: any;
};
export interface Options {
method?: "every" | "some";
}
export interface LoaderOptions extends Options {
routeId?: string;
prop?: string;
}
export function __create<V extends Values, C extends Conditions<V>>(
useValues: () => V,
defaultConditions?: C,
options: Options = {}
) {
const { method = "some" } = options;
// Curried comparison function
const is = curry((values: V, conditions: C | undefined) =>
Object.entries({
...defaultConditions,
...conditions,
})
.filter(([, condition]) => typeof condition !== "undefined")
.every(([name, condition]) => {
const value = values[name as keyof typeof values];
if (Array.isArray(value)) {
if (Array.isArray(condition)) {
return condition[method]((cond) => value.includes(cond));
}
return value.includes(condition);
}
if (Array.isArray(condition)) {
return condition.includes(value);
}
if (typeof value === "boolean") {
return value === Boolean(condition);
} else {
return value === condition;
}
})
);
// Hook
const useIs = (conditions?: C) => is(useValues(), conditions);
// Component
const Is = ({
children = null,
fallback = null,
...conditions
}: Partial<Merge<ElementProps, HandleUnknownKeys<C>>>) =>
useIs(conditions as C) ? children : fallback;
return { Is, useIs, is };
}
export function create<V extends Values>(
useValues: () => V,
defaultConditions?: Conditions<V>,
options: Options = {}
) {
const { Is, useIs } = __create(useValues, defaultConditions, options);
return [Is, useIs] as const;
}
export function createFromLoader<V extends Values>(
loadValues: Loader<V>,
defaultConditions?: Conditions<V>,
options: LoaderOptions = {}
) {
const { prop = "__is_values" } = options;
// The hook and the component get values from the root loader
const useValues = () => {
const root = useMatches()[0];
const { routeId = root?.id ?? "root" } = options;
const routeLoaderData = useRouteLoaderData(routeId);
return (
// Deprecated
routeLoaderData?.__is ??
//
routeLoaderData?.[prop] ??
{}
);
};
const { Is, useIs, is } = __create(useValues, defaultConditions, options);
async function loadIs(args: DataFunctionArgs) {
const values = await loadValues(args as any);
return Object.assign(is(values), {
__values: values, // Deprecated
[prop]: values,
});
}
return [Is, useIs, loadIs] as const;
}