Searchable

Summary: _typescript_

Reviewed By: passy

Differential Revision: D16807182

fbshipit-source-id: 68bc8365bc5b0d8075d0a93d5963c824c0d66769
This commit is contained in:
Daniel Büchele
2019-08-20 05:40:31 -07:00
committed by Facebook Github Bot
parent c8c90ee413
commit 62a204bdbe
9 changed files with 196 additions and 165 deletions

View File

@@ -51,8 +51,10 @@
}, },
"devDependencies": { "devDependencies": {
"@jest-runner/electron": "^2.0.1", "@jest-runner/electron": "^2.0.1",
"@types/deep-equal": "^1.0.1",
"@types/invariant": "^2.2.30", "@types/invariant": "^2.2.30",
"@types/jest": "^24.0.16", "@types/jest": "^24.0.16",
"@types/lodash.debounce": "^4.0.6",
"@types/react": "^16.8.24", "@types/react": "^16.8.24",
"@types/react-dom": "^16.8.5", "@types/react-dom": "^16.8.5",
"@types/react-redux": "^7.1.1", "@types/react-redux": "^7.1.1",

View File

@@ -157,15 +157,15 @@ export {
SearchBox, SearchBox,
SearchInput, SearchInput,
SearchIcon, SearchIcon,
SearchableProps,
default as Searchable, default as Searchable,
} from './ui/components/searchable/Searchable.js'; } from './ui/components/searchable/Searchable.tsx';
export { export {
default as SearchableTable, default as SearchableTable,
} from './ui/components/searchable/SearchableTable.js'; } from './ui/components/searchable/SearchableTable.tsx';
export { export {
default as SearchableTable_immutable, default as SearchableTable_immutable,
} from './ui/components/searchable/SearchableTable_immutable.js'; } from './ui/components/searchable/SearchableTable_immutable.tsx';
export {SearchableProps} from './ui/components/searchable/Searchable.js';
export { export {
ElementID, ElementID,
ElementData, ElementData,

View File

@@ -5,15 +5,18 @@
* @format * @format
*/ */
import type {Filter} from 'flipper'; import {Filter} from '../filter/types';
import {PureComponent} from 'react'; import {PureComponent} from 'react';
import Text from '../Text.tsx'; import Text from '../Text';
import styled from '../../styled/index.js'; import styled from 'react-emotion';
import {findDOMNode} from 'react-dom'; import {findDOMNode} from 'react-dom';
import {colors} from '../colors.tsx'; import {colors} from '../colors';
import electron from 'electron'; import electron, {MenuItemConstructorOptions} from 'electron';
import React from 'react';
import {ColorProperty} from 'csstype';
const Token = styled(Text)(props => ({ const Token = styled(Text)(
(props: {focused?: boolean; color?: ColorProperty}) => ({
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
backgroundColor: props.focused backgroundColor: props.focused
@@ -32,9 +35,11 @@ const Token = styled(Text)(props => ({
'&:first-of-type': { '&:first-of-type': {
marginLeft: 3, marginLeft: 3,
}, },
})); }),
);
const Key = styled(Text)(props => ({ const Key = styled(Text)(
(props: {type: 'exclude' | 'include' | 'enum'; focused?: boolean}) => ({
position: 'relative', position: 'relative',
fontWeight: 500, fontWeight: 500,
paddingRight: 12, paddingRight: 12,
@@ -51,7 +56,8 @@ const Key = styled(Text)(props => ({
'&:active:after': { '&:active:after': {
backgroundColor: colors.macOSHighlightActive, backgroundColor: colors.macOSHighlightActive,
}, },
})); }),
);
const Value = styled(Text)({ const Value = styled(Text)({
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -62,7 +68,7 @@ const Value = styled(Text)({
paddingLeft: 3, paddingLeft: 3,
}); });
const Chevron = styled('div')(props => ({ const Chevron = styled('div')((props: {focused?: boolean}) => ({
border: 0, border: 0,
paddingLeft: 3, paddingLeft: 3,
paddingRight: 1, paddingRight: 1,
@@ -81,23 +87,24 @@ const Chevron = styled('div')(props => ({
}, },
})); }));
type Props = {| type Props = {
filter: Filter, filter: Filter;
focused: boolean, focused: boolean;
index: number, index: number;
onFocus: (focusedToken: number) => void, onFocus: (focusedToken: number) => void;
onBlur: () => void, onBlur: () => void;
onDelete: (deletedToken: number) => void, onDelete: (deletedToken: number) => void;
onReplace: (index: number, filter: Filter) => void, onReplace: (index: number, filter: Filter) => void;
|}; };
export default class FilterToken extends PureComponent<Props> { export default class FilterToken extends PureComponent<Props> {
_ref: ?Element; _ref: Element | undefined;
onMouseDown = () => { onMouseDown = () => {
if ( if (
this.props.filter.persistent == null || this.props.filter.type !== 'enum' ||
this.props.filter.persistent === false (this.props.filter.persistent == null ||
this.props.filter.persistent === false)
) { ) {
this.props.onFocus(this.props.index); this.props.onFocus(this.props.index);
} }
@@ -112,7 +119,7 @@ export default class FilterToken extends PureComponent<Props> {
...this.props.filter.enum.map(({value, label}) => ({ ...this.props.filter.enum.map(({value, label}) => ({
label, label,
click: () => this.changeEnum(value), click: () => this.changeEnum(value),
type: 'checkbox', type: 'checkbox' as 'checkbox',
checked: this.props.filter.value.indexOf(value) > -1, checked: this.props.filter.value.indexOf(value) > -1,
})), })),
); );
@@ -144,12 +151,14 @@ export default class FilterToken extends PureComponent<Props> {
); );
} }
const menu = electron.remote.Menu.buildFromTemplate(menuTemplate); const menu = electron.remote.Menu.buildFromTemplate(menuTemplate);
const {bottom, left} = this._ref ? this._ref.getBoundingClientRect() : {}; const {bottom, left} = this._ref.getBoundingClientRect();
menu.popup({ menu.popup({
window: electron.remote.getCurrentWindow(), window: electron.remote.getCurrentWindow(),
// @ts-ignore: async is private API
async: true, async: true,
x: parseInt(left, 10), x: left,
y: parseInt(bottom, 10) + 8, y: bottom + 8,
}); });
}; };
@@ -185,7 +194,7 @@ export default class FilterToken extends PureComponent<Props> {
} }
}; };
setRef = (ref: React.ElementRef<*>) => { setRef = (ref: React.ReactInstance) => {
const element = findDOMNode(ref); const element = findDOMNode(ref);
if (element instanceof HTMLElement) { if (element instanceof HTMLElement) {
this._ref = element; this._ref = element;

View File

@@ -5,20 +5,21 @@
* @format * @format
*/ */
import type {Filter} from 'flipper'; import {Filter} from '../filter/types';
import type {TableColumns} from '../table/types'; import {TableColumns} from '../table/types';
import {PureComponent} from 'react'; import {PureComponent} from 'react';
import Toolbar from '../Toolbar.tsx'; import Toolbar from '../Toolbar';
import FlexRow from '../FlexRow.tsx'; import FlexRow from '../FlexRow';
import Input from '../Input.tsx'; import Input from '../Input';
import {colors} from '../colors.tsx'; import {colors} from '../colors';
import Text from '../Text.tsx'; import Text from '../Text';
import FlexBox from '../FlexBox.tsx'; import FlexBox from '../FlexBox';
import Glyph from '../Glyph.tsx'; import Glyph from '../Glyph';
import FilterToken from './FilterToken.js'; import FilterToken from './FilterToken';
import styled from '../../styled/index.js'; import styled from 'react-emotion';
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';
import ToggleSwitch from '../ToggleSwitch.tsx'; import ToggleSwitch from '../ToggleSwitch';
import React from 'react';
const SearchBar = styled(Toolbar)({ const SearchBar = styled(Toolbar)({
height: 42, height: 42,
@@ -35,7 +36,8 @@ export const SearchBox = styled(FlexBox)({
paddingLeft: 4, paddingLeft: 4,
}); });
export const SearchInput = styled(Input)(props => ({ export const SearchInput = styled(Input)(
(props: {focus?: boolean; regex?: boolean; isValidInput?: boolean}) => ({
border: props.focus ? '1px solid black' : 0, border: props.focus ? '1px solid black' : 0,
...(props.regex ? {fontFamily: 'monospace'} : {}), ...(props.regex ? {fontFamily: 'monospace'} : {}),
padding: 0, padding: 0,
@@ -50,7 +52,8 @@ export const SearchInput = styled(Input)(props => ({
color: colors.placeholder, color: colors.placeholder,
fontWeight: 300, fontWeight: 300,
}, },
})); }),
);
const Clear = styled(Text)({ const Clear = styled(Text)({
position: 'absolute', position: 'absolute',
@@ -83,34 +86,34 @@ const Actions = styled(FlexRow)({
flexShrink: 0, flexShrink: 0,
}); });
export type SearchableProps = {| export type SearchableProps = {
addFilter: (filter: Filter) => void, addFilter: (filter: Filter) => void;
searchTerm: string, searchTerm: string;
filters: Array<Filter>, filters: Array<Filter>;
allowRegexSearch?: boolean, allowRegexSearch?: boolean;
regexEnabled?: boolean, regexEnabled?: boolean;
|};
type Props = {|
placeholder?: string,
actions: React.Node,
tableKey: string,
columns?: TableColumns,
onFilterChange: (filters: Array<Filter>) => void,
defaultFilters: Array<Filter>,
allowRegexSearch: boolean,
|};
type State = {
filters: Array<Filter>,
focusedToken: number,
searchTerm: string,
hasFocus: boolean,
regexEnabled: boolean,
compiledRegex: ?RegExp,
}; };
function compileRegex(s: string): ?RegExp { type Props = {
placeholder?: string;
actions: React.ReactNode;
tableKey: string;
columns?: TableColumns;
onFilterChange: (filters: Array<Filter>) => void;
defaultFilters: Array<Filter>;
allowRegexSearch: boolean;
};
type State = {
filters: Array<Filter>;
focusedToken: number;
searchTerm: string;
hasFocus: boolean;
regexEnabled: boolean;
compiledRegex: RegExp | null | undefined;
};
function compileRegex(s: string): RegExp | null {
try { try {
return new RegExp(s); return new RegExp(s);
} catch (e) { } catch (e) {
@@ -135,7 +138,7 @@ const Searchable = (
compiledRegex: null, compiledRegex: null,
}; };
_inputRef: ?HTMLInputElement; _inputRef: HTMLInputElement | undefined;
componentDidMount() { componentDidMount() {
window.document.addEventListener('keydown', this.onKeyDown); window.document.addEventListener('keydown', this.onKeyDown);
@@ -223,7 +226,7 @@ const Searchable = (
window.document.removeEventListener('keydown', this.onKeyDown); window.document.removeEventListener('keydown', this.onKeyDown);
} }
getTableKey = (): ?string => { getTableKey = (): string | null | undefined => {
if (this.props.tableKey) { if (this.props.tableKey) {
return this.props.tableKey; return this.props.tableKey;
} else if (this.props.columns) { } else if (this.props.columns) {
@@ -238,7 +241,7 @@ const Searchable = (
} }
}; };
onKeyDown = (e: SyntheticKeyboardEvent<>) => { onKeyDown = (e: KeyboardEvent) => {
const ctrlOrCmd = e => const ctrlOrCmd = e =>
(e.metaKey && process.platform === 'darwin') || (e.metaKey && process.platform === 'darwin') ||
(e.ctrlKey && process.platform !== 'darwin'); (e.ctrlKey && process.platform !== 'darwin');
@@ -252,11 +255,12 @@ const Searchable = (
this._inputRef.blur(); this._inputRef.blur();
this.setState({searchTerm: ''}); this.setState({searchTerm: ''});
} else if (e.key === 'Backspace' && this.hasFocus()) { } else if (e.key === 'Backspace' && this.hasFocus()) {
const lastFilter = this.state.filters[this.state.filters.length - 1];
if ( if (
this.state.focusedToken === -1 && this.state.focusedToken === -1 &&
this.state.searchTerm === '' && this.state.searchTerm === '' &&
this._inputRef && this._inputRef &&
!this.state.filters[this.state.filters.length - 1].persistent (lastFilter.type !== 'enum' || !lastFilter.persistent)
) { ) {
this._inputRef.blur(); this._inputRef.blur();
this.setState({focusedToken: this.state.filters.length - 1}); this.setState({focusedToken: this.state.filters.length - 1});
@@ -274,7 +278,7 @@ const Searchable = (
} }
}; };
onChangeSearchTerm = (e: SyntheticInputEvent<HTMLInputElement>) => { onChangeSearchTerm = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ this.setState({
searchTerm: e.target.value, searchTerm: e.target.value,
compiledRegex: compileRegex(e.target.value), compiledRegex: compileRegex(e.target.value),
@@ -291,9 +295,9 @@ const Searchable = (
match.forEach((filter: string) => { match.forEach((filter: string) => {
const separator = const separator =
filter.indexOf(':') > filter.indexOf('=') ? ':' : '='; filter.indexOf(':') > filter.indexOf('=') ? ':' : '=';
let [key, ...value] = filter.split(separator); let [key, ...values] = filter.split(separator);
value = value.join(separator).trim(); let value = values.join(separator).trim();
let type = 'include'; let type: 'include' | 'exclude' | 'enum' = 'include';
// if value starts with !, it's an exclude filter // if value starts with !, it's an exclude filter
if (value.indexOf('!') === 0) { if (value.indexOf('!') === 0) {
type = 'exclude'; type = 'exclude';
@@ -315,7 +319,7 @@ const Searchable = (
} }
}, 200); }, 200);
setInputRef = (ref: ?HTMLInputElement) => { setInputRef = (ref: HTMLInputElement | undefined) => {
this._inputRef = ref; this._inputRef = ref;
}; };
@@ -326,12 +330,13 @@ const Searchable = (
if (filterIndex > -1) { if (filterIndex > -1) {
const filters = [...this.state.filters]; const filters = [...this.state.filters];
const defaultFilter: Filter = this.props.defaultFilters[filterIndex]; const defaultFilter: Filter = this.props.defaultFilters[filterIndex];
const filter = filters[filterIndex];
if ( if (
defaultFilter != null && defaultFilter != null &&
defaultFilter.type === 'enum' && defaultFilter.type === 'enum' &&
filters[filterIndex].type === 'enum' filter.type === 'enum'
) { ) {
filters[filterIndex].enum = defaultFilter.enum; filter.enum = defaultFilter.enum;
} }
this.setState({filters}); this.setState({filters});
// filter for this key already exists // filter for this key already exists
@@ -339,7 +344,7 @@ const Searchable = (
} }
// persistent filters are always at the front // persistent filters are always at the front
const filters = const filters =
filter.persistent === true filter.type === 'enum' && filter.persistent === true
? [filter, ...this.state.filters] ? [filter, ...this.state.filters]
: this.state.filters.concat(filter); : this.state.filters.concat(filter);
this.setState({ this.setState({
@@ -397,14 +402,14 @@ const Searchable = (
clear = () => clear = () =>
this.setState({ this.setState({
filters: this.state.filters.filter( filters: this.state.filters.filter(
f => f.persistent != null && f.persistent === true, f => f.type === 'enum' && f.persistent === true,
), ),
searchTerm: '', searchTerm: '',
}); });
getPersistKey = () => `SEARCHABLE_STORAGE_KEY_${this.getTableKey() || ''}`; getPersistKey = () => `SEARCHABLE_STORAGE_KEY_${this.getTableKey() || ''}`;
render(): React.Node { render() {
const {placeholder, actions, ...props} = this.props; const {placeholder, actions, ...props} = this.props;
return [ return [
<SearchBar position="top" key="searchbar"> <SearchBar position="top" key="searchbar">
@@ -438,7 +443,7 @@ const Searchable = (
? this.state.compiledRegex !== null ? this.state.compiledRegex !== null
: true : true
} }
regex={this.state.regexEnabled && this.state.searchTerm} regex={Boolean(this.state.regexEnabled && this.state.searchTerm)}
/> />
</SearchBox> </SearchBox>
{this.props.allowRegexSearch ? ( {this.props.allowRegexSearch ? (

View File

@@ -5,26 +5,24 @@
* @format * @format
*/ */
import type {ManagedTableProps, TableBodyRow, Filter} from 'flipper'; import {Filter} from '../filter/types';
import type {SearchableProps} from './Searchable.js'; import ManagedTable, {ManagedTableProps} from '../table/ManagedTable.js';
import {PureComponent} from 'react'; import {TableBodyRow} from '../table/types.js';
import ManagedTable from '../table/ManagedTable.js'; import Searchable, {SearchableProps} from './Searchable';
import React, {PureComponent} from 'react';
import textContent from '../../../utils/textContent.tsx'; import textContent from '../../../utils/textContent';
import Searchable from './Searchable.js';
import deepEqual from 'deep-equal'; import deepEqual from 'deep-equal';
type Props = {| type Props = {
...ManagedTableProps,
...SearchableProps,
/** Reference to the table */ /** Reference to the table */
innerRef?: (ref: React.ElementRef<*>) => void, innerRef?: (ref: React.RefObject<any>) => void;
/** Filters that are added to the filterbar by default */ /** Filters that are added to the filterbar by default */
defaultFilters: Array<Filter>, defaultFilters: Array<Filter>;
|}; } & ManagedTableProps &
SearchableProps;
type State = { type State = {
filterRows: (row: TableBodyRow) => boolean, filterRows: (row: TableBodyRow) => boolean;
}; };
const rowMatchesFilters = (filters: Array<Filter>, row: TableBodyRow) => const rowMatchesFilters = (filters: Array<Filter>, row: TableBodyRow) =>

View File

@@ -5,26 +5,26 @@
* @format * @format
*/ */
import type {ManagedTableProps_immutable, TableBodyRow, Filter} from 'flipper'; import {Filter} from '../filter/types';
import type {SearchableProps} from './Searchable.js'; import {ManagedTableProps_immutable} from '../table/ManagedTable_immutable.js';
import {TableBodyRow} from '../table/types.js';
import Searchable, {SearchableProps} from './Searchable';
import {PureComponent} from 'react'; import {PureComponent} from 'react';
import ManagedTable_immutable from '../table/ManagedTable_immutable.js'; import ManagedTable_immutable from '../table/ManagedTable_immutable.js';
import textContent from '../../../utils/textContent';
import textContent from '../../../utils/textContent.tsx';
import Searchable from './Searchable.js';
import deepEqual from 'deep-equal'; import deepEqual from 'deep-equal';
import React from 'react';
type Props = {| type Props = {
...ManagedTableProps_immutable,
...SearchableProps,
/** Reference to the table */ /** Reference to the table */
innerRef?: (ref: React.ElementRef<*>) => void, innerRef?: (ref: React.RefObject<any>) => void;
/** Filters that are added to the filterbar by default */ /** Filters that are added to the filterbar by default */
defaultFilters: Array<Filter>, defaultFilters: Array<Filter>;
|}; } & ManagedTableProps_immutable &
SearchableProps;
type State = { type State = {
filterRows: (row: TableBodyRow) => boolean, filterRows: (row: TableBodyRow) => boolean;
}; };
const rowMatchesFilters = (filters: Array<Filter>, row: TableBodyRow) => const rowMatchesFilters = (filters: Array<Filter>, row: TableBodyRow) =>

View File

@@ -14,7 +14,7 @@ import type {
import React from 'react'; import React from 'react';
import FilterRow from '../filter/FilterRow.tsx'; import FilterRow from '../filter/FilterRow.tsx';
import styled from '../../styled/index.js'; import styled from 'react-emotion';
import FlexRow from '../FlexRow.tsx'; import FlexRow from '../FlexRow.tsx';
import {colors} from '../colors.tsx'; import {colors} from '../colors.tsx';
import {normaliseColumnWidth} from './utils.js'; import {normaliseColumnWidth} from './utils.js';

View File

@@ -154,14 +154,14 @@ export {
SearchInput, SearchInput,
SearchIcon, SearchIcon,
default as Searchable, default as Searchable,
} from './components/searchable/Searchable.js'; } from './components/searchable/Searchable.tsx';
export { export {
default as SearchableTable, default as SearchableTable,
} from './components/searchable/SearchableTable.js'; } from './components/searchable/SearchableTable.tsx';
export { export {
default as SearchableTable_immutable, default as SearchableTable_immutable,
} from './components/searchable/SearchableTable_immutable.js'; } from './components/searchable/SearchableTable_immutable.tsx';
export type {SearchableProps} from './components/searchable/Searchable.js'; export type {SearchableProps} from './components/searchable/Searchable.tsx';
// //
export type { export type {

View File

@@ -1062,6 +1062,11 @@
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.4.tgz#56eec47706f0fd0b7c694eae2f3172e6b0b769da" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.4.tgz#56eec47706f0fd0b7c694eae2f3172e6b0b769da"
integrity sha512-D9MyoQFI7iP5VdpEyPZyjjqIJ8Y8EDNQFIFVLOmeg1rI1xiHOChyUPMPRUVfqFCerxfE+yS3vMyj37F6IdtOoQ== integrity sha512-D9MyoQFI7iP5VdpEyPZyjjqIJ8Y8EDNQFIFVLOmeg1rI1xiHOChyUPMPRUVfqFCerxfE+yS3vMyj37F6IdtOoQ==
"@types/deep-equal@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/deep-equal/-/deep-equal-1.0.1.tgz#71cfabb247c22bcc16d536111f50c0ed12476b03"
integrity sha512-mMUu4nWHLBlHtxXY17Fg6+ucS/MnndyOWyOe7MmwkoMYxvfQU2ajtRaEvqSUv+aVkMqH/C0NCI8UoVfRNQ10yg==
"@types/eslint-visitor-keys@^1.0.0": "@types/eslint-visitor-keys@^1.0.0":
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
@@ -1131,6 +1136,18 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A== integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==
"@types/lodash.debounce@^4.0.6":
version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60"
integrity sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.14.136"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.136.tgz#413e85089046b865d960c9ff1d400e04c31ab60f"
integrity sha512-0GJhzBdvsW2RUccNHOBkabI8HZVdOXmXbXhuKlDEd5Vv12P7oAVGfomGp3Ne21o5D/qu1WmthlNKFaoZJJeErA==
"@types/minimatch@*": "@types/minimatch@*":
version "3.0.3" version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"