22 |
23 | ## Why
24 |
25 | Most web applications usually need to support and function within a variety of distinct environments: local, development, staging, production, on-prem, etc. This project aims to provide flexibility to React applications by making certain properties configurable at runtime, allowing the app to be customized based on a pre-determined configmap respective to the environment. This is especially powerful when combined with [Kubernetes configmaps](https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/).
26 |
27 | Here are examples of some real-world values that can be helpful when configurable at runtime:
28 |
29 | - Primary Color
30 | - Backend API URL
31 | - Feature Flags
32 | - …
33 |
34 | ## How
35 |
36 | The configuration can be set by _either_:
37 |
38 | - setting a configuration property on `window` with reasonable defaults. Consider,
39 |
40 | ```js
41 | window.MY_APP_CONFIG = {
42 | primaryColor: "green",
43 | };
44 | ```
45 |
46 | - _or_ by setting a value in `localStorage`. Consider,
47 |
48 | ```js
49 | localStorage.setItem("MY_APP_CONFIG.primaryColor", "green");
50 | ```
51 |
52 | The `localStorage` option could provide a nice delineation between environments: you _could_ set your local environment to green, and staging to red for example, in order to never be confused about what you're looking at when developing locally and testing against a deployed development environment: if it's green, it's local.
53 |
54 | This configuration is then easily read by the simple React hook that this library exports.
55 |
56 | ## Getting started
57 |
58 | 1. `npm i react-runtime-config`
59 | 1. Create a namespace for your config:
60 |
61 | ```tsx
62 | // components/Config.tsx
63 | import createConfig from "react-runtime-config";
64 |
65 | /**
66 | * `useConfig` and `useAdminConfig` are now React hooks that you can use in your app.
67 | *
68 | * `useConfig` provides config getter & setter, `useAdminConfig` provides data in order
69 | * to visualize your config map with ease. More on this further down.
70 | */
71 | export const { useConfig, useAdminConfig } = createConfig({
72 | namespace: "MY_APP_CONFIG",
73 | schema: {
74 | color: {
75 | type: "string",
76 | enum: ["blue" as const, "green" as const, "pink" as const], // `as const` is required to have nice autocompletion
77 | description: "Main color of the application",
78 | },
79 | backend: {
80 | type: "string",
81 | description: "Backend url", // config without `default` need to be provided into `window.MY_APP_CONFIG`
82 | },
83 | port: {
84 | type: "number", // This schema can be retrieved after in `useAdminConfig().fields`
85 | description: "Backend port",
86 | min: 1,
87 | max: 65535,
88 | default: 8000, // config with `default` don't have to be set on `window.MY_APP_CONFIG`
89 | },
90 | monitoringLink: {
91 | type: "custom",
92 | description: "Link of the monitoring",
93 | parser: value => {
94 | if (typeof value === "object" && typeof value.url === "string" && typeof value.displayName === "string") {
95 | // The type will be inferred from the return type
96 | return { url: value.url as string, displayName: value.displayName as string };
97 | }
98 | // This error will be shown if the `window.MY_APP_CONFIG.monitoringLink` can't be parsed or if we `setConfig` an invalid value
99 | throw new Error("Monitoring link invalid!");
100 | },
101 | },
102 | isLive: {
103 | type: "boolean",
104 | default: false,
105 | },
106 | },
107 | });
108 | ```
109 |
110 | You can now use the created hooks everywhere in your application. Thoses hooks are totally typesafe, connected to your configuration. This means that you can easily track down all your configuration usage across your entire application and have autocompletion on the keys.
111 |
112 | ### Usage
113 |
114 | ```tsx
115 | // components/MyComponents.tsx
116 | import react from "React";
117 | import { useConfig } from "./Config";
118 |
119 | const MyComponent = () => {
120 | const { getConfig } = useConfig();
121 |
122 | return
My title
;
123 | };
124 | ```
125 |
126 | The title will have a different color regarding our current environment.
127 |
128 | The priority of config values is as follows:
129 |
130 | - `localStorage.getItem("MY_APP_CONFIG.color")`
131 | - `window.MY_APP_CONFIG.color`
132 | - `schema.color.default`
133 |
134 | ## Namespaced `useConfig` hook
135 |
136 | In a large application, you may have multiple instances of `useConfig` from different `createConfig`. So far every `useConfig` will return a set of `getConfig`, `setConfig` and `getAllConfig`.
137 |
138 | To avoid any confusion or having to manually rename every usage of `useConfig` in a large application, you can use the `configNamespace` options.
139 |
140 | ```ts
141 | // themeConfig.ts
142 | export const { useConfig: useThemeConfig } = createConfig({
143 | namespace: "theme",
144 | schema: {},
145 | configNamespace: "theme", // <- here
146 | });
147 |
148 | // apiConfig.ts
149 | export const { useConfig: useApiConfig } = createConfig({
150 | namespace: "api",
151 | schema: {},
152 | configNamespace: "api", // <- here
153 | });
154 |
155 | // App.ts
156 | import { useThemeConfig } from "./themeConfig";
157 | import { useApiConfig } from "./apiConfig";
158 |
159 | export const App = () => {
160 | // All methods are now namespaces
161 | // no more name conflicts :)
162 | const { getThemeConfig } = useThemeConfig();
163 | const { getApiConfig } = useApiConfig();
164 |
165 | return ;
166 | };
167 | ```
168 |
169 | ## Create an Administration Page
170 |
171 | To allow easy management of your configuration, we provide a smart react hook called `useAdminConfig` that provides all the data that you need in order to assemble an awesome administration page where the configuration of your app can be referenced and managed.
172 |
173 | **Note:** we are using [`@operational/components`](https://github.com/contiamo/operational-components) for this example, but a UI of config values _can_ be assembled with any UI library, or even with plain ole HTML-tag JSX.
174 |
175 | ```ts
176 | // pages/ConfigurationPage.tsx
177 | import { Page, Card, Input, Button, Checkbox } from "@operational/components";
178 | import { useAdminConfig } from "./components/Config";
179 |
180 | export default () => {
181 | const { fields, reset } = useAdminConfig();
182 |
183 | return (
184 |
185 |
186 | {fields.map(field =>
187 | field.type === "boolean" ? (
188 |
189 | ) : (
190 |
191 | ),
192 | )}
193 |
194 |
195 |
196 | );
197 | };
198 | ```
199 |
200 | You have also access to `field.windowValue` and `field.storageValue` if you want implement more advanced UX on this page.
201 |
202 | ## Multiconfiguration admin page
203 |
204 | As soon as you have more than one configuration in your project, you might want to merge all thoses configurations in one administration page. Of course, you will want a kind of `ConfigSection` component that take the result of any `useAdminConfig()` (so `field`, `reset` and `namespace` as props).
205 |
206 | Spoiler alert, having this kind of component type safe can be tricky, indeed you can try use `ReturnType | ReturnType` as props but typescript will fight you (`Array.map` will tell you that the signature are not compatible).
207 |
208 | Anyway, long story short, this library provide you an easy way to with this: `GenericAdminFields` type. This type is compatible with every configuration and will provide you a nice framework to create an amazing UX.
209 |
210 | ```tsx
211 | import { GenericAdminFields } from "react-runtime-config";
212 |
213 | export interface ConfigSectionProps {
214 | fields: GenericAdminFields;
215 | namespace: string;
216 | reset: () => void;
217 | }
218 |
219 | export const ConfigSection = ({ namespace, fields }: ConfigSectionProps) => {
220 | return (
221 |
222 | {fields.map(f => {
223 | if (f.type === "string" && !f.enum) {
224 | return ;
225 | }
226 | if (f.type === "number") {
227 | return ;
228 | }
229 | if (f.type === "boolean") {
230 | return ;
231 | }
232 | if (f.type === "string" && f.enum) {
233 | // `f.set` can take `any` but you still have runtime validation if a wrong value is provided.
234 | return ;
235 | }
236 | if (f.type === "custom") {
237 | /* Add some special handler/typeguard to retrieve the safety */
238 | }
239 | })}
240 |
241 | );
242 | };
243 | ```
244 |
245 | PS: If you have a better idea/pattern, please open an issue to tell me about it 😃
246 |
247 | ## Moar Power (if needed)
248 |
249 | We also expose from `createConfig` a simple `getConfig`, `getAllConfig` and `setConfig`. These functions can be used standalone and do not require use of the `useConfig` react hooks. This can be useful for accessing or mutating configuration values in component lifecycle hooks, or anywhere else outside of render.
250 |
251 | These functions are exactly the same as their counterparts available inside the `useConfig` react hook, the only thing you lose is the hot config reload.
252 |
--------------------------------------------------------------------------------
/assets/react-runtime-config-logo.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contiamo/react-runtime-config/99a30b689b0539948c9ae0dfb44dd49dad944a21/assets/react-runtime-config-logo.ai
--------------------------------------------------------------------------------
/assets/react-runtime-config-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contiamo/react-runtime-config/99a30b689b0539948c9ae0dfb44dd49dad944a21/assets/react-runtime-config-logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-runtime-config",
3 | "version": "3.0.2",
4 | "description": "Provide a typesafe runtime configuration inside a react app",
5 | "repository": {
6 | "type": "git",
7 | "url": "git@github.com:contiamo/react-runtime-config.git"
8 | },
9 | "main": "lib/index.js",
10 | "typings": "lib/index.d.ts",
11 | "files": [
12 | "lib"
13 | ],
14 | "scripts": {
15 | "start": "jest --watch",
16 | "test": "jest",
17 | "build": "tsc -p tsconfig.package.json",
18 | "prepublishOnly": "yarn test --ci && yarn build",
19 | "format": "eslint src/*.{ts,tsx} --fix && prettier src/*.{ts,tsx,json} --write"
20 | },
21 | "keywords": [
22 | "typescript",
23 | "react",
24 | "config",
25 | "configuration",
26 | "runtime"
27 | ],
28 | "author": "Fabien Bernard ",
29 | "license": "MIT",
30 | "devDependencies": {
31 | "@testing-library/react-hooks": "^5.1.1",
32 | "@types/jest": "^26.0.22",
33 | "@types/lodash": "^4.14.168",
34 | "@types/react": "^16.14.4",
35 | "@typescript-eslint/eslint-plugin": "^4.22.0",
36 | "@typescript-eslint/parser": "^4.22.0",
37 | "eslint": "^7.24.0",
38 | "eslint-config-prettier": "^8.1.0",
39 | "eslint-plugin-prettier": "^3.3.1",
40 | "husky": "^6.0.0",
41 | "jest": "^26.6.3",
42 | "prettier": "^2.2.1",
43 | "pretty-quick": "^3.1.0",
44 | "react": "^17.0.2",
45 | "react-dom": "^17.0.2",
46 | "react-test-renderer": "^17.0.2",
47 | "ts-jest": "^26.5.3",
48 | "ts-mockery": "^1.2.0",
49 | "typescript": "^4.2.4"
50 | },
51 | "dependencies": {
52 | "lodash": "^4.17.21"
53 | },
54 | "peerDependencies": {
55 | "react": ">=16"
56 | },
57 | "jest": {
58 | "preset": "ts-jest",
59 | "testEnvironment": "jsdom",
60 | "testMatch": [
61 | "**/*.test.ts"
62 | ]
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/createUseAdminConfig.ts:
--------------------------------------------------------------------------------
1 | import { InjectedProps, Config, ResolvedConfigValue, AdminFields } from "./types";
2 | import { useCallback, useMemo } from "react";
3 | import { useWatchLocalStorageEvents } from "./utils";
4 |
5 | export function createUseAdminConfig, TNamespace extends string>(
6 | props: InjectedProps,
7 | ) {
8 | return () => {
9 | const localStorageDependency = useWatchLocalStorageEvents(props.storage, props.localOverride);
10 |
11 | const configKeys: (keyof TSchema)[] = useMemo(() => Object.keys(props.schema), [props.schema]);
12 |
13 | const fields = useMemo(() => {
14 | return configKeys.map(key => ({
15 | key,
16 | path: `${props.namespace}.${key}`,
17 | ...props.schema[key],
18 | windowValue: props.getWindowValue(key),
19 | storageValue: props.getStorageValue(key),
20 | isFromStorage: props.getStorageValue(key) !== null,
21 | value: props.getConfig(key),
22 | set: (value: ResolvedConfigValue) => props.setConfig(key, value),
23 | })) as AdminFields;
24 | }, [localStorageDependency, configKeys]);
25 |
26 | const reset = useCallback(() => {
27 | configKeys.forEach(path => {
28 | props.storage.removeItem(`${props.namespace}.${path}`);
29 | });
30 | window.dispatchEvent(new Event("storage"));
31 | }, [configKeys, props.namespace]);
32 |
33 | return {
34 | /**
35 | * List of all config values
36 | */
37 | fields,
38 |
39 | /**
40 | * Reset the store
41 | */
42 | reset,
43 |
44 | /**
45 | * Namespace
46 | */
47 | namespace: props.namespace,
48 | };
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/src/createUseConfig.ts:
--------------------------------------------------------------------------------
1 | import { Config, InjectedProps, NamespacedUseConfigReturnType } from "./types";
2 | import { useCallback } from "react";
3 | import { useWatchLocalStorageEvents, capitalize } from "./utils";
4 |
5 | export function createUseConfig, Namespace extends string>(
6 | props: InjectedProps,
7 | ) {
8 | return () => {
9 | const localStorageDependency = useWatchLocalStorageEvents(props.storage, props.localOverride);
10 |
11 | const getConfig = useCallback(props.getConfig, [localStorageDependency]);
12 | const getAllConfig = useCallback(props.getAllConfig, [localStorageDependency]);
13 |
14 | return {
15 | [`get${capitalize(props.configNamespace)}Config`]: getConfig,
16 | [`getAll${capitalize(props.configNamespace)}Config`]: getAllConfig,
17 | [`set${capitalize(props.configNamespace)}Config`]: props.setConfig,
18 | } as NamespacedUseConfigReturnType;
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/index.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | import get from "lodash/get";
4 | import set from "lodash/set";
5 | import unset from "lodash/unset";
6 | import { Mock } from "ts-mockery";
7 |
8 | import createConfig from ".";
9 | import { renderHook, act } from "@testing-library/react-hooks";
10 | import { ConfigOptions } from "./types";
11 |
12 | // Localstorage mock
13 | let store = {};
14 |
15 | const storage = Mock.of({
16 | getItem: (path: string) => get(store, path, null),
17 | setItem: (path: string, value: string) => {
18 | set(store, path, value);
19 | window.dispatchEvent(new Event("storage"));
20 | },
21 | removeItem: (path: string) => {
22 | unset(store, path);
23 | window.dispatchEvent(new Event("storage"));
24 | },
25 | clear: () => {
26 | store = {};
27 | window.dispatchEvent(new Event("storage"));
28 | },
29 | });
30 |
31 | describe("localStorage mock", () => {
32 | afterEach(() => {
33 | storage.clear();
34 | });
35 |
36 | it("should set and get an item", () => {
37 | storage.setItem("plop", "coucou");
38 | expect(storage.getItem("plop")).toEqual("coucou");
39 | });
40 |
41 | it("should clear the store", () => {
42 | storage.setItem("plop", "coucou");
43 | storage.clear();
44 |
45 | expect(store).toEqual({});
46 | });
47 |
48 | it("should return null if the value is not in the store", () => {
49 | expect(storage.getItem("plop")).toBeNull();
50 | });
51 | });
52 |
53 | describe("react-runtime-config", () => {
54 | const namespace = "test";
55 | const createConfigWithDefaults = (
56 | config: Pick>, "localOverride" | "namespace"> = {},
57 | ) =>
58 | createConfig({
59 | namespace,
60 | storage,
61 | schema: {
62 | color: {
63 | type: "string",
64 | enum: ["blue" as const, "green" as const, "pink" as const],
65 | description: "Main color of the application",
66 | },
67 | backend: {
68 | type: "string",
69 | description: "Backend url",
70 | },
71 | port: {
72 | type: "number",
73 | description: "Backend port",
74 | min: 1,
75 | max: 65535,
76 | default: 8000,
77 | },
78 | monitoringLink: {
79 | type: "custom",
80 | description: "Link of the monitoring",
81 | parser: value => {
82 | if (typeof value === "object" && typeof value.url === "string" && typeof value.displayName === "string") {
83 | return {
84 | url: value.url as string,
85 | displayName: value.displayName as string,
86 | };
87 | }
88 | throw new Error("Monitoring link invalid!");
89 | },
90 | },
91 | isLive: {
92 | type: "boolean",
93 | default: false,
94 | },
95 | isAwesome: {
96 | type: "boolean",
97 | },
98 | },
99 | ...config,
100 | });
101 |
102 | beforeEach(() => {
103 | set(window, namespace, {
104 | color: "blue",
105 | backend: "http://localhost",
106 | monitoringLink: {
107 | url: "http://localhost:5000",
108 | displayName: "Monitoring",
109 | },
110 | isAwesome: true,
111 | });
112 | });
113 | afterEach(() => {
114 | act(() => storage.clear());
115 | delete (window as any)[namespace];
116 | });
117 |
118 | it("should throw if a window value don't fit the schema", () => {
119 | set(window, `${namespace}.color`, "red");
120 | expect(() => createConfigWithDefaults()).toThrowErrorMatchingInlineSnapshot(
121 | `"Config key \\"color\\" not valid: red not part of [\\"blue\\", \\"green\\", \\"pink\\"]"`,
122 | );
123 | });
124 |
125 | describe("getConfig", () => {
126 | it("should return the default value", () => {
127 | const { getConfig } = createConfigWithDefaults();
128 | expect(getConfig("port")).toBe(8000);
129 | });
130 |
131 | it("should return the default value (function)", () => {
132 | let port = 8000;
133 | const getPort = () => port;
134 | const { getConfig } = createConfig({
135 | namespace,
136 | storage,
137 | schema: {
138 | port: {
139 | type: "number",
140 | default: getPort,
141 | },
142 | },
143 | });
144 | expect(getConfig("port")).toBe(8000);
145 | port = 9000;
146 | expect(getConfig("port")).toBe(9000);
147 | });
148 |
149 | it("should return the window default", () => {
150 | const { getConfig } = createConfigWithDefaults();
151 | expect(getConfig("color")).toBe("blue");
152 | });
153 |
154 | it("should return a custom parsed value", () => {
155 | const { getConfig } = createConfigWithDefaults();
156 | const monitoringLink = getConfig("monitoringLink");
157 | expect(monitoringLink.url).toBe("http://localhost:5000");
158 | expect(monitoringLink.displayName).toBe("Monitoring");
159 | });
160 |
161 | it("should return a custom parsed value from localStorage", () => {
162 | const { getConfig, setConfig } = createConfigWithDefaults();
163 | act(() => setConfig("monitoringLink", { url: "http://localhost:6000", displayName: "from local" }));
164 |
165 | const monitoringLink = getConfig("monitoringLink");
166 | expect(monitoringLink.url).toBe("http://localhost:6000");
167 | expect(monitoringLink.displayName).toBe("from local");
168 | });
169 |
170 | it("should return the localStorage value (storage set before)", () => {
171 | storage.setItem(`${namespace}.color`, "pink");
172 | const { getConfig } = createConfigWithDefaults();
173 | expect(getConfig("color")).toBe("pink");
174 | });
175 |
176 | it("should return the localStorage value (storage set after)", () => {
177 | const { getConfig } = createConfigWithDefaults();
178 | storage.setItem(`${namespace}.color`, "pink");
179 | expect(getConfig("color")).toBe("pink");
180 | });
181 |
182 | it("should return the localStorage value (with setConfig)", () => {
183 | const { getConfig, setConfig } = createConfigWithDefaults();
184 | setConfig("color", "green");
185 | expect(getConfig("color")).toBe("green");
186 | });
187 |
188 | it("should return the localStorage value (with setConfig and number)", () => {
189 | const { getConfig, setConfig } = createConfigWithDefaults();
190 | setConfig("port", 666);
191 | expect(getConfig("port")).toBe(666);
192 | });
193 |
194 | it("should ignore the storage value (localOverride=false)", () => {
195 | const { getConfig, setConfig } = createConfigWithDefaults({
196 | localOverride: false,
197 | });
198 | setConfig("color", "green");
199 | expect(getConfig("color")).toBe("blue");
200 | });
201 |
202 | it("should throw on corrupted window value", () => {
203 | const { getConfig } = createConfigWithDefaults();
204 | set(window, `${namespace}.color`, 42);
205 | expect(() => getConfig("color")).toThrowError(`Config key "color" not valid: not a string`);
206 | });
207 |
208 | it("should ignore corrupted storage value", () => {
209 | const { getConfig } = createConfigWithDefaults();
210 | storage.setItem(`${namespace}.color`, "42");
211 | expect(getConfig("color")).toBe("blue");
212 | });
213 | });
214 |
215 | describe("getAllConfig", () => {
216 | it("should return the entire consolidate configuration", () => {
217 | const { getAllConfig, setConfig } = createConfigWithDefaults();
218 | setConfig("color", "green");
219 |
220 | expect(getAllConfig()).toMatchInlineSnapshot(`
221 | Object {
222 | "backend": "http://localhost",
223 | "color": "green",
224 | "isAwesome": true,
225 | "isLive": false,
226 | "monitoringLink": Object {
227 | "displayName": "Monitoring",
228 | "url": "http://localhost:5000",
229 | },
230 | "port": 8000,
231 | }
232 | `);
233 | });
234 | });
235 |
236 | describe("setConfig", () => {
237 | it("should set a value (enum)", () => {
238 | const { getConfig, setConfig } = createConfigWithDefaults();
239 | expect(getConfig("color")).toBe("blue");
240 | setConfig("color", "pink");
241 | expect(getConfig("color")).toBe("pink");
242 | });
243 |
244 | it("should set a value (string)", () => {
245 | const { getConfig, setConfig } = createConfigWithDefaults();
246 | expect(getConfig("backend")).toBe("http://localhost");
247 | setConfig("backend", "https://local");
248 | expect(getConfig("backend")).toBe("https://local");
249 | });
250 |
251 | it("should set a value (number)", () => {
252 | const { getConfig, setConfig } = createConfigWithDefaults();
253 | expect(getConfig("port")).toBe(8000);
254 | setConfig("port", 42);
255 | expect(getConfig("port")).toBe(42);
256 | });
257 |
258 | it("should set a value (boolean=true)", () => {
259 | const { getConfig, setConfig } = createConfigWithDefaults();
260 | expect(getConfig("isLive")).toBe(false);
261 | setConfig("isLive", true);
262 | expect(getConfig("isLive")).toBe(true);
263 | });
264 |
265 | it("should set a value (boolean=false)", () => {
266 | const { getConfig, setConfig } = createConfigWithDefaults();
267 | expect(getConfig("isAwesome")).toBe(true);
268 | setConfig("isAwesome", false);
269 | expect(getConfig("isAwesome")).toBe(false);
270 | });
271 |
272 | it("should remove the localStorage value if same as the window one", () => {
273 | const { setConfig } = createConfigWithDefaults();
274 | // Add a custom value
275 | setConfig("isAwesome", false);
276 | expect(storage.getItem("test.isAwesome")).toBe("false");
277 |
278 | // Set back the default value
279 | setConfig("isAwesome", true);
280 | expect(storage.getItem("test.isAwesome")).toBe(null);
281 | });
282 |
283 | it("should remove the localStorage value if same as the default one", () => {
284 | const { setConfig } = createConfigWithDefaults();
285 | // Add a custom value
286 | setConfig("isLive", true);
287 | expect(storage.getItem("test.isLive")).toBe("true");
288 |
289 | // Set back the default value
290 | setConfig("isLive", false);
291 | expect(storage.getItem("test.isLive")).toBe(null);
292 | });
293 |
294 | it("should throw if the type is not respected", () => {
295 | const { setConfig } = createConfigWithDefaults();
296 | expect(() => setConfig("port", "yolo" as any)).toThrowErrorMatchingInlineSnapshot(
297 | `"Expected \\"port=yolo\\" to be a \\"number\\""`,
298 | );
299 | });
300 |
301 | it("should throw if the min value is not respected", () => {
302 | const { setConfig } = createConfigWithDefaults();
303 | expect(() => setConfig("port", -1)).toThrowErrorMatchingInlineSnapshot(
304 | `"Expected \\"port=-1\\" to be greater than 1"`,
305 | );
306 | });
307 |
308 | it("should throw if the max value is not respected", () => {
309 | const { setConfig } = createConfigWithDefaults();
310 | expect(() => setConfig("port", 100000)).toThrowErrorMatchingInlineSnapshot(
311 | `"Expected \\"port=100000\\" to be lower than 65535"`,
312 | );
313 | });
314 |
315 | it("should throw if the enum value is not respected", () => {
316 | const { setConfig } = createConfigWithDefaults();
317 | expect(() => setConfig("color", "red" as any)).toThrowErrorMatchingInlineSnapshot(
318 | `"Expected \\"color=red\\" to be one of: blue, green, pink"`,
319 | );
320 | });
321 |
322 | it("should throw if the value is not respecting a custom parser", () => {
323 | const { setConfig } = createConfigWithDefaults();
324 | expect(() => setConfig("monitoringLink", "red" as any)).toThrowErrorMatchingInlineSnapshot(
325 | `"Monitoring link invalid!"`,
326 | );
327 | });
328 | });
329 |
330 | describe("useConfig", () => {
331 | it("should return the correct value from getConfig", () => {
332 | const { useConfig } = createConfigWithDefaults();
333 | const { result } = renderHook(useConfig);
334 | const color = result.current.getConfig("color");
335 | expect(color).toBe("blue");
336 | });
337 |
338 | it("should return the correct value after setConfig", () => {
339 | const { useConfig } = createConfigWithDefaults();
340 | const { result } = renderHook(useConfig);
341 | act(() => result.current.setConfig("color", "green"));
342 | const color = result.current.getConfig("color");
343 | expect(color).toBe("green");
344 | });
345 |
346 | it("should be able to get all the config", () => {
347 | const { useConfig } = createConfigWithDefaults();
348 | const { result } = renderHook(useConfig);
349 | act(() => result.current.setConfig("color", "green"));
350 | const all = result.current.getAllConfig();
351 | expect(all).toMatchInlineSnapshot(`
352 | Object {
353 | "backend": "http://localhost",
354 | "color": "green",
355 | "isAwesome": true,
356 | "isLive": false,
357 | "monitoringLink": Object {
358 | "displayName": "Monitoring",
359 | "url": "http://localhost:5000",
360 | },
361 | "port": 8000,
362 | }
363 | `);
364 | });
365 |
366 | it("should return namespaced method", () => {
367 | const { useConfig } = createConfig({
368 | namespace,
369 | schema: {
370 | oh: { type: "string", default: "yeah" },
371 | },
372 | configNamespace: "boom",
373 | });
374 | const { result } = renderHook(useConfig);
375 | const { getAllBoomConfig, getBoomConfig, setBoomConfig } = result.current;
376 |
377 | expect(typeof getAllBoomConfig).toBe("function");
378 | expect(typeof getBoomConfig).toBe("function");
379 | expect(typeof setBoomConfig).toBe("function");
380 |
381 | expect(getBoomConfig("oh")).toBe("yeah");
382 | act(() => setBoomConfig("oh", "popopo"));
383 | expect(getAllBoomConfig()).toMatchInlineSnapshot(`
384 | Object {
385 | "oh": "popopo",
386 | }
387 | `);
388 | });
389 | });
390 |
391 | describe("useAdminConfig", () => {
392 | it("should send back the namespace", () => {
393 | const { useAdminConfig } = createConfigWithDefaults();
394 | const { result } = renderHook(useAdminConfig);
395 | expect(result.current.namespace).toBe("test");
396 | });
397 |
398 | it("should have all the metadata about a field", () => {
399 | const { useAdminConfig } = createConfigWithDefaults();
400 | storage.setItem(`${namespace}.color`, "pink");
401 | const { result } = renderHook(useAdminConfig);
402 | const color = result.current.fields.find(({ key }) => key === "color");
403 |
404 | expect(color).toMatchInlineSnapshot(`
405 | Object {
406 | "description": "Main color of the application",
407 | "enum": Array [
408 | "blue",
409 | "green",
410 | "pink",
411 | ],
412 | "isFromStorage": true,
413 | "key": "color",
414 | "path": "test.color",
415 | "set": [Function],
416 | "storageValue": "pink",
417 | "type": "string",
418 | "value": "pink",
419 | "windowValue": "blue",
420 | }
421 | `);
422 | });
423 |
424 | it("should be able to set some fields or reset everything", () => {
425 | const { useAdminConfig } = createConfigWithDefaults();
426 | const { result } = renderHook(useAdminConfig);
427 | // Set some values
428 | result.current.fields.forEach(field => {
429 | if (field.key === "backend") {
430 | act(() => field.set("http://my-app.com"));
431 | }
432 | if (field.type === "number") {
433 | act(() => field.set(42));
434 | }
435 | });
436 |
437 | // Check the resulting state
438 | result.current.fields.forEach(field => {
439 | if (field.key === "backend") {
440 | expect(field.windowValue).toBe("http://localhost");
441 | expect(field.value).toBe("http://my-app.com");
442 | expect(field.storageValue).toBe("http://my-app.com");
443 | expect(field.isFromStorage).toBe(true);
444 | } else if (field.type === "number") {
445 | expect(field.storageValue).toBe(42);
446 | expect(field.value).toBe(42);
447 | expect(field.isFromStorage).toBe(true);
448 | } else {
449 | expect(field.isFromStorage).toBe(false);
450 | }
451 | });
452 |
453 | // Reset the store
454 | act(() => result.current.reset());
455 |
456 | // Check if everything is reset
457 | result.current.fields.forEach(field => {
458 | expect(field.isFromStorage).toBe(false);
459 | expect(field.storageValue).toBe(null);
460 | });
461 | });
462 | });
463 | });
464 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import get from "lodash/get";
2 | import { parse } from "./parsers";
3 | import {
4 | ConfigOptions,
5 | InjectedProps,
6 | Config,
7 | ResolvedSchema,
8 | isStringEnumConfig,
9 | isNumberConfig,
10 | isCustomConfig,
11 | isBooleanConfig,
12 | isStringConfig,
13 | StringConfig,
14 | StringEnumConfig,
15 | NumberConfig,
16 | BooleanConfig,
17 | CustomConfig,
18 | AdminField,
19 | AdminFields,
20 | GenericAdminFields,
21 | NamespacedUseConfigReturnType,
22 | } from "./types";
23 | import { createUseAdminConfig } from "./createUseAdminConfig";
24 | import { createUseConfig } from "./createUseConfig";
25 |
26 | export {
27 | // -- Options --
28 | ConfigOptions,
29 | // -- Configs --
30 | Config,
31 | StringConfig,
32 | StringEnumConfig,
33 | NumberConfig,
34 | BooleanConfig,
35 | CustomConfig,
36 | // -- Typeguards --
37 | isStringEnumConfig,
38 | isNumberConfig,
39 | isCustomConfig,
40 | isBooleanConfig,
41 | isStringConfig,
42 | // -- useConfigAdmin.fields --
43 | AdminField,
44 | AdminFields,
45 | GenericAdminFields,
46 | };
47 |
48 | export function createConfig, TNamespace extends string = "">(
49 | options: ConfigOptions,
50 | ) {
51 | const injected: Pick, keyof ConfigOptions> = {
52 | storage: window.localStorage,
53 | localOverride: true,
54 | configNamespace: "" as TNamespace,
55 | ...options,
56 | };
57 |
58 | /**
59 | * Get a config value from the storage (localStorage by default)
60 | */
61 | const getStorageValue = (path: keyof TSchema) => {
62 | if (injected.storage && injected.localOverride) {
63 | try {
64 | let rawValue = injected.storage.getItem(`${injected.namespace}.${path}`);
65 | try {
66 | rawValue = JSON.parse(rawValue || ""); // Handle objects stored as string
67 | } catch {}
68 | return parse(rawValue, options.schema[path]);
69 | } catch {
70 | return null;
71 | }
72 | } else {
73 | return null;
74 | }
75 | };
76 |
77 | /**
78 | * Get a config value from window
79 | *
80 | * @throws
81 | */
82 | const getWindowValue = (path: keyof TSchema) => {
83 | try {
84 | const rawValue = get(window, `${injected.namespace}.${path}`, null);
85 | return rawValue === null ? null : parse(rawValue, options.schema[path]);
86 | } catch (e) {
87 | throw new Error(`Config key "${path}" not valid: ${e.message}`);
88 | }
89 | };
90 |
91 | /**
92 | * Get a config value from storage, window or defaultValues
93 | *
94 | * @throws
95 | */
96 | function getConfig>(path: K): ResolvedSchema[K] {
97 | const defaultValue =
98 | typeof options.schema[path].default === "function"
99 | ? (options.schema[path].default as () => ResolvedSchema[K])()
100 | : (options.schema[path].default as ResolvedSchema[K]);
101 | const storageValue = getStorageValue(path);
102 | const windowValue = getWindowValue(path);
103 |
104 | if (defaultValue === undefined && windowValue === null) {
105 | throw new Error(`Config key "${path}" need to be defined in "window.${injected.namespace}.${path}!`);
106 | }
107 |
108 | return storageValue !== null ? storageValue : windowValue !== null ? windowValue : defaultValue;
109 | }
110 |
111 | /**
112 | * Set a config value in the storage.
113 | * This will also remove the value if the value is the same as the window one.
114 | *
115 | * @throws
116 | */
117 | function setConfig>(path: K, value: ResolvedSchema[K]) {
118 | const config = options.schema[path];
119 | try {
120 | parse(value, config); // Runtime validation of the value
121 | } catch (e) {
122 | if (isCustomConfig(config)) {
123 | throw e;
124 | }
125 | if (isStringEnumConfig(config)) {
126 | throw new Error(`Expected "${path}=${value}" to be one of: ${config.enum.join(", ")}`);
127 | } else if (isNumberConfig(config) && Number.isFinite(value)) {
128 | if (typeof config.min === "number" && value < config.min) {
129 | throw new Error(`Expected "${path}=${value}" to be greater than ${config.min}`);
130 | }
131 | if (typeof config.max === "number" && value > config.max) {
132 | throw new Error(`Expected "${path}=${value}" to be lower than ${config.max}`);
133 | }
134 | }
135 |
136 | throw new Error(`Expected "${path}=${value}" to be a "${config.type}"`);
137 | }
138 | if (getWindowValue(path) === value || config.default === value) {
139 | injected.storage.removeItem(`${injected.namespace}.${path}`);
140 | } else {
141 | const encodedValue = typeof value === "string" ? value : JSON.stringify(value);
142 | injected.storage.setItem(`${injected.namespace}.${path}`, encodedValue);
143 | }
144 | window.dispatchEvent(new Event("storage"));
145 | }
146 |
147 | /**
148 | * Get all consolidate config values.
149 | */
150 | function getAllConfig(): ResolvedSchema {
151 | return Object.keys(options.schema).reduce(
152 | (mem, key) => ({ ...mem, [key]: getConfig(key) }),
153 | {} as ResolvedSchema,
154 | );
155 | }
156 |
157 | // Validate all config from `window.{namespace}`
158 | getAllConfig();
159 |
160 | return {
161 | useConfig: createUseConfig({
162 | getConfig,
163 | getAllConfig,
164 | getStorageValue,
165 | getWindowValue,
166 | setConfig,
167 | ...injected,
168 | }) as () => NamespacedUseConfigReturnType, // Hint for the build
169 | useAdminConfig: createUseAdminConfig({
170 | getConfig,
171 | getAllConfig,
172 | getStorageValue,
173 | getWindowValue,
174 | setConfig,
175 | ...injected,
176 | }),
177 | getConfig,
178 | setConfig,
179 | getAllConfig,
180 | };
181 | }
182 |
183 | export default createConfig;
184 |
--------------------------------------------------------------------------------
/src/parsers.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Config,
3 | isBooleanConfig,
4 | isCustomConfig,
5 | isNumberConfig,
6 | isStringConfig,
7 | isStringEnumConfig,
8 | ResolvedConfigValue,
9 | } from "./types";
10 |
11 | function parseString(value: unknown): string {
12 | if (typeof value !== "string") {
13 | throw new Error("not a string");
14 | }
15 | return value;
16 | }
17 |
18 | function parseNumber(value: unknown): number {
19 | if (typeof value === "number" && Number.isFinite(value)) {
20 | return value;
21 | }
22 | if (typeof value === "string") {
23 | if (!Number.isFinite(parseFloat(value))) {
24 | throw new Error("not a number");
25 | }
26 | return parseFloat(value);
27 | }
28 | throw new Error("not a number");
29 | }
30 |
31 | function parseBoolean(value: unknown): boolean {
32 | if (typeof value === "boolean") {
33 | return value;
34 | }
35 | if (typeof value === "string" && ["true", "false"].includes(value.toLowerCase())) {
36 | return value === "true";
37 | }
38 | throw new Error("not a boolean");
39 | }
40 |
41 | export function parse(value: unknown, config: TConfig): ResolvedConfigValue {
42 | if (isStringEnumConfig(config)) {
43 | const parsedString = parseString(value) as ResolvedConfigValue;
44 | if (!config.enum.includes(parsedString as any)) {
45 | throw new Error(`${parsedString} not part of [${config.enum.map(i => `"${i}"`).join(", ")}]`);
46 | }
47 | return parsedString;
48 | }
49 | if (isStringConfig(config)) {
50 | return parseString(value) as ResolvedConfigValue;
51 | }
52 | if (isNumberConfig(config)) {
53 | const parsedNumber = parseNumber(value) as ResolvedConfigValue;
54 | if (typeof config.min === "number" && parsedNumber < config.min) {
55 | throw new Error(`${parseNumber} should be greater than ${config.min}`);
56 | }
57 | if (typeof config.max === "number" && parsedNumber > config.max) {
58 | throw new Error(`${parseNumber} should be lower than ${config.max}`);
59 | }
60 | return parsedNumber;
61 | }
62 | if (isBooleanConfig(config)) {
63 | return parseBoolean(value) as ResolvedConfigValue;
64 | }
65 | if (isCustomConfig(config)) {
66 | return config.parser(value) as ResolvedConfigValue;
67 | }
68 | throw new Error("unknown config type");
69 | }
70 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface ConfigOptions, TNamespace extends string> {
2 | /**
3 | * Namespace of the configuration
4 | *
5 | * This namespace is used to consume the configuration from `window` and `localStorage`
6 | */
7 | namespace: string;
8 |
9 | /**
10 | * Schema of the configuration (used for runtime validation)
11 | */
12 | schema: TSchema;
13 |
14 | /**
15 | * Storage adapter
16 | *
17 | * @default window.localStorage
18 | */
19 | storage?: Storage;
20 |
21 | /**
22 | * Permit to override any config values in storage
23 | *
24 | * @default true
25 | */
26 | localOverride?: boolean;
27 |
28 | /**
29 | * Namespace for `useConfig()` return methods.
30 | *
31 | * Example:
32 | * ```
33 | * // MyConfig.ts
34 | * export const { useConfig } = createConfig({
35 | * configNamespace: "hello"
36 | * });
37 | *
38 | * // In a react component
39 | * const {
40 | * getHelloConfig,
41 | * setHelloConfig,
42 | * getAllHelloConfig,
43 | * } = useConfig();
44 | * ```
45 | */
46 | configNamespace?: TNamespace;
47 | }
48 |
49 | export type Config = StringConfig | NumberConfig | BooleanConfig | CustomConfig;
50 |
51 | export interface StringConfig {
52 | type: "string";
53 | enum?: string[];
54 | default?: string | (() => string);
55 | description?: string;
56 | }
57 |
58 | export interface StringEnumConfig extends StringConfig {
59 | /**
60 | * List of allowed values
61 | */
62 | enum: string[];
63 | }
64 |
65 | export interface NumberConfig {
66 | type: "number";
67 | min?: number;
68 | max?: number;
69 | default?: number | (() => number);
70 | description?: string;
71 | }
72 |
73 | export interface BooleanConfig {
74 | type: "boolean";
75 | default?: boolean | (() => boolean);
76 | description?: string;
77 | }
78 |
79 | export interface CustomConfig {
80 | type: "custom";
81 | default?: T | (() => T);
82 | description?: string;
83 | /**
84 | * Custom parser.
85 | *
86 | * Should throw an error if the value can't be parsed
87 | */
88 | parser: (value: any) => T;
89 | }
90 |
91 | export type ResolvedSchema> = {
92 | [key in keyof TSchema]: ResolvedConfigValue;
93 | };
94 |
95 | export type ResolvedConfigValue = TValue extends StringEnumConfig
96 | ? TValue["enum"][-1]
97 | : TValue extends StringConfig
98 | ? string
99 | : TValue extends NumberConfig
100 | ? number
101 | : TValue extends BooleanConfig
102 | ? boolean
103 | : TValue extends CustomConfig
104 | ? ReturnType
105 | : never;
106 |
107 | export const isStringConfig = (config: Config): config is StringConfig => config.type === "string";
108 | export const isStringEnumConfig = (config: Config): config is StringEnumConfig =>
109 | config.type === "string" && Array.isArray(config.enum);
110 | export const isNumberConfig = (config: Config): config is NumberConfig => config.type === "number";
111 | export const isBooleanConfig = (config: Config): config is BooleanConfig => config.type === "boolean";
112 | export const isCustomConfig = (config: Config): config is CustomConfig => config.type === "custom";
113 |
114 | export interface InjectedProps<
115 | TSchema extends Record,
116 | TNamespace extends string,
117 | TConfig = ResolvedSchema
118 | > {
119 | namespace: string;
120 | configNamespace: TNamespace;
121 | schema: TSchema;
122 | storage: Storage;
123 | localOverride: boolean;
124 | getConfig: (key: K) => ResolvedConfigValue;
125 | setConfig: (key: K, value: ResolvedConfigValue) => void;
126 | getAllConfig: () => TConfig;
127 | getWindowValue: (key: K) => ResolvedConfigValue | null;
128 | getStorageValue: (key: K) => ResolvedConfigValue | null;
129 | }
130 |
131 | // useAdminConfig types
132 | export type AdminField, TKey extends keyof TSchema> = TSchema[TKey] & {
133 | /**
134 | * Schema key of the config
135 | */
136 | key: TKey;
137 | /**
138 | * Full path of the config (with `namespace`)
139 | */
140 | path: string;
141 | /**
142 | * Value stored in `window.{path}`
143 | */
144 | windowValue: ResolvedConfigValue | null;
145 | /**
146 | * Value stored in `storage.getItem({path})`
147 | */
148 | storageValue: ResolvedConfigValue | null;
149 | /**
150 | * True if a value is stored on the localStorage
151 | */
152 | isFromStorage: boolean;
153 | /**
154 | * Computed value from storage, window, schema[key].default
155 | */
156 | value: ResolvedConfigValue;
157 | /**
158 | * Value setter
159 | */
160 | set: (value: ResolvedConfigValue) => void;
161 | };
162 |
163 | type Lookup = K extends keyof T ? T[K] : never;
164 | type TupleFromInterface = Array> = {
165 | [I in keyof K]: Lookup;
166 | };
167 |
168 | export type AdminFields> = TupleFromInterface<
169 | {
170 | [key in keyof TSchema]: AdminField;
171 | }
172 | >;
173 |
174 | // useAdminConfig generic types
175 | type AdminProps = {
176 | key: string;
177 | path: string;
178 | windowValue: T | null;
179 | storageValue: T | null;
180 | isFromStorage: boolean;
181 | value: T;
182 | set: (value: U) => void;
183 | };
184 |
185 | /**
186 | * `useAdminConfig.fields` in a generic version.
187 | *
188 | * This should be used if you are implementing a generic component
189 | * that consume any `fields` as prop.
190 | *
191 | * Note: "custom" type and "string" with enum are defined as `any` to be
192 | * compatible with any schemas. You will need to validate them in your
193 | * implementation to retrieve a strict type.
194 | */
195 | export type GenericAdminFields = Array<
196 | | (StringConfig & AdminProps)
197 | | (NumberConfig & AdminProps)
198 | | (BooleanConfig & AdminProps)
199 | | (StringEnumConfig & AdminProps)
200 | | (CustomConfig & AdminProps)
201 | >;
202 |
203 | // useConfig types
204 | type UseConfigReturnType> = {
205 | getConfig: (path: K) => ResolvedConfigValue;
206 | getAllConfig: () => ResolvedSchema;
207 | setConfig: (path: K, value: ResolvedConfigValue) => void;
208 | };
209 |
210 | /**
211 | * Helper to inject a namespace inside the keys of the `useConfig` return type.
212 | *
213 | * example:
214 | * ```
215 | * type Namespaced<{getConfig: any; getAllConfig: any; setConfig: any}, "foo">
216 | * // { getFooConfig: any; getAllFooConfig: any; setFooConfig: any }
217 | * ```
218 | */
219 | type Namespaced = {
220 | [P in keyof T as P extends `getConfig`
221 | ? `get${Capitalize}Config`
222 | : P extends "getAllConfig"
223 | ? `getAll${Capitalize}Config`
224 | : `set${Capitalize}Config`]: T[P];
225 | };
226 |
227 | export type NamespacedUseConfigReturnType<
228 | TSchema extends Record,
229 | TNamespace extends string
230 | > = Namespaced, TNamespace>;
231 |
--------------------------------------------------------------------------------
/src/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { capitalize } from "./utils";
2 |
3 | describe("capitalize", () => {
4 | const tests: Array<[string, string]> = [
5 | ["", ""],
6 | ["a", "A"],
7 | ["A", "A"],
8 | ["al", "Al"],
9 | ["sTrAnGe", "STrAnGe"],
10 | ];
11 |
12 | tests.forEach(([input, expected]) => {
13 | it(`should return "${expected}" for "${input}"`, () => expect(capitalize(input)).toBe(expected));
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | /**
4 | * Utils to provide a key that depends to the localStorage state.
5 | *
6 | * @param storage
7 | * @param localOverride
8 | */
9 | export const useWatchLocalStorageEvents = (storage: Storage, localOverride: boolean) => {
10 | const [key, setKey] = React.useState(0);
11 |
12 | React.useEffect(() => {
13 | const onStorageUpdate = (_: StorageEvent) => {
14 | if (storage && localOverride) {
15 | setKey(i => (i + 1) % 10);
16 | }
17 | };
18 |
19 | window.addEventListener("storage", onStorageUpdate);
20 |
21 | return () => window.removeEventListener("storage", onStorageUpdate);
22 | }, [storage, localOverride, setKey]);
23 |
24 | return key;
25 | };
26 |
27 | export function capitalize(str: T) {
28 | if (!str || str.length < 1) return "" as Capitalize;
29 | return (str[0].toUpperCase() + str.slice(1)) as Capitalize;
30 | }
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src/**/*"],
3 | "compilerOptions": {
4 | "outDir": "lib",
5 | "skipLibCheck": true,
6 | "target": "es5",
7 | "module": "CommonJS",
8 | "lib": ["dom", "es2017"],
9 | "jsx": "react",
10 | "declaration": true,
11 | "declarationMap": true,
12 | "downlevelIteration": true,
13 | "strict": true,
14 | "noUnusedLocals": true,
15 | "noUnusedParameters": true,
16 | "noImplicitReturns": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "moduleResolution": "node",
19 | "allowSyntheticDefaultImports": true,
20 | "esModuleInterop": true,
21 | "inlineSourceMap": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.package.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["src/*.test.ts"]
4 | }
5 |
--------------------------------------------------------------------------------