licenses: replace sagas/reducers with useFetch hook

This commit is contained in:
David Lechner
2022-03-15 18:37:06 -05:00
parent a4660e979c
commit d1090d01d6
12 changed files with 123 additions and 259 deletions
+1 -16
View File
@@ -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();
});
-8
View File
@@ -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<typeof bootloaderDidError>)
console.error(action.err);
}
function handleLicenseDidFailToFetch(
action: ReturnType<typeof didFailToFetchList>,
): 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);
}
+53
View File
@@ -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(
<LicenseDialog isOpen={true} onClose={() => 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(
<LicenseDialog isOpen={true} onClose={() => 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();
});
});
+62 -52
View File
@@ -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<LicenseInfo>;
type LicenseListPanelProps = {
onItemClick(info: LicenseInfo): void;
};
@@ -31,18 +38,18 @@ type LicenseListPanelProps = {
const LicenseListPanel: React.VoidFunctionComponent<LicenseListPanelProps> = ({
onItemClick,
}) => {
const licenseList = useSelector((s) => s.licenses.list);
const { data, error } = useFetch<LicenseList>('static/oss-licenses.json');
return (
<div className="pb-license-list">
{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.
<NonIdealState>Failed to load license data.</NonIdealState>
) : (
<ButtonGroup minimal={true} vertical={true} alignText="left">
{licenseList.map((info, i) => (
{data.map((info, i) => (
<Button key={i} onClick={() => onItemClick(info)}>
{info.name}
</Button>
@@ -53,52 +60,57 @@ const LicenseListPanel: React.VoidFunctionComponent<LicenseListPanelProps> = ({
);
};
const LicenseInfoPanel = React.forwardRef<HTMLDivElement>((_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<HTMLDivElement, LicenseInfoPanelProps>(
({ licenseInfo }, ref) => {
const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en });
return (
<div className="pb-license-info" ref={ref}>
{licenseInfo == null ? (
<NonIdealState>
{i18n.translate(LicenseStringId.SelectPackageHelp)}
</NonIdealState>
) : (
<div>
<Card>
<p>
<strong>
{i18n.translate(LicenseStringId.PackageLabel)}
</strong>{' '}
{licenseInfo.name}{' '}
<span className={Classes.TEXT_MUTED}>
v{licenseInfo.version}
</span>
</p>
{licenseInfo.author && (
return (
<div className="pb-license-info" ref={ref}>
{licenseInfo == null ? (
<NonIdealState>
{i18n.translate(LicenseStringId.SelectPackageHelp)}
</NonIdealState>
) : (
<div>
<Card>
<p>
<strong>
{i18n.translate(LicenseStringId.AuthorLabel)}
{i18n.translate(LicenseStringId.PackageLabel)}
</strong>{' '}
{licenseInfo.author}
{licenseInfo.name}{' '}
<span className={Classes.TEXT_MUTED}>
v{licenseInfo.version}
</span>
</p>
)}
<p>
<strong>
{i18n.translate(LicenseStringId.LicenseLabel)}
</strong>{' '}
{licenseInfo.license}
</p>
</Card>
<div className="pb-license-text">
<pre>{licenseInfo.licenseText}</pre>
{licenseInfo.author && (
<p>
<strong>
{i18n.translate(LicenseStringId.AuthorLabel)}
</strong>{' '}
{licenseInfo.author}
</p>
)}
<p>
<strong>
{i18n.translate(LicenseStringId.LicenseLabel)}
</strong>{' '}
{licenseInfo.license}
</p>
</Card>
<div className="pb-license-text">
<pre>{licenseInfo.licenseText}</pre>
</div>
</div>
</div>
)}
</div>
);
});
)}
</div>
);
},
);
LicenseInfoPanel.displayName = 'LicenseInfoPanel';
@@ -111,10 +123,9 @@ const LicenseDialog: React.VoidFunctionComponent<LicenseDialogProps> = ({
isOpen,
onClose,
}) => {
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const infoDiv = React.useRef<HTMLDivElement>(null);
const dispatch = useDispatch();
const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en });
return (
@@ -122,7 +133,6 @@ const LicenseDialog: React.VoidFunctionComponent<LicenseDialogProps> = ({
className="pb-license-dialog"
title={i18n.translate(LicenseStringId.Title)}
isOpen={isOpen}
onOpening={() => dispatch(fetchList())}
onClose={onClose}
>
<div className={Classes.DIALOG_BODY}>
@@ -135,10 +145,10 @@ const LicenseDialog: React.VoidFunctionComponent<LicenseDialogProps> = ({
<LicenseListPanel
onItemClick={(info) => {
infoDiv.current?.scrollTo(0, 0);
dispatch(select(info));
setLicenseInfo(info);
}}
/>
<LicenseInfoPanel ref={infoDiv} />
<LicenseInfoPanel licenseInfo={licenseInfo} ref={infoDiv} />
</Callout>
</div>
</Dialog>
-24
View File
@@ -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,
}));
-27
View File
@@ -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<typeof reducers>;
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);
});
-33
View File
@@ -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<LicenseInfo>;
const list: Reducer<LicenseList | null> = (state = null, action) => {
if (didFetchList.matches(action)) {
return action.list;
}
return state;
};
const selected: Reducer<LicenseInfo | null> = (state = null, action) => {
if (select.matches(action)) {
return action.info;
}
return state;
};
export default combineReducers({ list, selected });
-67
View File
@@ -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();
});
});
-28
View File
@@ -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);
}
-2
View File
@@ -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,
});
/**
-2
View File
@@ -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(),
+7
View File
@@ -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.