mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
app: rework BeforeInstallPromptEvent
This removed passing the BeforeInstallPromptEvent as an action argument (action arguments should be serializable). Instead the saga just keeps a reference to the event and actions are used only to resolve the state of the UI.
This commit is contained in:
+11
-13
@@ -4,7 +4,6 @@
|
||||
// Actions for the app in general.
|
||||
|
||||
import { createAction } from '../actions';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
|
||||
/** Creates an action that requests the app to reload. */
|
||||
export const reload = createAction((registration: ServiceWorkerRegistration) => ({
|
||||
@@ -27,23 +26,22 @@ export const didCheckForUpdate = createAction((updateFound: boolean) => ({
|
||||
}));
|
||||
|
||||
/* Action that indicates the browser wants to prompt the use to install the app. */
|
||||
export const didBeforeInstallPrompt = createAction(
|
||||
(event: BeforeInstallPromptEvent) => ({
|
||||
type: 'app.action.didBeforeInstallPrompt',
|
||||
event,
|
||||
}),
|
||||
);
|
||||
export const appDidReceiveBeforeInstallPrompt = createAction(() => ({
|
||||
type: 'app.action.didBeforeInstallPrompt',
|
||||
}));
|
||||
|
||||
/* Action that requests to prompt the user to install the app. */
|
||||
export const installPrompt = createAction((event: BeforeInstallPromptEvent) => ({
|
||||
type: 'app.action.installPrompt',
|
||||
event,
|
||||
export const appShowInstallPrompt = createAction(() => ({
|
||||
type: 'app.action.showInstallPrompt',
|
||||
}));
|
||||
|
||||
/* Action that indicates that the user responded to the install prompt. */
|
||||
export const didInstallPrompt = createAction(() => ({
|
||||
type: 'app.action.didInstallPrompt',
|
||||
}));
|
||||
export const appDidResolveInstallPrompt = createAction(
|
||||
(result: { outcome: 'accepted' | 'dismissed'; platform: string }) => ({
|
||||
type: 'app.action.didResolveInstallPrompt',
|
||||
result,
|
||||
}),
|
||||
);
|
||||
|
||||
/* Action that indicates app was installed. */
|
||||
export const didInstall = createAction(() => ({
|
||||
|
||||
+45
-26
@@ -6,14 +6,13 @@ import {
|
||||
serviceWorkerDidSucceed,
|
||||
serviceWorkerDidUpdate,
|
||||
} from '../service-worker/actions';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
import {
|
||||
appDidReceiveBeforeInstallPrompt,
|
||||
appDidResolveInstallPrompt,
|
||||
appShowInstallPrompt,
|
||||
checkForUpdate,
|
||||
didBeforeInstallPrompt,
|
||||
didCheckForUpdate,
|
||||
didInstall,
|
||||
didInstallPrompt,
|
||||
installPrompt,
|
||||
} from './actions';
|
||||
import reducers from './reducers';
|
||||
|
||||
@@ -22,8 +21,8 @@ type State = ReturnType<typeof reducers>;
|
||||
test('initial state', () => {
|
||||
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"beforeInstallPrompt": null,
|
||||
"checkingForUpdate": false,
|
||||
"hasUnresolvedInstallPrompt": false,
|
||||
"promptingInstall": false,
|
||||
"readyForOfflineUse": false,
|
||||
"serviceWorker": null,
|
||||
@@ -78,29 +77,49 @@ test('updateAvailable', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('beforeInstallPrompt', () => {
|
||||
const event = {} as BeforeInstallPromptEvent;
|
||||
expect(
|
||||
reducers({ beforeInstallPrompt: null } as State, didBeforeInstallPrompt(event))
|
||||
.beforeInstallPrompt,
|
||||
).toBe(event);
|
||||
expect(
|
||||
reducers({ beforeInstallPrompt: event } as State, didInstall())
|
||||
.beforeInstallPrompt,
|
||||
).toBe(null);
|
||||
describe('hasUnresolvedInstallPrompt', () => {
|
||||
it('should be true after BeforeInstallPromptEvent is received', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ hasUnresolvedInstallPrompt: false } as State,
|
||||
appDidReceiveBeforeInstallPrompt(),
|
||||
).hasUnresolvedInstallPrompt,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false after the app was successfully installed', () => {
|
||||
expect(
|
||||
reducers({ hasUnresolvedInstallPrompt: true } as State, didInstall())
|
||||
.hasUnresolvedInstallPrompt,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('promptingInstall', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ promptingInstall: false } as State,
|
||||
installPrompt({} as BeforeInstallPromptEvent),
|
||||
).promptingInstall,
|
||||
).toBe(true);
|
||||
expect(
|
||||
reducers({ promptingInstall: true } as State, didInstallPrompt())
|
||||
.promptingInstall,
|
||||
).toBe(false);
|
||||
describe('promptingInstall', () => {
|
||||
it('should be true when action requesting to show install prompt is seen', () => {
|
||||
expect(
|
||||
reducers({ promptingInstall: false } as State, appShowInstallPrompt())
|
||||
.promptingInstall,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false when user accepts the prompt', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ promptingInstall: true } as State,
|
||||
appDidResolveInstallPrompt({ outcome: 'accepted', platform: 'web' }),
|
||||
).promptingInstall,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false when user dismisses the prompt', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ promptingInstall: true } as State,
|
||||
appDidResolveInstallPrompt({ outcome: 'dismissed', platform: 'web' }),
|
||||
).promptingInstall,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('readyForOfflineUse', () => {
|
||||
|
||||
+15
-14
@@ -8,14 +8,13 @@ import {
|
||||
serviceWorkerDidSucceed,
|
||||
serviceWorkerDidUpdate,
|
||||
} from '../service-worker/actions';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
import {
|
||||
appDidReceiveBeforeInstallPrompt,
|
||||
appDidResolveInstallPrompt,
|
||||
appShowInstallPrompt,
|
||||
checkForUpdate,
|
||||
didBeforeInstallPrompt,
|
||||
didCheckForUpdate,
|
||||
didInstall,
|
||||
didInstallPrompt,
|
||||
installPrompt,
|
||||
} from './actions';
|
||||
|
||||
const serviceWorker: Reducer<ServiceWorkerRegistration | null> = (
|
||||
@@ -57,27 +56,29 @@ const updateAvailable: Reducer<boolean> = (state = false, action) => {
|
||||
return state;
|
||||
};
|
||||
|
||||
const beforeInstallPrompt: Reducer<BeforeInstallPromptEvent | null> = (
|
||||
state = null,
|
||||
action,
|
||||
) => {
|
||||
if (didBeforeInstallPrompt.matches(action)) {
|
||||
return action.event;
|
||||
/**
|
||||
* Indicates that the app has received the BeforeInstallPromptEvent but the
|
||||
* app has not been installed yet.
|
||||
*/
|
||||
const hasUnresolvedInstallPrompt: Reducer<boolean> = (state = false, action) => {
|
||||
if (appDidReceiveBeforeInstallPrompt.matches(action)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (didInstall.matches(action)) {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/** Indicates that the browser install prompt is active. */
|
||||
const promptingInstall: Reducer<boolean> = (state = false, action) => {
|
||||
if (installPrompt.matches(action)) {
|
||||
if (appShowInstallPrompt.matches(action)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (didInstallPrompt.matches(action)) {
|
||||
if (appDidResolveInstallPrompt.matches(action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@ export default combineReducers({
|
||||
serviceWorker,
|
||||
checkingForUpdate,
|
||||
updateAvailable,
|
||||
beforeInstallPrompt,
|
||||
hasUnresolvedInstallPrompt,
|
||||
promptingInstall,
|
||||
readyForOfflineUse,
|
||||
});
|
||||
|
||||
+25
-16
@@ -1,15 +1,16 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
import { createEvent, fireEvent } from '@testing-library/dom';
|
||||
import { AsyncSaga, delay } from '../../test';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
import {
|
||||
appDidReceiveBeforeInstallPrompt,
|
||||
appDidResolveInstallPrompt,
|
||||
appShowInstallPrompt,
|
||||
checkForUpdate,
|
||||
didBeforeInstallPrompt,
|
||||
didCheckForUpdate,
|
||||
didInstall,
|
||||
didInstallPrompt,
|
||||
installPrompt,
|
||||
reload,
|
||||
} from './actions';
|
||||
import app from './sagas';
|
||||
@@ -28,11 +29,10 @@ test('monitorAppInstalled', async () => {
|
||||
test('monitorBeforeInstallPrompt', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
const event = new Event('beforeinstallprompt') as BeforeInstallPromptEvent;
|
||||
window.dispatchEvent(event);
|
||||
fireEvent(window, createEvent('beforeinstallprompt', window));
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toStrictEqual(didBeforeInstallPrompt(event));
|
||||
expect(action).toStrictEqual(appDidReceiveBeforeInstallPrompt());
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -82,21 +82,30 @@ test('checkForUpdates', async () => {
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('installPrompt', async () => {
|
||||
test('appShowInstallPrompt', async () => {
|
||||
const userChoice = {
|
||||
outcome: <'accepted' | 'dismissed'>'accepted',
|
||||
platform: 'web',
|
||||
};
|
||||
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
// mock registration as if service worker was register on app startup
|
||||
const event: Partial<BeforeInstallPromptEvent> = {
|
||||
prompt: jest.fn(),
|
||||
userChoice: Promise.resolve({ outcome: 'accepted', platform: 'web' }),
|
||||
};
|
||||
const event = createEvent('beforeinstallprompt', window);
|
||||
Object.assign(event, <Partial<BeforeInstallPromptEvent>>{
|
||||
prompt: () => Promise.resolve<void>(undefined),
|
||||
userChoice: Promise.resolve(userChoice),
|
||||
});
|
||||
|
||||
saga.put(installPrompt(event as BeforeInstallPromptEvent));
|
||||
|
||||
expect(event.prompt).toHaveBeenCalled();
|
||||
fireEvent(window, event);
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toStrictEqual(didInstallPrompt());
|
||||
expect(action).toStrictEqual(appDidReceiveBeforeInstallPrompt());
|
||||
|
||||
saga.put(appShowInstallPrompt());
|
||||
|
||||
const action2 = await saga.take();
|
||||
expect(action2).toStrictEqual(appDidResolveInstallPrompt(userChoice));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
+16
-11
@@ -5,12 +5,12 @@ import { eventChannel } from 'redux-saga';
|
||||
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
import {
|
||||
appDidReceiveBeforeInstallPrompt,
|
||||
appDidResolveInstallPrompt,
|
||||
appShowInstallPrompt,
|
||||
checkForUpdate,
|
||||
didBeforeInstallPrompt,
|
||||
didCheckForUpdate,
|
||||
didInstall,
|
||||
didInstallPrompt,
|
||||
installPrompt,
|
||||
reload,
|
||||
} from './actions';
|
||||
|
||||
@@ -31,6 +31,15 @@ function* monitorAppInstalled(): Generator {
|
||||
}
|
||||
}
|
||||
|
||||
function* handleBeforeInstallPromptEvent(event: BeforeInstallPromptEvent): Generator {
|
||||
// wait for user to request to install the app - may never happen
|
||||
yield* take(appShowInstallPrompt);
|
||||
|
||||
yield* call(() => event.prompt());
|
||||
const choice = yield* call(() => event.userChoice);
|
||||
yield* put(appDidResolveInstallPrompt(choice));
|
||||
}
|
||||
|
||||
function* monitorBeforeInstallPrompt(): Generator {
|
||||
const chan = eventChannel<BeforeInstallPromptEvent>((emit) => {
|
||||
const listener = (e: BeforeInstallPromptEvent) => {
|
||||
@@ -43,9 +52,12 @@ function* monitorBeforeInstallPrompt(): Generator {
|
||||
return () => window.removeEventListener('beforeinstallprompt', listener);
|
||||
});
|
||||
|
||||
// in theory, the before install prompt event should only happen once, so
|
||||
// we don't bother canceling the forked task when another event is received
|
||||
while (true) {
|
||||
const event = yield* take(chan);
|
||||
yield* put(didBeforeInstallPrompt(event));
|
||||
yield* fork(handleBeforeInstallPromptEvent, event);
|
||||
yield* put(appDidReceiveBeforeInstallPrompt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,16 +72,9 @@ function* handleCheckForUpdate(action: ReturnType<typeof checkForUpdate>): Gener
|
||||
yield* put(didCheckForUpdate(updateFound));
|
||||
}
|
||||
|
||||
function* handleInstallPrompt(action: ReturnType<typeof installPrompt>): Generator {
|
||||
yield* call(() => action.event.prompt());
|
||||
yield* call(() => action.event.userChoice);
|
||||
yield* put(didInstallPrompt());
|
||||
}
|
||||
|
||||
export default function* app(): Generator {
|
||||
yield* fork(monitorAppInstalled);
|
||||
yield* fork(monitorBeforeInstallPrompt);
|
||||
yield* takeEvery(reload, handleReload);
|
||||
yield* takeEvery(checkForUpdate, handleCheckForUpdate);
|
||||
yield* takeEvery(installPrompt, handleInstallPrompt);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
import {
|
||||
AnchorButton,
|
||||
@@ -21,7 +21,7 @@ import { useI18n } from '@shopify/react-i18n';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import AboutDialog from '../about/AboutDialog';
|
||||
import { checkForUpdate, installPrompt, reload } from '../app/actions';
|
||||
import { appShowInstallPrompt, checkForUpdate, reload } from '../app/actions';
|
||||
import {
|
||||
pybricksBugReportsUrl,
|
||||
pybricksGitterUrl,
|
||||
@@ -53,7 +53,9 @@ const SettingsDrawer: React.FunctionComponent<SettingsProps> = (props) => {
|
||||
const serviceWorker = useSelector((s) => s.app.serviceWorker);
|
||||
const checkingForUpdate = useSelector((s) => s.app.checkingForUpdate);
|
||||
const updateAvailable = useSelector((s) => s.app.updateAvailable);
|
||||
const beforeInstallPrompt = useSelector((s) => s.app.beforeInstallPrompt);
|
||||
const hasUnresolvedInstallPrompt = useSelector(
|
||||
(s) => s.app.hasUnresolvedInstallPrompt,
|
||||
);
|
||||
const promptingInstall = useSelector((s) => s.app.promptingInstall);
|
||||
const readyForOfflineUse = useSelector((s) => s.app.readyForOfflineUse);
|
||||
const hubName = useSelector((s) => s.settings.hubName);
|
||||
@@ -279,12 +281,10 @@ const SettingsDrawer: React.FunctionComponent<SettingsProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<ButtonGroup minimal={true} vertical={true} alignText="left">
|
||||
{beforeInstallPrompt && (
|
||||
{hasUnresolvedInstallPrompt && (
|
||||
<Button
|
||||
icon="add"
|
||||
onClick={() =>
|
||||
dispatch(installPrompt(beforeInstallPrompt))
|
||||
}
|
||||
onClick={() => dispatch(appShowInstallPrompt())}
|
||||
loading={promptingInstall}
|
||||
>
|
||||
{i18n.translate(SettingsStringId.AppInstallLabel)}
|
||||
|
||||
Reference in New Issue
Block a user