From c8c715ee4b13e790d9bd7ab09020f7acb73beedd Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:51:28 -0400 Subject: [PATCH] Add Onboarding controls to Redux --- .../actions/__tests__/onboarding.test.ts | 101 ++++++++++++++++++ app/soapbox/actions/onboarding.js | 8 -- app/soapbox/actions/onboarding.ts | 40 +++++++ app/soapbox/jest/test-helpers.tsx | 18 ++++ .../reducers/__tests__/onboarding.test.ts | 27 +++++ app/soapbox/reducers/index.ts | 2 + app/soapbox/reducers/onboarding.ts | 22 ++++ 7 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 app/soapbox/actions/__tests__/onboarding.test.ts delete mode 100644 app/soapbox/actions/onboarding.js create mode 100644 app/soapbox/actions/onboarding.ts create mode 100644 app/soapbox/reducers/__tests__/onboarding.test.ts create mode 100644 app/soapbox/reducers/onboarding.ts diff --git a/app/soapbox/actions/__tests__/onboarding.test.ts b/app/soapbox/actions/__tests__/onboarding.test.ts new file mode 100644 index 000000000..cdd268ed5 --- /dev/null +++ b/app/soapbox/actions/__tests__/onboarding.test.ts @@ -0,0 +1,101 @@ +import { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers'; +import rootReducer from 'soapbox/reducers'; + +import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding'; + +describe('checkOnboarding()', () => { + let mockGetItem: any; + + mockWindowProperty('localStorage', { + getItem: (key: string) => mockGetItem(key), + }); + + beforeEach(() => { + mockGetItem = jest.fn().mockReturnValue(null); + }); + + it('does nothing if localStorage item is not set', async() => { + mockGetItem = jest.fn().mockReturnValue(null); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('does nothing if localStorage item is invalid', async() => { + mockGetItem = jest.fn().mockReturnValue('invalid'); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('dispatches the correct action', async() => { + mockGetItem = jest.fn().mockReturnValue('1'); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); +}); + +describe('startOnboarding()', () => { + let mockSetItem: any; + + mockWindowProperty('localStorage', { + setItem: (key: string, value: string) => mockSetItem(key, value), + }); + + beforeEach(() => { + mockSetItem = jest.fn(); + }); + + it('dispatches the correct action', async() => { + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(startOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockSetItem.mock.calls.length).toBe(1); + }); +}); + +describe('endOnboarding()', () => { + let mockRemoveItem: any; + + mockWindowProperty('localStorage', { + removeItem: (key: string) => mockRemoveItem(key), + }); + + beforeEach(() => { + mockRemoveItem = jest.fn(); + }); + + it('dispatches the correct action', async() => { + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(endOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_END' }]); + expect(mockRemoveItem.mock.calls.length).toBe(1); + }); +}); diff --git a/app/soapbox/actions/onboarding.js b/app/soapbox/actions/onboarding.js deleted file mode 100644 index a1dd3a731..000000000 --- a/app/soapbox/actions/onboarding.js +++ /dev/null @@ -1,8 +0,0 @@ -import { changeSetting, saveSettings } from './settings'; - -export const INTRODUCTION_VERSION = 20181216044202; - -export const closeOnboarding = () => dispatch => { - dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); - dispatch(saveSettings()); -}; diff --git a/app/soapbox/actions/onboarding.ts b/app/soapbox/actions/onboarding.ts new file mode 100644 index 000000000..ff12bd074 --- /dev/null +++ b/app/soapbox/actions/onboarding.ts @@ -0,0 +1,40 @@ +const ONBOARDING_START = 'ONBOARDING_START'; +const ONBOARDING_END = 'ONBOARDING_END'; + +const ONBOARDING_LOCAL_STORAGE_KEY = 'soapbox:onboarding'; + +type OnboardingStartAction = { + type: typeof ONBOARDING_START +} + +type OnboardingEndAction = { + type: typeof ONBOARDING_END +} + +export type OnboardingActions = OnboardingStartAction | OnboardingEndAction + +const checkOnboardingStatus = () => (dispatch: React.Dispatch) => { + const needsOnboarding = localStorage.getItem(ONBOARDING_LOCAL_STORAGE_KEY) === '1'; + + if (needsOnboarding) { + dispatch({ type: ONBOARDING_START }); + } +}; + +const startOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.setItem(ONBOARDING_LOCAL_STORAGE_KEY, '1'); + dispatch({ type: ONBOARDING_START }); +}; + +const endOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.removeItem(ONBOARDING_LOCAL_STORAGE_KEY); + dispatch({ type: ONBOARDING_END }); +}; + +export { + ONBOARDING_END, + ONBOARDING_START, + checkOnboardingStatus, + endOnboarding, + startOnboarding, +}; diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 7a4f8f53b..0b195e404 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -63,6 +63,23 @@ const customRender = ( ...options, }); +const mockWindowProperty = (property: any, value: any) => { + const { [property]: originalProperty } = window; + delete window[property]; + + beforeAll(() => { + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value, + }); + }); + + afterAll(() => { + window[property] = originalProperty; + }); +}; + export * from '@testing-library/react'; export { customRender as render, @@ -70,4 +87,5 @@ export { applyActions, rootState, rootReducer, + mockWindowProperty, }; diff --git a/app/soapbox/reducers/__tests__/onboarding.test.ts b/app/soapbox/reducers/__tests__/onboarding.test.ts new file mode 100644 index 000000000..95ecdf755 --- /dev/null +++ b/app/soapbox/reducers/__tests__/onboarding.test.ts @@ -0,0 +1,27 @@ +import { ONBOARDING_START, ONBOARDING_END } from 'soapbox/actions/onboarding'; + +import reducer from '../onboarding'; + +describe('onboarding reducer', () => { + it('should return the initial state', () => { + expect(reducer(undefined, {})).toEqual({ + needsOnboarding: false, + }); + }); + + describe('ONBOARDING_START', () => { + it('sets "needsOnboarding" to "true"', () => { + const initialState = { needsOnboarding: false }; + const action = { type: ONBOARDING_START }; + expect(reducer(initialState, action).needsOnboarding).toEqual(true); + }); + }); + + describe('ONBOARDING_END', () => { + it('sets "needsOnboarding" to "false"', () => { + const initialState = { needsOnboarding: true }; + const action = { type: ONBOARDING_END }; + expect(reducer(initialState, action).needsOnboarding).toEqual(false); + }); + }); +}); diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index 4e54ec19f..61234e2ed 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -39,6 +39,7 @@ import meta from './meta'; import modals from './modals'; import mutes from './mutes'; import notifications from './notifications'; +import onboarding from './onboarding'; import patron from './patron'; import pending_statuses from './pending_statuses'; import polls from './polls'; @@ -118,6 +119,7 @@ const reducers = { accounts_meta, trending_statuses, verification, + onboarding, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/app/soapbox/reducers/onboarding.ts b/app/soapbox/reducers/onboarding.ts new file mode 100644 index 000000000..844d6b353 --- /dev/null +++ b/app/soapbox/reducers/onboarding.ts @@ -0,0 +1,22 @@ +import { ONBOARDING_START, ONBOARDING_END } from 'soapbox/actions/onboarding'; + +import type { OnboardingActions } from 'soapbox/actions/onboarding'; + +type OnboardingState = { + needsOnboarding: boolean, +} + +const initialState: OnboardingState = { + needsOnboarding: false, +}; + +export default function onboarding(state: OnboardingState = initialState, action: OnboardingActions): OnboardingState { + switch(action.type) { + case ONBOARDING_START: + return { ...state, needsOnboarding: true }; + case ONBOARDING_END: + return { ...state, needsOnboarding: false }; + default: + return state; + } +}