diff --git a/src/error-log/sagas.test.ts b/src/error-log/sagas.test.ts index 664e0991..52711661 100644 --- a/src/error-log/sagas.test.ts +++ b/src/error-log/sagas.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { AsyncSaga } from '../../test'; import { didFailToWrite } from '../ble-nordic-uart-service/actions'; @@ -8,7 +8,6 @@ import { BleDeviceFailToConnectReasonType, didFailToConnect as bleDidFailToConnect, } from '../ble/actions'; -import { didFailToFetchList } from '../licenses/actions'; import { BootloaderConnectionFailureReason, didError, @@ -76,17 +75,3 @@ test('bootloaderDidError', async () => { await saga.end(); }); - -test('licenseDidFailToFetch', async () => { - const saga = new AsyncSaga(errorLog); - - console.error = jest.fn(); - saga.put( - didFailToFetchList( - new Response(undefined, { status: 404, statusText: 'not found' }), - ), - ); - expect(console.error).toHaveBeenCalledTimes(1); - - await saga.end(); -}); diff --git a/src/error-log/sagas.ts b/src/error-log/sagas.ts index c9792a6f..b34eb94a 100644 --- a/src/error-log/sagas.ts +++ b/src/error-log/sagas.ts @@ -8,7 +8,6 @@ import { BleDeviceFailToConnectReasonType, didFailToConnect as bleDeviceDidFailToConnect, } from '../ble/actions'; -import { didFailToFetchList } from '../licenses/actions'; import { BootloaderConnectionFailureReason, didError as bootloaderDidError, @@ -47,17 +46,10 @@ function handleBootloaderDidError(action: ReturnType) console.error(action.err); } -function handleLicenseDidFailToFetch( - action: ReturnType, -): void { - console.error(`Failed to fetch licenses: ${action.reason.statusText}`); -} - export default function* (): Generator { yield* takeEvery(bleDeviceDidFailToConnect, handleBleDeviceDidFailToConnect); yield* takeEvery(pybricksEventProtocolError, handlePybricksEventProtocolError); yield* takeEvery(bleUartDidFailToWrite, handleBleUartDidFailToWrite); yield* takeEvery(bootloaderDidFailToConnect, handleBootloaderDidFailToConnect); yield* takeEvery(bootloaderDidError, handleBootloaderDidError); - yield* takeEvery(didFailToFetchList, handleLicenseDidFailToFetch); } diff --git a/src/licenses/LicenseDialog.test.tsx b/src/licenses/LicenseDialog.test.tsx new file mode 100644 index 00000000..2af73815 --- /dev/null +++ b/src/licenses/LicenseDialog.test.tsx @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { cleanup } from '@testing-library/react'; +import React from 'react'; +import { testRender } from '../../test'; +import LicenseDialog from './LicenseDialog'; + +afterEach(() => { + cleanup(); + localStorage.clear(); + jest.clearAllMocks(); +}); + +describe('LicenseDialog', () => { + it('should show placeholder if no license is selected', () => { + const [dialog] = testRender( + undefined} />, + ); + + expect(dialog.getByText('Select a package to view the license.')).toBeDefined(); + }); + + it('should show a license', async () => { + jest.spyOn(window, 'fetch').mockResolvedValue( + new Response( + JSON.stringify([ + { + name: 'super-duper', + version: '1.0.0', + author: 'Joe Somebody', + license: 'MIT', + licenseText: '...', + }, + ]), + ), + ); + + const [dialog] = testRender( + undefined} />, + ); + + // have to wait for async fetch + const button = await dialog.findByText('super-duper', { selector: 'button *' }); + + // when the dialog is first show, no license is selected + expect(dialog.queryByText('Joe Somebody')).toBeNull(); + + // then when you click on a license button, the license is shown + button.click(); + expect(dialog.getByText('Joe Somebody')).toBeDefined(); + }); +}); diff --git a/src/licenses/LicenseDialog.tsx b/src/licenses/LicenseDialog.tsx index 354399e1..f1f3c065 100644 --- a/src/licenses/LicenseDialog.tsx +++ b/src/licenses/LicenseDialog.tsx @@ -13,17 +13,24 @@ import { NonIdealState, } from '@blueprintjs/core'; import { useI18n } from '@shopify/react-i18n'; -import React from 'react'; -import { useDispatch } from 'react-redux'; +import React, { useState } from 'react'; +import { useFetch } from 'usehooks-ts'; import { appName } from '../app/constants'; -import { useSelector } from '../reducers'; -import { fetchList, select } from './actions'; import { LicenseStringId } from './i18n'; import en from './i18n.en.json'; -import { LicenseInfo } from './reducers'; import './license.scss'; +interface LicenseInfo { + readonly name: string; + readonly version: string; + readonly author: string | undefined; + readonly license: string; + readonly licenseText: string; +} + +type LicenseList = ReadonlyArray; + type LicenseListPanelProps = { onItemClick(info: LicenseInfo): void; }; @@ -31,18 +38,18 @@ type LicenseListPanelProps = { const LicenseListPanel: React.VoidFunctionComponent = ({ onItemClick, }) => { - const licenseList = useSelector((s) => s.licenses.list); + const { data, error } = useFetch('static/oss-licenses.json'); return (
- {licenseList === null ? ( + {error || !data ? ( // TODO: this should be translated and hooked to // state indicating if download is in progress // or there was an actual failure. Failed to load license data. ) : ( - {licenseList.map((info, i) => ( + {data.map((info, i) => ( @@ -53,52 +60,57 @@ const LicenseListPanel: React.VoidFunctionComponent = ({ ); }; -const LicenseInfoPanel = React.forwardRef((_props, ref) => { - const licenseInfo = useSelector((s) => s.licenses.selected); +type LicenseInfoPanelProps = { + /** The license info to show or null if no license info is selected. */ + licenseInfo: LicenseInfo | null; +}; - const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en }); +const LicenseInfoPanel = React.forwardRef( + ({ licenseInfo }, ref) => { + const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en }); - return ( -
- {licenseInfo == null ? ( - - {i18n.translate(LicenseStringId.SelectPackageHelp)} - - ) : ( -
- -

- - {i18n.translate(LicenseStringId.PackageLabel)} - {' '} - {licenseInfo.name}{' '} - - v{licenseInfo.version} - -

- {licenseInfo.author && ( + return ( +
+ {licenseInfo == null ? ( + + {i18n.translate(LicenseStringId.SelectPackageHelp)} + + ) : ( +
+

- {i18n.translate(LicenseStringId.AuthorLabel)} + {i18n.translate(LicenseStringId.PackageLabel)} {' '} - {licenseInfo.author} + {licenseInfo.name}{' '} + + v{licenseInfo.version} +

- )} -

- - {i18n.translate(LicenseStringId.LicenseLabel)} - {' '} - {licenseInfo.license} -

-
-
-
{licenseInfo.licenseText}
+ {licenseInfo.author && ( +

+ + {i18n.translate(LicenseStringId.AuthorLabel)} + {' '} + {licenseInfo.author} +

+ )} +

+ + {i18n.translate(LicenseStringId.LicenseLabel)} + {' '} + {licenseInfo.license} +

+ +
+
{licenseInfo.licenseText}
+
-
- )} -
- ); -}); + )} +
+ ); + }, +); LicenseInfoPanel.displayName = 'LicenseInfoPanel'; @@ -111,10 +123,9 @@ const LicenseDialog: React.VoidFunctionComponent = ({ isOpen, onClose, }) => { + const [licenseInfo, setLicenseInfo] = useState(null); const infoDiv = React.useRef(null); - const dispatch = useDispatch(); - const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en }); return ( @@ -122,7 +133,6 @@ const LicenseDialog: React.VoidFunctionComponent = ({ className="pb-license-dialog" title={i18n.translate(LicenseStringId.Title)} isOpen={isOpen} - onOpening={() => dispatch(fetchList())} onClose={onClose} >
@@ -135,10 +145,10 @@ const LicenseDialog: React.VoidFunctionComponent = ({ { infoDiv.current?.scrollTo(0, 0); - dispatch(select(info)); + setLicenseInfo(info); }} /> - +
diff --git a/src/licenses/actions.ts b/src/licenses/actions.ts deleted file mode 100644 index 5674e586..00000000 --- a/src/licenses/actions.ts +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2021-2022 The Pybricks Authors - -import { createAction } from '../actions'; -import { LicenseInfo, LicenseList } from './reducers'; - -export const fetchList = createAction(() => ({ - type: 'license.action.fetchList', -})); - -export const didFetchList = createAction((list: LicenseList) => ({ - type: 'license.action.didFetchList', - list, -})); - -export const didFailToFetchList = createAction((reason: Response) => ({ - type: 'license.action.didFailToFetchList', - reason, -})); - -export const select = createAction((info: LicenseInfo) => ({ - type: 'license.action.select', - info, -})); diff --git a/src/licenses/reducers.test.ts b/src/licenses/reducers.test.ts deleted file mode 100644 index dbbb9768..00000000 --- a/src/licenses/reducers.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors - -import { AnyAction } from 'redux'; -import { didFetchList, select } from './actions'; -import reducers, { LicenseInfo, LicenseList } from './reducers'; - -type State = ReturnType; - -test('initial state', () => { - expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` - Object { - "list": null, - "selected": null, - } - `); -}); - -test('list', () => { - const list = [] as LicenseList; - expect(reducers({ list: null } as State, didFetchList(list)).list).toBe(list); -}); - -test('selected', () => { - const info = {} as LicenseInfo; - expect(reducers({ selected: null } as State, select(info)).selected).toBe(info); -}); diff --git a/src/licenses/reducers.ts b/src/licenses/reducers.ts deleted file mode 100644 index 867cdeb9..00000000 --- a/src/licenses/reducers.ts +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2021-2022 The Pybricks Authors - -import { Reducer, combineReducers } from 'redux'; -import { didFetchList, select } from './actions'; - -export interface LicenseInfo { - readonly name: string; - readonly version: string; - readonly author: string | undefined; - readonly license: string; - readonly licenseText: string; -} - -export type LicenseList = ReadonlyArray; - -const list: Reducer = (state = null, action) => { - if (didFetchList.matches(action)) { - return action.list; - } - - return state; -}; - -const selected: Reducer = (state = null, action) => { - if (select.matches(action)) { - return action.info; - } - - return state; -}; - -export default combineReducers({ list, selected }); diff --git a/src/licenses/sagas.test.ts b/src/licenses/sagas.test.ts deleted file mode 100644 index f2b40d1e..00000000 --- a/src/licenses/sagas.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2020-2022 The Pybricks Authors - -// Tests for license sagas. - -import { AsyncSaga, delay } from '../../test'; -import { didFailToFetchList, didFetchList, fetchList } from './actions'; -import { LicenseList } from './reducers'; -import license from './sagas'; - -afterAll(() => { - jest.restoreAllMocks(); -}); - -describe('fetchLicenses', () => { - test('first call', async () => { - const testLicenseList: LicenseList = []; - const saga = new AsyncSaga(license); - - jest.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify(testLicenseList)), - ); - - // initially, license list starts as null, so fetch is called to get - // the list - saga.put(fetchList()); - - const action = await saga.take(); - expect(action).toEqual(didFetchList(testLicenseList)); - - await saga.end(); - }); - - test('second call', async () => { - const testLicenseList: LicenseList = []; - const saga = new AsyncSaga(license); - - saga.updateState({ licenses: { list: testLicenseList } }); - - jest.spyOn(globalThis, 'fetch').mockRejectedValue( - 'fetch() should not have been called', - ); - - // after we have the list, we don't fetch it again since it will - // always be the same list - saga.put(fetchList()); - - // have to yield to be sure fetch call would have taken place on error - await delay(0); - - await saga.end(); - }); - - test('failed fetch', async () => { - const failResponse = new Response(undefined, { status: 404 }); - const saga = new AsyncSaga(license); - - jest.spyOn(globalThis, 'fetch').mockResolvedValue(failResponse); - - saga.put(fetchList()); - - const action = await saga.take(); - expect(action).toEqual(didFailToFetchList(failResponse)); - - await saga.end(); - }); -}); diff --git a/src/licenses/sagas.ts b/src/licenses/sagas.ts deleted file mode 100644 index 7dff3cbc..00000000 --- a/src/licenses/sagas.ts +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2021-2022 The Pybricks Authors - -import { call, put, select, takeEvery } from 'typed-redux-saga/macro'; -import { RootState } from '../reducers'; -import { didFailToFetchList, didFetchList, fetchList } from './actions'; - -function* fetchLicenses(): Generator { - const licenses = yield* select((s: RootState) => s.licenses.list); - - // if we already have license list, nothing to do - if (licenses !== null) { - return; - } - - const response = yield* call(() => fetch('static/oss-licenses.json')); - if (!response.ok || response.body === null) { - yield* put(didFailToFetchList(response)); - return; - } - - const list = yield* call(() => response.json()); - yield* put(didFetchList(list)); -} - -export default function* (): Generator { - yield* takeEvery(fetchList, fetchLicenses); -} diff --git a/src/reducers.ts b/src/reducers.ts index e6d8ffa3..7d21bc3b 100644 --- a/src/reducers.ts +++ b/src/reducers.ts @@ -8,7 +8,6 @@ import ble from './ble/reducers'; import fileStorage from './fileStorage/reducers'; import firmware from './firmware/reducers'; import hub from './hub/reducers'; -import licenses from './licenses/reducers'; import bootloader from './lwp3-bootloader/reducers'; /** @@ -21,7 +20,6 @@ export const rootReducer = combineReducers({ fileStorage, firmware, hub, - licenses, }); /** diff --git a/src/sagas.ts b/src/sagas.ts index 4a5e7e21..79cf0cfe 100644 --- a/src/sagas.ts +++ b/src/sagas.ts @@ -12,7 +12,6 @@ import explorer from './explorer/sagas'; import fileStorage from './fileStorage/sagas'; import flashFirmware, { FirmwareSagaContext } from './firmware/sagas'; import hub from './hub/sagas'; -import licenses from './licenses/sagas'; import lwp3BootloaderProtocol from './lwp3-bootloader/sagas'; import lwp3BootloaderBle from './lwp3-bootloader/sagas-ble'; import mpy from './mpy/sagas'; @@ -33,7 +32,6 @@ export default function* (): Generator { explorer(), flashFirmware(), hub(), - licenses(), mpy(), notifications(), settings(), diff --git a/src/setupTests.ts b/src/setupTests.ts index fde67d37..772cd438 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -26,6 +26,13 @@ window.resizeTo = function resizeTo(width, height) { }).dispatchEvent(new this.Event('resize')); }; +// scroll functions are not implemented in jsdom +// https://github.com/jsdom/jsdom/issues/1695 + +if (!Element.prototype.scrollTo) { + Element.prototype.scrollTo = jest.fn(); +} + // HACK: work around https://github.com/palantir/blueprint/issues/4165 // userEvent.keyboard does not set which, so we have to do a reverse lookup // using the blueprintjs keymap.