From 6c022b8a11f801e6540bd9aba37d7b150a7d4e1e Mon Sep 17 00:00:00 2001 From: Nicola Molinari Date: Sun, 5 Jan 2020 11:24:19 +0100 Subject: [PATCH] refactor(types): to refine types for each adapter (#901) * refactor(types): to refine types for each adapter * refactor: use interfaces for conditionally check adapter args * fix: use isolatedModules for ts-jest * fix(github-actions): run actions on pull_request event --- .github/workflows/test.yml | 2 +- .jestrc.lint.json | 5 - .jestrc.test.json | 16 - jest.lint.config.js | 5 + jest.test.config.js | 27 ++ package.json | 6 +- .../modules/adapter/adapter.spec.js | 170 ++++++---- .../modules/adapter/adapter.ts | 317 +++++++++--------- .../modules/adapter/adapter.spec.js | 32 +- .../modules/adapter/adapter.ts | 109 +++--- .../modules/adapter/adapter.spec.js | 42 ++- .../memory-adapter/modules/adapter/adapter.ts | 176 +++++----- .../components/configure/configure.spec.js | 13 +- .../components/configure/configure.tsx | 51 +-- packages/react-broadcast/package.json | 3 +- .../branch-on-feature-toggle.spec.js | 2 +- .../components/configure/configure.spec.js | 19 +- .../components/configure/configure.tsx | 92 ++--- .../inject-feature-toggle.spec.js | 2 +- .../inject-feature-toggles.spec.js | 2 +- .../toggle-feature/toggle-feature.spec.js | 2 +- .../toggle-feature/toggle-feature.ts | 2 +- .../modules/ducks/flags/flags.spec.js | 2 +- .../react-redux/modules/ducks/flags/flags.ts | 2 +- packages/react-redux/modules/hooks/index.ts | 2 + .../modules/hooks/use-update-flags.ts | 14 + .../modules/hooks/use-update-status.ts | 15 + packages/react-redux/modules/index.ts | 7 +- .../react-redux/modules/store/constants.ts | 1 + .../modules/store/enhancer/enhancer.spec.js | 16 +- .../modules/store/enhancer/enhancer.ts | 8 +- packages/react-redux/modules/store/index.ts | 2 - packages/react-redux/modules/types.ts | 2 +- packages/react-redux/package.json | 1 - .../configure-adapter.spec.js | 24 +- .../configure-adapter/configure-adapter.tsx | 87 ++--- .../with-reconfiguration.tsx | 4 +- .../create-sequential-id.spec.js | 31 -- .../create-sequential-id.ts | 9 - .../helpers/create-sequential-id/index.ts | 1 - packages/react/modules/helpers/index.ts | 1 - packages/react/modules/index.ts | 2 +- .../modules/adapter/adapter.spec.js | 102 +++--- .../modules/adapter/adapter.ts | 117 +++---- packages/types/index.ts | 149 +++++++- yarn.lock | 7 - 46 files changed, 951 insertions(+), 750 deletions(-) delete mode 100644 .jestrc.lint.json delete mode 100644 .jestrc.test.json create mode 100644 jest.lint.config.js create mode 100644 jest.test.config.js create mode 100644 packages/react-redux/modules/hooks/use-update-flags.ts create mode 100644 packages/react-redux/modules/hooks/use-update-status.ts create mode 100644 packages/react-redux/modules/store/constants.ts delete mode 100644 packages/react-redux/modules/store/index.ts delete mode 100644 packages/react/modules/helpers/create-sequential-id/create-sequential-id.spec.js delete mode 100644 packages/react/modules/helpers/create-sequential-id/create-sequential-id.ts delete mode 100644 packages/react/modules/helpers/create-sequential-id/index.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d299c38e5..d54609408 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ name: Test and Build -on: [push] +on: [pull_request] jobs: install: diff --git a/.jestrc.lint.json b/.jestrc.lint.json deleted file mode 100644 index 67e9e5db6..000000000 --- a/.jestrc.lint.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "runner": "jest-runner-eslint", - "displayName": "lint", - "testMatch": ["/packages/**/*.js", "/packages/**/*.ts"] -} diff --git a/.jestrc.test.json b/.jestrc.test.json deleted file mode 100644 index bdc52065c..000000000 --- a/.jestrc.test.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "displayName": "test", - "preset": "ts-jest/presets/js-with-babel", - "setupFiles": ["raf/polyfill", "jest-localstorage-mock"], - "setupFilesAfterEnv": ["./jest-runner-test.config.js"], - "moduleFileExtensions": ["ts", "tsx", "js", "json"], - "testEnvironment": "jsdom", - "testURL": "http://localhost", - "watchPlugins": ["jest-plugin-filename", "jest-watch-yarn-workspaces"], - "testPathIgnorePatterns": [ - "/node_modules/", - "/packages/.*/node_modules", - "/packages/.*/dist" - ], - "coveragePathIgnorePatterns": ["/node_modules/"] -} diff --git a/jest.lint.config.js b/jest.lint.config.js new file mode 100644 index 000000000..669685c51 --- /dev/null +++ b/jest.lint.config.js @@ -0,0 +1,5 @@ +module.exports = { + runner: 'jest-runner-eslint', + displayName: 'lint', + testMatch: ['/packages/**/*.js', '/packages/**/*.ts'], +}; diff --git a/jest.test.config.js b/jest.test.config.js new file mode 100644 index 000000000..28683d6e1 --- /dev/null +++ b/jest.test.config.js @@ -0,0 +1,27 @@ +module.exports = { + displayName: 'test', + preset: 'ts-jest/presets/js-with-babel', + // Without this option, somehow CI fails to run the tests with the following error: + // TypeError: Unable to require `.d.ts` file. + // This is usually the result of a faulty configuration or import. Make sure there is a `.js`, `.json` or another executable extension available alongside `core.ts`. + // Fix is based on this comment: + // - https://github.com/kulshekhar/ts-jest/issues/805#issuecomment-456055213 + // - https://github.com/kulshekhar/ts-jest/blob/master/docs/user/config/isolatedModules.md + globals: { + 'ts-jest': { + isolatedModules: true, + }, + }, + setupFiles: ['raf/polyfill', 'jest-localstorage-mock'], + setupFilesAfterEnv: ['./jest-runner-test.config.js'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], + testEnvironment: 'jsdom', + testURL: 'http://localhost', + watchPlugins: ['jest-plugin-filename', 'jest-watch-yarn-workspaces'], + testPathIgnorePatterns: [ + '/node_modules/', + '/packages/.*/node_modules', + '/packages/.*/dist', + ], + coveragePathIgnorePatterns: ['/node_modules/'], +}; diff --git a/package.json b/package.json index 49e6c1dc4..44735c9cc 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,15 @@ "description": "Monorepository for flipflop and its projects e.g. react-redux, react and the wrapper", "scripts": { "postinstall": "check-node-version --package --print", - "develop": "jest --projects .jestrc.*.json --watch", - "lint": "jest --config .jestrc.lint.json --maxWorkers 5", + "develop": "jest --projects jest.*.config.js --watch", + "lint": "jest --config jest.lint.config.js --maxWorkers 5", "type-check": "tsc --noEmit", "format": "yarn format:ts && yarn format:md && yarn format:yaml", "format:ts": "prettier --write '**/*.{js, ts, tsx}'", "format:md": "prettier --parser markdown --write '**/*.md'", "format:yaml": "prettier --parser yaml --write '**/*.{yml,yaml}'", "fix:eslint": "eslint --fix --format=node_modules/eslint-formatter-pretty", - "test": "cross-env NODE_ENV=test jest --config .jestrc.test.json", + "test": "cross-env NODE_ENV=test jest --config jest.test.config.js", "test:sizes": "bundlesize", "test:ci": "cross-env NODE_ENV=test yarn test --no-watchman --maxWorkers 5 --no-cache", "test:ci:coverage": "cross-env NODE_ENV=test yarn test:ci --coverage && codecov", diff --git a/packages/launchdarkly-adapter/modules/adapter/adapter.spec.js b/packages/launchdarkly-adapter/modules/adapter/adapter.spec.js index e73e34a44..20a0793a0 100644 --- a/packages/launchdarkly-adapter/modules/adapter/adapter.spec.js +++ b/packages/launchdarkly-adapter/modules/adapter/adapter.spec.js @@ -68,12 +68,16 @@ describe('when configuring', () => { describe('with user key', () => { beforeEach(() => { - return adapter.configure({ - clientSideId, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }); + return adapter.configure( + { + clientSideId, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); it('should initialize the `ld-client` with `clientSideId` and given `user`', () => { @@ -95,12 +99,16 @@ describe('when configuring', () => { describe('without key', () => { beforeEach(() => - adapter.configure({ - clientSideId, - user: userWithoutKey, - onStatusStateChange, - onFlagsStateChange, - }) + adapter.configure( + { + clientSideId, + user: userWithoutKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ) ); it('should initialize the `ld-client` with `clientSideId` and no `user` `key`', () => { @@ -138,12 +146,16 @@ describe('when configuring', () => { ldClient.initialize.mockReturnValue(client); - return adapter.configure({ - clientSideId, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }); + return adapter.configure( + { + clientSideId, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); describe('when `ldClient` is ready', () => { @@ -199,13 +211,17 @@ describe('when configuring', () => { it('should reject the configuration with an error', async () => { await expect( - adapter.configure({ - clientSideId, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - throwOnInitializationFailure: true, - }) + adapter.configure( + { + clientSideId, + user: userWithKey, + throwOnInitializationFailure: true, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ) ).rejects.toThrow( '@flopflip/launchdarkly-adapter: adapter failed to initialize.' ); @@ -224,13 +240,17 @@ describe('when configuring', () => { it('should resolve the configuration', async () => { await expect( - adapter.configure({ - clientSideId, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - throwOnInitializationFailure: false, - }) + adapter.configure( + { + clientSideId, + user: userWithKey, + throwOnInitializationFailure: false, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ) ).resolves.toEqual(expect.anything()); }); }); @@ -248,13 +268,17 @@ describe('when configuring', () => { ldClient.initialize.mockReturnValue(client); - return adapter.configure({ - clientSideId, - user: userWithKey, - onStatusStateChange, - flags: flags, - onFlagsStateChange, - }); + return adapter.configure( + { + clientSideId, + user: userWithKey, + flags: flags, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); it('should `dispatch` `onUpdateStatus` action with `isReady`', () => { @@ -320,13 +344,17 @@ describe('when configuring', () => { ldClient.initialize.mockReturnValue(client); - await adapter.configure({ - subscribeToFlagChanges: false, - clientSideId, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }); + await adapter.configure( + { + subscribeToFlagChanges: false, + clientSideId, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); onFlagsStateChange.mockClear(); @@ -357,13 +385,17 @@ describe('when configuring', () => { ldClient.initialize.mockReturnValue(client); - return adapter.configure({ - flagsUpdateDelayMs, - clientSideId, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }); + return adapter.configure( + { + flagsUpdateDelayMs, + clientSideId, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); it('should `dispatch` `onFlagsStateChange` action once', () => { @@ -406,12 +438,16 @@ describe('when configuring', () => { ldClient.initialize.mockReturnValue(client); return adapter - .configure({ - clientSideId, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }) + .configure( + { + clientSideId, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ) .then(() => adapter.reconfigure({ user: nextUser })); }); @@ -440,12 +476,16 @@ describe('when configuring', () => { ldClient.initialize.mockReturnValue(client); - return adapter.configure({ - clientSideId, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }); + return adapter.configure( + { + clientSideId, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); describe('with partial prop update', () => { diff --git a/packages/launchdarkly-adapter/modules/adapter/adapter.ts b/packages/launchdarkly-adapter/modules/adapter/adapter.ts index 448f6935e..8a4f7455a 100644 --- a/packages/launchdarkly-adapter/modules/adapter/adapter.ts +++ b/packages/launchdarkly-adapter/modules/adapter/adapter.ts @@ -4,8 +4,10 @@ import { User, Flag, Flags, - OnFlagsStateChangeCallback, - OnStatusStateChangeCallback, + LaunchDarklyAdapterInterface, + LaunchDarklyAdapterArgs, + AdapterEventHandlers, + interfaceIdentifiers, } from '@flopflip/types'; import merge from 'deepmerge'; import warning from 'tiny-warning'; @@ -19,9 +21,6 @@ import { import camelCase from 'lodash/camelCase'; import kebabCase from 'lodash/kebabCase'; -type ClientOptions = { - fetchGoals?: boolean; -}; type AdapterState = { isReady: boolean; isConfigured: boolean; @@ -45,19 +44,6 @@ const updateFlagsInAdapterState = (updatedFlags: Flags): void => { }; }; -const getFlag = (flagName: FlagName): FlagVariation | undefined => - adapterState.flags[flagName]; - -const didFlagChange = (flagName: FlagName, nextFlagValue: FlagVariation) => { - const previousFlagValue = getFlag(flagName); - - if (previousFlagValue === undefined) return true; - - return previousFlagValue !== nextFlagValue; -}; - -const getIsReady = (): boolean => adapterState.isReady; - const normalizeFlag = (flagName: FlagName, flagValue?: FlagVariation): Flag => [ camelCase(flagName), // Multi variate flags contain a string or `null` - `false` seems more natural. @@ -65,89 +51,31 @@ const normalizeFlag = (flagName: FlagName, flagValue?: FlagVariation): Flag => [ ]; const denormalizeFlagName = (flagName: FlagName) => kebabCase(flagName); -const setupFlagSubcription = ({ - flagsFromSdk, - onFlagsStateChange, - flagsUpdateDelayMs, -}: { - flagsFromSdk: Flags; - onFlagsStateChange: OnFlagsStateChangeCallback; - flagsUpdateDelayMs?: number; -}): void => { - for (const flagName in flagsFromSdk) { - // Dispatch whenever a configured flag value changes - if ( - Object.prototype.hasOwnProperty.call(flagsFromSdk, flagName) && - adapterState.client - ) { - adapterState.client.on(`change:${flagName}`, flagValue => { - const [normalizedFlagName, normalizedFlagValue] = normalizeFlag( - flagName, - flagValue - ); - - // Sometimes the SDK flushes flag changes without a value having changed. - if (!didFlagChange(normalizedFlagName, normalizedFlagValue)) return; - - const updatedFlags: Flags = { - [normalizedFlagName]: normalizedFlagValue, - }; - - // NOTE: Adapter state needs to be updated outside of debounced-fn - // so that no flag updates are lost. - updateFlagsInAdapterState(updatedFlags); - - const updateFlags = () => { - onFlagsStateChange(adapterState.flags); - }; - - debounce(updateFlags, { - wait: flagsUpdateDelayMs, - immediate: !flagsUpdateDelayMs, - })(); - }); - } - } -}; - const getIsAnonymousUser = (user: User): boolean => !user?.key; -const ensureUser = (user: User): User => { + +const ensureUser = (user: User) => { const isAnonymousUser = getIsAnonymousUser(user); // NOTE: When marked `anonymous` the SDK will generate a unique key and cache it in local storage - return merge(user, { + return merge(user, { key: isAnonymousUser ? undefined : user.key, anonymous: isAnonymousUser, }); }; const initializeClient = ( - clientSideId: string, + clientSideId: LaunchDarklyAdapterArgs['clientSideId'], user: User, - clientOptions: ClientOptions + clientOptions: LaunchDarklyAdapterArgs['clientOptions'] ): LDClient => initializeLaunchDarklyClient(clientSideId, user as LDUser, clientOptions); + const changeUserContext = (nextUser: User): Promise => adapterState.client && adapterState.client.identify ? adapterState.client.identify(nextUser as LDUser) : Promise.reject( new Error('Can not change user context: client not yet initialized.') ); -const updateUserContext = (updatedUserProps: User): Promise => { - const isAdapterReady = adapterState.isConfigured && adapterState.isReady; - - warning( - isAdapterReady, - '@flopflip/launchdarkly-adapter: adapter not ready and configured. User context can not be updated before.' - ); - - if (!isAdapterReady) - return Promise.reject( - new Error('Can not update user context: adapter not yet ready.') - ); - - return changeUserContext({ ...adapterState.user, ...updatedUserProps }); -}; // NOTE: Exported for testing only export const normalizeFlags = (rawFlags: Flags): Flags => @@ -165,17 +93,13 @@ export const normalizeFlags = (rawFlags: Flags): Flags => {} ); -const getInitialFlags = ({ - onFlagsStateChange, - onStatusStateChange, - flags, - throwOnInitializationFailure, -}: { - onFlagsStateChange: OnFlagsStateChangeCallback; - onStatusStateChange: OnStatusStateChangeCallback; - flags: Flags; - throwOnInitializationFailure: boolean; -}): Promise<{ flagsFromSdk: Flags | null }> => { +const getInitialFlags = ( + { + flags, + throwOnInitializationFailure, + }: Pick, + adapterEventHandlers: AdapterEventHandlers +): Promise<{ flagsFromSdk: Flags | null }> => { if (adapterState.client) { return adapterState.client .waitForInitialization() @@ -206,13 +130,13 @@ const getInitialFlags = ({ const flags: Flags = normalizeFlags(flagsFromSdk); updateFlagsInAdapterState(flags); // ...and flush initial state of flags - onFlagsStateChange(flags); + adapterEventHandlers.onFlagsStateChange(flags); } // First update internal state adapterState.isReady = true; // ...to then signal that the adapter is ready - onStatusStateChange({ isReady: true }); + adapterEventHandlers.onStatusStateChange({ isReady: true }); return Promise.resolve({ flagsFromSdk }); }) @@ -234,73 +158,154 @@ const getInitialFlags = ({ ); }; -const configure = ({ - clientSideId, - user, - clientOptions = {}, - onFlagsStateChange, - onStatusStateChange, - flags, - subscribeToFlagChanges = true, - throwOnInitializationFailure = false, - flagsUpdateDelayMs, -}: { - clientSideId: string; - user: User; - clientOptions: ClientOptions; - onFlagsStateChange: OnFlagsStateChangeCallback; - onStatusStateChange: OnStatusStateChangeCallback; - flags: Flags; - subscribeToFlagChanges: boolean; - throwOnInitializationFailure: boolean; - flagsUpdateDelayMs?: number; -}): Promise => { - adapterState.user = ensureUser(user); - adapterState.client = initializeClient( - clientSideId, - adapterState.user, - clientOptions - ); - adapterState.isConfigured = true; +class LaunchDarklyAdapter implements LaunchDarklyAdapterInterface { + id: typeof interfaceIdentifiers.launchdarkly; - return getInitialFlags({ - onFlagsStateChange, - onStatusStateChange, - flags, - throwOnInitializationFailure, - }).then(({ flagsFromSdk }) => { - if (subscribeToFlagChanges && flagsFromSdk) - setupFlagSubcription({ - flagsFromSdk, - flagsUpdateDelayMs, - onFlagsStateChange, - }); + constructor() { + this.id = interfaceIdentifiers.launchdarkly; + } - return adapterState.client; - }); -}; + configure( + adapterArgs: LaunchDarklyAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise { + const { + clientSideId, + user, + clientOptions = {}, + flags, + subscribeToFlagChanges = true, + throwOnInitializationFailure = false, + flagsUpdateDelayMs, + } = adapterArgs; + + adapterState.user = ensureUser(user); + adapterState.client = initializeClient( + clientSideId, + adapterState.user, + clientOptions + ); + adapterState.isConfigured = true; + + return getInitialFlags( + { + flags, + throwOnInitializationFailure, + }, + adapterEventHandlers + ).then(({ flagsFromSdk }) => { + if (subscribeToFlagChanges && flagsFromSdk) + this._setupFlagSubcription( + { + flagsFromSdk, + flagsUpdateDelayMs, + }, + adapterEventHandlers + ); + + return adapterState.client; + }); + } + + reconfigure( + adapterArgs: LaunchDarklyAdapterArgs, + _adapterEventHandlers: AdapterEventHandlers + ): Promise { + if (!adapterState.isConfigured) + return Promise.reject( + new Error( + '@flopflip/launchdarkly-adapter: please configure adapter before reconfiguring.' + ) + ); + const nextUser = adapterArgs.user; + if (!isEqual(adapterState.user, nextUser)) { + adapterState.user = ensureUser(nextUser); + + return changeUserContext(adapterState.user); + } + + return Promise.resolve(); + } + + getIsReady(): boolean { + return adapterState.isReady; + } + + getFlag(flagName: FlagName): FlagVariation | undefined { + return adapterState.flags[flagName]; + } -const reconfigure = ({ user: nextUser }: { user: User }): Promise => { - if (!adapterState.isConfigured) - return Promise.reject( - new Error( - '@flopflip/launchdarkly-adapter: please configure adapter before reconfiguring.' - ) + updateUserContext(updatedUserProps: User): Promise { + const isAdapterReady = adapterState.isConfigured && adapterState.isReady; + + warning( + isAdapterReady, + '@flopflip/launchdarkly-adapter: adapter not ready and configured. User context can not be updated before.' ); - if (!isEqual(adapterState.user, nextUser)) { - adapterState.user = ensureUser(nextUser); + if (!isAdapterReady) + return Promise.reject( + new Error('Can not update user context: adapter not yet ready.') + ); - return changeUserContext(adapterState.user); + return changeUserContext({ ...adapterState.user, ...updatedUserProps }); } - return Promise.resolve(); -}; + private _didFlagChange(flagName: FlagName, nextFlagValue: FlagVariation) { + const previousFlagValue = this.getFlag(flagName); -export default { - configure, - reconfigure, - getFlag, - getIsReady, - updateUserContext, -}; + if (previousFlagValue === undefined) return true; + + return previousFlagValue !== nextFlagValue; + } + + private _setupFlagSubcription( + { + flagsFromSdk, + flagsUpdateDelayMs, + }: { + flagsFromSdk: Flags; + flagsUpdateDelayMs?: number; + }, + adapterEventHandlers: AdapterEventHandlers + ): void { + for (const flagName in flagsFromSdk) { + // Dispatch whenever a configured flag value changes + if ( + Object.prototype.hasOwnProperty.call(flagsFromSdk, flagName) && + adapterState.client + ) { + adapterState.client.on(`change:${flagName}`, flagValue => { + const [normalizedFlagName, normalizedFlagValue] = normalizeFlag( + flagName, + flagValue + ); + + // Sometimes the SDK flushes flag changes without a value having changed. + if (!this._didFlagChange(normalizedFlagName, normalizedFlagValue)) + return; + + const updatedFlags: Flags = { + [normalizedFlagName]: normalizedFlagValue, + }; + + // NOTE: Adapter state needs to be updated outside of debounced-fn + // so that no flag updates are lost. + updateFlagsInAdapterState(updatedFlags); + + const updateFlags = () => { + adapterEventHandlers.onFlagsStateChange(adapterState.flags); + }; + + debounce(updateFlags, { + wait: flagsUpdateDelayMs, + immediate: !flagsUpdateDelayMs, + })(); + }); + } + } + } +} + +const adapter = new LaunchDarklyAdapter(); +export default adapter; diff --git a/packages/localstorage-adapter/modules/adapter/adapter.spec.js b/packages/localstorage-adapter/modules/adapter/adapter.spec.js index 209c8729f..44b60e0b4 100644 --- a/packages/localstorage-adapter/modules/adapter/adapter.spec.js +++ b/packages/localstorage-adapter/modules/adapter/adapter.spec.js @@ -3,15 +3,15 @@ import adapter, { updateFlags, STORAGE_SLICE } from './adapter'; jest.mock('invariant'); -const createAdapterArgs = (customArgs = {}) => ({ +const createAdapterEventHandlers = (custom = {}) => ({ onFlagsStateChange: jest.fn(), onStatusStateChange: jest.fn(), - - ...customArgs, + ...custom, }); describe('when configuring', () => { - let adapterArgs = createAdapterArgs(); + let adapterArgs = {}; + let adapterEventHandlers = createAdapterEventHandlers(); describe('when not configured', () => { it('should indicate that the adapter is not ready', () => { @@ -30,7 +30,7 @@ describe('when configuring', () => { }); describe('when configured', () => { - beforeEach(() => adapter.configure(adapterArgs)); + beforeEach(() => adapter.configure(adapterArgs, adapterEventHandlers)); it('should indicate that the adapter is ready', () => { expect(adapter.getIsReady()).toBe(true); @@ -41,17 +41,17 @@ describe('when configuring', () => { }); it('should invoke `onStatusStateChange`', () => { - expect(adapterArgs.onStatusStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onStatusStateChange).toHaveBeenCalled(); }); it('should invoke `onStatusStateChange` with `isReady`', () => { - expect(adapterArgs.onStatusStateChange).toHaveBeenCalledWith({ + expect(adapterEventHandlers.onStatusStateChange).toHaveBeenCalledWith({ isReady: true, }); }); it('should invoke `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalled(); }); describe('when updating flags', () => { @@ -59,7 +59,7 @@ describe('when configuring', () => { beforeEach(() => { // From `configure` - adapterArgs.onFlagsStateChange.mockClear(); + adapterEventHandlers.onFlagsStateChange.mockClear(); updateFlags(updatedFlags); }); @@ -69,11 +69,11 @@ describe('when configuring', () => { }); it('should invoke `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalled(); }); it('should invoke `onFlagsStateChange` with `updatedFlags`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalledWith( + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalledWith( updatedFlags ); }); @@ -86,13 +86,13 @@ describe('when configuring', () => { }; beforeEach(() => { // From `configure` - adapterArgs.onFlagsStateChange.mockClear(); + adapterEventHandlers.onFlagsStateChange.mockClear(); updateFlags(nonNormalizedUpdatedFlags); }); it('should invoke `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalledWith( + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalledWith( expect.objectContaining({ flagA1: false, flagB: false, @@ -116,11 +116,13 @@ describe('when configuring', () => { }); it('should invoke `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalled(); }); it('should invoke `onFlagsStateChange` with empty flags', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalledWith({}); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalledWith( + {} + ); }); }); }); diff --git a/packages/localstorage-adapter/modules/adapter/adapter.ts b/packages/localstorage-adapter/modules/adapter/adapter.ts index aa1735cae..fc153ed57 100644 --- a/packages/localstorage-adapter/modules/adapter/adapter.ts +++ b/packages/localstorage-adapter/modules/adapter/adapter.ts @@ -4,11 +4,14 @@ import camelCase from 'lodash/camelCase'; import { User, AdapterStatus, - AdapterArgsWithEventHandlers, + AdapterEventHandlers, + LocalStorageAdapterInterface, + LocalStorageAdapterArgs, FlagName, FlagVariation, Flag, Flags, + interfaceIdentifiers, } from '@flopflip/types'; type Storage = { @@ -104,7 +107,7 @@ export const updateFlags = (flags: Flags): void => { const subscribeToFlagsChanges = ({ pollingInteral = 1000 * 60, }: { - pollingInteral: number; + pollingInteral?: number; }): void => { setInterval(() => { adapterState.emitter.emit( @@ -114,56 +117,72 @@ const subscribeToFlagsChanges = ({ }, pollingInteral); }; -const configure = ({ - user, - onFlagsStateChange, - onStatusStateChange, - adapterConfiguration, -}: AdapterArgsWithEventHandlers): Promise => { - adapterState.user = user; +class LocalStorageAdapter implements LocalStorageAdapterInterface { + id: typeof interfaceIdentifiers.localstorage; - return Promise.resolve().then(() => { - adapterState.isConfigured = true; - adapterState.isReady = true; + constructor() { + this.id = interfaceIdentifiers.localstorage; + } - adapterState.emitter.on('flagsStateChange', onFlagsStateChange); - adapterState.emitter.on('statusStateChange', onStatusStateChange); + configure( + adapterArgs: LocalStorageAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise { + const { user, adapterConfiguration } = adapterArgs; - adapterState.emitter.emit( - 'flagsStateChange', - normalizeFlags(storage.get('flags')) - ); - adapterState.emitter.emit('statusStateChange', { - isReady: adapterState.isReady, - }); + adapterState.user = user; - adapterState.emitter.emit('readyStateChange'); + return Promise.resolve().then(() => { + adapterState.isConfigured = true; + adapterState.isReady = true; - subscribeToFlagsChanges({ - pollingInteral: adapterConfiguration?.pollingInteral, - }); - }); -}; + adapterState.emitter.on( + 'flagsStateChange', + adapterEventHandlers.onFlagsStateChange + ); + adapterState.emitter.on( + 'statusStateChange', + adapterEventHandlers.onStatusStateChange + ); -const reconfigure = ({ user: nextUser }: { user: User }): Promise => { - storage.unset('flags'); - adapterState.user = nextUser; + adapterState.emitter.emit( + 'flagsStateChange', + normalizeFlags(storage.get('flags')) + ); + adapterState.emitter.emit('statusStateChange', { + isReady: adapterState.isReady, + }); - adapterState.emitter.emit('flagsStateChange', {}); + adapterState.emitter.emit('readyStateChange'); - return Promise.resolve(); -}; + subscribeToFlagsChanges({ + pollingInteral: adapterConfiguration?.pollingInteral, + }); + }); + } + + reconfigure( + adapterArgs: LocalStorageAdapterArgs, + _adapterEventHandlers: AdapterEventHandlers + ): Promise { + storage.unset('flags'); + const nextUser = adapterArgs.user; + adapterState.user = nextUser; + adapterState.emitter.emit('flagsStateChange', {}); + return Promise.resolve(); + } + + waitUntilConfigured(): Promise { + return new Promise(resolve => { + if (adapterState.isConfigured) resolve(); + else adapterState.emitter.on('readyStateChange', resolve); + }); + } -const waitUntilConfigured = (): Promise => - new Promise(resolve => { - if (adapterState.isConfigured) resolve(); - else adapterState.emitter.on('readyStateChange', resolve); - }); -const getIsReady = (): boolean => Boolean(adapterState.isReady); + getIsReady(): boolean { + return Boolean(adapterState.isReady); + } +} -export default { - configure, - waitUntilConfigured, - getIsReady, - reconfigure, -}; +const adapter = new LocalStorageAdapter(); +export default adapter; diff --git a/packages/memory-adapter/modules/adapter/adapter.spec.js b/packages/memory-adapter/modules/adapter/adapter.spec.js index b203bbe4b..64825d220 100644 --- a/packages/memory-adapter/modules/adapter/adapter.spec.js +++ b/packages/memory-adapter/modules/adapter/adapter.spec.js @@ -7,17 +7,23 @@ const createAdapterArgs = (customArgs = {}) => ({ user: { id: 'foo' }, onFlagsStateChange: jest.fn(), onStatusStateChange: jest.fn(), - ...customArgs, }); +const createAdapterEventHandlers = (custom = {}) => ({ + onFlagsStateChange: jest.fn(), + onStatusStateChange: jest.fn(), + ...custom, +}); describe('when configuring', () => { const updatedFlags = { fooFlag: true, barFlag: false }; let adapterArgs; + let adapterEventHandlers; beforeEach(() => { invariant.mockClear(); adapterArgs = createAdapterArgs(); + adapterEventHandlers = createAdapterEventHandlers(); }); it('should indicate that the adapter is not ready', () => { @@ -35,14 +41,14 @@ describe('when configuring', () => { }); describe('when configured', () => { - beforeEach(() => adapter.configure(adapterArgs)); + beforeEach(() => adapter.configure(adapterArgs, adapterEventHandlers)); it('should indicate that the adapter is ready', () => { expect(adapter.getIsReady()).toBe(true); }); it('should invoke `onStatusStateChange`', () => { - expect(adapterArgs.onStatusStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onStatusStateChange).toHaveBeenCalled(); }); it('should resolve `waitUntilConfigured`', async () => { @@ -50,7 +56,7 @@ describe('when configuring', () => { }); it('should invoke `onStatusStateChange` with `isReady`', () => { - expect(adapterArgs.onStatusStateChange).toHaveBeenCalledWith( + expect(adapterEventHandlers.onStatusStateChange).toHaveBeenCalledWith( expect.objectContaining({ isReady: true, }) @@ -58,7 +64,7 @@ describe('when configuring', () => { }); it('should invoke `onStatusStateChange` with `isConfigured`', () => { - expect(adapterArgs.onStatusStateChange).toHaveBeenCalledWith( + expect(adapterEventHandlers.onStatusStateChange).toHaveBeenCalledWith( expect.objectContaining({ isConfigured: true, }) @@ -66,13 +72,13 @@ describe('when configuring', () => { }); it('should invoke `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalled(); }); describe('when updating flags', () => { beforeEach(() => { // From `configure` - adapterArgs.onFlagsStateChange.mockClear(); + adapterEventHandlers.onFlagsStateChange.mockClear(); updateFlags(updatedFlags); }); @@ -82,11 +88,11 @@ describe('when configuring', () => { }); it('should invoke `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalled(); }); it('should invoke `onFlagsStateChange` with `updatedFlags`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalledWith( + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalledWith( expect.objectContaining(updatedFlags) ); }); @@ -99,13 +105,13 @@ describe('when configuring', () => { }; beforeEach(() => { // From `configure` - adapterArgs.onFlagsStateChange.mockClear(); + adapterEventHandlers.onFlagsStateChange.mockClear(); updateFlags(nonNormalizedUpdatedFlags); }); it('should invoke `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalled(); }); it('should normalise all flag names and values', () => { @@ -125,11 +131,13 @@ describe('when configuring', () => { }); it('should invoke `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalled(); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalled(); }); it('should invoke `onFlagsStateChange` with empty flags', () => { - expect(adapterArgs.onFlagsStateChange).toHaveBeenCalledWith({}); + expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalledWith( + {} + ); }); }); @@ -137,13 +145,13 @@ describe('when configuring', () => { beforeEach(() => { updateFlags(updatedFlags); - adapterArgs.onFlagsStateChange.mockClear(); + adapterEventHandlers.onFlagsStateChange.mockClear(); adapter.reset(); }); it('should invoke not `onFlagsStateChange`', () => { - expect(adapterArgs.onFlagsStateChange).not.toHaveBeenCalled(); + expect(adapterEventHandlers.onFlagsStateChange).not.toHaveBeenCalled(); }); it('should reset the flags', () => { @@ -154,7 +162,7 @@ describe('when configuring', () => { describe('when setting ready state', () => { beforeEach(() => { - adapterArgs.onStatusStateChange.mockClear(); + adapterEventHandlers.onStatusStateChange.mockClear(); adapter.setIsReady({ isReady: false }); }); @@ -164,7 +172,7 @@ describe('when configuring', () => { }); it('should invoke `onStatusStateChange` with `isReady`', () => { - expect(adapterArgs.onStatusStateChange).toHaveBeenCalledWith( + expect(adapterEventHandlers.onStatusStateChange).toHaveBeenCalledWith( expect.objectContaining({ isReady: false, }) diff --git a/packages/memory-adapter/modules/adapter/adapter.ts b/packages/memory-adapter/modules/adapter/adapter.ts index 4c370bbc3..574254eff 100644 --- a/packages/memory-adapter/modules/adapter/adapter.ts +++ b/packages/memory-adapter/modules/adapter/adapter.ts @@ -3,11 +3,14 @@ import mitt, { Emitter } from 'mitt'; import { User, AdapterStatus, - AdapterArgsWithEventHandlers, FlagName, FlagVariation, Flag, Flags, + AdapterEventHandlers, + MemoryAdapterInterface, + MemoryAdapterArgs, + interfaceIdentifiers, } from '@flopflip/types'; import camelCase from 'lodash/camelCase'; @@ -31,60 +34,6 @@ let adapterState: AdapterStatus & MemoryAdapterState = { ...intialAdapterState, }; -const configure = ({ - user, - onFlagsStateChange, - onStatusStateChange, -}: AdapterArgsWithEventHandlers): Promise => { - adapterState.user = user; - - return Promise.resolve().then(() => { - adapterState.isConfigured = true; - adapterState.isReady = true; - adapterState.flags = {}; - - updateUser(user); - - adapterState.emitter.on('flagsStateChange', onFlagsStateChange); - adapterState.emitter.on('statusStateChange', onStatusStateChange); - - adapterState.emitter.emit('flagsStateChange', adapterState.flags); - adapterState.emitter.emit('statusStateChange', { - isReady: adapterState.isReady, - isConfigured: adapterState.isConfigured, - }); - - adapterState.emitter.emit('readyStateChange'); - }); -}; - -const reconfigure = ({ user }: { user: User }): Promise => { - updateUser(user); - - adapterState.flags = {}; - adapterState.emitter.emit('flagsStateChange', adapterState.flags); - adapterState.emitter.emit('statusStateChange', { - isConfigured: adapterState.isConfigured, - }); - - return Promise.resolve(); -}; - -const getIsReady = (): boolean => Boolean(adapterState.isReady); -const setIsReady = (nextIsReady: { isReady: boolean }): void => { - adapterState.isReady = nextIsReady.isReady; - - adapterState.emitter.emit('statusStateChange', { - isReady: adapterState.isReady, - }); -}; - -const reset = (): void => { - adapterState = { - ...intialAdapterState, - }; -}; - const updateUser = (user: User): void => { adapterState.user = user; }; @@ -108,7 +57,10 @@ export const normalizeFlags = (rawFlags: Flags): Flags => }, {} ); -export const updateFlags = (flags: Flags): void => { + +export const getUser = (): User | undefined => adapterState.user; + +export const updateFlags = (flags: Flags) => { const isAdapterReady = Boolean( adapterState.isConfigured && adapterState.isReady ); @@ -128,22 +80,96 @@ export const updateFlags = (flags: Flags): void => { adapterState.emitter.emit('flagsStateChange', adapterState.flags); }; -export const getUser = (): User | undefined => adapterState.user; -const waitUntilConfigured = (): Promise => - new Promise(resolve => { - if (adapterState.isConfigured) resolve(); - else adapterState.emitter.on('readyStateChange', resolve); - }); - -const getFlag = (flagName: FlagName): FlagVariation | undefined => - adapterState.flags && adapterState.flags[flagName]; - -export default { - getIsReady, - setIsReady, - waitUntilConfigured, - getFlag, - reset, - configure, - reconfigure, -}; +class MemoryAdapter implements MemoryAdapterInterface { + id: typeof interfaceIdentifiers.memory; + + constructor() { + this.id = interfaceIdentifiers.memory; + } + + configure( + adapterArgs: MemoryAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise { + const { user } = adapterArgs; + + adapterState.user = user; + + return Promise.resolve().then(() => { + adapterState.isConfigured = true; + adapterState.isReady = true; + adapterState.flags = {}; + + updateUser(user); + + adapterState.emitter.on( + 'flagsStateChange', + adapterEventHandlers.onFlagsStateChange + ); + adapterState.emitter.on( + 'statusStateChange', + adapterEventHandlers.onStatusStateChange + ); + + adapterState.emitter.emit('flagsStateChange', adapterState.flags); + adapterState.emitter.emit('statusStateChange', { + isReady: adapterState.isReady, + isConfigured: adapterState.isConfigured, + }); + + adapterState.emitter.emit('readyStateChange'); + }); + } + + reconfigure( + adapterArgs: MemoryAdapterArgs, + _adapterEventHandlers: AdapterEventHandlers + ): Promise { + updateUser(adapterArgs.user); + + adapterState.flags = {}; + adapterState.emitter.emit('flagsStateChange', adapterState.flags); + adapterState.emitter.emit('statusStateChange', { + isConfigured: adapterState.isConfigured, + }); + + return Promise.resolve(); + } + + getIsReady(): boolean { + return Boolean(adapterState.isReady); + } + + setIsReady(nextState: AdapterStatus): void { + adapterState.isReady = nextState.isReady; + + adapterState.emitter.emit('statusStateChange', { + isReady: adapterState.isReady, + }); + } + + reset = (): void => { + adapterState = { + ...intialAdapterState, + }; + }; + + waitUntilConfigured(): Promise { + return new Promise(resolve => { + if (adapterState.isConfigured) resolve(); + else adapterState.emitter.on('readyStateChange', resolve); + }); + } + + getFlag(flagName: FlagName): FlagVariation | undefined { + return adapterState.flags && adapterState.flags[flagName]; + } + + // For convenience + updateFlags(flags: Flags): void { + return updateFlags(flags); + } +} + +const adapter = new MemoryAdapter(); +export default adapter; diff --git a/packages/react-broadcast/modules/components/configure/configure.spec.js b/packages/react-broadcast/modules/components/configure/configure.spec.js index cd2e4ba61..b313dada1 100644 --- a/packages/react-broadcast/modules/components/configure/configure.spec.js +++ b/packages/react-broadcast/modules/components/configure/configure.spec.js @@ -72,13 +72,14 @@ describe('rendering', () => { ); }); - it('should receive `onStatusStateChange` and `onFlagsStateChange` in `adapterArgs`', () => { + it('should receive `onStatusStateChange` and `onFlagsStateChange`', () => { expect(configureAdapterWrapper).toHaveProp( - 'adapterArgs', - expect.objectContaining({ - onStatusStateChange: wrapper.instance().handleUpdateStatus, - onFlagsStateChange: wrapper.instance().handleUpdateFlags, - }) + 'onFlagsStateChange', + wrapper.instance().handleUpdateFlags + ); + expect(configureAdapterWrapper).toHaveProp( + 'onStatusStateChange', + wrapper.instance().handleUpdateStatus ); }); diff --git a/packages/react-broadcast/modules/components/configure/configure.tsx b/packages/react-broadcast/modules/components/configure/configure.tsx index e8c6e6db4..c9ac6bb0b 100644 --- a/packages/react-broadcast/modules/components/configure/configure.tsx +++ b/packages/react-broadcast/modules/components/configure/configure.tsx @@ -1,43 +1,30 @@ import React from 'react'; -import { ConfigureAdapter, createSequentialId } from '@flopflip/react'; -import memoize from 'memoize-one'; +import { ConfigureAdapter } from '@flopflip/react'; import { Flags, Adapter, - AdapterArgs, - AdapterEventHandlers, AdapterStatus, ConfigureAdapterChildren, + ConfigureAdapterProps, } from '@flopflip/types'; import { FlagsContext } from '../flags-context'; -type Props = { +type BaseProps = { children?: ConfigureAdapterChildren; shouldDeferAdapterConfiguration?: boolean; defaultFlags?: Flags; - adapterArgs: AdapterArgs; - adapter: Adapter; }; +type Props = BaseProps & + ConfigureAdapterProps; type State = { flags: Flags; status: AdapterStatus; - configurationId?: string, + configurationId?: string; }; -const getConfigurationId = createSequentialId('Configure'); - -const createAdapterArgs = memoize( - ( - adapterArgs: AdapterArgs, - eventHandlers: AdapterEventHandlers, - _configurationId?: string - ) => ({ - ...adapterArgs, - ...eventHandlers, - }) -); - -export default class Configure extends React.PureComponent { +export default class Configure< + AdapterInstance extends Adapter +> extends React.PureComponent, State> { static displayName = 'ConfigureFlopflip'; static defaultProps = { @@ -51,15 +38,10 @@ export default class Configure extends React.PureComponent { isReady: false, isConfigured: false, }, - configurationId: undefined }; isUnmounted = false; - static getDerivedStateFromProps = () => ({ - configurationId: getConfigurationId(), - }); - componentDidMount(): void { this.isUnmounted = false; } @@ -68,7 +50,7 @@ export default class Configure extends React.PureComponent { this.isUnmounted = true; } - handleUpdateFlags = (nextFlags: Flags): void => { + handleUpdateFlags = (nextFlags: Flags) => { if (!this.isUnmounted) { this.setState(prevState => ({ flags: { @@ -79,7 +61,7 @@ export default class Configure extends React.PureComponent { } }; - handleUpdateStatus = (status: AdapterStatus): void => { + handleUpdateStatus = (status: AdapterStatus) => { if (!this.isUnmounted) { this.setState(prevState => ({ status: { @@ -95,19 +77,14 @@ export default class Configure extends React.PureComponent { {this.props.children} diff --git a/packages/react-broadcast/package.json b/packages/react-broadcast/package.json index efe98ef31..f7da2e9b5 100644 --- a/packages/react-broadcast/package.json +++ b/packages/react-broadcast/package.json @@ -50,8 +50,7 @@ "@babel/runtime": "7.7.7", "@flopflip/react": "^8.1.4", "@flopflip/types": "^2.2.1", - "lodash": "4.17.15", - "memoize-one": "5.1.1" + "lodash": "4.17.15" }, "keywords": [ "react", diff --git a/packages/react-redux/modules/components/branch-on-feature-toggle/branch-on-feature-toggle.spec.js b/packages/react-redux/modules/components/branch-on-feature-toggle/branch-on-feature-toggle.spec.js index 97c5db90c..00631a807 100644 --- a/packages/react-redux/modules/components/branch-on-feature-toggle/branch-on-feature-toggle.spec.js +++ b/packages/react-redux/modules/components/branch-on-feature-toggle/branch-on-feature-toggle.spec.js @@ -2,7 +2,7 @@ import React from 'react'; import { renderWithAdapter, components } from '@flopflip/test-utils'; import { Provider } from 'react-redux'; import { createStore } from '../../../test-utils'; -import { STATE_SLICE } from '../../store'; +import { STATE_SLICE } from '../../store/constants'; import branchOnFeatureToggle from './branch-on-feature-toggle'; import Configure from '../configure'; diff --git a/packages/react-redux/modules/components/configure/configure.spec.js b/packages/react-redux/modules/components/configure/configure.spec.js index ce104ab2b..9b80609cb 100644 --- a/packages/react-redux/modules/components/configure/configure.spec.js +++ b/packages/react-redux/modules/components/configure/configure.spec.js @@ -1,7 +1,9 @@ import React from 'react'; import { renderShallowly } from '@flopflip/test-utils'; import { ConfigureAdapter } from '@flopflip/react'; -import { Configure } from './configure'; +import Configure from './configure'; + +jest.mock('../../hooks'); const ChildComponent = () =>
; const createTestProps = custom => ({ @@ -14,10 +16,6 @@ const createTestProps = custom => ({ fooId: 'foo-id', }, - // HoC - handleUpdateFlags: jest.fn(), - handleUpdateStatus: jest.fn(), - ...custom, }); @@ -66,14 +64,9 @@ describe('rendering', () => { ); }); - it('should receive `onStatusStateChange` and `onFlagsStateChange` in `adapterArgs`', () => { - expect(configureAdapterWrapper).toHaveProp( - 'adapterArgs', - expect.objectContaining({ - onStatusStateChange: props.handleUpdateStatus, - onFlagsStateChange: props.handleUpdateFlags, - }) - ); + it('should receive `onStatusStateChange` and `onFlagsStateChange`', () => { + expect(configureAdapterWrapper).toHaveProp('onStatusStateChange'); + expect(configureAdapterWrapper).toHaveProp('onFlagsStateChange'); }); it('should receive `defaultFlags`', () => { diff --git a/packages/react-redux/modules/components/configure/configure.tsx b/packages/react-redux/modules/components/configure/configure.tsx index 96723a11a..7d99e5d72 100644 --- a/packages/react-redux/modules/components/configure/configure.tsx +++ b/packages/react-redux/modules/components/configure/configure.tsx @@ -1,76 +1,50 @@ import React from 'react'; -import { connect } from 'react-redux'; -import memoize from 'lodash/memoize'; import { ConfigureAdapter } from '@flopflip/react'; import { Flags, Adapter, - AdapterArgs, - AdapterEventHandlers, - AdapterStatus, + ConfigureAdapterProps, ConfigureAdapterChildren, } from '@flopflip/types'; -import { UpdateFlagsAction, UpdateStatusAction } from '../../types'; -import { updateStatus, updateFlags } from './../../ducks'; +import { useUpdateFlags, useUpdateStatus } from '../../hooks'; -type Props = { +type BaseProps = { children?: ConfigureAdapterChildren; shouldDeferAdapterConfiguration?: boolean; defaultFlags?: Flags; - adapterArgs: AdapterArgs; - adapter: Adapter; }; -type ConnectedProps = { - handleUpdateStatus: (status: AdapterStatus) => UpdateStatusAction; - handleUpdateFlags: (flags: Flags) => UpdateFlagsAction; -}; -type State = { - flags: Flags; -}; - -const createAdapterArgs = memoize( - (adapterArgs: AdapterArgs, eventHandlers: AdapterEventHandlers) => ({ - ...adapterArgs, - ...eventHandlers, - }) -); +type Props = BaseProps & + ConfigureAdapterProps; -export class Configure extends React.PureComponent< - Props & ConnectedProps, - State -> { - static displayName = 'ConfigureFlopflip'; - - static defaultProps = { - defaultFlags: {}, - shouldDeferAdapterConfiguration: false, - }; +const defaultProps: Pick< + BaseProps, + 'defaultFlags' | 'shouldDeferAdapterConfiguration' +> = { + defaultFlags: {}, + shouldDeferAdapterConfiguration: false, +}; - render(): React.ReactNode { - return ( - - {this.props.children} - - ); - } -} +const Configure = ( + props: Props +) => { + const handleUpdateFlags = useUpdateFlags(); + const handleUpdateStatus = useUpdateStatus(); -const mapDispatchToProps: ConnectedProps = { - handleUpdateStatus: updateStatus, - handleUpdateFlags: updateFlags, + return ( + + {props.children} + + ); }; -export default connect( - null, - mapDispatchToProps -)(Configure); +Configure.displayName = 'ConfigureFlopflip'; +Configure.defaultProps = defaultProps; + +export default Configure; diff --git a/packages/react-redux/modules/components/inject-feature-toggle/inject-feature-toggle.spec.js b/packages/react-redux/modules/components/inject-feature-toggle/inject-feature-toggle.spec.js index c31519e72..82d6a880a 100644 --- a/packages/react-redux/modules/components/inject-feature-toggle/inject-feature-toggle.spec.js +++ b/packages/react-redux/modules/components/inject-feature-toggle/inject-feature-toggle.spec.js @@ -2,7 +2,7 @@ import React from 'react'; import { renderWithAdapter, components } from '@flopflip/test-utils'; import { Provider } from 'react-redux'; import { createStore } from '../../../test-utils'; -import { STATE_SLICE } from '../../store'; +import { STATE_SLICE } from '../../store/constants'; import injectFeatureToggle from './inject-feature-toggle'; import Configure from '../configure'; diff --git a/packages/react-redux/modules/components/inject-feature-toggles/inject-feature-toggles.spec.js b/packages/react-redux/modules/components/inject-feature-toggles/inject-feature-toggles.spec.js index f339f8988..0377cf4cf 100644 --- a/packages/react-redux/modules/components/inject-feature-toggles/inject-feature-toggles.spec.js +++ b/packages/react-redux/modules/components/inject-feature-toggles/inject-feature-toggles.spec.js @@ -4,7 +4,7 @@ import { renderWithAdapter, components } from '@flopflip/test-utils'; import { ALL_FLAGS_PROP_KEY } from '@flopflip/react'; import { createStore } from '../../../test-utils'; import Configure from '../configure'; -import { STATE_SLICE } from './../../store'; +import { STATE_SLICE } from './../../store/constants'; import { mapStateToProps } from './inject-feature-toggles'; import injectFeatureToggles from './inject-feature-toggles'; diff --git a/packages/react-redux/modules/components/toggle-feature/toggle-feature.spec.js b/packages/react-redux/modules/components/toggle-feature/toggle-feature.spec.js index 876df81f5..35e0e7cc3 100644 --- a/packages/react-redux/modules/components/toggle-feature/toggle-feature.spec.js +++ b/packages/react-redux/modules/components/toggle-feature/toggle-feature.spec.js @@ -3,7 +3,7 @@ import { renderWithAdapter, components } from '@flopflip/test-utils'; import React from 'react'; import { createStore } from '../../../test-utils'; import Configure from '../configure'; -import { STATE_SLICE } from './../../store'; +import { STATE_SLICE } from './../../store/constants'; import ToggleFeature, { mapStateToProps } from './toggle-feature'; describe('mapStateToProps', () => { diff --git a/packages/react-redux/modules/components/toggle-feature/toggle-feature.ts b/packages/react-redux/modules/components/toggle-feature/toggle-feature.ts index 6b2b33142..b8615b956 100644 --- a/packages/react-redux/modules/components/toggle-feature/toggle-feature.ts +++ b/packages/react-redux/modules/components/toggle-feature/toggle-feature.ts @@ -7,7 +7,7 @@ import { setDisplayName, getIsFeatureEnabled, } from '@flopflip/react'; -import { STATE_SLICE } from './../../store'; +import { STATE_SLICE } from './../../store/constants'; type OwnProps = { flag: FlagName; diff --git a/packages/react-redux/modules/ducks/flags/flags.spec.js b/packages/react-redux/modules/ducks/flags/flags.spec.js index 42134a0ef..22bef1b0c 100644 --- a/packages/react-redux/modules/ducks/flags/flags.spec.js +++ b/packages/react-redux/modules/ducks/flags/flags.spec.js @@ -1,4 +1,4 @@ -import { STATE_SLICE } from '../../store'; +import { STATE_SLICE } from '../../store/constants'; import reducer, { UPDATE_FLAGS, updateFlags, diff --git a/packages/react-redux/modules/ducks/flags/flags.ts b/packages/react-redux/modules/ducks/flags/flags.ts index 61f7884a1..454589fc1 100644 --- a/packages/react-redux/modules/ducks/flags/flags.ts +++ b/packages/react-redux/modules/ducks/flags/flags.ts @@ -2,7 +2,7 @@ import isNil from 'lodash/isNil'; import { FlagName, FlagVariation, Flags } from '@flopflip/types'; import { UpdateFlagsAction } from './types.js'; import { State } from '../../types'; -import { STATE_SLICE } from '../../store'; +import { STATE_SLICE } from '../../store/constants'; import { Reducer } from 'redux'; // Actions diff --git a/packages/react-redux/modules/hooks/index.ts b/packages/react-redux/modules/hooks/index.ts index 14fc7cc76..13035727f 100644 --- a/packages/react-redux/modules/hooks/index.ts +++ b/packages/react-redux/modules/hooks/index.ts @@ -1 +1,3 @@ export { default as useAdapterReconfiguration } from './use-adapter-reconfiguration'; +export { default as useUpdateFlags } from './use-update-flags'; +export { default as useUpdateStatus } from './use-update-status'; diff --git a/packages/react-redux/modules/hooks/use-update-flags.ts b/packages/react-redux/modules/hooks/use-update-flags.ts new file mode 100644 index 000000000..b6c3db530 --- /dev/null +++ b/packages/react-redux/modules/hooks/use-update-flags.ts @@ -0,0 +1,14 @@ +import React from 'react'; +import { Dispatch } from 'redux'; +import { useDispatch } from 'react-redux'; +import { Flags } from '@flopflip/types'; +import { updateFlags } from '../ducks'; + +const useUpdateFlags = () => { + const dispatch = useDispatch>>(); + return React.useCallback((flags: Flags) => dispatch(updateFlags(flags)), [ + dispatch, + ]); +}; + +export default useUpdateFlags; diff --git a/packages/react-redux/modules/hooks/use-update-status.ts b/packages/react-redux/modules/hooks/use-update-status.ts new file mode 100644 index 000000000..d7ad4a846 --- /dev/null +++ b/packages/react-redux/modules/hooks/use-update-status.ts @@ -0,0 +1,15 @@ +import React from 'react'; +import { Dispatch } from 'redux'; +import { useDispatch } from 'react-redux'; +import { AdapterStatus } from '@flopflip/types'; +import { updateStatus } from '../ducks'; + +const useUpdateStatus = () => { + const dispatch = useDispatch>>(); + return React.useCallback( + (status: AdapterStatus) => dispatch(updateStatus(status)), + [dispatch] + ); +}; + +export default useUpdateStatus; diff --git a/packages/react-redux/modules/index.ts b/packages/react-redux/modules/index.ts index d3a5b61b7..e0301819d 100644 --- a/packages/react-redux/modules/index.ts +++ b/packages/react-redux/modules/index.ts @@ -1,9 +1,8 @@ const version = '__@FLOPFLIP/VERSION_OF_RELEASE__'; -export { - createFlopFlipEnhancer, - STATE_SLICE as FLOPFLIP_STATE_SLICE, -} from './store'; +export { default as createFlopFlipEnhancer } from './store/enhancer'; +// Import this separately to avoid a circular dependency +export { STATE_SLICE as FLOPFLIP_STATE_SLICE } from './store/constants'; export { createFlopflipReducer, flopflipReducer, diff --git a/packages/react-redux/modules/store/constants.ts b/packages/react-redux/modules/store/constants.ts new file mode 100644 index 000000000..521d12774 --- /dev/null +++ b/packages/react-redux/modules/store/constants.ts @@ -0,0 +1 @@ +export const STATE_SLICE = '@flopflip'; diff --git a/packages/react-redux/modules/store/enhancer/enhancer.spec.js b/packages/react-redux/modules/store/enhancer/enhancer.spec.js index 8cf0cfff9..fa5edd7c2 100644 --- a/packages/react-redux/modules/store/enhancer/enhancer.spec.js +++ b/packages/react-redux/modules/store/enhancer/enhancer.spec.js @@ -29,18 +29,9 @@ describe('when creating enhancer', () => { enhancer(next)(args); }); - it('should invoke `configure` on `adapter`', () => { - expect(adapter.configure).toHaveBeenCalled(); - }); - - it('should invoke `configure` on `adapter` with `adapterArgs`', () => { - expect(adapter.configure).toHaveBeenCalledWith( - expect.objectContaining(adapterArgs) - ); - }); - it('should invoke `configure` on `adapter` with `onFlagsStateChange`', () => { expect(adapter.configure).toHaveBeenCalledWith( + adapterArgs, expect.objectContaining({ onFlagsStateChange: expect.any(Function), }) @@ -49,6 +40,7 @@ describe('when creating enhancer', () => { it('should invoke `configure` on `adapter` with `onStatusStateChange`', () => { expect(adapter.configure).toHaveBeenCalledWith( + adapterArgs, expect.objectContaining({ onStatusStateChange: expect.any(Function), }) @@ -63,7 +55,7 @@ describe('when creating enhancer', () => { beforeEach(() => { const { onFlagsStateChange } = adapter.configure.mock.calls[ adapter.configure.mock.calls.length - 1 - ][0]; + ][1]; onFlagsStateChange(nextFlags); }); @@ -85,7 +77,7 @@ describe('when creating enhancer', () => { beforeEach(() => { const { onStatusStateChange } = adapter.configure.mock.calls[ adapter.configure.mock.calls.length - 1 - ][0]; + ][1]; onStatusStateChange(nextStatus); }); diff --git a/packages/react-redux/modules/store/enhancer/enhancer.ts b/packages/react-redux/modules/store/enhancer/enhancer.ts index bfc21985c..0c2391a13 100644 --- a/packages/react-redux/modules/store/enhancer/enhancer.ts +++ b/packages/react-redux/modules/store/enhancer/enhancer.ts @@ -6,16 +6,17 @@ import { } from 'redux'; import { Adapter, - AdapterArgsWithEventHandlers, + AdapterArgs, AdapterStatus, Flags, + AdapterInterface, } from '@flopflip/types'; import { State } from '../../types'; import { updateFlags, updateStatus } from '../../ducks'; export default function createFlopFlipEnhancer( adapter: Adapter, - adapterArgs: AdapterArgsWithEventHandlers + adapterArgs: AdapterArgs ): ( next: StoreEnhancerStoreCreator ) => ( @@ -25,8 +26,7 @@ export default function createFlopFlipEnhancer( return next => (...args) => { const store: Store = next(...args); - adapter.configure({ - ...adapterArgs, + (adapter as AdapterInterface).configure(adapterArgs, { // NOTE: This is like `bindActionCreators` but the bound action // creators are renamed to fit the adapter API and conventions. onFlagsStateChange: (flags: Flags) => { diff --git a/packages/react-redux/modules/store/index.ts b/packages/react-redux/modules/store/index.ts deleted file mode 100644 index ac9221073..000000000 --- a/packages/react-redux/modules/store/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as createFlopFlipEnhancer } from './enhancer'; -export const STATE_SLICE = '@flopflip'; diff --git a/packages/react-redux/modules/types.ts b/packages/react-redux/modules/types.ts index c6cebfbba..ce5c35ce2 100644 --- a/packages/react-redux/modules/types.ts +++ b/packages/react-redux/modules/types.ts @@ -1,7 +1,7 @@ import { Flags, AdapterStatus } from '@flopflip/types'; import { UpdateStatusAction as TUpdateStatusAction } from './ducks/status/types'; import { UpdateFlagsAction as TUpdateFlagsAction } from './ducks/flags/types'; -import { STATE_SLICE } from './store'; +import { STATE_SLICE } from './store/constants'; export type State = { [STATE_SLICE]: { diff --git a/packages/react-redux/package.json b/packages/react-redux/package.json index c0699fc63..f164747d4 100644 --- a/packages/react-redux/package.json +++ b/packages/react-redux/package.json @@ -50,7 +50,6 @@ "lodash": "4.17.15" }, "peerDependencies": { - "memoize-one": "5.1.1", "react": "^16.3", "react-dom": "^16.3", "react-redux": "^5.0 || ^7.0.0", diff --git a/packages/react/modules/components/configure-adapter/configure-adapter.spec.js b/packages/react/modules/components/configure-adapter/configure-adapter.spec.js index 7123ee7a1..794fd2846 100644 --- a/packages/react/modules/components/configure-adapter/configure-adapter.spec.js +++ b/packages/react/modules/components/configure-adapter/configure-adapter.spec.js @@ -22,9 +22,9 @@ const createTestProps = props => ({ user: { key: 'foo-user-key', }, - onFlagsStateChange: jest.fn(), - onStatusStateChange: jest.fn(), }, + onFlagsStateChange: jest.fn(), + onStatusStateChange: jest.fn(), adapter: createAdapter(), children: , @@ -204,7 +204,11 @@ describe('lifecycle', () => { it('should invoke `configure` on `adapter` with `adapterArgs`', () => { expect(props.adapter.configure).toHaveBeenCalledWith( - props.adapterArgs + props.adapterArgs, + { + onFlagsStateChange: props.onFlagsStateChange, + onStatusStateChange: props.onStatusStateChange, + } ); }); @@ -310,7 +314,7 @@ describe('lifecycle', () => { }); it('should invoke `onFlagsStateChange` on `adapterArgs` with `defaultFlags`', () => { - expect(props.adapterArgs.onFlagsStateChange).toHaveBeenCalledWith( + expect(props.onFlagsStateChange).toHaveBeenCalledWith( props.defaultFlags ); }); @@ -430,7 +434,11 @@ describe('lifecycle', () => { it('should invoke `configure` on `adapter` with `adapterArgs`', () => { expect(props.adapter.configure).toHaveBeenCalledWith( - props.adapterArgs + props.adapterArgs, + { + onFlagsStateChange: props.onFlagsStateChange, + onStatusStateChange: props.onStatusStateChange, + } ); }); @@ -511,7 +519,11 @@ describe('lifecycle', () => { it('should invoke `reconfigure` on `adapter` with `adapterArgs`', () => { expect(props.adapter.reconfigure).toHaveBeenCalledWith( - props.adapterArgs + props.adapterArgs, + { + onFlagsStateChange: props.onFlagsStateChange, + onStatusStateChange: props.onStatusStateChange, + } ); }); diff --git a/packages/react/modules/components/configure-adapter/configure-adapter.tsx b/packages/react/modules/components/configure-adapter/configure-adapter.tsx index a982cb88e..47114626e 100644 --- a/packages/react/modules/components/configure-adapter/configure-adapter.tsx +++ b/packages/react/modules/components/configure-adapter/configure-adapter.tsx @@ -3,7 +3,8 @@ import merge from 'deepmerge'; import { Flags, Adapter, - AdapterArgsWithEventHandlers, + AdapterInterface, + AdapterArgs, AdapterStatus, AdapterReconfiguration, AdapterReconfigurationOptions, @@ -28,32 +29,36 @@ export const AdapterStates: AdapterStates = { type Props = { shouldDeferAdapterConfiguration?: boolean; adapter: Adapter; - adapterArgs: AdapterArgsWithEventHandlers; + adapterArgs: AdapterArgs; adapterStatus?: AdapterStatus; defaultFlags?: Flags; + onFlagsStateChange: (flags: Flags) => void; + onStatusStateChange: (status: AdapterStatus) => void; render?: () => React.ReactNode; children?: ConfigureAdapterChildren; }; type State = { - appliedAdapterArgs: AdapterArgsWithEventHandlers; + appliedAdapterArgs: AdapterArgs; }; type AdapterState = valueof; const isFunctionChildren = ( children: ConfigureAdapterChildren -): children is ConfigureAdapterChildrenAsFunction => typeof children === 'function'; +): children is ConfigureAdapterChildrenAsFunction => + typeof children === 'function'; const isEmptyChildren = (children: ConfigureAdapterChildren): boolean => !isFunctionChildren(children) && React.Children.count(children) === 0; export const mergeAdapterArgs = ( - previousAdapterArgs: AdapterArgsWithEventHandlers, + previousAdapterArgs: AdapterArgs, { adapterArgs: nextAdapterArgs, options = {} }: AdapterReconfiguration -): AdapterArgsWithEventHandlers => +): AdapterArgs => options.shouldOverwrite ? nextAdapterArgs : merge(previousAdapterArgs, nextAdapterArgs); +// eslint-disable-next-line react/no-unsafe export default class ConfigureAdapter extends React.PureComponent< Props, State @@ -64,17 +69,19 @@ export default class ConfigureAdapter extends React.PureComponent< children: null, render: null, }; + adapterState: AdapterState = AdapterStates.UNCONFIGURED; - pendingAdapterArgs?: AdapterArgsWithEventHandlers | null = null; + pendingAdapterArgs?: AdapterArgs | null = null; - state: { appliedAdapterArgs: AdapterArgsWithEventHandlers } = { + state: { appliedAdapterArgs: AdapterArgs } = { appliedAdapterArgs: this.props.adapterArgs, }; setAdapterState = (nextAdapterState: AdapterState): void => { this.adapterState = nextAdapterState; }; - applyAdapterArgs = (nextAdapterArgs: AdapterArgsWithEventHandlers): void => + + applyAdapterArgs = (nextAdapterArgs: AdapterArgs): void => /** * NOTE: * We can only unset `pendingAdapterArgs` after be actually perform @@ -98,7 +105,7 @@ export default class ConfigureAdapter extends React.PureComponent< * this function has two arguments for clarify. */ reconfigureOrQueue = ( - nextAdapterArgs: AdapterArgsWithEventHandlers, + nextAdapterArgs: AdapterArgs, options: AdapterReconfigurationOptions ): void => this.adapterState === AdapterStates.CONFIGURED && @@ -123,10 +130,11 @@ export default class ConfigureAdapter extends React.PureComponent< * to contain the initial state (through property initializer). */ this.pendingAdapterArgs = mergeAdapterArgs( - this.pendingAdapterArgs || this.state.appliedAdapterArgs, + this.pendingAdapterArgs ?? this.state.appliedAdapterArgs, nextReconfiguration ); }; + /** * NOTE: * Whenever the adapter delays configuration pending adapterArgs will @@ -138,12 +146,12 @@ export default class ConfigureAdapter extends React.PureComponent< * be passed pending or applied adapterArgs. * */ - getAdapterArgsForConfiguration = (): AdapterArgsWithEventHandlers => - this.pendingAdapterArgs || this.state.appliedAdapterArgs; + getAdapterArgsForConfiguration = (): AdapterArgs => + this.pendingAdapterArgs ?? this.state.appliedAdapterArgs; handleDefaultFlags = (defaultFlags: Flags): void => { if (Object.keys(defaultFlags).length > 0) { - this.props.adapterArgs.onFlagsStateChange(defaultFlags); + this.props.onFlagsStateChange(defaultFlags); } }; @@ -183,8 +191,11 @@ export default class ConfigureAdapter extends React.PureComponent< if (!this.props.shouldDeferAdapterConfiguration) { this.setAdapterState(AdapterStates.CONFIGURING); - return this.props.adapter - .configure(this.getAdapterArgsForConfiguration()) + return (this.props.adapter as AdapterInterface) + .configure(this.getAdapterArgsForConfiguration(), { + onFlagsStateChange: this.props.onFlagsStateChange, + onStatusStateChange: this.props.onStatusStateChange, + }) .then(() => { this.setAdapterState(AdapterStates.CONFIGURED); if (this.pendingAdapterArgs) { @@ -209,34 +220,34 @@ export default class ConfigureAdapter extends React.PureComponent< ) { this.setAdapterState(AdapterStates.CONFIGURING); - return ( - this.props.adapter - // NOTE: ESLint otherwise fails for unknown reasons - // eslint-disable-next-line - .configure(this.getAdapterArgsForConfiguration()) - .then(() => { - this.setAdapterState(AdapterStates.CONFIGURED); + return (this.props.adapter as AdapterInterface) + .configure(this.getAdapterArgsForConfiguration(), { + onFlagsStateChange: this.props.onFlagsStateChange, + onStatusStateChange: this.props.onStatusStateChange, + }) + .then(() => { + this.setAdapterState(AdapterStates.CONFIGURED); - if (this.pendingAdapterArgs) { - this.applyAdapterArgs(this.pendingAdapterArgs); - } - }) - ); - } else if ( + if (this.pendingAdapterArgs) { + this.applyAdapterArgs(this.pendingAdapterArgs); + } + }); + } + + if ( this.adapterState === AdapterStates.CONFIGURED && this.adapterState !== AdapterStates.CONFIGURING ) { this.setAdapterState(AdapterStates.CONFIGURING); - return ( - this.props.adapter - // NOTE: ESLint otherwise fails for unknown reasons - // eslint-disable-next-line - .reconfigure(this.getAdapterArgsForConfiguration()) - .then(() => { - this.setAdapterState(AdapterStates.CONFIGURED); - }) - ); + return (this.props.adapter as AdapterInterface) + .reconfigure(this.getAdapterArgsForConfiguration(), { + onFlagsStateChange: this.props.onFlagsStateChange, + onStatusStateChange: this.props.onStatusStateChange, + }) + .then(() => { + this.setAdapterState(AdapterStates.CONFIGURED); + }); } } diff --git a/packages/react/modules/components/configure-adapter/with-reconfiguration.tsx b/packages/react/modules/components/configure-adapter/with-reconfiguration.tsx index fd337806a..316d40e94 100644 --- a/packages/react/modules/components/configure-adapter/with-reconfiguration.tsx +++ b/packages/react/modules/components/configure-adapter/with-reconfiguration.tsx @@ -7,9 +7,7 @@ type ProvidedProps = { reconfigure: ReconfigureAdapter; }; -const withReconfiguration = ( - propKey: string = 'reconfigure' -) => ( +const withReconfiguration = (propKey = 'reconfigure') => ( Component: React.ComponentType ): React.ComponentType => { class EnhancedComponent extends React.PureComponent { diff --git a/packages/react/modules/helpers/create-sequential-id/create-sequential-id.spec.js b/packages/react/modules/helpers/create-sequential-id/create-sequential-id.spec.js deleted file mode 100644 index 2e803b660..000000000 --- a/packages/react/modules/helpers/create-sequential-id/create-sequential-id.spec.js +++ /dev/null @@ -1,31 +0,0 @@ -import createSequentialId from './create-sequential-id'; - -it('should return a function', () => { - expect(typeof createSequentialId('foo')).toBe('function'); -}); - -describe('when the factory is created', () => { - let sequentialId; - beforeEach(() => { - sequentialId = createSequentialId('first-'); - }); - it('should return sequential ids', () => { - expect(sequentialId()).toBe('first-1'); - expect(sequentialId()).toBe('first-2'); - }); -}); - -describe('when two factories are created', () => { - let sequentialIdFirst; - let sequentialIdSecond; - beforeEach(() => { - sequentialIdFirst = createSequentialId('first-'); - sequentialIdSecond = createSequentialId('second-'); - }); - it('should have separate counters for each one', () => { - expect(sequentialIdFirst()).toBe('first-1'); - expect(sequentialIdFirst()).toBe('first-2'); - expect(sequentialIdSecond()).toBe('second-1'); - expect(sequentialIdFirst()).toBe('first-3'); - }); -}); diff --git a/packages/react/modules/helpers/create-sequential-id/create-sequential-id.ts b/packages/react/modules/helpers/create-sequential-id/create-sequential-id.ts deleted file mode 100644 index 564fc71f0..000000000 --- a/packages/react/modules/helpers/create-sequential-id/create-sequential-id.ts +++ /dev/null @@ -1,9 +0,0 @@ -const createSequentialId = (prefix: string) => { - let id = 0; - return () => { - id += 1; - return `${prefix}${id}`; - }; -}; - -export default createSequentialId; diff --git a/packages/react/modules/helpers/create-sequential-id/index.ts b/packages/react/modules/helpers/create-sequential-id/index.ts deleted file mode 100644 index d9f3ef058..000000000 --- a/packages/react/modules/helpers/create-sequential-id/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './create-sequential-id'; diff --git a/packages/react/modules/helpers/index.ts b/packages/react/modules/helpers/index.ts index 620ee4ddc..84d948d9d 100644 --- a/packages/react/modules/helpers/index.ts +++ b/packages/react/modules/helpers/index.ts @@ -1,3 +1,2 @@ export { default as getIsFeatureEnabled } from './get-is-feature-enabled'; export { default as getNormalizedFlagName } from './get-normalized-flag-name'; -export { default as createSequentialId } from './create-sequential-id'; diff --git a/packages/react/modules/index.ts b/packages/react/modules/index.ts index b3a592660..fb8318f2e 100644 --- a/packages/react/modules/index.ts +++ b/packages/react/modules/index.ts @@ -11,7 +11,7 @@ export { ReconfigureAdapter, } from './components'; -export { getIsFeatureEnabled, createSequentialId } from './helpers'; +export { getIsFeatureEnabled } from './helpers'; export { DEFAULT_FLAG_PROP_KEY, diff --git a/packages/splitio-adapter/modules/adapter/adapter.spec.js b/packages/splitio-adapter/modules/adapter/adapter.spec.js index c778de1cd..27dd6ae6f 100644 --- a/packages/splitio-adapter/modules/adapter/adapter.spec.js +++ b/packages/splitio-adapter/modules/adapter/adapter.spec.js @@ -52,12 +52,16 @@ describe('when configuring', () => { describe('with user key', () => { beforeEach(() => { - return adapter.configure({ - authorizationKey, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }); + return adapter.configure( + { + authorizationKey, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); it('should initialize the `SplitFactory` client with `authorizationKey` and given `user`', () => { @@ -72,12 +76,16 @@ describe('when configuring', () => { describe('without key', () => { beforeEach(() => - adapter.configure({ - authorizationKey, - user: userWithoutKey, - onStatusStateChange, - onFlagsStateChange, - }) + adapter.configure( + { + authorizationKey, + user: userWithoutKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ) ); it('should initialize the `SplitFactory` with `authorizationKey` and random `user` `key`', () => { @@ -96,13 +104,17 @@ describe('when configuring', () => { }; beforeEach(() => { - return adapter.configure({ - authorizationKey, - user: userWithKey, - options, - onStatusStateChange, - onFlagsStateChange, - }); + return adapter.configure( + { + authorizationKey, + user: userWithKey, + options, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); it('should initialize the `SplitFactory` client with `options`', () => { @@ -122,15 +134,19 @@ describe('when configuring', () => { }; beforeEach(() => { - return adapter.configure({ - authorizationKey, - user: userWithKey, - options: { - core: coreOptions, + return adapter.configure( + { + authorizationKey, + user: userWithKey, + options: { + core: coreOptions, + }, }, - onStatusStateChange, - onFlagsStateChange, - }); + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); it('should initialize the `SplitFactory` client with `core` options in `core` property', () => { @@ -171,12 +187,16 @@ describe('when configuring', () => { SplitFactory.mockReturnValue(factory); - return adapter.configure({ - authorizationKey, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }); + return adapter.configure( + { + authorizationKey, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ); }); describe('when `splitio` is ready', () => { @@ -234,12 +254,16 @@ describe('when configuring', () => { SplitFactory.mockReturnValue(factory); return adapter - .configure({ - authorizationKey, - user: userWithKey, - onStatusStateChange, - onFlagsStateChange, - }) + .configure( + { + authorizationKey, + user: userWithKey, + }, + { + onStatusStateChange, + onFlagsStateChange, + } + ) .then(() => { // NOTE: Clearing stubs as they are invoked // first during `configure`. diff --git a/packages/splitio-adapter/modules/adapter/adapter.ts b/packages/splitio-adapter/modules/adapter/adapter.ts index 63f702ef5..cb38d7823 100644 --- a/packages/splitio-adapter/modules/adapter/adapter.ts +++ b/packages/splitio-adapter/modules/adapter/adapter.ts @@ -6,6 +6,10 @@ import { Flags, OnFlagsStateChangeCallback, OnStatusStateChangeCallback, + AdapterEventHandlers, + SplitioAdapterInterface, + SplitioAdapterArgs, + interfaceIdentifiers, } from '@flopflip/types'; import merge from 'deepmerge'; import { SplitFactory } from '@splitsoftware/splitio'; @@ -26,13 +30,6 @@ type AdapterState = { splitioSettings?: SplitIO.IBrowserSettings; }; -type ClientInitializationOptions = { - [key: string]: any; - core?: { - [key: string]: string; - }; -}; - const adapterState: AdapterState = { isReady: false, isConfigured: false, @@ -112,7 +109,6 @@ const initializeClient = (): { client: SplitIO.IClient; manager: SplitIO.IManager; } => { - // eslint-disable-next-line new-cap if (!adapterState.splitioSettings) { throw Error( 'cannot initialize SplitIo without configured settings, call configure() first' @@ -166,8 +162,6 @@ const subscribe = ({ } else reject(); }); -const getIsReady = (): boolean => Boolean(adapterState.isReady); - const configureSplitio = () => { const { client, manager } = initializeClient(); adapterState.client = client; @@ -181,61 +175,68 @@ const configureSplitio = () => { }); }; -const configure = ({ - authorizationKey, - user, - options = {}, - onFlagsStateChange, - onStatusStateChange, -}: { - authorizationKey: string; - user: User; - options: ClientInitializationOptions; - onFlagsStateChange: OnFlagsStateChangeCallback; - onStatusStateChange: OnStatusStateChangeCallback; -}): Promise => { - adapterState.user = ensureUser(user); - adapterState.configuredCallbacks.onFlagsStateChange = onFlagsStateChange; - adapterState.configuredCallbacks.onStatusStateChange = onStatusStateChange; - adapterState.splitioSettings = { - ...omit(options, ['core']), - core: { - authorizationKey, - key: adapterState.user.key ?? createAnonymousUserKey(), - ...options.core, - }, - }; - return configureSplitio(); -}; +class SplitioAdapter implements SplitioAdapterInterface { + id: typeof interfaceIdentifiers.splitio; -const reconfigure = ({ user }: { user: User }): Promise => { - if ( - !adapterState.isReady || - !adapterState.isConfigured || - !adapterState.user - ) { - return Promise.reject( - new Error( - '@flopflip/splitio-adapter: please configure adapter before reconfiguring.' - ) - ); + constructor() { + this.id = interfaceIdentifiers.splitio; } - if (!isEqual(adapterState.user, user)) { + configure( + adapterArgs: SplitioAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise { + const { authorizationKey, user, options = {} } = adapterArgs; + adapterState.user = ensureUser(user); + adapterState.configuredCallbacks.onFlagsStateChange = + adapterEventHandlers.onFlagsStateChange; + adapterState.configuredCallbacks.onStatusStateChange = + adapterEventHandlers.onStatusStateChange; + adapterState.splitioSettings = { + ...omit(options, ['core']), + core: { + authorizationKey, + key: adapterState.user.key ?? createAnonymousUserKey(), + ...options.core, + }, + }; + return configureSplitio(); + } - if (adapterState.manager && adapterState.client) { - adapterState.client.destroy(); + reconfigure( + adapterArgs: SplitioAdapterArgs, + _adapterEventHandlers: AdapterEventHandlers + ): Promise { + if ( + !adapterState.isReady || + !adapterState.isConfigured || + !adapterState.user + ) { + return Promise.reject( + new Error( + '@flopflip/splitio-adapter: please configure adapter before reconfiguring.' + ) + ); } - return configureSplitio(); + if (!isEqual(adapterState.user, adapterArgs.user)) { + adapterState.user = ensureUser(adapterArgs.user); + + if (adapterState.manager && adapterState.client) { + adapterState.client.destroy(); + } + + return configureSplitio(); + } + + return Promise.resolve(); } - return Promise.resolve(); -}; + getIsReady(): boolean { + return Boolean(adapterState.isReady); + } +} -export default { - getIsReady, - configure, - reconfigure, -}; +const adapter = new SplitioAdapter(); +export default adapter; diff --git a/packages/types/index.ts b/packages/types/index.ts index f41802de2..cceaee24d 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -13,18 +13,147 @@ export type AdapterEventHandlers = { onFlagsStateChange: (flags: Flags) => void; onStatusStateChange: (status: AdapterStatus) => void; }; -export type AdapterArgs = { +export type BaseAdapterArgs = { user: User; - adapterConfiguration: { - pollingInteral: number; - }; +}; +export type LaunchDarklyAdapterArgs = BaseAdapterArgs & { + clientSideId: string; flags: Flags; + clientOptions?: { fetchGoals?: boolean }; + subscribeToFlagChanges?: boolean; + throwOnInitializationFailure?: boolean; + flagsUpdateDelayMs?: number; +}; +export type LocalStorageAdapterArgs = BaseAdapterArgs & { + adapterConfiguration?: { + pollingInteral?: number; + }; +}; +export type MemoryAdapterArgs = BaseAdapterArgs; +export type SplitioAdapterArgs = BaseAdapterArgs & { + authorizationKey: string; + options?: { + [key: string]: unknown; + core?: { + [key: string]: string; + }; + }; }; -export type AdapterArgsWithEventHandlers = AdapterArgs & AdapterEventHandlers; -export type Adapter = { - configure(adapterArgs: AdapterArgsWithEventHandlers): Promise; - reconfigure(adapterArgs: AdapterArgsWithEventHandlers): Promise; +export type AdapterArgs = + | LaunchDarklyAdapterArgs + | LocalStorageAdapterArgs + | MemoryAdapterArgs + | SplitioAdapterArgs; +export const interfaceIdentifiers = { + launchdarkly: 'launchdarkly', + localstorage: 'localstorage', + memory: 'memory', + splitio: 'splitio', +} as const; +export type AdapterInterfaceIdentifiers = typeof interfaceIdentifiers[keyof typeof interfaceIdentifiers]; +export interface AdapterInterface { + // Identifiers are used to uniquely identify an interface when performing a condition check. + id: AdapterInterfaceIdentifiers; + configure( + adapterArgs: Args, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + reconfigure( + adapterArgs: Args, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + getIsReady(): boolean; + setIsReady?(nextStatus: AdapterStatus): void; + waitUntilConfigured?(): Promise; + reset?(): void; + getFlag?(flagName: FlagName): FlagVariation | undefined; +} +export interface LaunchDarklyAdapterInterface + extends AdapterInterface { + id: typeof interfaceIdentifiers.launchdarkly; + configure( + adapterArgs: LaunchDarklyAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + reconfigure( + adapterArgs: LaunchDarklyAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + getIsReady(): boolean; + getFlag(flagName: FlagName): FlagVariation | undefined; + updateUserContext(updatedUserProps: User): Promise; +} +export interface LocalStorageAdapterInterface + extends AdapterInterface { + id: typeof interfaceIdentifiers.localstorage; + configure( + adapterArgs: LocalStorageAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + reconfigure( + adapterArgs: LocalStorageAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + getIsReady(): boolean; + waitUntilConfigured(): Promise; +} +export interface MemoryAdapterInterface + extends AdapterInterface { + id: typeof interfaceIdentifiers.memory; + configure( + adapterArgs: MemoryAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + reconfigure( + adapterArgs: MemoryAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + getIsReady(): boolean; + setIsReady(nextStatus: AdapterStatus): void; + waitUntilConfigured(): Promise; + reset(): void; + updateFlags(flags: Flags): void; +} +export interface SplitioAdapterInterface + extends AdapterInterface { + id: typeof interfaceIdentifiers.splitio; + configure( + adapterArgs: SplitioAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise; + reconfigure( + adapterArgs: SplitioAdapterArgs, + adapterEventHandlers: AdapterEventHandlers + ): Promise; getIsReady(): boolean; +} +export type Adapter = + | LaunchDarklyAdapterInterface + | LocalStorageAdapterInterface + | MemoryAdapterInterface + | SplitioAdapterInterface; +export type ConfigureAdapterArgs< + AdapterInstance extends Adapter +> = AdapterInstance extends LaunchDarklyAdapterInterface + ? LaunchDarklyAdapterArgs + : AdapterInstance extends LocalStorageAdapterInterface + ? LocalStorageAdapterArgs + : AdapterInstance extends MemoryAdapterInterface + ? MemoryAdapterArgs + : AdapterInstance extends SplitioAdapterInterface + ? SplitioAdapterArgs + : never; +export type ConfigureAdapterProps = { + adapter: AdapterInstance extends LaunchDarklyAdapterInterface + ? LaunchDarklyAdapterInterface + : AdapterInstance extends LocalStorageAdapterInterface + ? LocalStorageAdapterInterface + : AdapterInstance extends MemoryAdapterInterface + ? MemoryAdapterInterface + : AdapterInstance extends SplitioAdapterInterface + ? SplitioAdapterInterface + : never; + adapterArgs: ConfigureAdapterArgs; }; export type AdapterStatusChange = { [key: string]: boolean }; export type OnFlagsStateChangeCallback = (flags: Flags) => void; @@ -36,7 +165,7 @@ export type AdapterReconfigurationOptions = { shouldOverwrite?: boolean; }; export type AdapterReconfiguration = { - adapterArgs: AdapterArgsWithEventHandlers; + adapterArgs: AdapterArgs; options: AdapterReconfigurationOptions; }; export type ConfigureAdapterChildrenAsFunctionArgs = { @@ -49,7 +178,7 @@ export type ConfigureAdapterChildren = | ConfigureAdapterChildrenAsFunction | React.ReactNode; export type ReconfigureAdapter = ( - adapterArgs: AdapterArgsWithEventHandlers, + adapterArgs: AdapterArgs, options: AdapterReconfigurationOptions ) => void; export type AdapterContext = { diff --git a/yarn.lock b/yarn.lock index 4d163c8e4..4269882b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5208,7 +5208,6 @@ fsevents@^1.0.0, fsevents@^1.2.7: dependencies: bindings "^1.5.0" nan "^2.12.1" - node-pre-gyp "*" function-bind@^1.1.1: version "1.1.1" @@ -7572,11 +7571,6 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" -memoize-one@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" - integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== - meow@5.0.0, meow@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/meow/-/meow-5.0.0.tgz#dfc73d63a9afc714a5e371760eb5c88b91078aa4" @@ -8884,7 +8878,6 @@ prompts@^2.0.1: "prompts@git://github.com/terkelg/prompts.git#792824f8e5bfe3d632da0e48be23ab718b8f6646": version "0.1.4" - uid "792824f8e5bfe3d632da0e48be23ab718b8f6646" resolved "git://github.com/terkelg/prompts.git#792824f8e5bfe3d632da0e48be23ab718b8f6646" dependencies: clorox "^1.0.1"