From 4c78712d44fb32dbdc0690a6121bc35086dec44a Mon Sep 17 00:00:00 2001 From: Joseph Axisa Date: Fri, 5 May 2023 18:28:04 -0700 Subject: [PATCH] feat: QuickEmbed component (#1306) --- .../src/QuickEmbed/QuickEmbed.spec.tsx | 156 ++++++++++++++++++ .../src/QuickEmbed/QuickEmbed.tsx | 118 +++++++++++++ .../embed-components/src/QuickEmbed/index.ts | 26 +++ .../src/Theme/SelectTheme.spec.tsx | 2 +- .../src/Theme/SelectTheme.tsx | 4 +- .../src/Theme/state/sagas.spec.ts | 142 +++------------- .../embed-components/src/Theme/state/sagas.ts | 85 ++-------- .../embed-components/src/Theme/state/slice.ts | 43 +---- packages/embed-components/src/index.ts | 1 + packages/embed-playground/package.json | 4 +- .../embed-playground/src/EmbedPlayground.tsx | 74 ++++++++- .../src/StandaloneEmbedPlayground.tsx | 10 +- packages/embed-playground/src/index.tsx | 30 ++++ packages/embed-services/src/EmbedUrl.ts | 6 +- packages/embed-services/src/ItemList.spec.ts | 12 +- packages/embed-services/src/ItemList.ts | 41 ++++- .../embed-services/src/ThemeService.spec.ts | 11 +- packages/embed-services/src/ThemeService.ts | 25 +-- 18 files changed, 529 insertions(+), 261 deletions(-) create mode 100644 packages/embed-components/src/QuickEmbed/QuickEmbed.spec.tsx create mode 100644 packages/embed-components/src/QuickEmbed/QuickEmbed.tsx create mode 100644 packages/embed-components/src/QuickEmbed/index.ts create mode 100644 packages/embed-playground/src/index.tsx diff --git a/packages/embed-components/src/QuickEmbed/QuickEmbed.spec.tsx b/packages/embed-components/src/QuickEmbed/QuickEmbed.spec.tsx new file mode 100644 index 000000000..c996ae19c --- /dev/null +++ b/packages/embed-components/src/QuickEmbed/QuickEmbed.spec.tsx @@ -0,0 +1,156 @@ +/* + + 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 } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithTheme } from '@looker/components-test-utils' +import { useThemeActions, useThemesStoreState } from '../Theme/state' +import { QuickEmbed } from './QuickEmbed' + +jest.mock('../Theme/state', () => ({ + ...jest.requireActual('../Theme/state'), + useThemeActions: jest.fn(), + useThemesStoreState: jest.fn(), +})) + +describe('QuickEmbed', () => { + 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 = {}) => ({ + initialized: true, + selectedTheme, + themes, + defaultTheme: lookerTheme, + ...overrides, + }) + const onClose = jest.fn() + + beforeEach(() => { + jest.spyOn(window, 'location', 'get').mockReturnValue({ + href: 'https://example.com/dashboards/42', + pathname: '/dashboards/42', + } as Location) + ;(useThemeActions as jest.Mock).mockReturnValue({ + initAction: jest.fn(), + loadThemeDataAction: jest.fn(), + selectThemeAction: jest.fn(), + }) + ;(useThemesStoreState as jest.Mock).mockReturnValue(getMockStoreState()) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders', () => { + renderWithTheme() + + expect( + screen.getByRole('heading', { name: 'Get embed url' }) + ).toBeInTheDocument() + const textboxes = screen.getAllByRole('textbox') + + /** theme selector */ + expect(screen.getByText('Apply theme to dashboard URL')).toBeInTheDocument() + // surprisingly, the role of a selector is textbox + const selector = textboxes[0] + expect(selector).toHaveValue(lookerTheme.name) + + /** embed url */ + const url = textboxes[1] + expect(url).toHaveValue('https://example.com/embed/dashboards/42') + + /** switch for including/excluding params from target url */ + expect( + screen.getByText('Include current params in URL') + ).toBeInTheDocument() + expect(screen.getByRole('switch')).not.toBeChecked() + + expect( + screen.getByRole('button', { name: 'Copy Link' }) + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('does not render theme selector for non-themable content', () => { + jest.spyOn(window, 'location', 'get').mockReturnValue({ + href: 'https://example.com/looks/42', + pathname: '/looks/42', + } as Location) + renderWithTheme() + + expect( + screen.getByRole('heading', { name: 'Get embed url' }) + ).toBeInTheDocument() + + expect(screen.queryByText(/Apply theme to/)).not.toBeInTheDocument() + + expect(screen.getByRole('textbox')).toHaveValue( + 'https://example.com/embed/looks/42' + ) + }) + + it('can toggle between including and not include current url params', async () => { + jest.spyOn(window, 'location', 'get').mockReturnValue({ + href: 'https://example.com/dashboards/42?foo=bar', + pathname: '/dashboards/42', + } as Location) + ;(useThemesStoreState as jest.Mock).mockReturnValue( + getMockStoreState({ selectedTheme: customTheme1 }) + ) + renderWithTheme() + + expect( + screen.getByRole('heading', { name: 'Get embed url' }) + ).toBeInTheDocument() + + expect(screen.getByText('Apply theme to dashboard URL')).toBeInTheDocument() + const textboxes = screen.getAllByRole('textbox') + const selector = textboxes[0] + expect(selector).toHaveValue('custom_theme_1') + + const url = textboxes[1] + expect(url).toHaveValue( + 'https://example.com/embed/dashboards/42?theme=custom_theme_1' + ) + + const toggleSwitch = screen.getByRole('switch') + expect(toggleSwitch).not.toBeChecked() + + await userEvent.click(toggleSwitch) + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeChecked() + const textboxes = screen.getAllByRole('textbox') + expect(textboxes[1]).toHaveValue( + 'https://example.com/embed/dashboards/42?foo=bar&theme=custom_theme_1' + ) + }) + }) +}) diff --git a/packages/embed-components/src/QuickEmbed/QuickEmbed.tsx b/packages/embed-components/src/QuickEmbed/QuickEmbed.tsx new file mode 100644 index 000000000..f44441204 --- /dev/null +++ b/packages/embed-components/src/QuickEmbed/QuickEmbed.tsx @@ -0,0 +1,118 @@ +/* + + 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, { useEffect, useState } from 'react' +import { + InputText, + CopyToClipboard, + Space, + SpaceVertical, + Button, + Heading, + Label, + Span, + Section, + ButtonOutline, + ToggleSwitch, +} from '@looker/components' +import { Link } from '@styled-icons/material-outlined' +import { EmbedUrl } from '@looker/embed-services' +import { useThemesStoreState, SelectTheme, useThemeActions } from '../Theme' + +interface QuickEmbedProps { + onClose: () => void +} + +export const QuickEmbed = ({ onClose }: QuickEmbedProps) => { + const service = new EmbedUrl() + const [toggleValue, setToggle] = useState(false) + const [embedUrl, setEmbedUrl] = useState(service.embedUrl(false)) + const { selectedTheme } = useThemesStoreState() + const { selectThemeAction } = useThemeActions() + + const handleToggle = () => { + const newToggleValue = !toggleValue + if (newToggleValue) { + // Change the selected theme if there's a theme param in the url + const urlThemeName = service.searchParams.theme + if (urlThemeName) { + selectThemeAction({ key: urlThemeName }) + } + } + setToggle(newToggleValue) + } + + useEffect(() => { + let overrides + if (service.isThemable) { + overrides = { theme: selectedTheme.name } + } + const newUrl = service.embedUrl(toggleValue, overrides) + setEmbedUrl(newUrl) + }, [toggleValue, selectedTheme]) + + return ( +
+ + Get embed url + + + + {service.isThemable && ( + <> + + Apply theme to {service.contentType.toLocaleLowerCase()} URL + + + + )} + <> + + } + readOnly + value={embedUrl} + /> + + + + + + Include current params in URL + + + + + }>Copy Link + + + +
+ ) +} diff --git a/packages/embed-components/src/QuickEmbed/index.ts b/packages/embed-components/src/QuickEmbed/index.ts new file mode 100644 index 000000000..fad45008b --- /dev/null +++ b/packages/embed-components/src/QuickEmbed/index.ts @@ -0,0 +1,26 @@ +/* + + 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 './QuickEmbed' diff --git a/packages/embed-components/src/Theme/SelectTheme.spec.tsx b/packages/embed-components/src/Theme/SelectTheme.spec.tsx index b9b3c7af3..34d3414d9 100644 --- a/packages/embed-components/src/Theme/SelectTheme.spec.tsx +++ b/packages/embed-components/src/Theme/SelectTheme.spec.tsx @@ -114,7 +114,7 @@ describe('SelectTheme', () => { await waitFor(() => { expect(selectThemeActionSpy).toHaveBeenCalledWith({ - id: customTheme1.id, + key: customTheme1.id, }) }) }) diff --git a/packages/embed-components/src/Theme/SelectTheme.tsx b/packages/embed-components/src/Theme/SelectTheme.tsx index ee0fd0967..653271fdf 100644 --- a/packages/embed-components/src/Theme/SelectTheme.tsx +++ b/packages/embed-components/src/Theme/SelectTheme.tsx @@ -57,8 +57,8 @@ export const SelectTheme = () => { setOptions(themeOptions) }, [themes]) - const handleChange = (id: string) => { - selectThemeAction({ id }) + const handleChange = (key: string) => { + selectThemeAction({ key }) } return ( diff --git a/packages/embed-components/src/Theme/state/sagas.spec.ts b/packages/embed-components/src/Theme/state/sagas.spec.ts index 1e233dc64..3ab776df3 100644 --- a/packages/embed-components/src/Theme/state/sagas.spec.ts +++ b/packages/embed-components/src/Theme/state/sagas.spec.ts @@ -43,6 +43,9 @@ import { import * as sagas from './sagas' describe('SelectTheme sagas', () => { + const lookerTheme = { id: '1', name: 'Looker' } as ITheme + const defaultTheme = { id: '2', name: 'custom_theme' } as ITheme + const anotherTheme = { id: '3', name: 'custom_theme_1' } as ITheme let sagaTester: ReduxSagaTester const sdk: IAPIMethods = new LookerSDK(session) @@ -95,9 +98,6 @@ describe('SelectTheme sagas', () => { loadThemeDataSuccessAction, setFailureAction, } = themeActions - const lookerTheme = { id: '1', name: 'Looker' } as ITheme - const defaultTheme = { id: '2', name: 'custom_theme' } as ITheme - const anotherTheme = { id: '3', name: 'custom_theme_1' } as ITheme let loadSpy: jest.SpyInstance const { location } = window @@ -159,165 +159,63 @@ describe('SelectTheme sagas', () => { }) }) - describe('getThemesSaga', () => { - const { getThemesAction, getThemesSuccessAction, setFailureAction } = - themeActions - - it('sends getThemesSuccessAction on success', async () => { - registerThemeService() - const service = getThemeService() - const getAllSpy = jest - .spyOn(service, 'getAll') - .mockResolvedValueOnce(service) - - sagaTester.dispatch(getThemesAction()) - - await sagaTester.waitFor('themes/getThemesSuccessAction') - const calledActions = sagaTester.getCalledActions() - expect(calledActions).toHaveLength(2) - expect(calledActions[0]).toEqual(getThemesAction()) - expect(getAllSpy).toHaveBeenCalledTimes(1) - expect(calledActions[1]).toEqual( - getThemesSuccessAction({ themes: service.items }) - ) - }) - - it('sends setFailureAction on error', async () => { - destroyFactory() - - sagaTester.dispatch(getThemesAction()) - - await sagaTester.waitFor('themes/setFailureAction') - const calledActions = sagaTester.getCalledActions() - expect(calledActions).toHaveLength(2) - expect(calledActions[0]).toEqual(getThemesAction()) - expect(calledActions[1]).toEqual( - setFailureAction({ error: 'Factory must be created with an SDK.' }) - ) - }) - }) - - describe('getDefaultThemeSaga', () => { - const { - getDefaultThemeAction, - getDefaultThemeSuccessAction, - setFailureAction, - } = themeActions - - it('sends getDefaultThemeSuccessAction on success', async () => { - registerThemeService() - const service = getThemeService() - const defaultTheme = { name: 'Looker', id: '1' } as ITheme - const getDefaultThemeSpy = jest - .spyOn(service, 'getDefaultTheme') - .mockResolvedValueOnce(defaultTheme) - - sagaTester.dispatch(getDefaultThemeAction()) - - await sagaTester.waitFor('themes/getDefaultThemeSuccessAction') - const calledActions = sagaTester.getCalledActions() - expect(calledActions).toHaveLength(2) - expect(calledActions[0]).toEqual(getDefaultThemeAction()) - expect(getDefaultThemeSpy).toHaveBeenCalledTimes(1) - expect(calledActions[1]).toEqual( - getDefaultThemeSuccessAction({ defaultTheme }) - ) - }) - - it('sends setFailureAction on error', async () => { - destroyFactory() - - sagaTester.dispatch(getDefaultThemeAction()) - await sagaTester.waitFor('themes/setFailureAction') - const calledActions = sagaTester.getCalledActions() - expect(calledActions).toHaveLength(2) - expect(calledActions[0]).toEqual(getDefaultThemeAction()) - expect(calledActions[1]).toEqual( - setFailureAction({ error: 'Factory must be created with an SDK.' }) - ) - }) - }) - describe('selectThemeSaga', () => { - const { - refreshAction, - refreshSuccessAction, - selectThemeAction, - selectThemeSuccessAction, - setFailureAction, - } = themeActions + const { selectThemeAction, selectThemeSuccessAction, setFailureAction } = + themeActions it('sends selectThemeSuccessAction on success', async () => { registerThemeService() const service = getThemeService() + service.items = [lookerTheme, defaultTheme, anotherTheme] + service.index() const selectedTheme = { name: 'Looker', id: '1' } as ITheme jest.spyOn(service, 'expired').mockReturnValue(false) - 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! })) + sagaTester.dispatch(selectThemeAction({ key: selectedTheme.id! })) await sagaTester.waitFor('themes/selectThemeSuccessAction') const calledActions = sagaTester.getCalledActions() expect(calledActions).toHaveLength(2) expect(calledActions[0]).toEqual( - selectThemeAction({ id: selectedTheme.id! }) + selectThemeAction({ key: selectedTheme.id! }) ) - expect(getSpy).toHaveBeenCalledWith(selectedTheme.id) expect(calledActions[1]).toEqual( selectThemeSuccessAction({ selectedTheme }) ) }) - it('refreshes the store if cache has expired', async () => { + it('can select a theme by name', async () => { registerThemeService() const service = getThemeService() + service.items = [lookerTheme, defaultTheme, anotherTheme] + service.index() const selectedTheme = { name: 'Looker', id: '1' } as ITheme - jest.spyOn(service, 'expired').mockReturnValue(true) - const getSpy = jest - .spyOn(service, 'get') - .mockResolvedValueOnce(selectedTheme) - jest.spyOn(service, 'getAll').mockResolvedValueOnce(service) - jest - .spyOn(service, 'getDefaultTheme') - .mockResolvedValueOnce(selectedTheme) + jest.spyOn(service, 'expired').mockReturnValue(false) - sagaTester.dispatch(selectThemeAction({ id: selectedTheme.id! })) + sagaTester.dispatch(selectThemeAction({ key: selectedTheme.name! })) await sagaTester.waitFor('themes/selectThemeSuccessAction') const calledActions = sagaTester.getCalledActions() - expect(calledActions).toHaveLength(4) + expect(calledActions).toHaveLength(2) expect(calledActions[0]).toEqual( - selectThemeAction({ id: selectedTheme.id! }) + selectThemeAction({ key: selectedTheme.name! }) ) - expect(calledActions[1]).toEqual(refreshAction()) - expect(getSpy).toHaveBeenCalledWith(selectedTheme.id) - expect(calledActions[2]).toEqual( + expect(calledActions[1]).toEqual( selectThemeSuccessAction({ selectedTheme }) ) - expect(calledActions[3]).toEqual( - refreshSuccessAction({ - themes: service.items, - defaultTheme: service.defaultTheme!, - }) - ) }) it('sends setFailureAction on error', async () => { destroyFactory() - sagaTester.dispatch(selectThemeAction({ id: 'foo' })) + sagaTester.dispatch(selectThemeAction({ key: 'foo' })) + await sagaTester.waitFor('themes/setFailureAction') const calledActions = sagaTester.getCalledActions() expect(calledActions).toHaveLength(2) - expect(calledActions[0]).toEqual(selectThemeAction({ id: 'foo' })) + expect(calledActions[0]).toEqual(selectThemeAction({ key: 'foo' })) expect(calledActions[1]).toEqual( setFailureAction({ error: 'Factory must be created with an SDK.' }) ) diff --git a/packages/embed-components/src/Theme/state/sagas.ts b/packages/embed-components/src/Theme/state/sagas.ts index fe8bf7969..c3c5e7d99 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(10) + registerThemeService() yield* put(initSuccessAction()) } catch (error: any) { yield* put(setFailureAction({ error: error.message })) @@ -65,7 +65,7 @@ function* loadThemeDataSaga() { loadThemeDataSuccessAction({ themes: service.items, defaultTheme: service.defaultTheme!, - selectedTheme: (urlTheme ?? service.defaultTheme)!, + selectedTheme: (urlTheme ?? service.defaultTheme!)!, }) ) } catch (error: any) { @@ -74,85 +74,34 @@ function* loadThemeDataSaga() { } /** - * Gets all available themes + * Sets the selected theme by id or name + * @param action containing id or name of theme to select */ -function* getThemesSaga() { - const { getThemesSuccessAction, setFailureAction } = themeActions +function* selectThemeSaga(action: PayloadAction) { + const { selectThemeSuccessAction, setFailureAction } = themeActions try { const service = getThemeService() yield* call([service, 'getAll']) - yield* put(getThemesSuccessAction({ themes: service.items })) - } catch (error: any) { - yield* put(setFailureAction({ error: error.message })) - } -} - -/** - * Gets default theme - */ -function* getDefaultThemeSaga() { - const { getDefaultThemeSuccessAction, setFailureAction } = themeActions - try { - const service = getThemeService() - const defaultTheme = yield* call([service, 'getDefaultTheme']) - yield* put(getDefaultThemeSuccessAction({ defaultTheme })) + const key = action.payload.key + let item: ITheme | undefined = service.indexedItems[key] + if (!item) { + item = service.find(['id', 'name'], `^${key}$`) + } + yield* put( + selectThemeSuccessAction({ selectedTheme: item ?? service.defaultTheme! }) + ) } catch (error: any) { - yield* put(setFailureAction({ error: error.message })) - } -} - -/** - * Fetches the latest themes and defaultTheme - */ -function* refreshSaga() { - const { refreshSuccessAction, setFailureAction } = themeActions - try { - const service = getThemeService() - yield* call([service, 'getDefaultTheme']) - yield* call([service, 'getAll']) yield* put( - refreshSuccessAction({ - themes: service.items, - defaultTheme: service.defaultTheme!, + setFailureAction({ + error: error.message, }) ) - } catch (error: any) { - yield* put(setFailureAction({ error: error.message })) - } -} - -/** - * Sets the selected theme by id - * @param action containing id of theme to select - */ -function* selectThemeSaga(action: PayloadAction) { - const { refreshAction, selectThemeSuccessAction, setFailureAction } = - themeActions - try { - const service = getThemeService() - if (service.expired()) { - yield* put(refreshAction()) - } - const selectedTheme = yield* call([service, 'get'], action.payload.id) - yield* put(selectThemeSuccessAction({ selectedTheme })) - } catch (error: any) { - yield* put(setFailureAction({ error: error.message })) } } export function* saga() { - const { - initAction, - loadThemeDataAction, - getThemesAction, - getDefaultThemeAction, - refreshAction, - selectThemeAction, - } = themeActions + const { initAction, loadThemeDataAction, selectThemeAction } = themeActions yield* takeEvery(initAction, initSaga) yield* takeEvery(loadThemeDataAction, loadThemeDataSaga) - yield* takeEvery(getThemesAction, getThemesSaga) - yield* takeEvery(getDefaultThemeAction, getDefaultThemeSaga) yield* takeEvery(selectThemeAction, selectThemeSaga) - yield* takeEvery(refreshAction, refreshSaga) } diff --git a/packages/embed-components/src/Theme/state/slice.ts b/packages/embed-components/src/Theme/state/slice.ts index 7b7a0a823..25515f073 100644 --- a/packages/embed-components/src/Theme/state/slice.ts +++ b/packages/embed-components/src/Theme/state/slice.ts @@ -47,11 +47,7 @@ export const defaultThemesState: ThemesState = { working: false, } -type GetThemesSuccessAction = Pick - -type GetDefaultThemeSuccessAction = Pick - -export type SelectThemeAction = Record<'id', string> +export type SelectThemeAction = Record<'key', string> type SelectThemeSuccessAction = Pick @@ -62,8 +58,6 @@ type LoadThemeDataSuccessAction = Pick< 'defaultTheme' | 'themes' | 'selectedTheme' > -type RefreshSuccessAction = Pick - export const THEMES_SLICE_NAME = 'themes' export const themesSlice = createSlice({ @@ -83,31 +77,10 @@ export const themesSlice = createSlice({ state, action: PayloadAction ) { - state = { ...state, ...action.payload, working: false } - }, - getThemesAction(state) { - state.working = true - }, - getThemesSuccessAction( - state, - action: PayloadAction - ) { - state = { ...state, ...action.payload, working: false } - }, - getDefaultThemeAction(state) { - state.working = true - }, - getDefaultThemeSuccessAction( - state, - action: PayloadAction - ) { - state = { ...state, ...action.payload, working: false } - }, - refreshAction(state) { - state.working = true - }, - refreshSuccessAction(state, action: PayloadAction) { - state = { ...state, ...action.payload, working: false } + state.themes = action.payload.themes + state.defaultTheme = action.payload.defaultTheme + state.selectedTheme = action.payload.selectedTheme + state.working = false }, selectThemeAction(state, _action: PayloadAction) { state.working = true @@ -116,10 +89,12 @@ export const themesSlice = createSlice({ state, action: PayloadAction ) { - state = { ...state, ...action.payload, working: false } + state.selectedTheme = action.payload.selectedTheme + state.working = false }, setFailureAction(state, action: PayloadAction) { - state = { ...state, ...action.payload, working: false } + state.error = action.payload.error + state.working = false }, }, }) diff --git a/packages/embed-components/src/index.ts b/packages/embed-components/src/index.ts index f598ccc96..46ada37d4 100644 --- a/packages/embed-components/src/index.ts +++ b/packages/embed-components/src/index.ts @@ -25,3 +25,4 @@ */ export * from './GlobalStore' export * from './Theme' +export * from './QuickEmbed' diff --git a/packages/embed-playground/package.json b/packages/embed-playground/package.json index 6aabe432e..bb6aa65d9 100644 --- a/packages/embed-playground/package.json +++ b/packages/embed-playground/package.json @@ -38,6 +38,8 @@ }, "dependencies": { "@looker/components": "^4.1.1", + "@looker/embed-services": "0.0.1-alpha", + "@looker/embed-components": "0.0.1-alpha", "@looker/extension-utils": "^0.1.21", "@looker/redux": "0.0.0", "@looker/sdk": "^23.2.0", @@ -45,7 +47,7 @@ "@styled-icons/material": "^10.47.0", "react": "16.14.0", "react-dom": "16.14.0", - "react-redux": "^7.2.3", + "react-redux": "^7.2.9", "react-router": "^5.3.4", "react-router-dom": "^5.3.4", "styled-components": "^5.3.1" diff --git a/packages/embed-playground/src/EmbedPlayground.tsx b/packages/embed-playground/src/EmbedPlayground.tsx index 45f007234..8a251f34f 100644 --- a/packages/embed-playground/src/EmbedPlayground.tsx +++ b/packages/embed-playground/src/EmbedPlayground.tsx @@ -24,9 +24,23 @@ */ import React, { useState, useEffect } from 'react' -import { ComponentsProvider } from '@looker/components' +import { + ComponentsProvider, + Dialog, + IconButton, + Flex, + FlexItem, + Heading, +} from '@looker/components' import type { IEnvironmentAdaptor } from '@looker/extension-utils' import { me } from '@looker/sdk' +import { + useFactoryActions, + useFactoryStoreState, + useThemesStoreState, + QuickEmbed, +} from '@looker/embed-components' +import { FlashOn } from '@styled-icons/material' interface EmbedPlaygroundProps { adaptor: IEnvironmentAdaptor @@ -34,20 +48,68 @@ interface EmbedPlaygroundProps { } export const EmbedPlayground = ({ adaptor }: EmbedPlaygroundProps) => { - const [greeting, setGreeting] = useState('Hello World!') + const { initFactoryAction } = useFactoryActions() + const { initialized } = useFactoryStoreState() + useThemesStoreState() + const [greeting, setGreeting] = useState('') + const [isOpen, setIsOpen] = useState(false) const sdk = adaptor.sdk + useEffect(() => { const getCurrentUser = async () => { const currentUser = await sdk.ok(me(sdk)) if (currentUser) { - const { first_name, last_name } = currentUser + const { first_name } = currentUser - setGreeting(`Hello ${first_name} ${last_name}!`) + setGreeting(`Hi ${first_name}, are you ready to embed?`) } return currentUser } getCurrentUser() - }) + }, [initialized]) + + useEffect(() => { + initFactoryAction({ sdk }) + }, []) + + const themeOverrides = adaptor.themeOverrides() + + const handleClose = () => { + setIsOpen(false) + } + + const handleOpen = () => { + setIsOpen(true) + } - return {greeting} + return ( + + {initialized && greeting && ( + + + + {greeting} + + + } + > + + } + size="large" + onClick={handleOpen} + /> + + + + )} + + ) } diff --git a/packages/embed-playground/src/StandaloneEmbedPlayground.tsx b/packages/embed-playground/src/StandaloneEmbedPlayground.tsx index 9f07494d3..75a5753c2 100644 --- a/packages/embed-playground/src/StandaloneEmbedPlayground.tsx +++ b/packages/embed-playground/src/StandaloneEmbedPlayground.tsx @@ -41,9 +41,9 @@ import { DefaultSettings, } from '@looker/sdk-rtl' import { functionalSdk40 } from '@looker/sdk' -import { store } from './state' +import { store } from '@looker/embed-components' import { Loader } from './components' -import { EmbedPlayground } from '.' +import { EmbedPlayground } from './EmbedPlayground' const ConfigKey = 'EPConfig' @@ -72,7 +72,7 @@ export const StandaloneEmbedPlayground = () => { sdk.authSession.settings as OAuthConfigProvider ).authIsConfigured() const canLogin = - !authIsConfigured && !sdk.authSession.isAuthenticated() && !oauthReturn + authIsConfigured && !sdk.authSession.isAuthenticated() && !oauthReturn useEffect(() => { const login = async () => await adaptor.login() @@ -117,12 +117,12 @@ export const StandaloneEmbedPlayground = () => { } return ( - <> + {oauthReturn ? ( ) : ( )} - + ) } diff --git a/packages/embed-playground/src/index.tsx b/packages/embed-playground/src/index.tsx new file mode 100644 index 000000000..42d8243ae --- /dev/null +++ b/packages/embed-playground/src/index.tsx @@ -0,0 +1,30 @@ +/* + + 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 { render } from 'react-dom' +import { StandaloneEmbedPlayground } from './StandaloneEmbedPlayground' + +render(, document.getElementById('container')) diff --git a/packages/embed-services/src/EmbedUrl.ts b/packages/embed-services/src/EmbedUrl.ts index e72297816..1c83821df 100644 --- a/packages/embed-services/src/EmbedUrl.ts +++ b/packages/embed-services/src/EmbedUrl.ts @@ -124,7 +124,11 @@ export class EmbedUrl implements IEmbedUrl { if (typeof value === 'object') { overrideValue = JSON.stringify(value) } - embedUrlParams[key] = overrideValue + if (key === 'theme' && overrideValue === 'Looker') { + delete embedUrlParams.theme + } else { + embedUrlParams[key] = overrideValue + } }) } diff --git a/packages/embed-services/src/ItemList.spec.ts b/packages/embed-services/src/ItemList.spec.ts index 785a821da..af728f915 100644 --- a/packages/embed-services/src/ItemList.spec.ts +++ b/packages/embed-services/src/ItemList.spec.ts @@ -126,6 +126,16 @@ describe('ItemList', () => { expect(ItemList.find('name', 'barName')).toEqual(items[1]) }) + + it('can search for a value in multiple keys', () => { + const actual = ItemList.find(['id', 'name'], 'fooName') + expect(actual).toBe(items[0]) + }) + + it('correctly parses an expression', () => { + const actual = ItemList.find('id', 'foo|qux') + expect(actual).toBe(items[0]) + }) }) describe('getCacheDefault', () => { @@ -135,7 +145,7 @@ describe('ItemList', () => { }) it('gets value from itemCache option when specified', () => { - const actual = ItemList.getCacheDefault({ itemCache: false }) + const actual = ItemList.getCacheDefault({ useCache: false }) expect(actual).toBe(false) }) }) diff --git a/packages/embed-services/src/ItemList.ts b/packages/embed-services/src/ItemList.ts index df492ce08..c6c3f8a95 100644 --- a/packages/embed-services/src/ItemList.ts +++ b/packages/embed-services/src/ItemList.ts @@ -30,7 +30,7 @@ import { EntityService } from './EntityService' export const DEFAULT_TTL = 900 // 15 minutes export interface GetOptions { - itemCache?: boolean + useCache?: boolean [key: string]: any } @@ -44,7 +44,7 @@ export interface IItemList { expired(): boolean setExpiration(): void clearIfExpired(): void - find(key: keyof T, value: any): T | undefined + find(key: keyof T | Array, value: any): T | undefined } export interface IEntityService extends IItemList { @@ -109,13 +109,36 @@ export abstract class ItemList> /** * Searches the collection for an item with the specified key/value pair - * @param key to search - * @param value to match + * @param key or keys to search + * @param expression to match */ - find(key: keyof T, value: any): T | undefined { - return this.items.find((item) => item[key as string] === value) as - | T - | undefined + find(key: keyof T | Array, expression: string): T | undefined { + let result: T | undefined + let keys: Array + + if (typeof key === 'string') { + keys = [key] + } else { + keys = key as Array + } + + let rx: RegExp + try { + rx = new RegExp(expression, 'i') + + for (const item of this.items) { + for (const k of keys) { + const match = item[k]?.toString().match(rx) + if (match) { + result = item as T + return result + } + } + } + return result + } catch (e: any) { + throw new Error(e) + } } /** @@ -123,7 +146,7 @@ export abstract class ItemList> * @param options to check */ getCacheDefault(options?: GetOptions) { - const cache = options && 'itemCache' in options ? options.itemCache : true + const cache = options && 'useCache' in options ? options.useCache : true return cache } } diff --git a/packages/embed-services/src/ThemeService.spec.ts b/packages/embed-services/src/ThemeService.spec.ts index 70114107f..31d206bdd 100644 --- a/packages/embed-services/src/ThemeService.spec.ts +++ b/packages/embed-services/src/ThemeService.spec.ts @@ -128,7 +128,7 @@ describe('ThemeService', () => { const cachedTheme = themes[0] const expectedName = cachedTheme.name cachedTheme.name += 'cached' - const actual = await service.get(cachedTheme.id!, { itemCache: false }) + const actual = await service.get(cachedTheme.id!, { useCache: false }) expect(actual.name).toEqual(expectedName) }) }) @@ -170,6 +170,15 @@ describe('ThemeService', () => { await service.getDefaultTheme() expect(service.defaultTheme).toBeDefined() }) + + it('gets from cache if valid', async () => { + expect(service.defaultTheme).toBeUndefined() + const cachedTheme = await service.getDefaultTheme() + const expectedName = cachedTheme.name + cachedTheme.name += 'cached' + const actual = await service.getDefaultTheme() + expect(actual.name).toEqual(expectedName) + }) }) describe('load', () => { diff --git a/packages/embed-services/src/ThemeService.ts b/packages/embed-services/src/ThemeService.ts index acd273d9d..dfcd5da91 100644 --- a/packages/embed-services/src/ThemeService.ts +++ b/packages/embed-services/src/ThemeService.ts @@ -41,8 +41,8 @@ export interface IThemeService extends IItemList, IEntityService { defaultTheme?: ITheme - getDefaultTheme(ts?: Date): Promise - load(): Promise + getDefaultTheme(ts?: Date, options?: GetOptions): Promise + load(options?: GetOptions): Promise } class ThemeService extends ItemList implements IThemeService { @@ -74,13 +74,14 @@ class ThemeService extends ItemList implements IThemeService { } /** - * Get all themes + * Get all themes, including the default theme * @param options to get */ async getAll(options?: GetOptions) { - this.items = await this.sdk.ok(all_themes(this.sdk, options?.fields)) - this.index() - this.setExpiration() + if (this.getCacheDefault(options) && !this.expired()) { + return this + } + await this.load(options) return this } @@ -105,8 +106,10 @@ class ThemeService extends ItemList implements IThemeService { * @param ts Timestamp representing the target datetime for the active period. Defaults to 'now' */ async getDefaultTheme(ts?: Date) { - this.defaultTheme = await this.sdk.ok(default_theme(this.sdk, ts)) - return this.defaultTheme + if (this.expired()) { + this.defaultTheme = await this.sdk.ok(default_theme(this.sdk, ts)) + } + return this.defaultTheme as ITheme } /** @@ -123,9 +126,11 @@ class ThemeService extends ItemList implements IThemeService { /** * Retrieves all themes and the default theme */ - async load() { - await this.getAll() + async load(options?: GetOptions) { await this.getDefaultTheme() + this.items = await this.sdk.ok(all_themes(this.sdk, options?.fields)) + this.index() + this.setExpiration() return this } }