Store collapsed status of sidebar sections

Summary:
See previous diff, let's store the collapsed state of sidebar sections in local storage.

Introduced a reusable hook to take care of that.

Changelog: Device plugins are now expanded by default, and the expand / collapse state will now be remembered across restarts

Reviewed By: passy

Differential Revision: D21903394

fbshipit-source-id: a3c0231acc0aa0877522ec328eedd09cb11aedb1
This commit is contained in:
Michel Weststrate
2020-06-05 08:28:41 -07:00
committed by Facebook GitHub Bot
parent 163cbe83e2
commit 95d319a700
4 changed files with 192 additions and 5 deletions

View File

@@ -35,6 +35,7 @@ import React, {
useCallback,
useState,
useEffect,
useRef,
} from 'react';
import NotificationScreen from '../NotificationScreen';
import {
@@ -60,6 +61,7 @@ import {
getColorByApp,
getFavoritePlugins,
} from './sidebarUtils';
import {useLocalStorage} from '../../utils/useLocalStorage';
type FlipperPlugins = typeof FlipperPlugin[];
type PluginsByCategory = [string, FlipperPlugins][];
@@ -122,15 +124,21 @@ const SidebarSection: React.FC<{
title: string | React.ReactNode | ((collapsed: boolean) => React.ReactNode);
level: SectionLevel;
color?: string;
}> = ({children, title, level, color, defaultCollapsed}) => {
const [collapsed, setCollapsed] = useState(!!defaultCollapsed);
storageKey: string;
}> = ({children, title, level, color, defaultCollapsed, storageKey}) => {
const hasMounted = useRef(false);
const [collapsed, setCollapsed] = useLocalStorage(
storageKey,
!!defaultCollapsed,
);
color = color || colors.macOSTitleBarIconActive;
useEffect(() => {
// if default collapsed changed to false, propagate that
if (!defaultCollapsed && collapsed) {
// if default collapsed changed to false after mounting, propagate that
if (hasMounted.current && !defaultCollapsed && collapsed) {
setCollapsed(!collapsed);
}
hasMounted.current = true;
}, [defaultCollapsed]);
return (
@@ -259,6 +267,7 @@ class MainSidebar2 extends PureComponent<Props, State> {
<SidebarSection
title={device.displayTitle()}
key={device.serial}
storageKey={device.serial}
level={1}
defaultCollapsed={!canBeDefaultDevice(device)}>
{this.showArchivedDeviceDetails(device)}
@@ -266,6 +275,7 @@ class MainSidebar2 extends PureComponent<Props, State> {
<SidebarSection
level={2}
title="Device Plugins"
storageKey={device.serial + ':device-plugins'}
defaultCollapsed={false}>
{devicePluginsItems}
</SidebarSection>
@@ -292,10 +302,15 @@ class MainSidebar2 extends PureComponent<Props, State> {
renderUnitializedClients() {
const {uninitializedClients} = this.props;
return uninitializedClients.length > 0 ? (
<SidebarSection title="Connecting..." key="unitializedClients" level={1}>
<SidebarSection
title="Connecting..."
key="unitializedClients"
level={1}
storageKey="unitializedClients">
{uninitializedClients.map((entry) => (
<SidebarSection
color={getColorByApp(entry.client.appName)}
storageKey={'unitializedClients:' + JSON.stringify(entry.client)}
key={JSON.stringify(entry.client)}
title={
<HBox grow="left">
@@ -502,6 +517,7 @@ const PluginList = memo(function PluginList({
level={2}
key={client.id}
title={client.query.app}
storageKey={`${device.serial}:${client.query.app}`}
color={getColorByApp(client.query.app)}>
{favoritePlugins.length === 0 ? (
<ListItem>
@@ -523,6 +539,7 @@ const PluginList = memo(function PluginList({
<SidebarSection
level={3}
color={colors.macOSTitleBarIconBlur}
storageKey={`${device.serial}:${client.query.app}:disabled-plugins`}
defaultCollapsed={
favoritePlugins.length > 0 && !selectedNonFavoritePlugin
}

View File

@@ -188,3 +188,4 @@ export {getFlipperMediaCDN} from './fb-stubs/user';
export {Rect} from './utils/geometry';
export {Logger} from './fb-interfaces/Logger';
export {callVSCode, getVSCodeUrl} from './utils/vscodeUtils';
export {useLocalStorage} from './utils/useLocalStorage';

View File

@@ -0,0 +1,107 @@
/**
* 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 * as React from 'react';
import {render, fireEvent, act} from '@testing-library/react';
import {useLocalStorage} from '../useLocalStorage';
function TestComponent({
storageKey,
value,
}: {
storageKey: string;
value: number;
}) {
const [current, setCurrent] = useLocalStorage(storageKey, value);
return (
<div>
<div data-testid="value">{current}</div>
<button
data-testid="inc"
onClick={() => {
setCurrent((c) => c + 1);
}}></button>
</div>
);
}
let getSpy: jest.SpyInstance;
let setSpy: jest.SpyInstance;
let storage: Record<string, any> = {};
beforeEach(() => {
storage = {};
getSpy = jest
.spyOn(Storage.prototype, 'getItem') // https://github.com/facebook/jest/issues/6798#issuecomment-412871616
.mockImplementation((key: string) => {
return storage[key];
});
setSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation((key: string, value: any) => {
storage[key] = value;
});
});
afterEach(() => {
getSpy.mockRestore();
setSpy.mockRestore();
});
test('it can store values', async () => {
const res = render(<TestComponent storageKey="x" value={1} />);
expect((await res.findByTestId('value')).textContent).toEqual('1');
await act(async () => {
fireEvent.click(await res.findByTestId('inc'));
});
expect((await res.findByTestId('value')).textContent).toEqual('2');
expect(storage).toMatchInlineSnapshot(`
Object {
"[useLocalStorage]x": "2",
}
`);
});
test('it can read default from storage', async () => {
storage['[useLocalStorage]x'] = '3';
const res = render(<TestComponent storageKey="x" value={1} />);
expect((await res.findByTestId('value')).textContent).toEqual('3');
await act(async () => {
fireEvent.click(await res.findByTestId('inc'));
});
expect((await res.findByTestId('value')).textContent).toEqual('4');
expect(storage).toMatchInlineSnapshot(`
Object {
"[useLocalStorage]x": "4",
}
`);
});
test('it does not allow changing key', async () => {
const res = render(<TestComponent storageKey="x" value={1} />);
expect(() => {
const orig = console.error;
try {
// supress error in console
console.error = jest.fn();
res.rerender(<TestComponent storageKey="y" value={1} />);
} finally {
console.error = orig;
}
}).toThrowErrorMatchingInlineSnapshot(
`"The key passed to useLocalStorage should not be changed, 'x' -> 'y'"`,
);
});

View File

@@ -0,0 +1,62 @@
/**
* 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 {useState, useCallback} from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: (() => T) | T,
): [T, (newState: T | ((current: T) => T)) => void] {
const [storedKey] = useState(key);
if (storedKey !== key) {
throw new Error(
`The key passed to useLocalStorage should not be changed, '${storedKey}' -> '${key}'`,
);
}
// Based on https://usehooks.com/useLocalStorage/ (with minor adaptions)
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(() => {
try {
// Get from local storage by key
const item = window.localStorage.getItem('[useLocalStorage]' + key);
// Parse stored json or if none return initialValue
return item
? JSON.parse(item)
: typeof initialValue === 'function'
? (initialValue as any)()
: initialValue;
} catch (error) {
// If error also return initialValue
console.log(error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = useCallback(
(value) => {
setStoredValue((storedValue) => {
const nextValue =
typeof value === 'function' ? value(storedValue) : value;
// Save to local storage
window.localStorage.setItem(
'[useLocalStorage]' + key,
JSON.stringify(nextValue),
);
return nextValue;
});
},
[key],
);
return [storedValue, setValue];
}