From 5eeaaae979be1a5a8625cec700203fa20667f962 Mon Sep 17 00:00:00 2001 From: Joseph Axisa Date: Fri, 14 Apr 2023 19:43:45 +0000 Subject: [PATCH 1/6] feat: SelectTheme component --- packages/embed-components/package.json | 17 + .../embed-components/src/GlobalStore/index.ts | 27 + .../src/GlobalStore/sagas.spec.ts | 92 ++ .../embed-components/src/GlobalStore/sagas.ts | 46 + .../embed-components/src/GlobalStore/slice.ts | 72 + .../embed-components/src/GlobalStore/store.ts | 46 + .../src/Theme/SelectTheme.spec.tsx | 153 +++ .../src/Theme/SelectTheme.tsx | 74 + packages/embed-components/src/Theme/index.ts | 27 + .../embed-components/src/Theme/state/index.ts | 26 + .../src/Theme/state/sagas.spec.ts | 280 ++++ .../embed-components/src/Theme/state/sagas.ts | 132 ++ .../embed-components/src/Theme/state/slice.ts | 122 ++ packages/embed-components/src/index.ts | 3 +- .../embed-components/src/test-utils/index.ts | 27 + .../embed-components/src/test-utils/store.tsx | 49 + .../embed-components/src/test-utils/utils.ts | 54 + packages/embed-services/src/index.ts | 1 + yarn.lock | 1188 ++++++++++------- 19 files changed, 1986 insertions(+), 450 deletions(-) create mode 100644 packages/embed-components/src/GlobalStore/index.ts create mode 100644 packages/embed-components/src/GlobalStore/sagas.spec.ts create mode 100644 packages/embed-components/src/GlobalStore/sagas.ts create mode 100644 packages/embed-components/src/GlobalStore/slice.ts create mode 100644 packages/embed-components/src/GlobalStore/store.ts create mode 100644 packages/embed-components/src/Theme/SelectTheme.spec.tsx create mode 100644 packages/embed-components/src/Theme/SelectTheme.tsx create mode 100644 packages/embed-components/src/Theme/index.ts create mode 100644 packages/embed-components/src/Theme/state/index.ts create mode 100644 packages/embed-components/src/Theme/state/sagas.spec.ts create mode 100644 packages/embed-components/src/Theme/state/sagas.ts create mode 100644 packages/embed-components/src/Theme/state/slice.ts create mode 100644 packages/embed-components/src/test-utils/index.ts create mode 100644 packages/embed-components/src/test-utils/store.tsx create mode 100644 packages/embed-components/src/test-utils/utils.ts diff --git a/packages/embed-components/package.json b/packages/embed-components/package.json index aad398a6c..f16cb6c74 100644 --- a/packages/embed-components/package.json +++ b/packages/embed-components/package.json @@ -34,8 +34,25 @@ }, "homepage": "https://github.com/looker-open-source/sdk-codegen/tree/master/packages/embed-components", "devDependencies": { + "redux-saga-tester": "^1.0.874", + "@looker/sdk-node": "^23.4.0", + "@testing-library/react": "^11.2.7", + "@looker/components-test-utils": "^1.5.27", + "react-redux": "^7.2.9", + "@types/react-redux": "^7.1.25", + "@testing-library/user-event": "^14.4.3" }, "dependencies": { + "@looker/components": "^4.1.3", + "@looker/embed-services": "0.0.1-alpha", + "@looker/redux": "0.0.0", + "@looker/sdk": "^23.2.0", + "@reduxjs/toolkit": "^1.9.3", + "@styled-icons/material-outlined": "^10.47.0", + "react": "16.14.0", + "react-dom": "16.14.0", + "styled-components": "^5.3.1", + "typed-redux-saga": "^1.5.0" }, "keywords": [ "Looker", diff --git a/packages/embed-components/src/GlobalStore/index.ts b/packages/embed-components/src/GlobalStore/index.ts new file mode 100644 index 000000000..8b01399cb --- /dev/null +++ b/packages/embed-components/src/GlobalStore/index.ts @@ -0,0 +1,27 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ +export * from './slice' +export * from './store' diff --git a/packages/embed-components/src/GlobalStore/sagas.spec.ts b/packages/embed-components/src/GlobalStore/sagas.spec.ts new file mode 100644 index 000000000..c8927b1ca --- /dev/null +++ b/packages/embed-components/src/GlobalStore/sagas.spec.ts @@ -0,0 +1,92 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ +import ReduxSagaTester from 'redux-saga-tester' +import { getFactory, createFactory } from '@looker/embed-services' +import type { IAPIMethods } from '@looker/sdk-rtl' +import { + factoryActions, + FACTORY_SLICE_NAME, + factorySlice, + defaultFactoryState, +} from './slice' +import * as sagas from './sagas' + +jest.mock('@looker/embed-services', () => ({ + ...jest.requireActual('@looker/embed-services'), + createFactory: jest.fn(), +})) + +describe('Factory sagas', () => { + let sagaTester: ReduxSagaTester + const mockSdk = {} as IAPIMethods + const { initFactoryAction, initFactorySuccessAction, setFailureAction } = + factoryActions + + beforeEach(() => { + sagaTester = new ReduxSagaTester({ + initialState: { [FACTORY_SLICE_NAME]: defaultFactoryState }, + reducers: { + [FACTORY_SLICE_NAME]: factorySlice.reducer, + }, + }) + sagaTester.start(sagas.saga) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('initSaga', () => { + it('sends initFactorySuccessAction on success', async () => { + expect(getFactory).toThrow('Factory must be created with an SDK.') + + sagaTester.dispatch(initFactoryAction({ sdk: mockSdk })) + + await sagaTester.waitFor('factory/initFactorySuccessAction') + + const calledActions = sagaTester.getCalledActions() + expect(calledActions).toHaveLength(2) + expect(calledActions[0]).toEqual(initFactoryAction({ sdk: mockSdk })) + expect(calledActions[1]).toEqual(initFactorySuccessAction()) + }) + + it('sends setFailureAction on error', async () => { + const expectedError = 'Failed to create factory' + ;(createFactory as jest.Mock).mockImplementationOnce(() => { + throw new Error(expectedError) + }) + sagaTester.dispatch(initFactoryAction({ sdk: mockSdk })) + + await sagaTester.waitFor('factory/setFailureAction') + const calledActions = sagaTester.getCalledActions() + expect(calledActions).toHaveLength(2) + expect(calledActions[0]).toEqual(initFactoryAction({ sdk: mockSdk })) + expect(calledActions[1]).toEqual( + setFailureAction({ error: expectedError }) + ) + }) + }) +}) diff --git a/packages/embed-components/src/GlobalStore/sagas.ts b/packages/embed-components/src/GlobalStore/sagas.ts new file mode 100644 index 000000000..abcb8b256 --- /dev/null +++ b/packages/embed-components/src/GlobalStore/sagas.ts @@ -0,0 +1,46 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ +import { takeEvery, put } from 'typed-redux-saga' +import { createFactory } from '@looker/embed-services' +import type { PayloadAction } from '@reduxjs/toolkit' + +import { factoryActions } from './slice' +import type { InitFactoryAction } from './slice' + +function* initSaga(action: PayloadAction) { + const { initFactorySuccessAction, setFailureAction } = factoryActions + try { + createFactory(action.payload.sdk) + yield* put(initFactorySuccessAction()) + } catch (error: any) { + yield* put(setFailureAction({ error: error.message })) + } +} + +export function* saga() { + const { initFactoryAction } = factoryActions + yield* takeEvery(initFactoryAction, initSaga) +} diff --git a/packages/embed-components/src/GlobalStore/slice.ts b/packages/embed-components/src/GlobalStore/slice.ts new file mode 100644 index 000000000..a0ca8171a --- /dev/null +++ b/packages/embed-components/src/GlobalStore/slice.ts @@ -0,0 +1,72 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ +import { createSlice } from '@reduxjs/toolkit' +import { createSliceHooks } from '@looker/redux' +import type { IAPIMethods } from '@looker/sdk-rtl' +import type { PayloadAction } from '@reduxjs/toolkit' +import { saga } from './sagas' + +export interface FactoryState { + initialized: boolean + error?: string +} + +export const defaultFactoryState: FactoryState = { + initialized: false, +} + +export interface InitFactoryAction { + sdk: IAPIMethods +} + +type SetFailureAction = Record<'error', string> + +export const FACTORY_SLICE_NAME = 'factory' + +export const factorySlice = createSlice({ + name: FACTORY_SLICE_NAME, + initialState: defaultFactoryState, + reducers: { + initFactoryAction(_state, _action: PayloadAction) { + // noop + }, + initFactorySuccessAction(state) { + state.initialized = true + }, + destroyFactoryAction() { + // noop + }, + setFailureAction(state, action: PayloadAction) { + state.error = action.payload.error + }, + }, +}) + +export const factoryActions = factorySlice.actions +export const { + useActions: useFactoryActions, + useStoreState: useFactoryStoreState, +} = createSliceHooks(factorySlice, saga) diff --git a/packages/embed-components/src/GlobalStore/store.ts b/packages/embed-components/src/GlobalStore/store.ts new file mode 100644 index 000000000..4212931f4 --- /dev/null +++ b/packages/embed-components/src/GlobalStore/store.ts @@ -0,0 +1,46 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ +import { createStore } from '@looker/redux' +import { defaultThemesState, themesSlice } from '../Theme' +import type { ThemesState } from '../Theme' +import { defaultFactoryState, factorySlice } from './slice' +import type { FactoryState } from './slice' + +export const store = createStore({ + preloadedState: { + factory: defaultFactoryState, + themes: defaultThemesState, + }, + reducer: { + factory: factorySlice.reducer, + themes: themesSlice.reducer, + }, +}) + +export interface RootState { + factory: FactoryState + themes?: ThemesState +} diff --git a/packages/embed-components/src/Theme/SelectTheme.spec.tsx b/packages/embed-components/src/Theme/SelectTheme.spec.tsx new file mode 100644 index 000000000..565e2ab76 --- /dev/null +++ b/packages/embed-components/src/Theme/SelectTheme.spec.tsx @@ -0,0 +1,153 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ +import React from 'react' +import { screen, waitFor, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Provider } from 'react-redux' +import { renderWithTheme } from '@looker/components-test-utils' +import { createTestStore } from '../test-utils' +import { SelectTheme } from './SelectTheme' +import { useThemeActions } from './state' + +jest.mock('./state', () => ({ + ...jest.requireActual('./state'), + useThemeActions: jest.fn(), +})) + +describe('SelectTheme', () => { + const lookerTheme = { id: '1', name: 'Looker' } + const customTheme1 = { id: '2', name: 'custom_theme_1' } + const customTheme2 = { id: '3', name: 'custom_theme_2' } + const selectedTheme = lookerTheme + const themes = [lookerTheme, customTheme1, customTheme2] + + const getMockStoreState = (overrides: Record = {}) => ({ + themes: { + initialized: true, + selectedTheme, + themes, + defaultTheme: lookerTheme, + ...overrides, + }, + }) + + const initActionSpy = jest.fn() + const loadThemeDataActionSpy = jest.fn() + const selectThemeActionSpy = jest.fn() + + beforeEach(() => { + ;(useThemeActions as jest.Mock).mockReturnValue({ + initAction: initActionSpy, + loadThemeDataAction: loadThemeDataActionSpy, + selectThemeAction: selectThemeActionSpy, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders with the default theme selected', async () => { + const mockStore = createTestStore(getMockStoreState()) + renderWithTheme( + + + + ) + + expect(initActionSpy).toHaveBeenCalled() + expect(loadThemeDataActionSpy).toHaveBeenCalled() + expect(selectThemeActionSpy).not.toHaveBeenCalled() + + const selector = screen.getByRole('textbox') + expect(selector).toHaveValue(lookerTheme.name) + + userEvent.click(selector) + + await waitFor(() => { + expect(screen.getAllByRole('option')).toHaveLength(themes.length) + expect( + screen.getByRole('option', { name: customTheme1.name }) + ).toBeInTheDocument() + expect( + screen.getByRole('option', { name: customTheme2.name }) + ).toBeInTheDocument() + }) + }) + + it('selects on select', async () => { + const mockStore = createTestStore(getMockStoreState()) + renderWithTheme( + + + + ) + + const selector = screen.getByRole('textbox') + expect(selector).toHaveValue(lookerTheme.name) + + expect(screen.queryByRole('option')).not.toBeInTheDocument() + + fireEvent.click(selector) + + await waitFor(() => { + expect(screen.getAllByRole('option')).toHaveLength(themes.length) + }) + + const anotherTheme = screen.getByRole('option', { + name: customTheme1.name, + }) + + // Using fireEvent here because userEvent was causing overlapping act warnings + fireEvent.click(anotherTheme) + + await waitFor(() => { + expect(selectThemeActionSpy).toHaveBeenCalledWith({ + id: customTheme1.id, + }) + }) + }) + + it('is disabled when only one theme is available', () => { + const mockStore = createTestStore( + getMockStoreState({ + selectedTheme: lookerTheme, + themes: [lookerTheme], + defaultTheme: lookerTheme, + }) + ) + + renderWithTheme( + + + + ) + + const selector = screen.getByRole('textbox') + expect(selector).toHaveValue(lookerTheme.name) + expect(selector).toBeDisabled() + }) +}) diff --git a/packages/embed-components/src/Theme/SelectTheme.tsx b/packages/embed-components/src/Theme/SelectTheme.tsx new file mode 100644 index 000000000..3dfdfd0ea --- /dev/null +++ b/packages/embed-components/src/Theme/SelectTheme.tsx @@ -0,0 +1,74 @@ +/* + + MIT License + + Copyright (c) 2023 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ +import React, { useState, useEffect } from 'react' +import { Select } from '@looker/components' +import type { SelectOptionObject } from '@looker/components' +import { useThemeActions, useThemesStoreState } from './state' + +export const SelectTheme = () => { + const { initialized, themes, selectedTheme } = useThemesStoreState() + const { initAction, loadThemeDataAction, selectThemeAction } = + useThemeActions() + const [options, setOptions] = useState() + + useEffect(() => { + /** initialize theme service */ + initAction() + }, []) + + useEffect(() => { + if (initialized) { + /** If theme service is initialized, load all theme data */ + loadThemeDataAction() + } + }, [initialized]) + + useEffect(() => { + const themeOptions: SelectOptionObject[] = [] + themes.forEach((theme) => { + themeOptions.push({ + value: theme.id!, + label: theme.name, + }) + }) + themeOptions.sort((x, y) => x.label!.localeCompare(y.label!)) + setOptions(themeOptions) + }, [themes]) + + const handleChange = (id: string) => { + selectThemeAction({ id }) + } + + return ( + + + { }) describe('selectThemeSaga', () => { - const { selectThemeAction, selectThemeSuccessAction, setFailureAction } = - themeActions + const { + selectThemeAction, + getThemesSuccessAction, + getDefaultThemeSuccessAction, + selectThemeSuccessAction, + setFailureAction, + } = themeActions it('sends selectThemeSuccessAction on success', async () => { registerThemeService() @@ -249,17 +254,27 @@ describe('SelectTheme sagas', () => { const getSpy = jest .spyOn(service, 'get') .mockResolvedValueOnce(selectedTheme) + jest.spyOn(service, 'getAll').mockResolvedValueOnce(service) + jest + .spyOn(service, 'getDefaultTheme') + .mockResolvedValueOnce(selectedTheme) sagaTester.dispatch(selectThemeAction({ id: selectedTheme.id! })) await sagaTester.waitFor('themes/selectThemeSuccessAction') const calledActions = sagaTester.getCalledActions() - expect(calledActions).toHaveLength(2) + expect(calledActions).toHaveLength(4) expect(calledActions[0]).toEqual( selectThemeAction({ id: selectedTheme.id! }) ) - expect(getSpy).toHaveBeenCalledWith(selectedTheme.id) expect(calledActions[1]).toEqual( + getThemesSuccessAction({ themes: service.items }) + ) + expect(calledActions[2]).toEqual( + getDefaultThemeSuccessAction({ defaultTheme: selectedTheme }) + ) + expect(getSpy).toHaveBeenCalledWith(selectedTheme.id) + expect(calledActions[3]).toEqual( selectThemeSuccessAction({ selectedTheme }) ) }) diff --git a/packages/embed-components/src/Theme/state/sagas.ts b/packages/embed-components/src/Theme/state/sagas.ts index 2758c6aad..33e14f827 100644 --- a/packages/embed-components/src/Theme/state/sagas.ts +++ b/packages/embed-components/src/Theme/state/sagas.ts @@ -40,7 +40,7 @@ import type { SelectThemeAction } from './slice' function* initSaga() { const { initSuccessAction, setFailureAction } = themeActions try { - registerThemeService() + registerThemeService(10) yield* put(initSuccessAction()) } catch (error: any) { yield* put(setFailureAction({ error: error.message })) @@ -109,6 +109,10 @@ function* selectThemeSaga(action: PayloadAction) { const { selectThemeSuccessAction, setFailureAction } = themeActions try { const service = getThemeService() + if (service.expired()) { + yield* call(getThemesSaga) + yield* call(getDefaultThemeSaga) + } const selectedTheme = yield* call([service, 'get'], action.payload.id) yield* put(selectThemeSuccessAction({ selectedTheme })) } catch (error: any) { diff --git a/packages/embed-components/src/Theme/state/slice.ts b/packages/embed-components/src/Theme/state/slice.ts index 22710a8c5..2785d311c 100644 --- a/packages/embed-components/src/Theme/state/slice.ts +++ b/packages/embed-components/src/Theme/state/slice.ts @@ -36,6 +36,7 @@ export interface ThemesState { selectedTheme: ITheme initialized: boolean error?: string + working: boolean } export const defaultThemesState: ThemesState = { @@ -43,6 +44,7 @@ export const defaultThemesState: ThemesState = { defaultTheme: {} as ITheme, selectedTheme: {} as ITheme, themes: [], + working: false, } type GetThemesSuccessAction = Pick @@ -72,46 +74,44 @@ export const themesSlice = createSlice({ initSuccessAction(state) { state.initialized = true }, - loadThemeDataAction() { - // noop + loadThemeDataAction(state) { + state.working = true }, loadThemeDataSuccessAction( state, action: PayloadAction ) { - state.defaultTheme = action.payload.defaultTheme - state.themes = action.payload.themes - state.selectedTheme = action.payload.selectedTheme + state = { ...state, ...action.payload, working: false } }, - getThemesAction() { - // noop + getThemesAction(state) { + state.working = true }, getThemesSuccessAction( state, action: PayloadAction ) { - state.themes = action.payload.themes + state = { ...state, ...action.payload, working: false } }, - getDefaultThemeAction() { - // noop + getDefaultThemeAction(state) { + state.working = true }, getDefaultThemeSuccessAction( state, action: PayloadAction ) { - state.defaultTheme = action.payload.defaultTheme + state = { ...state, ...action.payload, working: false } }, - selectThemeAction(_state, _action: PayloadAction) { - // noop + selectThemeAction(state, _action: PayloadAction) { + state.working = true }, selectThemeSuccessAction( state, action: PayloadAction ) { - state.selectedTheme = action.payload.selectedTheme + state = { ...state, ...action.payload, working: false } }, setFailureAction(state, action: PayloadAction) { - state.error = action.payload.error + state = { ...state, ...action.payload, working: false } }, }, }) diff --git a/packages/embed-components/src/index.ts b/packages/embed-components/src/index.ts index 52f7c24e1..bcc8d3bca 100644 --- a/packages/embed-components/src/index.ts +++ b/packages/embed-components/src/index.ts @@ -23,5 +23,6 @@ SOFTWARE. */ -export * from './Theme' export * from './GlobalStore' +export * from './QuickEmbed' +export * from './Theme' diff --git a/packages/embed-services/src/EntityService.ts b/packages/embed-services/src/EntityService.ts index 822e283fb..b66f19514 100644 --- a/packages/embed-services/src/EntityService.ts +++ b/packages/embed-services/src/EntityService.ts @@ -37,5 +37,5 @@ export abstract class EntityService implements IEmbedService { * @param sdk * @param timeToLive */ - constructor(public sdk: IAPIMethods, readonly timeToLive: number) {} + constructor(public sdk: IAPIMethods, readonly timeToLive = 900) {} } diff --git a/packages/embed-services/src/ItemList.ts b/packages/embed-services/src/ItemList.ts index 053eaf76c..df492ce08 100644 --- a/packages/embed-services/src/ItemList.ts +++ b/packages/embed-services/src/ItemList.ts @@ -41,6 +41,7 @@ export interface IItemList { readonly expiresAt: number index(key?: keyof T): ItemList indexedItems: Record + expired(): boolean setExpiration(): void clearIfExpired(): void find(key: keyof T, value: any): T | undefined @@ -92,7 +93,7 @@ export abstract class ItemList> /** * Determines if the cache has expired */ - protected expired() { + expired() { return this.expiresAt <= Date.now() } diff --git a/packages/embed-services/src/ServiceFactory.ts b/packages/embed-services/src/ServiceFactory.ts index 14334a6a4..4306d7737 100644 --- a/packages/embed-services/src/ServiceFactory.ts +++ b/packages/embed-services/src/ServiceFactory.ts @@ -29,7 +29,11 @@ export type ServiceCreatorFunc = (sdk: IAPIMethods, timeToLive?: number) => T export interface IServiceFactory { get(serviceName: string): T - register(serviceName: string, serviceCreator: ServiceCreatorFunc): void + register( + serviceName: string, + serviceCreator: ServiceCreatorFunc, + timeToLive?: number + ): void } /** diff --git a/packages/embed-services/src/ThemeService.ts b/packages/embed-services/src/ThemeService.ts index a16e3ebaf..acd273d9d 100644 --- a/packages/embed-services/src/ThemeService.ts +++ b/packages/embed-services/src/ThemeService.ts @@ -132,6 +132,11 @@ class ThemeService extends ItemList implements IThemeService { export const THEME_SERVICE_NAME = 'ThemeService' +/** + * A theme service creator helper function + * @param sdk + * @param timeToLive in seconds for the cache + */ export const themeServiceCreator: ServiceCreatorFunc = ( sdk: IAPIMethods, timeToLive?: number @@ -139,9 +144,16 @@ export const themeServiceCreator: ServiceCreatorFunc = ( return new ThemeService(sdk, timeToLive) } -export const registerThemeService = () => { - getFactory().register(THEME_SERVICE_NAME, themeServiceCreator) +/** + * Creates and registers the theme service with the service factory + * @param timeToLive in seconds for the cache + */ +export const registerThemeService = (timeToLive?: number) => { + getFactory().register(THEME_SERVICE_NAME, themeServiceCreator, timeToLive) } +/** + * Gets the theme service registered with the service factory + */ export const getThemeService = () => getFactory().get(THEME_SERVICE_NAME) From ac252f348acc6e2e327d3df5bee6c9a86f8aa245 Mon Sep 17 00:00:00 2001 From: Joseph Axisa Date: Thu, 20 Apr 2023 21:36:11 +0000 Subject: [PATCH 5/6] create a refreshAction and call it instead --- .../src/Theme/SelectTheme.tsx | 2 +- .../src/Theme/state/sagas.spec.ts | 45 ++++++++++++++++--- .../embed-components/src/Theme/state/sagas.ts | 26 +++++++++-- .../embed-components/src/Theme/state/slice.ts | 9 ++++ packages/embed-components/src/index.ts | 1 - 5 files changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/embed-components/src/Theme/SelectTheme.tsx b/packages/embed-components/src/Theme/SelectTheme.tsx index 9385d53b9..ee0fd0967 100644 --- a/packages/embed-components/src/Theme/SelectTheme.tsx +++ b/packages/embed-components/src/Theme/SelectTheme.tsx @@ -64,7 +64,7 @@ export const SelectTheme = () => { return (