Unify error notifications (#1483)

Summary:
Note: this is to be stacked upon https://github.com/facebook/flipper/pull/1479

Note: this PR will probably not succeed against FB internal flipper, as I'm pretty sure there are more call sites that need to be updated. So consider this WIP

Currently connection errors are managed in the connection reducers, and are displayed through their own means, the error bar. Showing console.errors is also hooked up to this mechanism in FB internal flipper, but not at all in the OSS version, which means that some connection errors are never shown to the user.

Besides that there is a notification system that is used by for example the crash reporter and plugin updater.

Having effectively (at least) two notifications mechanisms is confusing and error prone. This PR unifies both approaches, and rather than having the connection reducer manage it's own errors, it leverages the more generic notifications reducer. Since, in the previous PR, console errors and warnings have become user facing (even in OSS and production builds, which wasn't the case before), there is no need anymore for a separate error bar.

I left the notifications mechanism itself as-is, but as discussed in the Sandy project the notification screen will probably be overhauled, and the system wide notifications will become in-app notifications.

## Changelog

Pull Request resolved: https://github.com/facebook/flipper/pull/1483

Test Plan: Only updated the unit tests at this point. Manual tests still need to be done.

Reviewed By: passy

Differential Revision: D23220896

Pulled By: mweststrate

fbshipit-source-id: 8ea37cf69ce9605dc232ca90afe9e2f70da26652
This commit is contained in:
Michel Weststrate
2020-08-21 10:05:19 -07:00
committed by Facebook GitHub Bot
parent 76b72f3d77
commit 81eb09e7b0
11 changed files with 133 additions and 435 deletions

View File

@@ -12,7 +12,6 @@ import {FlexRow, styled, Layout} from 'flipper';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import TitleBar from './chrome/TitleBar'; import TitleBar from './chrome/TitleBar';
import MainSidebar2 from './chrome/mainsidebar/MainSidebar2'; import MainSidebar2 from './chrome/mainsidebar/MainSidebar2';
import ErrorBar from './chrome/ErrorBar';
import DoctorBar from './chrome/DoctorBar'; import DoctorBar from './chrome/DoctorBar';
import ShareSheetExportUrl from './chrome/ShareSheetExportUrl'; import ShareSheetExportUrl from './chrome/ShareSheetExportUrl';
import SignInSheet from './chrome/SignInSheet'; import SignInSheet from './chrome/SignInSheet';
@@ -40,7 +39,7 @@ import {
} from './reducers/application'; } from './reducers/application';
import {Logger} from './fb-interfaces/Logger'; import {Logger} from './fb-interfaces/Logger';
import {State as Store} from './reducers/index'; import {State as Store} from './reducers/index';
import {StaticView, FlipperError} from './reducers/connections'; import {StaticView} from './reducers/connections';
import PluginManager from './chrome/plugin-manager/PluginManager'; import PluginManager from './chrome/plugin-manager/PluginManager';
import StatusBar from './chrome/StatusBar'; import StatusBar from './chrome/StatusBar';
import SettingsSheet from './chrome/SettingsSheet'; import SettingsSheet from './chrome/SettingsSheet';
@@ -59,7 +58,6 @@ type OwnProps = {
type StateFromProps = { type StateFromProps = {
leftSidebarVisible: boolean; leftSidebarVisible: boolean;
errors: FlipperError[];
activeSheet: ActiveSheet; activeSheet: ActiveSheet;
share: ShareType | null; share: ShareType | null;
staticView: StaticView; staticView: StaticView;
@@ -174,7 +172,6 @@ export class App extends React.Component<Props> {
<> <>
<TitleBar version={version} /> <TitleBar version={version} />
<DoctorBar /> <DoctorBar />
<ErrorBar />
</> </>
<> <>
<Sheet>{this.getSheet}</Sheet> <Sheet>{this.getSheet}</Sheet>
@@ -214,12 +211,11 @@ export class App extends React.Component<Props> {
export default connect<StateFromProps, DispatchProps, OwnProps, Store>( export default connect<StateFromProps, DispatchProps, OwnProps, Store>(
({ ({
application: {leftSidebarVisible, activeSheet, share}, application: {leftSidebarVisible, activeSheet, share},
connections: {errors, staticView}, connections: {staticView},
}) => ({ }) => ({
leftSidebarVisible, leftSidebarVisible,
activeSheet, activeSheet,
share: share, share: share,
errors,
staticView, staticView,
}), }),
{ {

View File

@@ -25,7 +25,6 @@ Object {
"title": "MockAndroidDevice", "title": "MockAndroidDevice",
}, },
], ],
"errors": Array [],
"selectedApp": "TestApp#Android#MockAndroidDevice#serial", "selectedApp": "TestApp#Android#MockAndroidDevice#serial",
"selectedDevice": Object { "selectedDevice": Object {
"deviceType": "physical", "deviceType": "physical",

View File

@@ -1,200 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {styled, colors, Glyph} from 'flipper';
import React, {useState, memo} from 'react';
import {connect} from 'react-redux';
import {FlipperError, dismissError} from '../reducers/connections';
import {State as Store} from '../reducers/index';
import {ErrorBlock, ButtonGroup, Button} from 'flipper';
import {FlexColumn, FlexRow} from 'flipper';
type StateFromProps = {
errors: FlipperError[];
};
type DispatchFromProps = {
dismissError: typeof dismissError;
};
type Props = DispatchFromProps & StateFromProps;
const ErrorBar = memo(function ErrorBar(props: Props) {
const [collapsed, setCollapsed] = useState(true);
if (!props.errors.length) {
return null;
}
const errorCount = props.errors.reduce(
(sum, error) => sum + (error.occurrences || 1),
0,
);
const urgentErrors = props.errors.filter((e) => e.urgent);
return (
<ErrorBarContainer>
<ErrorRows
className={collapsed && urgentErrors.length === 0 ? 'collapsed' : ''}>
{(collapsed ? urgentErrors : props.errors).map((error, index) => (
<ErrorTile
onDismiss={() => props.dismissError(index)}
key={index}
error={error}
/>
))}
</ErrorRows>
<DismissAllErrors
onClick={() => setCollapsed((c) => !c)}
title="Show / hide errors">
<Glyph
color={colors.white}
size={8}
name={collapsed ? 'chevron-down' : 'chevron-up'}
style={{marginRight: 4}}
/>
{collapsed && errorCount}
</DismissAllErrors>
</ErrorBarContainer>
);
});
export default connect<StateFromProps, DispatchFromProps, {}, Store>(
({connections: {errors}}) => ({
errors,
}),
{
dismissError,
},
)(ErrorBar);
function ErrorTile({
onDismiss,
error,
}: {
onDismiss: () => void;
error: FlipperError;
}) {
const [collapsed, setCollapsed] = useState(true);
return (
<ErrorRow className={`${error.urgent ? 'urgent' : ''}`}>
<FlexRow style={{flexDirection: 'row-reverse'}}>
<ButtonSection>
<ButtonGroup>
{(error.details || error.error) && (
<Button
onClick={() => setCollapsed((s) => !s)}
icon={collapsed ? `chevron-down` : 'chevron-up'}
iconSize={12}
/>
)}
<Button onClick={onDismiss} icon="cross-circle" iconSize={12} />
</ButtonGroup>
</ButtonSection>
{error.occurrences! > 1 && (
<ErrorCounter title="Nr of times this error occurred">
{error.occurrences}
</ErrorCounter>
)}
<FlexColumn
style={
collapsed
? {overflow: 'hidden', whiteSpace: 'nowrap', flexGrow: 1}
: {flexGrow: 1}
}
title={error.message}>
{error.message}
</FlexColumn>
</FlexRow>
{!collapsed && (
<FlexRow>
<ErrorDetails>
{error.details}
{error.error && <ErrorBlock error={error.error} />}
</ErrorDetails>
</FlexRow>
)}
</ErrorRow>
);
}
const ErrorBarContainer = styled.div({
boxShadow: '2px 2px 2px #ccc',
userSelect: 'text',
});
const DismissAllErrors = styled.div({
boxShadow: '2px 2px 2px #ccc',
backgroundColor: colors.cherryDark3,
color: '#fff',
textAlign: 'center',
borderBottomLeftRadius: '4px',
borderBottomRightRadius: '4px',
position: 'absolute',
width: '48px',
height: '16px',
zIndex: 2,
right: '20px',
fontSize: '6pt',
lineHeight: '16px',
cursor: 'pointer',
alignItems: 'center',
});
const ErrorDetails = styled.div({
width: '100%',
marginTop: 4,
});
const ErrorRows = styled.div({
color: '#fff',
maxHeight: '600px',
overflowY: 'auto',
overflowX: 'hidden',
transition: 'max-height 0.3s ease',
borderBottom: '1px solid #b3b3b3',
'&.collapsed': {
maxHeight: '0px',
borderBottom: 'none',
},
});
const ErrorRow = styled.div({
padding: '4px 12px',
verticalAlign: 'middle',
lineHeight: '28px',
backgroundColor: colors.yellowTint,
color: colors.yellow,
'&.urgent': {
backgroundColor: colors.redTint,
color: colors.red,
},
});
const ButtonSection = styled(FlexColumn)({
marginLeft: '8px',
flexShrink: 0,
flexGrow: 0,
});
const ErrorCounter = styled(FlexColumn)({
border: `1px solid ${colors.light20}`,
color: colors.light20,
width: 20,
height: 20,
borderRadius: 20,
marginTop: 4,
lineHeight: '18px',
textAlign: 'center',
flexShrink: 0,
flexGrow: 0,
marginLeft: '8px',
fontSize: '10px',
});

View File

@@ -19,7 +19,7 @@ import which from 'which';
import {promisify} from 'util'; import {promisify} from 'util';
import {ServerPorts} from '../reducers/application'; import {ServerPorts} from '../reducers/application';
import {Client as ADBClient} from 'adbkit'; import {Client as ADBClient} from 'adbkit';
import {addNotification} from '../reducers/notifications'; import {addErrorNotification} from '../reducers/notifications';
function createDevice( function createDevice(
adbClient: ADBClient, adbClient: ADBClient,
@@ -84,22 +84,14 @@ function createDevice(
const isAuthorizationError = (e?.message as string)?.includes( const isAuthorizationError = (e?.message as string)?.includes(
'device unauthorized', 'device unauthorized',
); );
console.error('Failed to initialize device: ' + device.id, e);
store.dispatch( store.dispatch(
addNotification({ addErrorNotification(
client: null, 'Could not connect to ' + device.id,
notification: { isAuthorizationError
id: 'androidDeviceConnectionError' + device.id, ? 'Make sure to authorize debugging on the phone'
title: 'Could not connect to ' + device.id, : 'Failed to setup connection',
severity: 'error', e,
message: `Failed to connect to '${device.id}': ${ ),
isAuthorizationError
? 'make sure to authorize debugging on the phone'
: JSON.stringify(e, null, 2)
}`,
},
pluginId: 'androidDevice',
}),
); );
} }
resolve(undefined); // not ready yet, we will find it in the next tick resolve(undefined); // not ready yet, we will find it in the next tick

View File

@@ -20,6 +20,7 @@ import iosUtil from '../utils/iOSContainerUtility';
import IOSDevice from '../devices/IOSDevice'; import IOSDevice from '../devices/IOSDevice';
import isProduction from '../utils/isProduction'; import isProduction from '../utils/isProduction';
import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice'; import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice';
import {addErrorNotification} from '../reducers/notifications';
type iOSSimulatorDevice = { type iOSSimulatorDevice = {
state: 'Booted' | 'Shutdown' | 'Shutting Down'; state: 'Booted' | 'Shutdown' | 'Shutting Down';
@@ -209,18 +210,10 @@ async function checkXcodeVersionMismatch(store: Store) {
); );
const runningVersion = match && match.length > 0 ? match[0].trim() : null; const runningVersion = match && match.length > 0 ? match[0].trim() : null;
if (runningVersion && runningVersion !== xcodeCLIVersion) { if (runningVersion && runningVersion !== xcodeCLIVersion) {
const errorMessage = `Xcode version mismatch: Simulator is running from "${runningVersion}" while Xcode CLI is "${xcodeCLIVersion}". Running "xcode-select --switch ${runningVersion}" can fix this.`; const errorMessage = `Xcode version mismatch: Simulator is running from "${runningVersion}" while Xcode CLI is "${xcodeCLIVersion}". Running "xcode-select --switch ${runningVersion}" can fix this. For example: "sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"`;
store.dispatch({ store.dispatch(
type: 'SERVER_ERROR', addErrorNotification('Xcode version mismatch', errorMessage),
payload: { );
message: errorMessage,
details:
"You might want to run 'sudo xcode-select -s /Applications/Xcode.app/Contents/Developer'",
urgent: true,
},
});
// Fire a console.error as well, so that it gets reported to the backend.
console.error(errorMessage);
xcodeVersionMismatchFound = true; xcodeVersionMismatchFound = true;
break; break;
} }

View File

@@ -13,6 +13,7 @@ import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice';
import MetroDevice from '../devices/MetroDevice'; import MetroDevice from '../devices/MetroDevice';
import {ArchivedDevice} from 'flipper'; import {ArchivedDevice} from 'flipper';
import http from 'http'; import http from 'http';
import {addErrorNotification} from '../reducers/notifications';
const METRO_PORT = 8081; const METRO_PORT = 8081;
const METRO_HOST = 'localhost'; const METRO_HOST = 'localhost';
@@ -129,15 +130,12 @@ export default (store: Store, logger: Logger) => {
const guard = setTimeout(() => { const guard = setTimeout(() => {
// Metro is running, but didn't respond to /events endpoint // Metro is running, but didn't respond to /events endpoint
store.dispatch({ store.dispatch(
type: 'SERVER_ERROR', addErrorNotification(
payload: { 'Failed to connect to Metro',
message: `Flipper did find a running Metro instance, but couldn't connect to the logs. Probably your React Native version is too old to support Flipper. Cause: Failed to get a connection to ${METRO_LOGS_ENDPOINT} in a timely fashion`,
"Found a running Metro instance, but couldn't connect to the logs. Probably your React Native version is too old to support Flipper.", ),
details: `Failed to get a connection to ${METRO_LOGS_ENDPOINT} in a timely fashion`, );
urgent: true,
},
});
registerDevice(undefined, store, logger); registerDevice(undefined, store, logger);
// Note: no scheduleNext, we won't retry until restart // Note: no scheduleNext, we won't retry until restart
}, 5000); }, 5000);

View File

@@ -13,6 +13,7 @@ import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger'; import {Logger} from '../fb-interfaces/Logger';
import Client from '../Client'; import Client from '../Client';
import {UninitializedClient} from '../UninitializedClient'; import {UninitializedClient} from '../UninitializedClient';
import {addErrorNotification} from '../reducers/notifications';
export default (store: Store, logger: Logger) => { export default (store: Store, logger: Logger) => {
const server = new Server(logger, store); const server = new Server(logger, store);
@@ -42,17 +43,14 @@ export default (store: Store, logger: Logger) => {
}); });
server.addListener('error', (err) => { server.addListener('error', (err) => {
const message: string = store.dispatch(
err.code === 'EADDRINUSE' addErrorNotification(
? "Couldn't start websocket server. Looks like you have multiple copies of Flipper running." 'Failed to start websocket server',
: err.message || 'Unknown error'; err.code === 'EADDRINUSE'
const urgent = err.code === 'EADDRINUSE'; ? "Couldn't start websocket server. Looks like you have multiple copies of Flipper running."
: err.message || 'Unknown error',
store.dispatch({ ),
type: 'SERVER_ERROR', );
payload: {message},
urgent,
});
}); });
server.addListener('start-client-setup', (client: UninitializedClient) => { server.addListener('start-client-setup', (client: UninitializedClient) => {
@@ -74,11 +72,14 @@ export default (store: Store, logger: Logger) => {
server.addListener( server.addListener(
'client-setup-error', 'client-setup-error',
(payload: {client: UninitializedClient; error: Error}) => { ({client, error}: {client: UninitializedClient; error: Error}) => {
store.dispatch({ store.dispatch(
type: 'CLIENT_SETUP_ERROR', addErrorNotification(
payload: payload, `Connection to '${client.appName}' on '${client.deviceName}' failed`,
}); 'Failed to start client connection',
error,
),
);
}, },
); );

View File

@@ -14,27 +14,6 @@ import MacDevice from '../../devices/MacDevice';
import {FlipperDevicePlugin} from '../../plugin'; import {FlipperDevicePlugin} from '../../plugin';
import MetroDevice from '../../devices/MetroDevice'; import MetroDevice from '../../devices/MetroDevice';
test('REGISTER_DEVICE doesnt remove error', () => {
const initialState: State = reducer(undefined, {
type: 'SERVER_ERROR',
payload: {message: 'something went wrong'},
});
// Precondition
expect(initialState.errors).toEqual([
{message: 'something went wrong', occurrences: 1},
]);
const endState = reducer(initialState, {
type: 'REGISTER_DEVICE',
payload: new BaseDevice('serial', 'physical', 'title', 'Android'),
});
expect(endState.errors).toEqual([
{message: 'something went wrong', occurrences: 1},
]);
});
test('doing a double REGISTER_DEVICE keeps the last', () => { test('doing a double REGISTER_DEVICE keeps the last', () => {
const device1 = new BaseDevice('serial', 'physical', 'title', 'Android'); const device1 = new BaseDevice('serial', 'physical', 'title', 'Android');
const device2 = new BaseDevice('serial', 'physical', 'title2', 'Android'); const device2 = new BaseDevice('serial', 'physical', 'title2', 'Android');
@@ -105,71 +84,6 @@ test('triggering REGISTER_DEVICE before REGISTER_PLUGINS still registers device
expect(endState.devices[0].devicePlugins).toEqual(['test']); expect(endState.devices[0].devicePlugins).toEqual(['test']);
}); });
test('errors are collected on a by name basis', () => {
const initialState: State = reducer(undefined, {
type: 'SERVER_ERROR',
payload: {
message: 'error1',
error: 'stack1',
},
});
expect(initialState.errors).toMatchInlineSnapshot(`
Array [
Object {
"error": "stack1",
"message": "error1",
"occurrences": 1,
},
]
`);
const state2: State = reducer(initialState, {
type: 'SERVER_ERROR',
payload: {
message: 'error2',
error: 'stack2',
},
});
// There are now two errors
expect(state2.errors).toMatchInlineSnapshot(`
Array [
Object {
"error": "stack1",
"message": "error1",
"occurrences": 1,
},
Object {
"error": "stack2",
"message": "error2",
"occurrences": 1,
},
]
`);
const state3: State = reducer(state2, {
type: 'SERVER_ERROR',
payload: {
message: 'error1',
error: 'stack3',
},
});
// Still two errors, but error1 has been updated and occurrences increased
expect(state3.errors).toMatchInlineSnapshot(`
Array [
Object {
"error": "stack3",
"message": "error1",
"occurrences": 2,
},
Object {
"error": "stack2",
"message": "error2",
"occurrences": 1,
},
]
`);
});
test('selectPlugin sets deepLinkPayload correctly', () => { test('selectPlugin sets deepLinkPayload correctly', () => {
const state = reducer( const state = reducer(
undefined, undefined,

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
import {State} from '../notifications'; import {State, addNotification} from '../notifications';
import { import {
default as reducer, default as reducer,
@@ -106,3 +106,66 @@ test('reduce setActiveNotifications', () => {
], ],
}); });
}); });
test('addNotification removes duplicates', () => {
let res = reducer(
getInitialState(),
addNotification({
pluginId: 'test',
client: null,
notification,
}),
);
res = reducer(
res,
addNotification({
pluginId: 'test',
client: null,
notification: {
...notification,
id: 'otherId',
},
}),
);
res = reducer(
res,
addNotification({
pluginId: 'test',
client: null,
notification: {
...notification,
message: 'slightly different message',
},
}),
);
expect(res).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": null,
"notification": Object {
"id": "otherId",
"message": "message",
"severity": "warning",
"title": "title",
},
"pluginId": "test",
},
Object {
"client": null,
"notification": Object {
"id": "id",
"message": "slightly different message",
"severity": "warning",
"title": "title",
},
"pluginId": "test",
},
],
"blacklistedCategories": Array [],
"blacklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});

View File

@@ -37,14 +37,6 @@ export type StaticView =
| typeof SupportRequestDetails | typeof SupportRequestDetails
| typeof ConsoleLogs; | typeof ConsoleLogs;
export type FlipperError = {
occurrences?: number;
message: string;
details?: string;
error?: Error | string;
urgent?: boolean; // if true this error should always popup up
};
export type State = { export type State = {
devices: Array<BaseDevice>; devices: Array<BaseDevice>;
androidEmulators: Array<string>; androidEmulators: Array<string>;
@@ -55,7 +47,6 @@ export type State = {
userPreferredPlugin: null | string; userPreferredPlugin: null | string;
userPreferredApp: null | string; userPreferredApp: null | string;
userStarredPlugins: {[client: string]: string[]}; userStarredPlugins: {[client: string]: string[]};
errors: FlipperError[];
clients: Array<Client>; clients: Array<Client>;
uninitializedClients: Array<{ uninitializedClients: Array<{
client: UninitializedClient; client: UninitializedClient;
@@ -97,10 +88,6 @@ export type Action =
type: 'SELECT_USER_PREFERRED_PLUGIN'; type: 'SELECT_USER_PREFERRED_PLUGIN';
payload: string; payload: string;
} }
| {
type: 'SERVER_ERROR';
payload: null | FlipperError;
}
| { | {
type: 'NEW_CLIENT'; type: 'NEW_CLIENT';
payload: Client; payload: Client;
@@ -121,19 +108,11 @@ export type Action =
type: 'FINISH_CLIENT_SETUP'; type: 'FINISH_CLIENT_SETUP';
payload: {client: UninitializedClient; deviceId: string}; payload: {client: UninitializedClient; deviceId: string};
} }
| {
type: 'CLIENT_SETUP_ERROR';
payload: {client: UninitializedClient; error: FlipperError};
}
| { | {
type: 'SET_STATIC_VIEW'; type: 'SET_STATIC_VIEW';
payload: StaticView; payload: StaticView;
deepLinkPayload: unknown; deepLinkPayload: unknown;
} }
| {
type: 'DISMISS_ERROR';
payload: number;
}
| { | {
// Implemented by rootReducer in `store.tsx` // Implemented by rootReducer in `store.tsx`
type: 'STAR_PLUGIN'; type: 'STAR_PLUGIN';
@@ -160,7 +139,6 @@ const INITAL_STATE: State = {
userPreferredPlugin: null, userPreferredPlugin: null,
userPreferredApp: null, userPreferredApp: null,
userStarredPlugins: {}, userStarredPlugins: {},
errors: [],
clients: [], clients: [],
uninitializedClients: [], uninitializedClients: [],
deepLinkPayload: null, deepLinkPayload: null,
@@ -327,13 +305,6 @@ export default (state: State = INITAL_STATE, action: Actions): State => {
const {payload: userPreferredDevice} = action; const {payload: userPreferredDevice} = action;
return {...state, userPreferredDevice}; return {...state, userPreferredDevice};
} }
case 'SERVER_ERROR': {
const {payload} = action;
if (!payload) {
return state;
}
return {...state, errors: mergeError(state.errors, payload)};
}
case 'START_CLIENT_SETUP': { case 'START_CLIENT_SETUP': {
const {payload} = action; const {payload} = action;
return { return {
@@ -357,39 +328,6 @@ export default (state: State = INITAL_STATE, action: Actions): State => {
.sort((a, b) => a.client.appName.localeCompare(b.client.appName)), .sort((a, b) => a.client.appName.localeCompare(b.client.appName)),
}; };
} }
case 'CLIENT_SETUP_ERROR': {
const {payload} = action;
const errorMessage =
payload.error instanceof Error
? payload.error.message
: '' + payload.error;
const details = `Client setup error: ${errorMessage} while setting up client: ${payload.client.os}:${payload.client.deviceName}:${payload.client.appName}`;
console.error(details);
return {
...state,
uninitializedClients: state.uninitializedClients
.map((c) =>
isEqual(c.client, payload.client)
? {...c, errorMessage: errorMessage}
: c,
)
.sort((a, b) => a.client.appName.localeCompare(b.client.appName)),
errors: mergeError(state.errors, {
message: `Client setup error: ${errorMessage}`,
details,
error: payload.error instanceof Error ? payload.error : undefined,
}),
};
}
case 'DISMISS_ERROR': {
const errors = state.errors.slice();
errors.splice(action.payload, 1);
return {
...state,
errors,
};
}
case 'REGISTER_PLUGINS': { case 'REGISTER_PLUGINS': {
// plugins are registered after creating the base devices, so update them // plugins are registered after creating the base devices, so update them
const plugins = action.payload; const plugins = action.payload;
@@ -409,26 +347,6 @@ export default (state: State = INITAL_STATE, action: Actions): State => {
} }
}; };
function mergeError(
errors: FlipperError[],
newError: FlipperError,
): FlipperError[] {
const idx = errors.findIndex((error) => error.message === newError.message);
const results = errors.slice();
if (idx !== -1) {
results[idx] = {
...newError,
occurrences: (errors[idx].occurrences || 0) + 1,
};
} else {
results.push({
...newError,
occurrences: 1,
});
}
return results;
}
export const selectDevice = (payload: BaseDevice): Action => ({ export const selectDevice = (payload: BaseDevice): Action => ({
type: 'SELECT_DEVICE', type: 'SELECT_DEVICE',
payload, payload,
@@ -472,11 +390,6 @@ export const starPlugin = (payload: {
payload, payload,
}); });
export const dismissError = (index: number): Action => ({
type: 'DISMISS_ERROR',
payload: index,
});
export const selectClient = (clientId: string): Action => ({ export const selectClient = (clientId: string): Action => ({
type: 'SELECT_CLIENT', type: 'SELECT_CLIENT',
payload: clientId, payload: clientId,

View File

@@ -9,6 +9,7 @@
import {Notification} from '../plugin'; import {Notification} from '../plugin';
import {Actions} from './'; import {Actions} from './';
import {getStringFromErrorLike} from '../utils';
export type PluginNotification = { export type PluginNotification = {
notification: Notification; notification: Notification;
pluginId: string; pluginId: string;
@@ -101,7 +102,16 @@ export default function reducer(
case 'ADD_NOTIFICATION': case 'ADD_NOTIFICATION':
return { return {
...state, ...state,
activeNotifications: [...state.activeNotifications, action.payload], // while adding notifications, remove old duplicates
activeNotifications: [
...state.activeNotifications.filter(
(notif) =>
notif.client !== action.payload.client ||
notif.pluginId !== action.payload.pluginId ||
notif.notification.id !== action.payload.notification.id,
),
action.payload,
],
}; };
default: default:
return state; return state;
@@ -156,6 +166,25 @@ export function addNotification(payload: PluginNotification): Action {
}; };
} }
export function addErrorNotification(
title: string,
message: string,
error?: any,
): Action {
// TODO: use this method for https://github.com/facebook/flipper/pull/1478/files as well
console.error(title, message, error);
return addNotification({
client: null,
pluginId: 'globalError',
notification: {
id: title,
title,
message: error ? message + ' ' + getStringFromErrorLike(error) : message,
severity: 'error',
},
});
}
export function setActiveNotifications(payload: { export function setActiveNotifications(payload: {
notifications: Array<Notification>; notifications: Array<Notification>;
client: null | string; client: null | string;