Add Flipper Doctor Component

Summary:
As designed by Vince [here](https://www.figma.com/file/4e6BMdm2SuZ1L7FSuOPQVC/Flipper?node-id=585%3A127550), this diff adds Flipper Doctor into Sandy

Note:
- The dot on Doctor icon will act similarly to `DoctorBar`
- add type to `count` for `LeftRailButton` to act like dot badge
- Get rid of padding in `antd` modal

Reviewed By: nikoant, mweststrate

Differential Revision: D24137349

fbshipit-source-id: 8ce441e0ed96083eba09d98dfd3a45ff9b5be027
This commit is contained in:
Chaiwat Ekkaewnumchai
2020-10-07 08:46:21 -07:00
committed by Facebook GitHub Bot
parent 41928e0e3a
commit 2b6bac4227
4 changed files with 342 additions and 8 deletions

View File

@@ -7,7 +7,7 @@
* @format
*/
import React, {cloneElement, useState, useCallback} from 'react';
import React, {cloneElement, useState, useCallback, useMemo} from 'react';
import {styled, Layout} from 'flipper';
import {Button, Divider, Badge, Tooltip, Avatar, Popover} from 'antd';
import {
@@ -25,6 +25,7 @@ import {SidebarLeft, SidebarRight} from './SandyIcons';
import {useDispatch, useStore} from '../utils/useStore';
import {toggleLeftSidebarVisible} from '../reducers/application';
import {theme} from './theme';
import SetupDoctorScreen, {checkHasNewProblem} from './SetupDoctorScreen';
import SettingsSheet from '../chrome/SettingsSheet';
import WelcomeScreen from './WelcomeScreen';
import SignInSheet from '../chrome/SignInSheet';
@@ -56,14 +57,19 @@ function LeftRailButton({
small?: boolean;
toggled?: boolean;
selected?: boolean; // TODO: make sure only one element can be selected
count?: number;
count?: number | true;
title: string;
onClick?: React.MouseEventHandler<HTMLElement>;
}) {
let iconElement =
icon && cloneElement(icon, {style: {fontSize: small ? 16 : 24}});
if (count !== undefined) {
iconElement = <Badge count={count}>{iconElement}</Badge>;
iconElement =
count === true ? (
<Badge dot>{iconElement}</Badge>
) : (
<Badge count={count}>{iconElement}</Badge>
);
}
return (
<Tooltip title={title} placement="right">
@@ -113,11 +119,7 @@ export function LeftRail({
/>
</Layout.Vertical>
<Layout.Vertical center gap={10}>
<LeftRailButton
icon={<MedicineBoxOutlined />}
small
title="Setup Doctor"
/>
<SetupDoctorButton />
<WelcomeScreenButton />
<ShowSettingsButton />
<LeftRailButton
@@ -176,6 +178,27 @@ function DebugLogsButton({
);
}
function SetupDoctorButton() {
const [visible, setVisible] = useState(false);
const result = useStore(
(state) => state.healthchecks.healthcheckReport.result,
);
const hasNewProblem = useMemo(() => checkHasNewProblem(result), [result]);
const onClose = useCallback(() => setVisible(false), []);
return (
<>
<LeftRailButton
icon={<MedicineBoxOutlined />}
small
title="Setup Doctor"
count={hasNewProblem ? true : undefined}
onClick={() => setVisible(true)}
/>
<SetupDoctorScreen visible={visible} onClose={onClose} />
</>
);
}
function ShowSettingsButton() {
const [showSettings, setShowSettings] = useState(false);
const onClose = useCallback(() => setShowSettings(false), []);

View File

@@ -0,0 +1,301 @@
/**
* 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 React, {useEffect, useCallback, useMemo, useState} from 'react';
import {useDispatch, useStore} from '../utils/useStore';
import {Typography, Collapse, Button, Modal, Checkbox, Alert} from 'antd';
import {
CheckCircleFilled,
CloseCircleFilled,
WarningFilled,
QuestionCircleFilled,
LoadingOutlined,
UpOutlined,
DownOutlined,
} from '@ant-design/icons';
import {Layout} from '../ui';
import {
HealthcheckReport,
HealthcheckReportItem,
HealthcheckStatus,
HealthcheckResult,
} from '../reducers/healthchecks';
import {theme} from './theme';
import {
startHealthchecks,
updateHealthcheckResult,
finishHealthchecks,
acknowledgeProblems,
resetAcknowledgedProblems,
} from '../reducers/healthchecks';
import runHealthchecks from '../utils/runHealthchecks';
import {Healthchecks} from 'flipper-doctor';
import {reportUsage} from '../utils/metrics';
const {Title, Paragraph, Text} = Typography;
const borderStyle = {
border: `0px solid ${theme.backgroundTransparentHover}`,
borderBottomWidth: '1px',
borderRadius: 0,
};
const statusTypeAndMessage: {
[key in HealthcheckStatus]: {
type: 'success' | 'info' | 'warning' | 'error';
message: string;
};
} = {
IN_PROGRESS: {type: 'info', message: 'Doctor is running healthchecks...'},
FAILED: {
type: 'error',
message:
'Problems have been discovered with your installation. Please expand items for details.',
},
WARNING: {
type: 'warning',
message: 'Doctor has discoverd warnings. Please expand items for details.',
},
SUCCESS: {
type: 'success',
message:
'All good! Doctor has not discovered any issues with your installation.',
},
// This is deduced from default case (for completeness)
SKIPPED: {
type: 'success',
message:
'All good! Doctor has not discovered any issues with your installation.',
},
};
function checkHasProblem(result: HealthcheckResult) {
return result.status === 'FAILED' || result.status === 'WARNING';
}
export function checkHasNewProblem(result: HealthcheckResult) {
return checkHasProblem(result) && !result.isAcknowledged;
}
function ResultTopDialog(props: {status: HealthcheckStatus}) {
const messages = statusTypeAndMessage[props.status];
return (
<Alert
type={messages.type}
showIcon
message={messages.message}
style={{
fontSize: theme.fontSize.smallBody,
lineHeight: '16px',
fontWeight: 'bold',
paddingTop: '10px',
}}
/>
);
}
function CheckIcon(props: {status: HealthcheckStatus}) {
switch (props.status) {
case 'SUCCESS':
return (
<CheckCircleFilled style={{fontSize: 24, color: theme.successColor}} />
);
case 'FAILED':
return (
<CloseCircleFilled style={{fontSize: 24, color: theme.errorColor}} />
);
case 'WARNING':
return (
<WarningFilled style={{fontSize: 24, color: theme.warningColor}} />
);
case 'SKIPPED':
return (
<QuestionCircleFilled
style={{fontSize: 24, color: theme.disabledColor}}
/>
);
case 'IN_PROGRESS':
return (
<LoadingOutlined style={{fontSize: 24, color: theme.primaryColor}} />
);
}
}
function CollapsableCategory(props: {checks: Array<HealthcheckReportItem>}) {
return (
<Collapse
expandIconPosition="right"
expandIcon={({isActive}) =>
isActive ? <UpOutlined /> : <DownOutlined />
}
bordered={false}
style={{backgroundColor: theme.backgroundDefault, border: 0}}>
{props.checks.map((check) => (
<Collapse.Panel
key={check.key}
header={
<Layout.Container padv="small">
<Layout.Horizontal gap="medium" center>
<CheckIcon status={check.result.status} />
<Title level={4}>{check.label}</Title>
</Layout.Horizontal>
</Layout.Container>
}
style={borderStyle}>
<Paragraph style={{paddingLeft: '40px'}}>
{check.result.message}
</Paragraph>
</Collapse.Panel>
))}
</Collapse>
);
}
function HealthCheckList(props: {report: HealthcheckReport}) {
useEffect(() => reportUsage('doctor:report:opened'), []);
return (
<Layout.Vertical>
<ResultTopDialog status={props.report.result.status} />
{Object.values(props.report.categories).map((category) => (
<Layout.Container key={category.key}>
<Title
level={3}
style={{...borderStyle, padding: `${theme.space.small}px 0`}}>
{category.label}
</Title>
<CollapsableCategory
checks={
category.result.status !== 'SKIPPED'
? Object.values(category.checks)
: [
{
key: 'Skipped',
label: 'Skipped',
result: {
status: 'SKIPPED',
message: category.result.message,
},
},
]
}
/>
</Layout.Container>
))}
</Layout.Vertical>
);
}
function SetupDoctorFooter(props: {
onClose: () => void;
onRerunDoctor: () => Promise<void>;
showAcknowledgeCheckbox: boolean;
acknowledgeCheck: boolean;
onAcknowledgeCheck: (checked: boolean) => void;
disableRerun: boolean;
}) {
return (
<Layout.Right>
{props.showAcknowledgeCheckbox ? (
<Checkbox
checked={props.acknowledgeCheck}
onChange={(e) => props.onAcknowledgeCheck(e.target.checked)}
style={{display: 'flex', alignItems: 'center'}}>
<Text style={{fontSize: theme.fontSize.smallBody}}>
Do not show warning about these problems at startup
</Text>
</Checkbox>
) : (
<Layout.Container />
)}
<Layout.Horizontal>
<Button onClick={props.onClose}>Close</Button>
<Button
type="primary"
onClick={() => props.onRerunDoctor()}
disabled={props.disableRerun}>
Re-run
</Button>
</Layout.Horizontal>
</Layout.Right>
);
}
export default function SetupDoctorScreen(props: {
visible: boolean;
onClose: () => void;
}) {
const healthcheckReport = useStore(
(state) => state.healthchecks.healthcheckReport,
);
const settings = useStore((state) => state.settingsState);
const dispatch = useDispatch();
const [acknowlodgeProblem, setAcknowlodgeProblem] = useState(
checkHasNewProblem(healthcheckReport.result),
);
const hasProblem = useMemo(() => checkHasProblem(healthcheckReport.result), [
healthcheckReport,
]);
const onCloseModal = useCallback(() => {
const hasNewProblem = checkHasNewProblem(healthcheckReport.result);
if (acknowlodgeProblem) {
if (hasNewProblem) {
reportUsage('doctor:report:closed:newProblems:acknowledged');
}
reportUsage('doctor:report:closed:acknowleged');
dispatch(acknowledgeProblems());
} else {
if (hasNewProblem) {
reportUsage('doctor:report:closed:newProblems:notAcknowledged');
}
reportUsage('doctor:report:closed:notAcknowledged');
dispatch(resetAcknowledgedProblems());
}
props.onClose();
}, [healthcheckReport.result, acknowlodgeProblem, props, dispatch]);
const runDoctor = useCallback(async () => {
await runHealthchecks({
settings,
startHealthchecks: (healthchecks: Healthchecks) =>
dispatch(startHealthchecks(healthchecks)),
updateHealthcheckResult: (
categoryKey: string,
itemKey: string,
result: HealthcheckResult,
) => dispatch(updateHealthcheckResult(categoryKey, itemKey, result)),
finishHealthchecks: () => dispatch(finishHealthchecks()),
});
}, [settings, dispatch]);
// This will act like componentDidMount
useEffect(() => {
runDoctor();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Modal
width={570}
title="Setup Doctor"
visible={props.visible}
destroyOnClose
footer={
<SetupDoctorFooter
onClose={onCloseModal}
onRerunDoctor={runDoctor}
showAcknowledgeCheckbox={hasProblem}
acknowledgeCheck={acknowlodgeProblem}
onAcknowledgeCheck={(checked) => setAcknowlodgeProblem(checked)}
disableRerun={healthcheckReport.result.status === 'IN_PROGRESS'}
/>
}
onCancel={onCloseModal}>
<HealthCheckList report={healthcheckReport} />
</Modal>
);
}

View File

@@ -18,6 +18,12 @@
@code-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
monospace;
/**
This section adds specific paddings to ANT component to match design
*/
@collapse-header-padding: 0;
@collapse-content-padding: 0;
/**
This section maps theme base colors as defined in light/dark.less to ANT variables
(as far as they aren't already)

View File

@@ -73,6 +73,10 @@
@link-hover: @primary-color;
@link-hover-decoration: underline;
// Modal
@modal-header-title-font-size: @heading-2-size;
@modal-header-title-line-height: @heading-2-line-height;
.flipperlegacy_design {
// Prevents ANT breaking global styles implicitly used by old Flipper design
line-height: 1;