Expose Panel and useLocalStorageState
Summary: Expose a Panel api from Sandy, which is quite similar to the old one, except that it uses Antd, and it will remember the users closed / open preference through sessions, a much requested feature. Reviewed By: nikoant Differential Revision: D27966607 fbshipit-source-id: 9b18df377215c1e6c5844d0bf972058c8c574cbb
This commit is contained in:
committed by
Facebook GitHub Bot
parent
faf8588097
commit
c005753018
@@ -38,6 +38,7 @@ test('Correct top level API exposed', () => {
|
||||
"Layout",
|
||||
"MarkerTimeline",
|
||||
"NUX",
|
||||
"Panel",
|
||||
"TestUtils",
|
||||
"Tracked",
|
||||
"TrackingScope",
|
||||
@@ -49,6 +50,7 @@ test('Correct top level API exposed', () => {
|
||||
"sleep",
|
||||
"styled",
|
||||
"theme",
|
||||
"useLocalStorageState",
|
||||
"useLogger",
|
||||
"useMemoize",
|
||||
"usePlugin",
|
||||
|
||||
@@ -89,6 +89,8 @@ export {
|
||||
Interactive as _Interactive,
|
||||
InteractiveProps as _InteractiveProps,
|
||||
} from './ui/Interactive';
|
||||
export {Panel} from './ui/Panel';
|
||||
export {useLocalStorageState} from './utils/useLocalStorageState';
|
||||
|
||||
export {HighlightManager} from './ui/Highlight';
|
||||
export {
|
||||
|
||||
85
desktop/flipper-plugin/src/ui/Panel.tsx
Normal file
85
desktop/flipper-plugin/src/ui/Panel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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 {Collapse} from 'antd';
|
||||
import {TrackingScope} from './Tracked';
|
||||
import {useLocalStorageState} from '../utils/useLocalStorageState';
|
||||
import {useCallback} from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {Spacing, theme} from './theme';
|
||||
import {Layout} from './Layout';
|
||||
|
||||
export const Panel: React.FC<{
|
||||
title: string;
|
||||
/**
|
||||
* Whether the panel can be collapsed. Defaults to true
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
/**
|
||||
* Initial state for panel if it is collapsable
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
pad: Spacing;
|
||||
}> = (props) => {
|
||||
const [collapsed, setCollapsed] = useLocalStorageState(
|
||||
`panel:${props.title}:collapsed`,
|
||||
props.collapsed,
|
||||
);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
console.log('click');
|
||||
setCollapsed((c) => !c);
|
||||
}, [setCollapsed]);
|
||||
|
||||
return (
|
||||
<TrackingScope scope={props.title}>
|
||||
<StyledCollapse
|
||||
bordered={false}
|
||||
activeKey={collapsed ? undefined : props.title}
|
||||
onChange={toggle}>
|
||||
<Collapse.Panel
|
||||
key={props.title}
|
||||
collapsible={props.collapsible ? undefined : 'disabled'}
|
||||
header={props.title}>
|
||||
<Layout.Container pad={props.pad}>{props.children}</Layout.Container>
|
||||
</Collapse.Panel>
|
||||
</StyledCollapse>
|
||||
</TrackingScope>
|
||||
);
|
||||
};
|
||||
|
||||
Panel.defaultProps = {
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
};
|
||||
|
||||
const StyledCollapse = styled(Collapse)({
|
||||
background: theme.backgroundDefault,
|
||||
borderRadius: 0,
|
||||
'& > .ant-collapse-item .ant-collapse-header': {
|
||||
background: theme.backgroundWash,
|
||||
paddingTop: theme.space.tiny,
|
||||
paddingBottom: theme.space.tiny,
|
||||
paddingLeft: 26,
|
||||
fontWeight: 'bold',
|
||||
'> .anticon': {
|
||||
padding: `5px 0px`,
|
||||
left: 8,
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
'& > .ant-collapse-item': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
'& > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box': {
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
@@ -57,6 +57,14 @@ export function TrackingScope({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the name of the current scope that is currently rendering.
|
||||
* Typically the current plugin id, but can be further refined by using TrackingScopes
|
||||
*/
|
||||
export function useCurrentScopeName(): string {
|
||||
return useContext(TrackingScopeContext);
|
||||
}
|
||||
|
||||
export function Tracked({
|
||||
events = 'onClick',
|
||||
children,
|
||||
@@ -73,7 +81,7 @@ export function Tracked({
|
||||
action?: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
const scope = useContext(TrackingScopeContext);
|
||||
const scope = useCurrentScopeName();
|
||||
return Children.map(children, (child: any) => {
|
||||
if (!child || typeof child !== 'object') {
|
||||
return child;
|
||||
|
||||
107
desktop/flipper-plugin/src/ui/__tests__/useLocalStorage.node.tsx
Normal file
107
desktop/flipper-plugin/src/ui/__tests__/useLocalStorage.node.tsx
Normal 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 {useLocalStorageState} from '../../utils/useLocalStorageState';
|
||||
|
||||
function TestComponent({
|
||||
storageKey,
|
||||
value,
|
||||
}: {
|
||||
storageKey: string;
|
||||
value: number;
|
||||
}) {
|
||||
const [current, setCurrent] = useLocalStorageState(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][Flipper]x": "2",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('it can read default from storage', async () => {
|
||||
storage['[useLocalStorage][Flipper]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][Flipper]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(
|
||||
`"[useAssertStableRef] An unstable reference was passed to this component as property 'key'. For optimization purposes we expect that this prop doesn't change over time. You might want to create the value passed to this prop outside the render closure, store it in useCallback / useMemo / useState, or set a key on the parent component"`,
|
||||
);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import {useRef} from 'react';
|
||||
* (intentionally or accidentally)
|
||||
*/
|
||||
export const useAssertStableRef =
|
||||
process.env.NODE_ENV === 'development'
|
||||
process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
|
||||
? function useAssertStableRef(value: any, prop: string) {
|
||||
const ref = useRef(value);
|
||||
if (ref.current !== value) {
|
||||
|
||||
58
desktop/flipper-plugin/src/utils/useLocalStorageState.tsx
Normal file
58
desktop/flipper-plugin/src/utils/useLocalStorageState.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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';
|
||||
import {useCurrentScopeName} from '../ui/Tracked';
|
||||
import {useAssertStableRef} from './useAssertStableRef';
|
||||
|
||||
export function useLocalStorageState<T>(
|
||||
key: string,
|
||||
initialValue: (() => T) | T,
|
||||
): [T, (newState: T | ((current: T) => T)) => void] {
|
||||
useAssertStableRef(key, 'key');
|
||||
const scope = useCurrentScopeName();
|
||||
const storageKey = `[useLocalStorage][${scope}]${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(storageKey);
|
||||
// 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(storageKey, JSON.stringify(nextValue));
|
||||
return nextValue;
|
||||
});
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
Reference in New Issue
Block a user