From 41fe7d3c710e9ec9f0987a14e0d28125d6699a15 Mon Sep 17 00:00:00 2001 From: Joseph Axisa Date: Fri, 14 Apr 2023 19:43:45 +0000 Subject: [PATCH] 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 | 1714 ++++++++++------- 19 files changed, 2274 insertions(+), 688 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 ( +