Enable search on layout2 for archived devices

Summary:
Enables search on the imported layout data. The way search is implemented is that the Flipper app asks for the search results from the mobile clients. Since mobile client will not exist in the archived case, the search won't work. To solve this problem I added a proxy client which will get all the messages fired by the Layout Inspector and it will accordingly revert back with the responses. In the case of search, it will give back the search result tree for a particular query.

Also added extensive tests for the proxy client

Reviewed By: danielbuechele

Differential Revision: D14281856

fbshipit-source-id: 651436084ebacd57f86e4fe9bb2036e7f666880c
This commit is contained in:
Pritesh Nandgaonkar
2019-03-08 10:15:59 -08:00
committed by Facebook Github Bot
parent 44b7d4c6c3
commit b65581262a
9 changed files with 633 additions and 21 deletions

View File

@@ -17,6 +17,7 @@ import {
FlexRow,
colors,
styled,
ArchivedDevice,
} from 'flipper';
import React from 'react';
import {connect} from 'react-redux';
@@ -66,6 +67,7 @@ type Props = {|
pluginKey: string,
state: Object,
}) => void,
isArchivedDevice: boolean,
|};
class PluginContainer extends PureComponent<Props> {
@@ -91,20 +93,21 @@ class PluginContainer extends PureComponent<Props> {
activePlugin,
pluginKey,
target,
isArchivedDevice,
} = this.props;
if (!activePlugin || !target) {
return null;
}
const props: PluginProps<Object> = {
key: pluginKey,
logger: this.props.logger,
persistedState: activePlugin.defaultPersistedState
? {
...activePlugin.defaultPersistedState,
...pluginState,
}
: pluginState,
persistedState:
!isArchivedDevice && activePlugin.defaultPersistedState
? {
...activePlugin.defaultPersistedState,
...pluginState,
}
: pluginState,
setPersistedState: state => setPluginState({pluginKey, state}),
target,
deepLinkPayload: this.props.deepLinkPayload,
@@ -125,8 +128,8 @@ class PluginContainer extends PureComponent<Props> {
}
},
ref: this.refChanged,
isArchivedDevice,
};
return (
<React.Fragment>
<Container key="plugin">
@@ -180,6 +183,7 @@ export default connect<Props, OwnProps, _, _, _, _>(
pluginKey = getPluginKey(target.id, activePlugin.id);
}
}
const isArchivedDevice = selectedDevice instanceof ArchivedDevice;
return {
pluginState: pluginStates[pluginKey],
@@ -187,6 +191,7 @@ export default connect<Props, OwnProps, _, _, _, _>(
target,
deepLinkPayload,
pluginKey,
isArchivedDevice,
};
},
// $FlowFixMe

View File

@@ -40,6 +40,7 @@ export {createTablePlugin} from './createTablePlugin.js';
export {default as DetailSidebar} from './chrome/DetailSidebar.js';
export {default as AndroidDevice} from './devices/AndroidDevice.js';
export {default as ArchivedDevice} from './devices/ArchivedDevice.js';
export {default as Device} from './devices/BaseDevice.js';
export {default as IOSDevice} from './devices/IOSDevice.js';
export type {OS} from './devices/BaseDevice.js';

View File

@@ -28,12 +28,16 @@ export function callClient(
return (method, params) => client.call(id, method, false, params);
}
export type PluginClient = {|
send: (method: string, params?: Object) => void,
call: (method: string, params?: Object) => Promise<any>,
subscribe: (method: string, callback: (params: any) => void) => void,
supportsMethod: (method: string) => Promise<boolean>,
|};
export interface PluginClient {
// eslint-disable-next-line
send(method: string, params?: Object): void;
// eslint-disable-next-line
call(method: string, params?: Object): Promise<any>;
// eslint-disable-next-line
subscribe(method: string, callback: (params: any) => void): void;
// eslint-disable-next-line
supportsMethod(method: string): Promise<boolean>;
}
type PluginTarget = BaseDevice | Client;
@@ -54,6 +58,7 @@ export type Props<T> = {
target: PluginTarget,
deepLinkPayload: ?string,
selectPlugin: (pluginID: string, deepLinkPayload: ?string) => boolean,
isArchivedDevice: boolean,
};
export class FlipperBasePlugin<

View File

@@ -0,0 +1,169 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import type {Element, ElementID} from 'flipper';
import type {PersistedState} from './index';
import type {SearchResultTree} from './Search';
import {cloneDeep} from 'lodash';
const propsForPersistedState = (
AXMode: boolean,
): {ROOT: string, ELEMENTS: string, ELEMENT: string} => {
return {
ROOT: AXMode ? 'rootAXElement' : 'rootElement',
ELEMENTS: AXMode ? 'AXelements' : 'elements',
ELEMENT: AXMode ? 'axElement' : 'element',
};
};
function constructSearchResultTree(
node: Element,
isMatch: boolean,
children: Array<SearchResultTree>,
AXMode: boolean,
): SearchResultTree {
let searchResult = {
id: node.id,
isMatch,
hasChildren: children.length > 0,
children: children.length > 0 ? children : null,
element: node,
axElement: null,
};
searchResult[`${propsForPersistedState(AXMode).ELEMENT}`] = node;
return searchResult;
}
function isMatch(element: Element, query: string): boolean {
const nameMatch = element.name.toLowerCase().includes(query.toLowerCase());
return nameMatch || element.id === query;
}
export function searchNodes(
node: Element,
query: string,
AXMode: boolean,
state: PersistedState,
): ?SearchResultTree {
const elements = state[propsForPersistedState(AXMode).ELEMENTS];
const children: Array<SearchResultTree> = [];
const match = isMatch(node, query);
for (const childID of node.children) {
const child = elements[childID];
const tree = searchNodes(child, query, AXMode, state);
if (tree) {
children.push(tree);
}
}
if (match || children.length > 0) {
return cloneDeep(constructSearchResultTree(node, match, children, AXMode));
}
return null;
}
class ProxyArchiveClient {
constructor(persistedState: PersistedState) {
this.persistedState = cloneDeep(persistedState);
}
persistedState: PersistedState;
subscribe(method: string, callback: (params: any) => void): void {
return;
}
supportsMethod(method: string): Promise<boolean> {
return Promise.resolve(false);
}
send(method: string, params?: Object): void {
return;
}
call(method: string, params?: Object): Promise<any> {
const paramaters = params;
switch (method) {
case 'getRoot': {
const {rootElement} = this.persistedState;
if (!rootElement) {
return Promise.resolve(null);
}
return Promise.resolve(this.persistedState.elements[rootElement]);
}
case 'getAXRoot': {
const {rootAXElement} = this.persistedState;
if (!rootAXElement) {
return Promise.resolve(null);
}
return Promise.resolve(this.persistedState.AXelements[rootAXElement]);
}
case 'getNodes': {
if (!paramaters) {
return Promise.reject(new Error('Called getNodes with no params'));
}
const {ids} = paramaters;
const arr: Array<Element> = [];
for (let id: ElementID of ids) {
arr.push(this.persistedState.elements[id]);
}
return Promise.resolve({elements: arr});
}
case 'getAXNodes': {
if (!paramaters) {
return Promise.reject(new Error('Called getAXNodes with no params'));
}
const {ids} = paramaters;
const arr: Array<Element> = [];
for (let id: ElementID of ids) {
arr.push(this.persistedState.AXelements[id]);
}
return Promise.resolve({elements: arr});
}
case 'getSearchResults': {
const {rootElement, rootAXElement} = this.persistedState;
if (!paramaters) {
return Promise.reject(
new Error('Called getSearchResults with no params'),
);
}
const {query, axEnabled} = paramaters;
if (!query) {
return Promise.reject(
new Error('query is not passed as a params to getSearchResults'),
);
}
let element = {};
if (axEnabled) {
if (!rootAXElement) {
return Promise.reject(new Error('rootAXElement is undefined'));
}
element = this.persistedState.AXelements[rootAXElement];
} else {
if (!rootElement) {
return Promise.reject(new Error('rootElement is undefined'));
}
element = this.persistedState.elements[rootElement];
}
const output = searchNodes(
element,
query,
axEnabled,
this.persistedState,
);
return Promise.resolve({results: output, query});
}
case 'isConsoleEnabled': {
return Promise.resolve(false);
}
default: {
return Promise.resolve();
}
}
}
}
export default ProxyArchiveClient;

View File

@@ -18,13 +18,13 @@ import {
} from 'flipper';
import {Component} from 'react';
type SearchResultTree = {|
export type SearchResultTree = {|
id: string,
isMatch: Boolean,
isMatch: boolean,
hasChildren: boolean,
children: ?Array<SearchResultTree>,
element: Element,
axElement: Element,
axElement: ?Element, // Not supported in iOS
|};
type Props = {

View File

@@ -0,0 +1,414 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {
default as ProxyArchiveClient,
searchNodes,
} from '../ProxyArchiveClient';
import type {PersistedState} from '../index';
import type {ElementID, Element} from 'flipper';
import type {SearchResultTree} from '../Search';
function constructElement(
id: string,
name: string,
children: Array<ElementID>,
): Element {
return {
id,
name,
expanded: false,
children,
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
};
}
function constructPersistedState(axMode: boolean): PersistedState {
if (!axMode) {
return {
rootElement: 'root',
rootAXElement: null,
elements: {},
AXelements: {},
};
}
return {
rootElement: null,
rootAXElement: 'root',
elements: {},
AXelements: {},
};
}
let state = constructPersistedState(false);
function populateChildren(state: PersistedState, axMode: boolean) {
let elements = {};
elements['root'] = constructElement('root', 'root view', [
'child0',
'child1',
]);
elements['child0'] = constructElement('child0', 'child0 view', [
'child0_child0',
'child0_child1',
]);
elements['child1'] = constructElement('child1', 'child1 view', [
'child1_child0',
'child1_child1',
]);
elements['child0_child0'] = constructElement(
'child0_child0',
'child0_child0 view',
[],
);
elements['child0_child1'] = constructElement(
'child0_child1',
'child0_child1 view',
[],
);
elements['child1_child0'] = constructElement(
'child1_child0',
'child1_child0 view',
[],
);
elements['child1_child1'] = constructElement(
'child1_child1',
'child1_child1 view',
[],
);
if (axMode) {
state.AXelements = elements;
} else {
state.elements = elements;
}
}
beforeEach(() => {
state = constructPersistedState(false);
populateChildren(state, false);
});
test('test the searchNode for root in axMode false', async () => {
let searchResult: ?SearchResultTree = await searchNodes(
state.elements['root'],
'root',
false,
state,
);
expect(searchResult).toBeDefined();
expect(searchResult).toEqual({
id: 'root',
isMatch: true,
hasChildren: false,
children: null,
element: state.elements['root'],
axElement: null,
});
});
test('test the searchNode for root in axMode true', async () => {
state = constructPersistedState(true);
populateChildren(state, true);
let searchResult: ?SearchResultTree = await searchNodes(
state.AXelements['root'],
'RoOT',
true,
state,
);
expect(searchResult).toBeDefined();
expect(searchResult).toEqual({
id: 'root',
isMatch: true,
hasChildren: false,
children: null,
element: state.AXelements['root'], // Even though AXElement exists, normal element will exist too
axElement: state.AXelements['root'],
});
});
test('test the searchNode which matches just one child', async () => {
let searchResult: ?SearchResultTree = await searchNodes(
state.elements['root'],
'child0_child0',
false,
state,
);
expect(searchResult).toBeDefined();
expect(searchResult).toEqual({
id: 'root',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child0',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child0_child0',
isMatch: true,
hasChildren: false,
children: null,
element: state.elements['child0_child0'],
axElement: null,
},
],
element: state.elements['child0'],
axElement: null,
},
],
element: state.elements['root'],
axElement: null,
});
});
test('test the searchNode for which matches multiple child', async () => {
let searchResult: ?SearchResultTree = await searchNodes(
state.elements['root'],
'child0',
false,
state,
);
expect(searchResult).toBeDefined();
let expectedSearchResult = {
id: 'root',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child0',
isMatch: true,
hasChildren: true,
children: [
{
id: 'child0_child0',
isMatch: true,
hasChildren: false,
children: null,
element: state.elements['child0_child0'],
axElement: null,
},
{
id: 'child0_child1',
isMatch: true,
hasChildren: false,
children: null,
element: state.elements['child0_child1'],
axElement: null,
},
],
element: state.elements['child0'],
axElement: null,
},
{
id: 'child1',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child1_child0',
isMatch: true,
hasChildren: false,
children: null,
element: state.elements['child1_child0'],
axElement: null,
},
],
element: state.elements['child1'],
axElement: null,
},
],
element: state.elements['root'],
axElement: null,
};
expect(searchResult).toEqual(expectedSearchResult);
});
test('test the searchNode, it should not be case sensitive', async () => {
let searchResult: ?SearchResultTree = await searchNodes(
state.elements['root'],
'ChIlD0',
false,
state,
);
expect(searchResult).toBeDefined();
let expectedSearchResult = {
id: 'root',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child0',
isMatch: true,
hasChildren: true,
children: [
{
id: 'child0_child0',
isMatch: true,
hasChildren: false,
children: null,
element: state.elements['child0_child0'],
axElement: null,
},
{
id: 'child0_child1',
isMatch: true,
hasChildren: false,
children: null,
element: state.elements['child0_child1'],
axElement: null,
},
],
element: state.elements['child0'],
axElement: null,
},
{
id: 'child1',
isMatch: false,
hasChildren: true,
children: [
{
id: 'child1_child0',
isMatch: true,
hasChildren: false,
children: null,
element: state.elements['child1_child0'],
axElement: null,
},
],
element: state.elements['child1'],
axElement: null,
},
],
element: state.elements['root'],
axElement: null,
};
expect(searchResult).toEqual(expectedSearchResult);
});
test('test the searchNode for non existent query', async () => {
let searchResult: ?SearchResultTree = await searchNodes(
state.elements['root'],
'Unknown query',
false,
state,
);
expect(searchResult).toBeNull();
});
test('test the call method with getRoot', async () => {
let proxyClient = new ProxyArchiveClient(state);
let root: Element = await proxyClient.call('getRoot');
expect(root).toEqual(state.elements['root']);
});
test('test the call method with getAXRoot', async () => {
state = constructPersistedState(true);
populateChildren(state, true);
let proxyClient = new ProxyArchiveClient(state);
let root: Element = await proxyClient.call('getAXRoot');
expect(root).toEqual(state.AXelements['root']);
});
test('test the call method with getNodes', async () => {
let proxyClient = new ProxyArchiveClient(state);
let nodes: Array<Element> = await proxyClient.call('getNodes', {
ids: ['child0_child1', 'child1_child0'],
});
expect(nodes).toEqual({
elements: [
{
id: 'child0_child1',
name: 'child0_child1 view',
expanded: false,
children: [],
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
},
{
id: 'child1_child0',
name: 'child1_child0 view',
expanded: false,
children: [],
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
},
],
});
});
test('test the call method with getAXNodes', async () => {
state = constructPersistedState(true);
populateChildren(state, true);
let proxyClient = new ProxyArchiveClient(state);
let nodes: Array<Element> = await proxyClient.call('getAXNodes', {
ids: ['child0_child1', 'child1_child0'],
});
expect(nodes).toEqual({
elements: [
{
id: 'child0_child1',
name: 'child0_child1 view',
expanded: false,
children: [],
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
},
{
id: 'child1_child0',
name: 'child1_child0 view',
expanded: false,
children: [],
attributes: [],
data: {},
decoration: 'decoration',
extraInfo: {},
},
],
});
});
test('test different methods of calls with no params', async () => {
let proxyClient = new ProxyArchiveClient(state);
await expect(proxyClient.call('getNodes')).rejects.toThrow(
new Error('Called getNodes with no params'),
);
await expect(proxyClient.call('getAXNodes')).rejects.toThrow(
new Error('Called getAXNodes with no params'),
);
// let result: Error = await proxyClient.call('getSearchResults');
await expect(proxyClient.call('getSearchResults')).rejects.toThrow(
new Error('Called getSearchResults with no params'),
);
await expect(
proxyClient.call('getSearchResults', {
query: 'random',
axEnabled: true,
}),
).rejects.toThrow(new Error('rootAXElement is undefined'));
await expect(
proxyClient.call('getSearchResults', {
axEnabled: false,
}),
).rejects.toThrow(
new Error('query is not passed as a params to getSearchResults'),
);
});
test('test call method isConsoleEnabled', () => {
let proxyClient = new ProxyArchiveClient(state);
return expect(proxyClient.call('isConsoleEnabled')).resolves.toBe(false);
});

View File

@@ -5,7 +5,13 @@
* @format
*/
import type {ElementID, Element, ElementSearchResultSet, Store} from 'flipper';
import type {
ElementID,
Element,
ElementSearchResultSet,
Store,
PluginClient,
} from 'flipper';
import {
FlexColumn,
@@ -22,6 +28,7 @@ import Inspector from './Inspector';
import ToolbarIcon from './ToolbarIcon';
import InspectorSidebar from './InspectorSidebar';
import Search from './Search';
import ProxyArchiveClient from './ProxyArchiveClient';
type State = {|
init: boolean,
@@ -115,6 +122,11 @@ export default class Layout extends FlipperPlugin<State, void, PersistedState> {
this.setState({inAXMode: !this.state.inAXMode});
};
getClient(): PluginClient {
return this.props.isArchivedDevice
? new ProxyArchiveClient(this.props.persistedState)
: this.client;
}
onToggleAlignmentMode = () => {
if (this.state.selectedElement) {
this.client.send('setHighlighted', {
@@ -139,7 +151,7 @@ export default class Layout extends FlipperPlugin<State, void, PersistedState> {
render() {
const inspectorProps = {
client: this.client,
client: this.getClient(),
inAlignmentMode: this.state.inAlignmentMode,
selectedElement: this.state.selectedElement,
selectedAXElement: this.state.selectedAXElement,
@@ -192,7 +204,7 @@ export default class Layout extends FlipperPlugin<State, void, PersistedState> {
active={this.state.inAlignmentMode}
/>
<Search
client={this.client}
client={this.getClient()}
setPersistedState={this.props.setPersistedState}
persistedState={this.props.persistedState}
onSearchResults={searchResults =>
@@ -223,7 +235,7 @@ export default class Layout extends FlipperPlugin<State, void, PersistedState> {
</FlexRow>
<DetailSidebar>
<InspectorSidebar
client={this.client}
client={this.getClient()}
realClient={this.realClient}
element={element}
onValueChanged={this.onDataValueChanged}

View File

@@ -5,6 +5,7 @@
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"lodash": "^4.17.11",
"lodash.debounce": "^4.0.8"
},
"title": "Layout",

View File

@@ -10,3 +10,8 @@ deep-equal@^1.0.1:
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
lodash@^4.17.11:
version "4.17.11"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==