From 84292201644143dd3f2aace2753f10caec339840 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 1 May 2019 10:43:40 -0700 Subject: [PATCH] Added filter UI (but with a lot of $FlowFixMe comments) --- src/backend/agent.js | 8 +- src/backend/renderer.js | 62 ++- src/backend/types.js | 4 +- src/constants.js | 2 +- src/devtools/store.js | 24 +- src/devtools/views/Button.js | 4 +- src/devtools/views/ButtonIcon.js | 50 ++- .../Components/ComponentFiltersModal.css | 119 +++++ .../views/Components/ComponentFiltersModal.js | 406 ++++++++++++++++++ .../ComponentFiltersModalContext.js | 37 ++ .../ToggleComponentFiltersModalButton.js | 42 ++ src/devtools/views/Components/Tree.css | 1 + src/devtools/views/Components/Tree.js | 78 ++-- .../Profiler/ClearProfilingDataButton.js | 2 +- src/devtools/views/Settings/Settings.js | 58 --- .../views/Settings/SettingsContext.js | 5 + src/devtools/views/root.css | 8 + src/types.js | 38 +- src/utils.js | 79 ++-- 19 files changed, 831 insertions(+), 196 deletions(-) create mode 100644 src/devtools/views/Components/ComponentFiltersModal.css create mode 100644 src/devtools/views/Components/ComponentFiltersModal.js create mode 100644 src/devtools/views/Components/ComponentFiltersModalContext.js create mode 100644 src/devtools/views/Components/ToggleComponentFiltersModalButton.js diff --git a/src/backend/agent.js b/src/backend/agent.js index fed7344dbe..32982814df 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -16,7 +16,7 @@ import type { RendererID, RendererInterface, } from './types'; -import type { Bridge, FilterPreferences } from '../types'; +import type { Bridge, ComponentFilter } from '../types'; const debug = (methodName, ...args) => { if (__DEBUG__) { @@ -118,7 +118,7 @@ export default class Agent extends EventEmitter { this.syncSelectionFromNativeElementsPanel ); bridge.addListener('shutdown', this.shutdown); - bridge.addListener('updateFilterPreferences', this.updateFilterPreferences); + bridge.addListener('updateComponentFilters', this.updateComponentFilters); bridge.addListener('viewElementSource', this.viewElementSource); if (this._isProfiling) { @@ -490,12 +490,12 @@ export default class Agent extends EventEmitter { this._bridge.send('profilingStatus', this._isProfiling); }; - updateFilterPreferences = (filterPreferences: FilterPreferences) => { + updateComponentFilters = (componentFilters: Array) => { for (let rendererID in this._rendererInterfaces) { const renderer = ((this._rendererInterfaces[ (rendererID: any) ]: any): RendererInterface); - renderer.updateFilterPreferences(filterPreferences); + renderer.updateComponentFilters(componentFilters); } }; diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 4808b694e4..403f6277c9 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -2,6 +2,9 @@ import { gte } from 'semver'; import { + ComponentFilterDisplayName, + ComponentFilterElementType, + ComponentFilterPath, ElementTypeClass, ElementTypeContext, ElementTypeEventComponent, @@ -17,7 +20,7 @@ import { } from 'src/types'; import { getDisplayName, - getSavedFilterPreferences, + getSavedComponentFilters, getUID, utfEncodeString, } from 'src/utils'; @@ -47,7 +50,7 @@ import type { RendererInterface, } from './types'; import type { InspectedElement } from 'src/devtools/views/Components/types'; -import type { ElementType, FilterPreferences } from 'src/types'; +import type { ComponentFilter, ElementType } from 'src/types'; function getInternalReactConstants(version) { const ReactSymbols = { @@ -261,17 +264,53 @@ export function attach( } }; - let { - hideElementsWithTypes, - hideElementsWithDisplayNames, - hideElementsWithPaths, - } = getSavedFilterPreferences(); + // Configurable Components tree filters. + const hideElementsWithDisplayNames: Set = new Set(); + const hideElementsWithPaths: Set = new Set(); + const hideElementsWithTypes: Set = new Set(); + + function applyComponentFilters(componentFilters: Array) { + hideElementsWithTypes.clear(); + hideElementsWithDisplayNames.clear(); + hideElementsWithPaths.clear(); + + componentFilters.forEach(componentFilter => { + if (!componentFilter.isEnabled) { + return; + } + + switch (componentFilter.type) { + case ComponentFilterDisplayName: + if (componentFilter.isValid && componentFilter.value !== '') { + hideElementsWithDisplayNames.add( + new RegExp(componentFilter.value, 'i') + ); + } + break; + case ComponentFilterElementType: + hideElementsWithTypes.add(componentFilter.value); + break; + case ComponentFilterPath: + if (componentFilter.isValid && componentFilter.value !== '') { + hideElementsWithPaths.add(new RegExp(componentFilter.value, 'i')); + } + break; + default: + console.warn( + `Invalid component filter type "${componentFilter.type}"` + ); + break; + } + }); + } + + applyComponentFilters(getSavedComponentFilters()); // TODO (filter) Should we make this operation more efficient? // For example, we could add a new recursive unmount tree operation. // The unmount operations are already significantly smaller than mount opreations though. // This is something to keep in mind for later. - function updateFilterPreferences(filterPreferences: FilterPreferences) { + function updateComponentFilters(componentFilters: Array) { if (this._isProfiling) { // Re-mounting a tree while profiling is in progress might break a lot of assumptions. // If necessary, we could support this- but it doesn't seem like a necessary use case. @@ -286,10 +325,7 @@ export function attach( currentRootID = -1; }); - hideElementsWithTypes = filterPreferences.hideElementsWithTypes; - hideElementsWithDisplayNames = - filterPreferences.hideElementsWithDisplayNames; - hideElementsWithPaths = filterPreferences.hideElementsWithPaths; + applyComponentFilters(componentFilters); // Recursively re-mount all roots with new filter criteria applied. hook.getFiberRoots(rendererID).forEach(root => { @@ -2280,6 +2316,6 @@ export function attach( setTrackedPath, startProfiling, stopProfiling, - updateFilterPreferences, + updateComponentFilters, }; } diff --git a/src/backend/types.js b/src/backend/types.js index a12dda6292..1675416f96 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -1,6 +1,6 @@ // @flow -import type { ElementType, FilterPreferences } from 'src/types'; +import type { ComponentFilter, ElementType } from 'src/types'; import type { InspectedElement } from 'src/devtools/views/Components/types'; type BundleType = @@ -139,7 +139,7 @@ export type RendererInterface = { setTrackedPath: (path: Array | null) => void, startProfiling: () => void, stopProfiling: () => void, - updateFilterPreferences: (filterPreferences: FilterPreferences) => void, + updateComponentFilters: (somponentFilters: Array) => void, }; export type Handler = (data: any) => void; diff --git a/src/constants.js b/src/constants.js index f5c7683d69..fcbf9cb65e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -6,7 +6,7 @@ export const TREE_OPERATION_REORDER_CHILDREN = 3; export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4; export const LOCAL_STORAGE_FILTER_PREFERENCES_KEY = - 'React::DevTools::filterPreferences'; + 'React::DevTools::componentFilters'; export const LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY = 'React::DevTools::reloadAndProfile'; diff --git a/src/devtools/store.js b/src/devtools/store.js index 7c062247cf..6da87d3032 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -11,8 +11,8 @@ import { } from '../constants'; import { ElementTypeRoot } from '../types'; import { - getSavedFilterPreferences, - saveFilterPreferences, + getSavedComponentFilters, + saveComponentFilters, utfDecodeString, } from '../utils'; import { __DEBUG__ } from '../constants'; @@ -24,7 +24,7 @@ import type { ImportedProfilingData, ProfilingSnapshotNode, } from './views/Profiler/types'; -import type { Bridge, ElementType, FilterPreferences } from '../types'; +import type { Bridge, ComponentFilter, ElementType } from '../types'; const debug = (methodName, ...args) => { if (__DEBUG__) { @@ -69,7 +69,7 @@ export default class Store extends EventEmitter { // Should new nodes be collapsed by default when added to the tree? _collapseNodesByDefault: boolean = true; - _filterPreferences: FilterPreferences; + _componentFilters: Array; // At least one of the injected renderers contains (DEV only) owner metadata. _hasOwnerMetadata: boolean = false; @@ -143,7 +143,7 @@ export default class Store extends EventEmitter { localStorage.getItem(LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY) !== 'false'; - this._filterPreferences = getSavedFilterPreferences(); + this._componentFilters = getSavedComponentFilters(); if (config != null) { const { @@ -239,26 +239,26 @@ export default class Store extends EventEmitter { this.emit('collapseNodesByDefault'); } - get filterPreferences(): FilterPreferences { - return this._filterPreferences; + get componentFilters(): Array { + return this._componentFilters; } - set filterPreferences(value: FilterPreferences): void { + set componentFilters(value: Array): void { if (this._isProfiling) { // Re-mounting a tree while profiling is in progress might break a lot of assumptions. // If necessary, we could support this- but it doesn't seem like a necessary use case. throw Error('Cannot modify filter preferences while profiling'); } - this._filterPreferences = value; + this._componentFilters = value; // Update persisted filter preferences stored in localStorage. - saveFilterPreferences(value); + saveComponentFilters(value); // Notify the renderer that filter prefernces have changed. // This is an expensive opreation; it unmounts and remounts the entire tree. - this._bridge.send('updateFilterPreferences', value); + this._bridge.send('updateComponentFilters', value); - this.emit('filterPreferences'); + this.emit('componentFilters'); } get hasOwnerMetadata(): boolean { diff --git a/src/devtools/views/Button.js b/src/devtools/views/Button.js index 78a9c8201f..bb55bca22f 100644 --- a/src/devtools/views/Button.js +++ b/src/devtools/views/Button.js @@ -9,13 +9,13 @@ import tooltipStyles from './Tooltip.css'; type Props = { children: React$Node, className?: string, - title: string, + title?: string, }; export default function Button({ children, className = '', - title, + title = '', ...rest }: Props) { let button = ( diff --git a/src/devtools/views/ButtonIcon.js b/src/devtools/views/ButtonIcon.js index 3099068b5b..0e05ce771f 100644 --- a/src/devtools/views/ButtonIcon.js +++ b/src/devtools/views/ButtonIcon.js @@ -6,6 +6,7 @@ import styles from './ButtonIcon.css'; export type IconType = | 'add' | 'cancel' + | 'clear' | 'close' | 'collapsed' | 'copy' @@ -21,7 +22,11 @@ export type IconType = | 'previous' | 'record' | 'reload' + | 'save' | 'search' + | 'settings' + | 'toggle_off' + | 'toggle_on' | 'undo' | 'up' | 'view-dom' @@ -41,6 +46,9 @@ export default function ButtonIcon({ className = '', type }: Props) { case 'cancel': pathData = PATH_CANCEL; break; + case 'clear': + pathData = PATH_CLEAR; + break; case 'close': pathData = PATH_CLOSE; break; @@ -86,9 +94,21 @@ export default function ButtonIcon({ className = '', type }: Props) { case 'reload': pathData = PATH_RELOAD; break; + case 'save': + pathData = PATH_SAVE; + break; case 'search': pathData = PATH_SEARCH; break; + case 'settings': + pathData = PATH_SETTINGS; + break; + case 'toggle_off': + pathData = PATH_TOGGLE_OFF; + break; + case 'toggle_on': + pathData = PATH_TOGGLE_ON; + break; case 'undo': pathData = PATH_UNDO; break; @@ -120,9 +140,14 @@ export default function ButtonIcon({ className = '', type }: Props) { ); } -const PATH_ADD = 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z'; +const PATH_ADD = + 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z'; const PATH_CANCEL = ` + M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z +`; + +const PATH_CLEAR = ` M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z `; @@ -138,8 +163,8 @@ const PATH_COPY = ` `; const PATH_DELETE = ` - M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zm2.46-7.12l1.41-1.41L12 12.59l2.12-2.12 1.41 1.41L13.41 14l2.12 - 2.12-1.41 1.41L12 15.41l-2.12 2.12-1.41-1.41L10.59 14l-2.13-2.12zM15.5 4l-1-1h-5l-1 1H5v2h14V4z + M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 + 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z `; const PATH_DOWN = 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'; @@ -177,11 +202,30 @@ const PATH_RELOAD = ` 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z `; +const PATH_SAVE = ` + M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z +`; + const PATH_SEARCH = ` M8.5,22H3.7l-1.4-1.5V3.8l1.3-1.5h17.2l1,1.5v4.9h-1.3V4.3l-0.4-0.6H4.2L3.6,4.3V20l0.7,0.7h4.2V22z M23,13.9l-4.6,3.6l4.6,4.6l-1.1,1.1l-4.7-4.4l-3.3,4.4l-3.2-12.3L23,13.9z `; +const PATH_SETTINGS = ` + M15.95 10.78c.03-.25.05-.51.05-.78s-.02-.53-.06-.78l1.69-1.32c.15-.12.19-.34.1-.51l-1.6-2.77c-.1-.18-.31-.24-.49-.18l-1.99.8c-.42-.32-.86-.58-1.35-.78L12 + 2.34c-.03-.2-.2-.34-.4-.34H8.4c-.2 0-.36.14-.39.34l-.3 2.12c-.49.2-.94.47-1.35.78l-1.99-.8c-.18-.07-.39 + 0-.49.18l-1.6 2.77c-.1.18-.06.39.1.51l1.69 + 1.32c-.04.25-.07.52-.07.78s.02.53.06.78L2.37 12.1c-.15.12-.19.34-.1.51l1.6 2.77c.1.18.31.24.49.18l1.99-.8c.42.32.86.58 + 1.35.78l.3 2.12c.04.2.2.34.4.34h3.2c.2 0 .37-.14.39-.34l.3-2.12c.49-.2.94-.47 1.35-.78l1.99.8c.18.07.39 0 + .49-.18l1.6-2.77c.1-.18.06-.39-.1-.51l-1.67-1.32zM10 13c-1.65 0-3-1.35-3-3s1.35-3 3-3 3 1.35 3 3-1.35 3-3 3z +`; + +const PATH_TOGGLE_OFF = + 'M17 7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h10c2.76 0 5-2.24 5-5s-2.24-5-5-5zM7 15c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z'; + +const PATH_TOGGLE_ON = + 'M17 7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h10c2.76 0 5-2.24 5-5s-2.24-5-5-5zm0 8c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z'; + const PATH_UNDO = ` M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z diff --git a/src/devtools/views/Components/ComponentFiltersModal.css b/src/devtools/views/Components/ComponentFiltersModal.css new file mode 100644 index 0000000000..1e16c7e3de --- /dev/null +++ b/src/devtools/views/Components/ComponentFiltersModal.css @@ -0,0 +1,119 @@ +.Background { + position: absolute; + width: 100%; + height: 100%; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 1rem; + background-color: var(--color-modal-background); + overflow: auto; +} + +.Modal { + position: relative; + z-index: 3; + min-width: 20rem; + max-width: 100%; + display: inline-block; + background-color: var(--color-background); + padding: 0.5rem; + border: 1px solid var(--color-border); + border-radius: 0.25rem; +} + +.LeftRight { + display: flex; +} +.Left { +} +.Right { + flex: 1; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.ButtonIcon { + margin-right: 0.25rem; +} + +.NoFiltersCell { + padding: 0.25rem 0; + color: var(--color-dim); +} + +.Table { + min-width: 20rem; + margin-top: 0.5rem; + border-spacing: 0; +} + +.TableRow { + padding-bottom: 0.5rem; +} + +.TableCell { + padding: 0; + padding-right: 0.5rem; +} +.TableCell:last-of-type { + text-align: right; + padding-right: 0; +} + +.Select { +} + +.Input { + border: none; + border-radius: 0.125rem; + padding: 0.125rem; +} + +.CancelButton { + margin-right: 0.25rem; +} + +.InvalidRegExp, +.InvalidRegExp:active, +.InvalidRegExp:focus, +.InvalidRegExp:hover { + color: var(--color-value-invalid); +} + +.ToggleOffInvalid, +.ToggleOnInvalid, +.ToggleOff, +.ToggleOn { + border-radius: 0.75rem; + width: 1rem; + height: 0.625rem; + display: flex; + align-items: center; + padding: 0.125rem; +} +.ToggleOffInvalid { + background-color: var(--color-toggle-background-invalid); + justify-content: flex-start; +} +.ToggleOnInvalid { + background-color: var(--color-toggle-background-invalid); + justify-content: flex-end; +} +.ToggleOff { + background-color: var(--color-toggle-background-off); + justify-content: flex-start; +} +.ToggleOn { + background-color: var(--color-toggle-background-on); + justify-content: flex-end; +} + +.ToggleInsideOff, +.ToggleInsideOn { + border-radius: 0.375rem; + width: 0.375rem; + height: 0.375rem; + background-color: var(--color-toggle-text); +} diff --git a/src/devtools/views/Components/ComponentFiltersModal.js b/src/devtools/views/Components/ComponentFiltersModal.js new file mode 100644 index 0000000000..e7a29f4e58 --- /dev/null +++ b/src/devtools/views/Components/ComponentFiltersModal.js @@ -0,0 +1,406 @@ +// @flow + +import React, { + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; +import { useModalDismissSignal, useSubscription } from '../hooks'; +import { ComponentFiltersModalContext } from './ComponentFiltersModalContext'; +import { StoreContext } from '../context'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import Toggle from '../Toggle'; +import Store from 'src/devtools/store'; +import { + ComponentFilterElementType, + ComponentFilterDisplayName, + ComponentFilterPath, + ElementTypeClass, + ElementTypeContext, + ElementTypeEventTarget, + ElementTypeFunction, + ElementTypeForwardRef, + ElementTypeHostComponent, + ElementTypeMemo, + ElementTypeOtherOrUnknown, + ElementTypeProfiler, + ElementTypeSuspense, +} from 'src/types'; +import styles from './ComponentFiltersModal.css'; + +import type { + ComponentFilter, + ElementType, + ElementTypeComponentFilter, + FilterType, + RegExpComponentFilter, +} from 'src/types'; + +export default function ComponentFiltersModalWrapper(_: {||}) { + const store = useContext(StoreContext); + + const { isModalShowing, setIsModalShowing } = useContext( + ComponentFiltersModalContext + ); + + // Re-mounting a tree while profiling is in progress might break a lot of assumptions. + // If necessary, we could support this- but it doesn't seem like a necessary use case. + const isProfilingSubscription = useMemo( + () => ({ + getCurrentValue: () => store.isProfiling, + subscribe: (callback: Function) => { + store.addListener('isProfiling', callback); + return () => store.removeListener('isProfiling', callback); + }, + }), + [store] + ); + const isProfiling = useSubscription(isProfilingSubscription); + if (isProfiling && isModalShowing) { + setIsModalShowing(false); + } + + return isModalShowing ? ( + + ) : null; +} + +type Props = {| + store: Store, + setIsModalShowing: (value: boolean) => void, +|}; + +function ComponentFiltersModal({ store, setIsModalShowing }: Props) { + const dismissModal = useCallback(() => setIsModalShowing(false), [ + setIsModalShowing, + ]); + + const modalRef = useRef(null); + + useModalDismissSignal(modalRef, dismissModal); + + const { + addFilter, + changeFilterType, + updateFilterValueElementType, + updateFilterValueRegExp, + componentFilters, + removeFilter, + saveFilters, + toggleFilterIsEnabled, + } = useComponentFilters(); + + const saveAndClose = useCallback(() => { + saveFilters(); + dismissModal(); + }, [dismissModal, saveFilters]); + + return ( +
+
+
+
Hide components where...
+
+ +
+
+ + + {componentFilters.length === 0 && ( + + + + )} + {componentFilters.map((componentFilter, index) => ( + + + + + + + + ))} + +
+ No filters have been added. +
+ + toggleFilterIsEnabled(componentFilter, isEnabled) + } + title={ + componentFilter.isValid === false + ? 'Filter invalid' + : componentFilter.isEnabled + ? 'Filter enabled' + : 'Filter disabled' + } + > + + + + + + {componentFilter.type === ComponentFilterElementType + ? 'equals' + : 'matches'} + + {componentFilter.type === ComponentFilterElementType ? ( + + ) : ( + + updateFilterValueRegExp( + componentFilter, + currentTarget.value + ) + } + value={componentFilter.value} + /> + )} + + +
+
+
+ + +
+
+
+
+ ); +} + +type ToggleIconProps = {| + isEnabled: boolean, + isValid: boolean, +|}; +function ToggleIcon({ isEnabled, isValid }: ToggleIconProps) { + let className; + if (isValid) { + className = isEnabled ? styles.ToggleOn : styles.ToggleOff; + } else { + className = isEnabled ? styles.ToggleOnInvalid : styles.ToggleOffInvalid; + } + return ( +
+
+
+ ); +} + +function useComponentFilters() { + const store = useContext(StoreContext); + + const [componentFilters, setComponentFilters] = useState< + Array + >(() => [...store.componentFilters]); + + const addFilter = useCallback(() => { + setComponentFilters(componentFilters => { + return [ + ...componentFilters, + { + type: ComponentFilterElementType, + value: ElementTypeHostComponent, + isEnabled: true, + }, + ]; + }); + }, []); + + const changeFilterType = useCallback( + (componentFilter: ComponentFilter, type: FilterType) => { + setComponentFilters(componentFilters => { + const cloned: Array = [...componentFilters]; + const index = componentFilters.indexOf(componentFilter); + if (index >= 0) { + if (type === ComponentFilterElementType) { + // $FlowFixMe TODO (filters) + cloned[index] = ({ + type, + isEnabled: componentFilter.isEnabled, + value: ElementTypeHostComponent, + }: ElementTypeComponentFilter); + } else if ( + type === ComponentFilterDisplayName || + type === ComponentFilterPath + ) { + // $FlowFixMe TODO (filters) + cloned[index] = ({ + type, + isEnabled: componentFilter.isEnabled, + isValid: true, + value: '', + }: RegExpComponentFilter); + } + } + return cloned; + }); + }, + [] + ); + + const updateFilterValueElementType = useCallback( + (componentFilter: ComponentFilter, value: ElementType) => { + if (componentFilter.type !== ComponentFilterElementType) { + throw Error('Invalid value for element type filter'); + } + + setComponentFilters(componentFilters => { + const cloned: Array = [...componentFilters]; + const index = componentFilters.indexOf(componentFilter); + if (index >= 0) { + // $FlowFixMe TODO (filters) + cloned[index] = { + ...componentFilter, + value, + }; + } + return cloned; + }); + }, + [] + ); + + const updateFilterValueRegExp = useCallback( + (componentFilter: ComponentFilter, value: string) => { + if (componentFilter.type === ComponentFilterElementType) { + throw Error('Invalid value for element type filter'); + } + + setComponentFilters(componentFilters => { + const cloned: Array = [...componentFilters]; + const index = componentFilters.indexOf(componentFilter); + if (index >= 0) { + let isValid = true; + try { + new RegExp(value); + } catch (error) { + isValid = false; + } + // $FlowFixMe TODO (filters) + cloned[index] = { + ...componentFilter, + isValid, + value, + }; + } + return cloned; + }); + }, + [] + ); + + const removeFilter = useCallback((index: number) => { + setComponentFilters(componentFilters => { + const cloned: Array = [...componentFilters]; + cloned.splice(index, 1); + return cloned; + }); + }, []); + + const saveFilters = useCallback(() => { + store.componentFilters = [...componentFilters]; + }, [componentFilters, store]); + + const toggleFilterIsEnabled = useCallback( + (componentFilter: ComponentFilter, isEnabled: boolean) => { + setComponentFilters(componentFilters => { + const cloned: Array = [...componentFilters]; + const index = componentFilters.indexOf(componentFilter); + if (index >= 0) { + // $FlowFixMe TODO (filters) + cloned[index] = { + ...cloned[index], + isEnabled, + }; + } + return cloned; + }); + }, + [] + ); + + return { + addFilter, + changeFilterType, + componentFilters, + removeFilter, + saveFilters, + toggleFilterIsEnabled, + updateFilterValueElementType, + updateFilterValueRegExp, + }; +} diff --git a/src/devtools/views/Components/ComponentFiltersModalContext.js b/src/devtools/views/Components/ComponentFiltersModalContext.js new file mode 100644 index 0000000000..6d2c46452a --- /dev/null +++ b/src/devtools/views/Components/ComponentFiltersModalContext.js @@ -0,0 +1,37 @@ +// @flow + +import React, { createContext, useMemo, useState } from 'react'; + +type Context = {| + isModalShowing: boolean, + setIsModalShowing: (value: boolean) => void, +|}; + +const ComponentFiltersModalContext = createContext( + ((null: any): Context) +); +ComponentFiltersModalContext.displayName = 'ComponentFiltersModalContext'; + +type Props = {| + children: React$Node, +|}; + +function ComponentFiltersModalContextController({ children }: Props) { + const [isModalShowing, setIsModalShowing] = useState(false); + + const value = useMemo( + () => ({ + isModalShowing, + setIsModalShowing, + }), + [isModalShowing] + ); + + return ( + + {children} + + ); +} + +export { ComponentFiltersModalContext, ComponentFiltersModalContextController }; diff --git a/src/devtools/views/Components/ToggleComponentFiltersModalButton.js b/src/devtools/views/Components/ToggleComponentFiltersModalButton.js new file mode 100644 index 0000000000..6268492030 --- /dev/null +++ b/src/devtools/views/Components/ToggleComponentFiltersModalButton.js @@ -0,0 +1,42 @@ +// @flow + +import React, { useContext, useMemo } from 'react'; +import { useSubscription } from '../hooks'; +import { ComponentFiltersModalContext } from './ComponentFiltersModalContext'; +import { StoreContext } from '../context'; +import Toggle from '../Toggle'; +import ButtonIcon from '../ButtonIcon'; +import Store from 'src/devtools/store'; + +export default function ToggleCommitFilterModalButton() { + const store = useContext(StoreContext); + + const { isModalShowing, setIsModalShowing } = useContext( + ComponentFiltersModalContext + ); + + // Re-mounting a tree while profiling is in progress might break a lot of assumptions. + // If necessary, we could support this- but it doesn't seem like a necessary use case. + const isProfilingSubscription = useMemo( + () => ({ + getCurrentValue: () => store.isProfiling, + subscribe: (callback: Function) => { + store.addListener('isProfiling', callback); + return () => store.removeListener('isProfiling', callback); + }, + }), + [store] + ); + const isProfiling = useSubscription(isProfilingSubscription); + + return ( + + + + ); +} diff --git a/src/devtools/views/Components/Tree.css b/src/devtools/views/Components/Tree.css index 98b9b85870..3ce44f0828 100644 --- a/src/devtools/views/Components/Tree.css +++ b/src/devtools/views/Components/Tree.css @@ -1,4 +1,5 @@ .Tree { + position: relative; height: 100%; width: 100%; display: flex; diff --git a/src/devtools/views/Components/Tree.js b/src/devtools/views/Components/Tree.js index 0a96f163b0..8c3c4031f7 100644 --- a/src/devtools/views/Components/Tree.js +++ b/src/devtools/views/Components/Tree.js @@ -18,6 +18,9 @@ import ElementView from './Element'; import InspectHostNodesToggle from './InspectHostNodesToggle'; import OwnersStack from './OwnersStack'; import SearchInput from './SearchInput'; +import { ComponentFiltersModalContextController } from './ComponentFiltersModalContext'; +import ToggleComponentFiltersModalButton from './ToggleComponentFiltersModalButton'; +import ComponentFiltersModal from './ComponentFiltersModal'; import styles from './Tree.css'; @@ -269,42 +272,47 @@ export default function Tree(props: Props) { ); return ( -
-
- -
- {ownerStack.length > 0 ? : } + +
+
+ +
+ {ownerStack.length > 0 ? : } +
+ +
+
+ + {({ height, width }) => ( + // $FlowFixMe https://github.com/facebook/flow/issues/7341 + + {ElementView} + + )} + +
+
-
- - {({ height, width }) => ( - // $FlowFixMe https://github.com/facebook/flow/issues/7341 - - {ElementView} - - )} - -
-
+ ); } diff --git a/src/devtools/views/Profiler/ClearProfilingDataButton.js b/src/devtools/views/Profiler/ClearProfilingDataButton.js index 082d3ec7b1..a8be6b0f6c 100644 --- a/src/devtools/views/Profiler/ClearProfilingDataButton.js +++ b/src/devtools/views/Profiler/ClearProfilingDataButton.js @@ -18,7 +18,7 @@ export default function ClearProfilingDataButton() { onClick={clear} title="Clear profiling data" > - + ); } diff --git a/src/devtools/views/Settings/Settings.js b/src/devtools/views/Settings/Settings.js index cc4faeff51..645ee25422 100644 --- a/src/devtools/views/Settings/Settings.js +++ b/src/devtools/views/Settings/Settings.js @@ -1,7 +1,6 @@ // @flow import React, { useCallback, useContext, useMemo } from 'react'; -import { ElementTypeHostComponent } from 'src/types'; import { useSubscription } from '../hooks'; import { StoreContext } from '../context'; import { SettingsContext } from './SettingsContext'; @@ -10,8 +9,6 @@ import portaledContent from '../portaledContent'; import styles from './Settings.css'; -import type { FilterPreferences } from 'src/types'; - function Settings(_: {||}) { const store = useContext(StoreContext); const { displayDensity, setDisplayDensity, theme, setTheme } = useContext( @@ -46,49 +43,6 @@ function Settings(_: {||}) { collapseNodesByDefaultSubscription ); - // Re-mounting a tree while profiling is in progress might break a lot of assumptions. - // If necessary, we could support this- but it doesn't seem like a necessary use case. - const isProfilingSubscription = useMemo( - () => ({ - getCurrentValue: () => store.isProfiling, - subscribe: (callback: Function) => { - store.addListener('isProfiling', callback); - return () => store.removeListener('isProfiling', callback); - }, - }), - [store] - ); - const isProfiling = useSubscription(isProfilingSubscription); - - const filterPreferencesSubscription = useMemo( - () => ({ - getCurrentValue: () => store.filterPreferences, - subscribe: (callback: Function) => { - store.addListener('filterPreferences', callback); - return () => store.removeListener('filterPreferences', callback); - }, - }), - [store] - ); - const filterPreferences = useSubscription( - filterPreferencesSubscription - ); - - const updateFilterPreferences = useCallback( - ({ currentTarget }) => { - const filterPreferences = store.filterPreferences; - if (currentTarget.checked) { - filterPreferences.hideElementsWithTypes.add(ElementTypeHostComponent); - } else { - filterPreferences.hideElementsWithTypes.delete( - ElementTypeHostComponent - ); - } - store.filterPreferences = { ...filterPreferences }; - }, - [store] - ); - const updateDisplayDensity = useCallback( ({ currentTarget }) => { setDisplayDensity(currentTarget.value); @@ -189,18 +143,6 @@ function Settings(_: {||}) { />{' '} Collapse newly added components by default - -
{store.supportsCaptureScreenshots && ( diff --git a/src/devtools/views/Settings/SettingsContext.js b/src/devtools/views/Settings/SettingsContext.js index 0cc5dfb9a4..e6f0ffbe80 100644 --- a/src/devtools/views/Settings/SettingsContext.js +++ b/src/devtools/views/Settings/SettingsContext.js @@ -247,6 +247,11 @@ function updateThemeVariables( updateStyleHelper(theme, 'color-tab-selected-border', documentElements); updateStyleHelper(theme, 'color-text', documentElements); updateStyleHelper(theme, 'color-text-selected', documentElements); + updateStyleHelper(theme, 'color-toggle-background-invalid', documentElements); + updateStyleHelper(theme, 'color-toggle-background-on', documentElements); + updateStyleHelper(theme, 'color-toggle-background-off', documentElements); + updateStyleHelper(theme, 'color-toggle-text', documentElements); + updateStyleHelper(theme, 'color-toggle-text', documentElements); updateStyleHelper(theme, 'color-tooltip-background', documentElements); updateStyleHelper(theme, 'color-tooltip-text', documentElements); diff --git a/src/devtools/views/root.css b/src/devtools/views/root.css index ca0582a171..296d7dd1a3 100644 --- a/src/devtools/views/root.css +++ b/src/devtools/views/root.css @@ -48,6 +48,10 @@ --light-color-tab-selected-border: #0088fa; --light-color-text: #000000; --light-color-text-selected: #ffffff; + --light-color-toggle-background-invalid: #fc3a4b; + --light-color-toggle-background-on: #0088fa; + --light-color-toggle-background-off: #cfd1d5; + --light-color-toggle-text: #ffffff; --light-color-tooltip-background: rgba(0, 0, 0, 0.9); --light-color-tooltip-text: #ffffff; @@ -96,6 +100,10 @@ --dark-color-tab-selected-border: #178fb9; --dark-color-text: #ffffff; --dark-color-text-selected: #ffffff; + --dark-color-toggle-background-invalid: #fc3a4b; + --dark-color-toggle-background-on: #178fb9; + --dark-color-toggle-background-off: #777d88; + --dark-color-toggle-text: #ffffff; --dark-color-tooltip-background: rgba(255, 255, 255, 0.9); --dark-color-tooltip-text: #000000; diff --git a/src/types.js b/src/types.js index b1af981fde..fdcac11395 100644 --- a/src/types.js +++ b/src/types.js @@ -13,7 +13,7 @@ export type Wall = {| |}; // WARNING -// The values below are referenced by FilterPreferences (which is saved via localStorage). +// The values below are referenced by ComponentFilters (which are saved via localStorage). // Do not change them or it will break previously saved user customizations. // If new element types are added, use new numbers rather than re-ordering existing ones. export const ElementTypeClass = 1; @@ -34,15 +34,33 @@ export const ElementTypeSuspense = 12; // or to enable/disable certain functionality. export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; -export type FilterPreferences = {| - // Hide all elements of types in this Set. - // We hide host components only by default. - hideElementsWithTypes: Set, +// WARNING +// The values below are referenced by ComponentFilters (which are saved via localStorage). +// Do not change them or it will break previously saved user customizations. +// If new filter types are added, use new numbers rather than re-ordering existing ones. +export const ComponentFilterElementType = 1; +export const ComponentFilterDisplayName = 2; +export const ComponentFilterPath = 3; - // Hide all elements with displayNames matching one or more of the RegExps in this Set. - hideElementsWithDisplayNames: Set, +export type FilterType = 1 | 2 | 3; - // Hide all elements within paths matching one or more of the RegExps in this Set. - // This filter is only used for elements that include debug source location. - hideElementsWithPaths: Set, +// Hide all elements of types in this Set. +// We hide host components only by default. +export type ElementTypeComponentFilter = {| + isEnabled: boolean, + type: 1, + value: ElementType, |}; + +// Hide all elements with displayNames or paths matching one or more of the RegExps in this Set. +// Path filters are only used when elements include debug source location. +export type RegExpComponentFilter = {| + isEnabled: boolean, + isValid: boolean, + type: 2 | 3, + value: string, +|}; + +export type ComponentFilter = + | ElementTypeComponentFilter + | RegExpComponentFilter; diff --git a/src/utils.js b/src/utils.js index 0e5caa2c93..1200687434 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2,9 +2,9 @@ import LRU from 'lru-cache'; import { LOCAL_STORAGE_FILTER_PREFERENCES_KEY } from './constants'; -import { ElementTypeHostComponent } from './types'; +import { ComponentFilterElementType, ElementTypeHostComponent } from './types'; -import type { FilterPreferences } from './types'; +import type { ComponentFilter } from './types'; const FB_MODULE_RE = /^(.*) \[from (.*)\]$/; const cachedDisplayNames: WeakMap = new WeakMap(); @@ -81,65 +81,34 @@ function toCodePoint(string: string) { return string.codePointAt(0); } -export function getDefaultFilterPreferences(): FilterPreferences { - return { - hideElementsWithTypes: new Set([ElementTypeHostComponent]), - hideElementsWithDisplayNames: new Set(), - hideElementsWithPaths: new Set(), - }; +// TODO (filters) Save the filters as the frontend needs them (an array, with type and "enabled" status) +// Convert the fitlers to Sets for the renderer to consume. + +export function getDefaultComponentFilters(): Array { + return [ + { + type: ComponentFilterElementType, + value: ElementTypeHostComponent, + isEnabled: true, + }, + ]; } -function getSavedFilterPreferencesFilter(key, value) { - if (typeof value === 'string' && value.indexOf('__REGEXP__') === 0) { - const match = value.substr(9).match(/\/(.*)\/(.*)?/); - return new RegExp(match[1], match[2] || ''); - } - return value; +export function getSavedComponentFilters(): Array { + try { + const raw = localStorage.getItem(LOCAL_STORAGE_FILTER_PREFERENCES_KEY); + if (raw != null) { + return JSON.parse(raw); + } + } catch (error) {} + return getDefaultComponentFilters(); } -export function getSavedFilterPreferences(): FilterPreferences { - const raw = localStorage.getItem(LOCAL_STORAGE_FILTER_PREFERENCES_KEY); - if (raw != null) { - const json = JSON.parse(raw, getSavedFilterPreferencesFilter); - return { - hideElementsWithTypes: new Set(json.hideElementsWithTypes), - hideElementsWithDisplayNames: new Set( - json.hideElementsWithDisplayNames.map(source => new RegExp(source)) - ), - hideElementsWithPaths: new Set( - json.hideElementsWithPaths.map(source => new RegExp(source)) - ), - }; - } else { - return getDefaultFilterPreferences(); - } -} - -function saveFilterPreferencesFilter(key, value) { - if (value instanceof RegExp) { - return '__REGEXP__' + value.toString(); - } - return value; -} - -export function saveFilterPreferences( - filterPreferences: FilterPreferences +export function saveComponentFilters( + componentFilters: Array ): void { localStorage.setItem( LOCAL_STORAGE_FILTER_PREFERENCES_KEY, - JSON.stringify( - { - hideElementsWithTypes: Array.from( - filterPreferences.hideElementsWithTypes - ), - hideElementsWithDisplayNames: Array.from( - filterPreferences.hideElementsWithDisplayNames - ), - hideElementsWithPaths: Array.from( - filterPreferences.hideElementsWithPaths - ), - }, - saveFilterPreferencesFilter - ) + JSON.stringify(componentFilters) ); }