mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-14 10:35:11 +00:00
licenses: replace sagas/reducers with useFetch hook
This commit is contained in:
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user