Refactor graph sidebar, move to backend search

This commit is contained in:
Tao Bojlén 2019-07-23 12:20:34 +00:00
parent 3ebab9d452
commit 6673a24466
30 changed files with 682 additions and 468 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"elixirLS.projectDir": "backend/"
}

View File

@ -1,38 +1,61 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added ### Added
- It's now shown in the front-end if an instance wasn't crawled because of its robots.txt. - It's now shown in the front-end if an instance wasn't crawled because of its robots.txt.
- You can now link directly to instances at e.g. /instance/mastodon.social. - You can now link directly to instances at e.g. /instance/mastodon.social.
- Instance details now have a link to the corresponding fediverse.network page. - Instance details now have a link to the corresponding fediverse.network page.
- The reset-graph-view button now explains what it's for when you hover over it.
### Changed ### Changed
- You no longer have to zoom completely in to see labels. - You no longer have to zoom completely in to see labels.
- Label size is now dependent on the instance size. - Label size is now dependent on the instance size.
- The instance lookup field is now front-and-center. Is also uses the backend for faster lookups. This is to improve
performance, and it lays the groundwork for full-text search over instance names and descriptions.
### Deprecated ### Deprecated
### Removed ### Removed
### Fixed ### Fixed
- Previously, direct links to /about would return a 404 on Netlify's infrastructure. Now it's fixed. - Previously, direct links to /about would return a 404 on Netlify's infrastructure. Now it's fixed.
### Security ### Security
## [2.0.0] - 2019-07-20 ## [2.0.0] - 2019-07-20
### Added ### Added
- The backend has been completely rewritten in Elixir for improved stability and performance. - The backend has been completely rewritten in Elixir for improved stability and performance.
- An "insularity score" was added to show the percentage of mentions to users on the same instance. - An "insularity score" was added to show the percentage of mentions to users on the same instance.
- The crawler now respects robots.txt. - The crawler now respects robots.txt.
### Changed ### Changed
- Migrated the frontend graph from Sigma.js to Cytoscape.js. - Migrated the frontend graph from Sigma.js to Cytoscape.js.
- To improve performance, instances with no neighbors are no longer shown on the graph. - To improve performance, instances with no neighbors are no longer shown on the graph.
### Deprecated ### Deprecated
- The /api/v1 endpoint no longer exists; now there's a new /api. - The /api/v1 endpoint no longer exists; now there's a new /api.
### Security ### Security
- Spam domains can be blacklisted in the backend crawler's config. - Spam domains can be blacklisted in the backend crawler's config.
- Add basic automated security scanning (using [Sobelow](https://github.com/andmarti1424/sc-im.git) and Gitlab's dependency scanning). - Add basic automated security scanning (using [Sobelow](https://github.com/andmarti1424/sc-im.git) and Gitlab's dependency scanning).
## [1.0.0] - 2018-09-01 ## [1.0.0] - 2018-09-01
### Added ### Added
- Initial release. The date above is inaccurate; this first version was released sometime in the fall of 2018. - Initial release. The date above is inaccurate; this first version was released sometime in the fall of 2018.
- This release had a Django backend and a [Sigma.js](http://sigmajs.org/) graph. - This release had a Django backend and a [Sigma.js](http://sigmajs.org/) graph.

View File

@ -52,4 +52,16 @@ defmodule Backend.Api do
) )
|> Repo.all() |> Repo.all()
end end
def search_instances(query, cursor_after \\ nil) do
ilike_query = "%#{query}%"
%{entries: instances, metadata: metadata} =
Instance
|> where([i], ilike(i.domain, ^ilike_query))
|> order_by(asc: :id)
|> Repo.paginate(after: cursor_after, cursor_fields: [:id], limit: 50)
%{instances: instances, next: metadata.after}
end
end end

View File

@ -3,6 +3,8 @@ defmodule Backend.Repo do
otp_app: :backend, otp_app: :backend,
adapter: Ecto.Adapters.Postgres adapter: Ecto.Adapters.Postgres
use Paginator
def init(_type, config) do def init(_type, config) do
{:ok, Keyword.put(config, :url, System.get_env("DATABASE_URL"))} {:ok, Keyword.put(config, :url, System.get_env("DATABASE_URL"))}
end end

View File

@ -0,0 +1,13 @@
defmodule BackendWeb.SearchController do
use BackendWeb, :controller
alias Backend.Api
action_fallback(BackendWeb.FallbackController)
def index(conn, params) do
query = Map.get(params, "query")
cursor_after = Map.get(params, "after", nil)
%{instances: instances, next: next} = Api.search_instances(query, cursor_after)
render(conn, "index.json", instances: instances, next: next)
end
end

View File

@ -2,13 +2,14 @@ defmodule BackendWeb.Router do
use BackendWeb, :router use BackendWeb, :router
pipeline :api do pipeline :api do
plug :accepts, ["json"] plug(:accepts, ["json"])
end end
scope "/api", BackendWeb do scope "/api", BackendWeb do
pipe_through :api pipe_through(:api)
resources "/instances", InstanceController, only: [:index, :show] resources("/instances", InstanceController, only: [:index, :show])
resources "/graph", GraphController, only: [:index] resources("/graph", GraphController, only: [:index])
resources("/search", SearchController, only: [:index])
end end
end end

View File

@ -0,0 +1,20 @@
defmodule BackendWeb.SearchView do
use BackendWeb, :view
alias BackendWeb.SearchView
require Logger
def render("index.json", %{instances: instances, next: next}) do
%{
results: render_many(instances, SearchView, "instance.json", as: :instance),
next: next
}
end
def render("instance.json", %{instance: instance}) do
%{
name: instance.domain,
description: instance.description,
userCount: instance.user_count
}
end
end

View File

@ -47,7 +47,8 @@ defmodule Backend.MixProject do
{:quantum, "~> 2.3"}, {:quantum, "~> 2.3"},
{:corsica, "~> 1.1.2"}, {:corsica, "~> 1.1.2"},
{:sobelow, "~> 0.8", only: :dev}, {:sobelow, "~> 0.8", only: :dev},
{:gollum, "~> 0.3.2"} {:gollum, "~> 0.3.2"},
{:paginator, "~> 0.6.0"}
] ]
end end

View File

@ -25,6 +25,7 @@
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"paginator": {:hex, :paginator, "0.6.0", "bc2c01abdd98281ff39b6a7439cf540091122a7927bdaabc167c61d4508f9cbb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.4.9", "746d098e10741c334d88143d3c94cab1756435f94387a63441792e66ec0ee974", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix": {:hex, :phoenix, "1.4.9", "746d098e10741c334d88143d3c94cab1756435f94387a63441792e66ec0ee974", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},

View File

@ -36,12 +36,13 @@
"cross-fetch": "^3.0.4", "cross-fetch": "^3.0.4",
"cytoscape": "^3.8.1", "cytoscape": "^3.8.1",
"cytoscape-popper": "^1.0.4", "cytoscape-popper": "^1.0.4",
"lodash": "^4.17.14", "inflection": "^1.12.0",
"lodash": "^4.17.15",
"moment": "^2.22.2", "moment": "^2.22.2",
"normalize.css": "^8.0.0", "normalize.css": "^8.0.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"react": "^16.4.2", "react": "^16.8.0",
"react-dom": "^16.4.2", "react-dom": "^16.8.0",
"react-redux": "^7.1.0", "react-redux": "^7.1.0",
"react-router-dom": "^5.0.1", "react-router-dom": "^5.0.1",
"react-scripts": "^3.0.1", "react-scripts": "^3.0.1",
@ -56,19 +57,20 @@
"devDependencies": { "devDependencies": {
"@blueprintjs/tslint-config": "^1.8.1", "@blueprintjs/tslint-config": "^1.8.1",
"@types/classnames": "^2.2.9", "@types/classnames": "^2.2.9",
"@types/cytoscape": "^3.4.3", "@types/cytoscape": "^3.8.0",
"@types/inflection": "^1.5.28",
"@types/jest": "^24.0.15", "@types/jest": "^24.0.15",
"@types/lodash": "^4.14.136", "@types/lodash": "^4.14.136",
"@types/node": "^12.6.2", "@types/node": "^12.6.8",
"@types/numeral": "^0.0.25", "@types/numeral": "^0.0.25",
"@types/react": "^16.8.23", "@types/react": "^16.8.23",
"@types/react-dom": "^16.8.4", "@types/react-dom": "^16.8.4",
"@types/react-redux": "^7.1.1", "@types/react-redux": "^7.1.1",
"@types/react-router-dom": "^4.3.4", "@types/react-router-dom": "^4.3.4",
"@types/react-virtualized": "^9.21.2", "@types/react-virtualized": "^9.21.3",
"@types/sanitize-html": "^1.20.1", "@types/sanitize-html": "^1.20.1",
"@types/styled-components": "4.1.18", "@types/styled-components": "4.1.18",
"husky": "^3.0.0", "husky": "^3.0.1",
"lint-staged": "^9.2.0", "lint-staged": "^9.2.0",
"react-axe": "^3.2.0", "react-axe": "^3.2.0",
"tslint": "^5.18.0", "tslint": "^5.18.0",

View File

@ -4,10 +4,10 @@ import { Button, Classes, Dialog } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import { ConnectedRouter } from "connected-react-router"; import { ConnectedRouter } from "connected-react-router";
import { Route, RouteComponentProps } from "react-router-dom"; import { Route } from "react-router-dom";
import { Nav } from "./components/organisms/"; import { Nav } from "./components/organisms/";
import { AboutScreen, GraphScreen } from "./components/screens/"; import { AboutScreen, GraphScreen } from "./components/screens/";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants"; import { DESKTOP_WIDTH_THRESHOLD } from "./constants";
import { history } from "./index"; import { history } from "./index";
interface IAppLocalState { interface IAppLocalState {
@ -24,13 +24,9 @@ export class AppRouter extends React.Component<{}, IAppLocalState> {
<ConnectedRouter history={history}> <ConnectedRouter history={history}>
<div className={`${Classes.DARK} App`}> <div className={`${Classes.DARK} App`}>
<Nav /> <Nav />
{/* We use `children={}` instead of `component={}` such that the graph is never unmounted */}
<Route exact={true} path="/">
<Route path={INSTANCE_DOMAIN_PATH}>
{(routeProps: RouteComponentProps<IInstanceDomainPath>) => <GraphScreen {...routeProps} />}
</Route>
</Route>
<Route path="/about" component={AboutScreen} /> <Route path="/about" component={AboutScreen} />
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
<GraphScreen />
{this.renderMobileDialog()} {this.renderMobileDialog()}
</div> </div>
</ConnectedRouter> </ConnectedRouter>

View File

@ -154,7 +154,7 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
public resetGraphPosition() { public resetGraphPosition() {
if (!this.cy) { if (!this.cy) {
return; throw new Error("Expected cytoscape, but there wasn't one!");
} }
const { currentNodeId } = this.props; const { currentNodeId } = this.props;
if (currentNodeId) { if (currentNodeId) {
@ -175,7 +175,7 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
*/ */
private setNodeSelection = (prevNodeId?: string | null) => { private setNodeSelection = (prevNodeId?: string | null) => {
if (!this.cy) { if (!this.cy) {
return; throw new Error("Expected cytoscape, but there wasn't one!");
} }
if (prevNodeId) { if (prevNodeId) {
this.cy.$id(prevNodeId).unselect(); this.cy.$id(prevNodeId).unselect();

View File

@ -7,7 +7,7 @@ interface IFloatingResetButtonProps {
} }
const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => ( const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
<FloatingCard> <FloatingCard>
<Button icon="compass" onClick={onClick} /> <Button icon="compass" title="Reset graph view" onClick={onClick} />
</FloatingCard> </FloatingCard>
); );
export default FloatingResetButton; export default FloatingResetButton;

View File

@ -0,0 +1,48 @@
import { Card, Classes, Elevation, H4 } from "@blueprintjs/core";
import inflection from "inflection";
import * as numeral from "numeral";
import React from "react";
import sanitize from "sanitize-html";
import styled from "styled-components";
import { ISearchResultInstance } from "../../redux/types";
const StyledCard = styled(Card)`
width: 80%;
margin: 1em auto;
background-color: #394b59 !important;
text-align: left;
`;
const StyledH4 = styled(H4)`
margin-bottom: 5px;
`;
const StyledUserCount = styled.div`
margin: 0;
`;
const StyledDescription = styled.div`
margin-top: 10px;
`;
interface ISearchResultProps {
result: ISearchResultInstance;
onClick: () => void;
}
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick }) => {
let shortenedDescription;
if (result.description) {
shortenedDescription = result.description && sanitize(result.description);
if (shortenedDescription.length > 100) {
shortenedDescription = shortenedDescription.substring(0, 100) + "...";
}
}
return (
<StyledCard elevation={Elevation.ONE} interactive={true} key={result.name} onClick={onClick}>
<StyledH4>{result.name}</StyledH4>
{result.userCount && (
<StyledUserCount className={Classes.TEXT_MUTED}>
{numeral.default(result.userCount).format("0,0")} {inflection.inflect("people", result.userCount, "person")}
</StyledUserCount>
)}
{shortenedDescription && <StyledDescription dangerouslySetInnerHTML={{ __html: shortenedDescription }} />}
</StyledCard>
);
};
export default SearchResult;

View File

@ -1,3 +1,4 @@
export { default as Cytoscape } from "./Cytoscape"; export { default as Cytoscape } from "./Cytoscape";
export { default as ErrorState } from "./ErrorState"; export { default as ErrorState } from "./ErrorState";
export { default as FloatingResetButton } from "./FloatingResetButton"; export { default as FloatingResetButton } from "./FloatingResetButton";
export { default as SearchResult } from "./SearchResult";

View File

@ -1,21 +1,25 @@
import * as React from "react"; import * as React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { NonIdealState, Spinner } from "@blueprintjs/core";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { fetchGraph } from "../../redux/actions";
import { IAppState, IGraph } from "../../redux/types"; import { IAppState, IGraph } from "../../redux/types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, FloatingResetButton } from "../molecules/"; import { Cytoscape, ErrorState, FloatingResetButton } from "../molecules/";
const GraphDiv = styled.div` const GraphDiv = styled.div`
flex: 3; flex: 2;
`; `;
// TODO: merge this component with Cytoscape.tsx
interface IGraphProps { interface IGraphProps {
graph?: IGraph;
currentInstanceName: string | null; currentInstanceName: string | null;
fetchGraph: () => void;
graph?: IGraph;
graphLoadError: boolean;
isLoadingGraph: boolean;
navigate: (path: string) => void; navigate: (path: string) => void;
} }
class GraphImpl extends React.Component<IGraphProps> { class GraphImpl extends React.Component<IGraphProps> {
@ -26,25 +30,40 @@ class GraphImpl extends React.Component<IGraphProps> {
this.cytoscapeComponent = React.createRef(); this.cytoscapeComponent = React.createRef();
} }
public componentDidMount() {
this.loadGraph();
}
public render() { public render() {
if (!this.props.graph) { let content;
return <ErrorState />; if (this.props.isLoadingGraph) {
content = <NonIdealState icon={<Spinner />} title="Loading..." />;
} else if (this.props.graphLoadError || !this.props.graph) {
content = <ErrorState />;
} else {
content = (
<>
<Cytoscape
currentNodeId={this.props.currentInstanceName}
elements={this.props.graph}
navigateToInstancePath={this.navigateToInstancePath}
navigateToRoot={this.navigateToRoot}
ref={this.cytoscapeComponent}
/>
<FloatingResetButton onClick={this.resetGraphPosition} />
</>
);
} }
return ( return <GraphDiv>{content}</GraphDiv>;
<GraphDiv>
<Cytoscape
currentNodeId={this.props.currentInstanceName}
elements={this.props.graph}
navigateToInstancePath={this.navigateToInstancePath}
navigateToRoot={this.navigateToRoot}
ref={this.cytoscapeComponent}
/>
<FloatingResetButton onClick={this.resetGraphPosition} />
</GraphDiv>
);
} }
private loadGraph = () => {
if (!this.props.isLoadingGraph && !this.props.graphLoadError) {
this.props.fetchGraph();
}
};
private resetGraphPosition = () => { private resetGraphPosition = () => {
if (this.cytoscapeComponent.current) { if (this.cytoscapeComponent.current) {
this.cytoscapeComponent.current.resetGraphPosition(); this.cytoscapeComponent.current.resetGraphPosition();
@ -63,10 +82,13 @@ const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
graph: state.data.graph graph: state.data.graph,
graphLoadError: state.data.error,
isLoadingGraph: state.data.isLoadingGraph
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any),
navigate: (path: string) => dispatch(push(path)) navigate: (path: string) => dispatch(push(path))
}); });
const Graph = connect( const Graph = connect(

View File

@ -1,91 +0,0 @@
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { Button, MenuItem } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { IItemRendererProps, ItemPredicate, Select } from "@blueprintjs/select";
import { push } from "connected-react-router";
import { IAppState, IInstance } from "../../redux/types";
import { domainMatchSelector } from "../../util";
interface IInstanceSearchProps {
currentInstanceName: string | null;
pathname: string;
instances?: IInstance[];
selectInstance: (instanceName: string) => void;
}
const InstanceSelect = Select.ofType<IInstance>();
class InstanceSearchImpl extends React.Component<IInstanceSearchProps> {
public render() {
return (
<InstanceSelect
items={this.props.instances || []}
itemRenderer={this.itemRenderer}
onItemSelect={this.onItemSelect}
itemPredicate={this.itemPredicate}
disabled={
!this.props.instances || (!this.props.pathname.startsWith("/instance/") && this.props.pathname !== "/")
}
initialContent={this.renderInitialContent()}
noResults={this.renderNoResults()}
popoverProps={{ popoverClassName: "fediverse-instance-search-popover" }}
>
<Button
icon={IconNames.SELECTION}
rightIcon={IconNames.CARET_DOWN}
text={this.props.currentInstanceName || "Select an instance"}
disabled={!this.props.instances}
/>
</InstanceSelect>
);
}
private renderInitialContent = () => {
return <MenuItem disabled={true} text={"Start typing"} />;
};
private renderNoResults = () => {
return <MenuItem disabled={true} text={"Keep typing"} />;
};
private itemRenderer = (item: IInstance, itemProps: IItemRendererProps) => {
if (!itemProps.modifiers.matchesPredicate) {
return null;
}
return (
<MenuItem text={item.name} key={item.name} active={itemProps.modifiers.active} onClick={itemProps.handleClick} />
);
};
private itemPredicate: ItemPredicate<IInstance> = (query, item, index) => {
if (!item.name || query.length < 4) {
return false;
}
return item.name.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
private onItemSelect = (item: IInstance, event?: React.SyntheticEvent<HTMLElement>) => {
this.props.selectInstance(item.name);
};
}
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
instances: state.data.instances,
pathname: state.router.location.pathname
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
selectInstance: (domain: string) => dispatch(push(`/instance/${domain}`))
});
const InstanceSearch = connect(
mapStateToProps,
mapDispatchToProps
)(InstanceSearchImpl);
export default InstanceSearch;

View File

@ -5,7 +5,6 @@ import { IconNames } from "@blueprintjs/icons";
import { Classes } from "@blueprintjs/core"; import { Classes } from "@blueprintjs/core";
import { match, NavLink } from "react-router-dom"; import { match, NavLink } from "react-router-dom";
import { InstanceSearch } from ".";
import { IInstanceDomainPath } from "../../constants"; import { IInstanceDomainPath } from "../../constants";
interface INavState { interface INavState {
@ -45,9 +44,6 @@ class Nav extends React.Component<{}, INavState> {
About About
</NavLink> </NavLink>
</Navbar.Group> </Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<InstanceSearch />
</Navbar.Group>
</Navbar> </Navbar>
); );
} }

View File

@ -0,0 +1,27 @@
import { Card, Elevation } from "@blueprintjs/core";
import React from "react";
import styled from "styled-components";
const RightDiv = styled.div`
align-self: right;
background-color: grey;
flex: 1;
overflow: scroll;
overflow-x: hidden;
`;
const StyledCard = styled(Card)`
min-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
`;
const SidebarContainer: React.FC = ({ children }) => {
return (
<RightDiv>
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
</RightDiv>
);
};
export default SidebarContainer;

View File

@ -1,4 +1,3 @@
export { default as Graph } from "./Graph"; export { default as Graph } from "./Graph";
export { default as Sidebar } from "./Sidebar";
export { default as Nav } from "./Nav"; export { default as Nav } from "./Nav";
export { default as InstanceSearch } from "./InstanceSearch"; export { default as SidebarContainer } from "./SidebarContainer";

View File

@ -3,13 +3,13 @@ import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { NonIdealState, Spinner } from "@blueprintjs/core"; import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
import { InstanceScreen, SearchScreen } from ".";
import { fetchGraph, fetchInstances, loadInstance } from "../../redux/actions"; import { INSTANCE_DOMAIN_PATH } from "../../constants";
import { loadInstance } from "../../redux/actions";
import { IAppState } from "../../redux/types"; import { IAppState } from "../../redux/types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector } from "../../util";
import { ErrorState } from "../molecules/"; import { Graph, SidebarContainer } from "../organisms/";
import { Graph, Sidebar } from "../organisms/";
const GraphContainer = styled.div` const GraphContainer = styled.div`
display: flex; display: flex;
@ -24,39 +24,22 @@ const FullDiv = styled.div`
right: 0; right: 0;
`; `;
interface IGraphScreenProps { interface IGraphScreenProps extends RouteComponentProps {
currentInstanceName: string | null; currentInstanceName: string | null;
pathname: string; pathname: string;
isLoadingGraph: boolean;
isLoadingInstances: boolean;
graphLoadError: boolean; graphLoadError: boolean;
loadInstance: (domain: string | null) => void; loadInstance: (domain: string | null) => void;
fetchInstances: () => void;
fetchGraph: () => void;
} }
/** /**
* This component takes care of loading or deselecting the current instance when the URL path changes. * This component takes care of loading or deselecting the current instance when the URL path changes.
* It also handles changing and animating the screen shown in the sidebar.
*/ */
class GraphScreenImpl extends React.Component<IGraphScreenProps> { class GraphScreenImpl extends React.Component<IGraphScreenProps> {
public render() { public render() {
let content; return <Route render={this.renderRoutes} />;
if (this.props.isLoadingInstances || this.props.isLoadingGraph) {
content = this.loadingState("Loading...");
} else if (!!this.props.graphLoadError) {
content = <ErrorState />;
} else {
content = (
<GraphContainer>
<Graph />
<Sidebar />
</GraphContainer>
);
}
return <FullDiv>{content}</FullDiv>;
} }
public componentDidMount() { public componentDidMount() {
this.loadInstancesAndGraph();
this.loadCurrentInstance(); this.loadCurrentInstance();
} }
@ -64,45 +47,40 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps> {
this.loadCurrentInstance(prevProps.currentInstanceName); this.loadCurrentInstance(prevProps.currentInstanceName);
} }
private loadInstancesAndGraph = () => { private renderRoutes = ({ location }: RouteComponentProps) => (
if (!this.props.isLoadingGraph && !this.props.graphLoadError) { <FullDiv>
this.props.fetchGraph(); <GraphContainer>
} <Graph />
if (!this.props.isLoadingInstances && !this.props.graphLoadError) { <SidebarContainer>
this.props.fetchInstances(); <Switch>
} <Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
}; <Route exact={true} path="/" component={SearchScreen} />
</Switch>
</SidebarContainer>
</GraphContainer>
</FullDiv>
);
private loadCurrentInstance = (prevInstanceName?: string | null) => { private loadCurrentInstance = (prevInstanceName?: string | null) => {
if (prevInstanceName !== this.props.currentInstanceName) { if (prevInstanceName !== this.props.currentInstanceName) {
this.props.loadInstance(this.props.currentInstanceName); this.props.loadInstance(this.props.currentInstanceName);
} }
}; };
private loadingState = (title?: string) => {
return <NonIdealState icon={<Spinner />} title={title || "Loading..."} />;
};
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
graph: state.data.graph,
graphLoadError: state.data.error, graphLoadError: state.data.error,
instances: state.data.instances,
isLoadingGraph: state.data.isLoadingGraph,
isLoadingInstances: state.data.isLoadingInstances,
pathname: state.router.location.pathname pathname: state.router.location.pathname
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any),
fetchInstances: () => dispatch(fetchInstances() as any),
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any) loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any)
}); });
const GraphScreen = connect( const GraphScreen = connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(GraphScreenImpl); )(GraphScreenImpl);
export default GraphScreen; export default withRouter(GraphScreen);

View File

@ -1,7 +1,7 @@
import { orderBy } from "lodash"; import { orderBy } from "lodash";
import moment from "moment"; import moment from "moment";
import * as numeral from "numeral"; import * as numeral from "numeral";
import * as React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import sanitize from "sanitize-html"; import sanitize from "sanitize-html";
@ -9,114 +9,122 @@ import {
AnchorButton, AnchorButton,
Button, Button,
Callout, Callout,
Card,
Classes, Classes,
Code, Code,
Divider, Divider,
Elevation,
H2, H2,
H4,
HTMLTable, HTMLTable,
Icon, Icon,
NonIdealState, NonIdealState,
Position, Position,
Spinner,
Tab, Tab,
Tabs, Tabs,
Tooltip Tooltip
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { IAppState, IGraph, IInstanceDetails } from "../../redux/types"; import { IAppState, IGraph, IInstanceDetails } from "../../redux/types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector } from "../../util";
import { ErrorState } from "../molecules/"; import { ErrorState } from "../molecules/";
import { FullDiv } from "../styled-components";
interface IClosedProp { const InstanceScreenContainer = styled.div`
closed?: boolean; margin-bottom: auto;
} display: flex;
const SidebarContainer = styled.div<IClosedProp>` flex-direction: column;
position: fixed; flex: 1;
top: 50px;
bottom: 0;
right: ${props => (props.closed ? "-400px" : 0)};
min-width: 400px;
width: 25%;
z-index: 20;
overflow: scroll;
overflow-x: hidden;
transition-property: all;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
@media screen and (min-width: 1600px) {
right: ${props => (props.closed ? "-25%" : 0)};
}
`; `;
const StyledCard = styled(Card)` const HeadingContainer = styled.div`
min-height: 100%; display: flex;
flex-direction: row;
align-items: center;
width: 100%; width: 100%;
`; `;
const StyledButton = styled(Button)` const StyledHeadingH2 = styled(H2)`
position: absolute; margin: 0;
top: 0; `;
left: -40px; const StyledCloseButton = styled(Button)`
z-index: 20; justify-self: flex-end;
transition-property: all; `;
transition-duration: 0.5s; const StyledHeadingTooltip = styled(Tooltip)`
transition-timing-function: cubic-bezier(0, 1, 0.5, 1); margin-left: 5px;
flex-grow: 1;
`; `;
const StyledHTMLTable = styled(HTMLTable)` const StyledHTMLTable = styled(HTMLTable)`
width: 100%; width: 100%;
`; `;
const StyledLinkToFdNetwork = styled.div` const StyledLinkToFdNetwork = styled.div`
margin-top: 3em;
text-align: center; text-align: center;
margin-top: auto;
`; `;
const StyledTabs = styled(Tabs)`
interface ISidebarProps { width: 100%;
`;
interface IInstanceScreenProps {
graph?: IGraph; graph?: IGraph;
instanceName: string | null; instanceName: string | null;
instanceLoadError: boolean; instanceLoadError: boolean;
instanceDetails: IInstanceDetails | null; instanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean; isLoadingInstanceDetails: boolean;
navigateToRoot: () => void;
} }
interface ISidebarState {
isOpen: boolean; interface IInstanceScreenState {
neighbors?: string[]; neighbors?: string[];
isProcessingNeighbors: boolean; isProcessingNeighbors: boolean;
} }
class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> { class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> {
constructor(props: ISidebarProps) { public constructor(props: IInstanceScreenProps) {
super(props); super(props);
const isOpen = window.innerWidth >= 900 ? true : false; this.state = { isProcessingNeighbors: false };
this.state = { isOpen, isProcessingNeighbors: false }; }
public render() {
let content;
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors) {
content = this.renderLoadingState();
} else if (!this.props.instanceDetails) {
return this.renderEmptyState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) {
content = this.renderRobotsTxtState();
} else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState();
} else if (this.props.instanceLoadError) {
return (content = <ErrorState />);
} else {
content = this.renderTabs();
}
return (
<InstanceScreenContainer>
<HeadingContainer>
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
</StyledHeadingTooltip>
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
</HeadingContainer>
<Divider />
{content}
</InstanceScreenContainer>
);
} }
public componentDidMount() { public componentDidMount() {
this.processEdgesToFindNeighbors(); this.processEdgesToFindNeighbors();
} }
public componentDidUpdate(prevProps: ISidebarProps, prevState: ISidebarState) { public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) {
if (prevProps.instanceName !== this.props.instanceName) { if (prevProps.instanceName !== this.props.instanceName) {
this.processEdgesToFindNeighbors(); this.processEdgesToFindNeighbors();
} }
} }
public render() {
const buttonIcon = this.state.isOpen ? IconNames.DOUBLE_CHEVRON_RIGHT : IconNames.DOUBLE_CHEVRON_LEFT;
return (
<SidebarContainer closed={!this.state.isOpen}>
<StyledButton onClick={this.handleToggle} large={true} icon={buttonIcon} minimal={true} />
<StyledCard elevation={Elevation.TWO}>{this.renderSidebarContents()}</StyledCard>
</SidebarContainer>
);
}
private handleToggle = () => {
this.setState({ isOpen: !this.state.isOpen });
};
private processEdgesToFindNeighbors = () => { private processEdgesToFindNeighbors = () => {
const { graph, instanceName } = this.props; const { graph, instanceName } = this.props;
if (!graph || !instanceName) { if (!graph || !instanceName) {
@ -135,63 +143,38 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
this.setState({ neighbors, isProcessingNeighbors: false }); this.setState({ neighbors, isProcessingNeighbors: false });
}; };
private renderSidebarContents = () => {
let content;
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors) {
content = this.renderLoadingState();
} else if (!this.props.instanceDetails) {
return this.renderEmptyState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) {
content = this.renderRobotsTxtState();
} else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState();
} else if (this.props.instanceLoadError) {
return (content = <ErrorState />);
} else {
content = this.renderTabs();
}
return (
<FullDiv>
{this.renderHeading()}
{content}
</FullDiv>
);
};
private renderTabs = () => { private renderTabs = () => {
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0; const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
const insularCallout = hasNeighbors ? ( const insularCallout =
undefined this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors ? (
) : ( <Callout icon={IconNames.INFO_SIGN} title="Insular instance">
<Callout icon={IconNames.INFO_SIGN} title="Insular instance"> <p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
<p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p> </Callout>
</Callout> ) : (
); undefined
);
return ( return (
<div> <>
{insularCallout} {insularCallout}
<Tabs> <StyledTabs>
{this.props.instanceDetails!.description && ( {this.props.instanceDetails!.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} /> <Tab id="description" title="Description" panel={this.renderDescription()} />
)} )}
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />} {this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} /> <Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
<Tab id="peers" title="Known peers" panel={this.renderPeers()} /> <Tab id="peers" title="Known peers" panel={this.renderPeers()} />
</Tabs> </StyledTabs>
<StyledLinkToFdNetwork> <StyledLinkToFdNetwork>
<a <AnchorButton
href={`https://fediverse.network/${this.props.instanceName}`} href={`https://fediverse.network/${this.props.instanceName}`}
minimal={true}
rightIcon={IconNames.SHARE}
target="_blank" target="_blank"
rel="noopener noreferrer" text="See more statistics at fediverse.network"
className={`${Classes.BUTTON} bp3-icon-${IconNames.LINK}`} />
>
See more statistics at fediverse.network
</a>
</StyledLinkToFdNetwork> </StyledLinkToFdNetwork>
</div> </>
); );
}; };
@ -200,29 +183,6 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
return details && (details.version || details.userCount || details.statusCount || details.domainCount); return details && (details.version || details.userCount || details.statusCount || details.domainCount);
}; };
private renderHeading = () => {
let content: JSX.Element;
if (!this.props.instanceName) {
return;
} else {
content = (
<span>
{this.props.instanceName + " "}
<Tooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
</Tooltip>
</span>
);
}
return (
<div>
<H2>{content}</H2>
<Divider />
</div>
);
};
private renderDescription = () => { private renderDescription = () => {
const description = this.props.instanceDetails!.description; const description = this.props.instanceDetails!.description;
if (!description) { if (!description) {
@ -237,51 +197,49 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
} }
const { version, userCount, statusCount, domainCount, lastUpdated, insularity } = this.props.instanceDetails; const { version, userCount, statusCount, domainCount, lastUpdated, insularity } = this.props.instanceDetails;
return ( return (
<div> <StyledHTMLTable small={true} striped={true}>
<StyledHTMLTable small={true} striped={true}> <tbody>
<tbody> <tr>
<tr> <td>Version</td>
<td>Version</td> <td>{<Code>{version}</Code> || "Unknown"}</td>
<td>{<Code>{version}</Code> || "Unknown"}</td> </tr>
</tr> <tr>
<tr> <td>Users</td>
<td>Users</td> <td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td>
<td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td> </tr>
</tr> <tr>
<tr> <td>Statuses</td>
<td>Statuses</td> <td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td>
<td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td> </tr>
</tr> <tr>
<tr> <td>
<td> Insularity{" "}
Insularity{" "} <Tooltip
<Tooltip content={
content={ <span>
<span> The percentage of mentions that are directed
The percentage of mentions that are directed <br />
<br /> toward users on the same instance.
toward users on the same instance. </span>
</span> }
} position={Position.TOP}
position={Position.TOP} className={Classes.DARK}
className={Classes.DARK} >
> <Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} /> </Tooltip>
</Tooltip> </td>
</td> <td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td> </tr>
</tr> <tr>
<tr> <td>Known peers</td>
<td>Known peers</td> <td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td> </tr>
</tr> <tr>
<tr> <td>Last updated</td>
<td>Last updated</td> <td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td> </tr>
</tr> </tbody>
</tbody> </StyledHTMLTable>
</StyledHTMLTable>
</div>
); );
}; };
@ -369,34 +327,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
); );
}; };
private renderLoadingState = () => { private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
return (
<div>
<H4>
<span className={Classes.SKELETON}>Description</span>
</H4>
<p className={Classes.SKELETON}>
Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt. Cupiditate sit
voluptates quia nulla et saepe id suscipit. Voluptas sed rerum placeat consectetur pariatur necessitatibus
tempora. Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt.
Cupiditate sit voluptates quia nulla et saepe id suscipit. Voluptas sed rerum placeat consectetur pariatur
necessitatibus tempora.
</p>
<H4>
<span className={Classes.SKELETON}>Version</span>
</H4>
<p className={Classes.SKELETON}>Eaque rerum sequi unde omnis voluptatibus non quia fugit.</p>
<H4>
<span className={Classes.SKELETON}>Stats</span>
</H4>
<p className={Classes.SKELETON}>
Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt. Cupiditate sit
voluptates quia nulla et saepe id suscipit. Eaque rerum sequi unde omnis voluptatibus non quia fugit.
Dignissimos asperiores aut incidunt. Cupiditate sit voluptates quia nulla et saepe id suscipit.
</p>
</div>
);
};
private renderPersonalInstanceErrorState = () => { private renderPersonalInstanceErrorState = () => {
return ( return (
@ -415,7 +346,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
private renderMissingDataState = () => { private renderMissingDataState = () => {
return ( return (
<FullDiv> <>
<NonIdealState <NonIdealState
icon={IconNames.ERROR} icon={IconNames.ERROR}
title="No data" title="No data"
@ -424,7 +355,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}> <span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
{this.props.instanceDetails && this.props.instanceDetails.status} {this.props.instanceDetails && this.props.instanceDetails.status}
</span> </span>
</FullDiv> </>
); );
}; };
@ -457,5 +388,11 @@ const mapStateToProps = (state: IAppState) => {
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
}; };
}; };
const Sidebar = connect(mapStateToProps)(SidebarImpl); const mapDispatchToProps = (dispatch: Dispatch) => ({
export default Sidebar; navigateToRoot: () => dispatch(push("/"))
});
const InstanceScreen = connect(
mapStateToProps,
mapDispatchToProps
)(InstanceScreenImpl);
export default InstanceScreen;

View File

@ -0,0 +1,136 @@
import { Button, Classes, H2, NonIdealState, Spinner } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router";
import React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import styled from "styled-components";
import { updateSearch } from "../../redux/actions";
import { IAppState, ISearchResultInstance } from "../../redux/types";
import { SearchResult } from "../molecules";
const SearchContainer = styled.div`
align-self: center;
text-align: center;
width: 100%;
`;
const SearchBarContainer = styled.div`
width: 80%;
margin: 0 auto;
text-align: center;
`;
const SearchResults = styled.div`
width: 100%;
`;
const StyledSpinner = styled(Spinner)`
margin-top: 10px;
`;
interface ISearchScreenProps {
error: boolean;
isLoadingResults: boolean;
query: string;
hasMoreResults: boolean;
results: ISearchResultInstance[];
handleSearch: (query: string) => void;
navigateToInstance: (domain: string) => void;
}
interface ISearchScreenState {
currentQuery: string;
}
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> {
public constructor(props: ISearchScreenProps) {
super(props);
this.state = { currentQuery: "" };
}
public componentDidMount() {
if (this.props.query) {
this.setState({ currentQuery: this.props.query });
}
}
public render() {
const { error, hasMoreResults, results, isLoadingResults, query } = this.props;
if (error) {
return <NonIdealState icon={IconNames.ERROR} title="Something went wrong." action={this.renderSearchBar()} />;
} else if (!isLoadingResults && query && results.length === 0) {
return (
<NonIdealState
icon={IconNames.SEARCH}
title="No search results"
description="Try searching for something else."
action={this.renderSearchBar()}
/>
);
}
return (
<SearchContainer>
<H2>Find an instance</H2>
{this.renderSearchBar()}
<SearchResults>
{results.map(result => (
<SearchResult result={result} key={result.name} onClick={this.selectInstanceFactory(result.name)} />
))}
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
{!isLoadingResults && hasMoreResults && (
<Button onClick={this.search} minimal={true}>
Load more results
</Button>
)}
</SearchResults>
</SearchContainer>
);
}
private handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ currentQuery: event.currentTarget.value });
};
private handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
this.search();
}
};
private search = () => {
this.props.handleSearch(this.state.currentQuery);
};
private selectInstanceFactory = (domain: string) => () => {
this.props.navigateToInstance(domain);
};
private renderSearchBar = () => (
<SearchBarContainer className={`${Classes.INPUT_GROUP} ${Classes.LARGE}`}>
<span className={`${Classes.ICON} bp3-icon-${IconNames.SEARCH}`} />
<input
className={Classes.INPUT}
type="search"
placeholder="Instance name"
dir="auto"
value={this.state.currentQuery}
onChange={this.handleInputChange}
onKeyPress={this.handleKeyPress}
/>
</SearchBarContainer>
);
}
const mapStateToProps = (state: IAppState) => ({
error: state.search.error,
hasMoreResults: !!state.search.next,
isLoadingResults: state.search.isLoadingResults,
query: state.search.query,
results: state.search.results
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
handleSearch: (query: string) => dispatch(updateSearch(query) as any),
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(SearchScreen);

View File

@ -1,2 +1,4 @@
export { default as AboutScreen } from "./AboutScreen"; export { default as AboutScreen } from "./AboutScreen";
export { default as GraphScreen } from "./GraphScreen"; export { default as GraphScreen } from "./GraphScreen";
export { default as SearchScreen } from "./SearchScreen";
export { default as InstanceScreen } from "./InstanceScreen";

View File

@ -1,6 +0,0 @@
import styled from "styled-components";
export const FullDiv = styled.div`
width: 100%;
height: 100%;
`;

View File

@ -2,76 +2,77 @@ import { Dispatch } from "redux";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { getFromApi } from "../util"; import { getFromApi } from "../util";
import { ActionType, IAppState, IGraph, IInstance, IInstanceDetails } from "./types"; import { ActionType, IAppState, IGraph, IInstanceDetails, ISearchResponse } from "./types";
// requestInstanceDetails and deselectInstance are not exported since we only call them from loadInstance() // Instance details
const requestInstanceDetails = (instanceName: string) => { const requestInstanceDetails = (instanceName: string) => {
return { return {
payload: instanceName, payload: instanceName,
type: ActionType.REQUEST_INSTANCE_DETAILS type: ActionType.REQUEST_INSTANCE_DETAILS
}; };
}; };
const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => {
return {
payload: instanceDetails,
type: ActionType.RECEIVE_INSTANCE_DETAILS
};
};
const instanceLoadFailed = () => {
return {
type: ActionType.INSTANCE_LOAD_ERROR
};
};
const deselectInstance = () => { const deselectInstance = () => {
return { return {
type: ActionType.DESELECT_INSTANCE type: ActionType.DESELECT_INSTANCE
}; };
}; };
export const requestInstances = () => { // Graph
return { const requestGraph = () => {
type: ActionType.REQUEST_INSTANCES
};
};
export const receiveInstances = (instances: IInstance[]) => {
return {
payload: instances,
type: ActionType.RECEIVE_INSTANCES
};
};
export const requestGraph = () => {
return { return {
type: ActionType.REQUEST_GRAPH type: ActionType.REQUEST_GRAPH
}; };
}; };
const receiveGraph = (graph: IGraph) => {
export const receiveGraph = (graph: IGraph) => {
return { return {
payload: graph, payload: graph,
type: ActionType.RECEIVE_GRAPH type: ActionType.RECEIVE_GRAPH
}; };
}; };
const graphLoadFailed = () => { const graphLoadFailed = () => {
return { return {
type: ActionType.GRAPH_LOAD_ERROR type: ActionType.GRAPH_LOAD_ERROR
}; };
}; };
const instanceLoadFailed = () => { // Search
const requestSearchResult = (query: string) => {
return { return {
type: ActionType.INSTANCE_LOAD_ERROR payload: query,
type: ActionType.REQUEST_SEARCH_RESULTS
};
};
const receiveSearchResults = (result: ISearchResponse) => {
return {
payload: result,
type: ActionType.RECEIVE_SEARCH_RESULTS
};
};
const searchFailed = () => {
return {
type: ActionType.SEARCH_RESULTS_ERROR
}; };
}; };
export const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => { const resetSearch = () => {
return { return {
payload: instanceDetails, type: ActionType.RESET_SEARCH
type: ActionType.RECEIVE_INSTANCE_DETAILS
}; };
}; };
/** Async actions: https://redux.js.org/advanced/asyncactions */ /** Async actions: https://redux.js.org/advanced/asyncactions */
export const fetchInstances = () => {
return (dispatch: Dispatch) => {
dispatch(requestInstances());
return getFromApi("instances")
.then(instances => dispatch(receiveInstances(instances)))
.catch(e => dispatch(graphLoadFailed()));
};
};
export const loadInstance = (instanceName: string | null) => { export const loadInstance = (instanceName: string | null) => {
return (dispatch: Dispatch, getState: () => IAppState) => { return (dispatch: Dispatch, getState: () => IAppState) => {
if (!instanceName) { if (!instanceName) {
@ -84,7 +85,26 @@ export const loadInstance = (instanceName: string | null) => {
dispatch(requestInstanceDetails(instanceName)); dispatch(requestInstanceDetails(instanceName));
return getFromApi("instances/" + instanceName) return getFromApi("instances/" + instanceName)
.then(details => dispatch(receiveInstanceDetails(details))) .then(details => dispatch(receiveInstanceDetails(details)))
.catch(e => dispatch(instanceLoadFailed())); .catch(() => dispatch(instanceLoadFailed()));
};
};
export const updateSearch = (query: string) => {
return (dispatch: Dispatch, getState: () => IAppState) => {
if (!query) {
dispatch(resetSearch());
return;
}
const next = getState().search.next;
let url = `search/?query=${query}`;
if (next) {
url += `&after=${next}`;
}
dispatch(requestSearchResult(query));
return getFromApi(url)
.then(result => dispatch(receiveSearchResults(result)))
.catch(() => dispatch(searchFailed()));
}; };
}; };
@ -93,6 +113,6 @@ export const fetchGraph = () => {
dispatch(requestGraph()); dispatch(requestGraph());
return getFromApi("graph") return getFromApi("graph")
.then(graph => dispatch(receiveGraph(graph))) .then(graph => dispatch(receiveGraph(graph)))
.catch(e => dispatch(graphLoadFailed())); .catch(() => dispatch(graphLoadFailed()));
}; };
}; };

View File

@ -2,27 +2,14 @@ import { connectRouter } from "connected-react-router";
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import { History } from "history"; import { History } from "history";
import { ActionType, IAction, ICurrentInstanceState, IDataState } from "./types"; import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types";
const initialDataState = { const initialDataState = {
error: false, error: false,
isLoadingGraph: false, isLoadingGraph: false
isLoadingInstances: false
}; };
const data = (state: IDataState = initialDataState, action: IAction) => { const data = (state: IDataState = initialDataState, action: IAction) => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_INSTANCES:
return {
...state,
instances: [],
isLoadingInstances: true
};
case ActionType.RECEIVE_INSTANCES:
return {
...state,
instances: action.payload,
isLoadingInstances: false
};
case ActionType.REQUEST_GRAPH: case ActionType.REQUEST_GRAPH:
return { return {
...state, ...state,
@ -38,8 +25,7 @@ const data = (state: IDataState = initialDataState, action: IAction) => {
return { return {
...state, ...state,
error: true, error: true,
isLoadingGraph: false, isLoadingGraph: false
isLoadingInstances: false
}; };
default: default:
return state; return state;
@ -83,10 +69,54 @@ const currentInstance = (state = initialCurrentInstanceState, action: IAction):
} }
}; };
const initialSearchState: ISearchState = {
error: false,
isLoadingResults: false,
next: "",
query: "",
results: []
};
const search = (state = initialSearchState, action: IAction): ISearchState => {
switch (action.type) {
case ActionType.REQUEST_SEARCH_RESULTS:
const query = action.payload;
const isNewQuery = state.query !== query;
return {
...state,
error: false,
isLoadingResults: true,
query,
results: isNewQuery ? [] : state.results
};
case ActionType.RECEIVE_SEARCH_RESULTS:
return {
...state,
error: false,
isLoadingResults: false,
next: action.payload.next,
results: state.results.concat(action.payload.results)
};
case ActionType.SEARCH_RESULTS_ERROR:
return {
...state,
error: true,
isLoadingResults: false,
next: "",
query: "",
results: []
};
case ActionType.RESET_SEARCH:
return initialSearchState;
default:
return state;
}
};
export default (history: History) => export default (history: History) =>
combineReducers({ combineReducers({
router: connectRouter(history), router: connectRouter(history),
// tslint:disable-next-line:object-literal-sort-keys // tslint:disable-next-line:object-literal-sort-keys
currentInstance, currentInstance,
data data,
search
}); });

View File

@ -1,15 +1,23 @@
import { RouterState } from "connected-react-router"; import { RouterState } from "connected-react-router";
export enum ActionType { export enum ActionType {
// Instance details
REQUEST_INSTANCE_DETAILS = "REQUEST_INSTANCE_DETAILS", REQUEST_INSTANCE_DETAILS = "REQUEST_INSTANCE_DETAILS",
REQUEST_INSTANCES = "REQUEST_INSTANCES", RECEIVE_INSTANCE_DETAILS = "RECEIVE_INSTANCE_DETAILS",
RECEIVE_INSTANCES = "RECEIVE_INSTANCES", INSTANCE_LOAD_ERROR = "INSTANCE_LOAD_ERROR",
// Graph
REQUEST_GRAPH = "REQUEST_GRAPH", REQUEST_GRAPH = "REQUEST_GRAPH",
RECEIVE_GRAPH = "RECEIVE_GRAPH", RECEIVE_GRAPH = "RECEIVE_GRAPH",
RECEIVE_INSTANCE_DETAILS = "RECEIVE_INSTANCE_DETAILS",
DESELECT_INSTANCE = "DESELECT_INSTANCE",
GRAPH_LOAD_ERROR = "GRAPH_LOAD_ERROR", GRAPH_LOAD_ERROR = "GRAPH_LOAD_ERROR",
INSTANCE_LOAD_ERROR = "INSTANCE_LOAD_ERROR" // Nav
DESELECT_INSTANCE = "DESELECT_INSTANCE",
// Search
REQUEST_SEARCH_RESULTS = "REQUEST_SEARCH_RESULTS",
RECEIVE_SEARCH_RESULTS = "RECEIVE_SEARCH_RESULTS",
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
RESET_SEARCH = "RESET_SEARCH"
// REQUEST_INSTANCES = "REQUEST_INSTANCES",
// RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
} }
export interface IAction { export interface IAction {
@ -21,6 +29,12 @@ export interface IInstance {
name: string; name: string;
} }
export interface ISearchResultInstance {
name: string;
description?: string;
userCount?: number;
}
export interface IInstanceDetails { export interface IInstanceDetails {
name: string; name: string;
description?: string; description?: string;
@ -60,6 +74,11 @@ export interface IGraph {
edges: IGraphEdge[]; edges: IGraphEdge[];
} }
export interface ISearchResponse {
results: ISearchResultInstance[];
next: string | null;
}
// Redux state // Redux state
// The current instance name is stored in the URL. See state -> router -> location // The current instance name is stored in the URL. See state -> router -> location
@ -70,15 +89,22 @@ export interface ICurrentInstanceState {
} }
export interface IDataState { export interface IDataState {
instances?: IInstance[];
graph?: IGraph; graph?: IGraph;
isLoadingInstances: boolean;
isLoadingGraph: boolean; isLoadingGraph: boolean;
error: boolean; error: boolean;
} }
export interface ISearchState {
error: boolean;
isLoadingResults: boolean;
next: string;
query: string;
results: ISearchResultInstance[];
}
export interface IAppState { export interface IAppState {
router: RouterState; router: RouterState;
currentInstance: ICurrentInstanceState; currentInstance: ICurrentInstanceState;
data: IDataState; data: IDataState;
search: ISearchState;
} }

View File

@ -12,8 +12,7 @@ if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
export const getFromApi = (path: string): Promise<any> => { export const getFromApi = (path: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/"; const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
path = path.endsWith("/") ? path : path + "/"; return fetch(encodeURI(domain + path)).then(response => response.json());
return fetch(domain + path).then(response => response.json());
}; };
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH); export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);

View File

@ -1318,10 +1318,10 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.9.tgz#d868b6febb02666330410fe7f58f3c4b8258be7b" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.9.tgz#d868b6febb02666330410fe7f58f3c4b8258be7b"
integrity sha512-MNl+rT5UmZeilaPxAVs6YaPC2m6aA8rofviZbhbxpPpl61uKodfdQVsBtgJGTqGizEf02oW3tsVe7FYB8kK14A== integrity sha512-MNl+rT5UmZeilaPxAVs6YaPC2m6aA8rofviZbhbxpPpl61uKodfdQVsBtgJGTqGizEf02oW3tsVe7FYB8kK14A==
"@types/cytoscape@^3.4.3": "@types/cytoscape@^3.8.0":
version "3.4.3" version "3.8.0"
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.4.3.tgz#8b9353154dc895231cd344ed1c7eff2d1391c103" resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.8.0.tgz#334006612fc6285dac83ee3665132743d7651f58"
integrity sha512-uADb/vBj/xTeNNRvtYlzPz1rftMR4Jf6ipq4jqKfYibMZ173sAbdFM3Fl2fPbGfP28CWJpqhcpHp4+NUq3Ma4g== integrity sha512-8TJL7HuMEgjQRCcUC3xKenb7Y6Ra3ZJ3LvYDlpxlt5LlAatzRyTBtIuE1JOdRelqAla8r87XJUzTgi92mlUlQQ==
"@types/dom4@^2.0.1": "@types/dom4@^2.0.1":
version "2.0.1" version "2.0.1"
@ -1376,6 +1376,11 @@
"@types/domutils" "*" "@types/domutils" "*"
"@types/node" "*" "@types/node" "*"
"@types/inflection@^1.5.28":
version "1.5.28"
resolved "https://registry.yarnpkg.com/@types/inflection/-/inflection-1.5.28.tgz#43d55e0d72cf333a2dffd9c4ec0407455a1b0931"
integrity sha1-Q9VeDXLPMzot/9nE7AQHRVobCTE=
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff"
@ -1423,10 +1428,10 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.4.tgz#f83ec3c3e05b174b7241fadeb6688267fe5b22ca" resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.4.tgz#f83ec3c3e05b174b7241fadeb6688267fe5b22ca"
integrity sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ== integrity sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ==
"@types/node@^12.6.2": "@types/node@^12.6.8":
version "12.6.2" version "12.6.8"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.2.tgz#a5ccec6abb6060d5f20d256fb03ed743e9774999" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.8.tgz#e469b4bf9d1c9832aee4907ba8a051494357c12c"
integrity sha512-gojym4tX0FWeV2gsW4Xmzo5wxGjXGm550oVUII7f7G5o4BV6c7DBdiG1RRQd+y1bvqRyYtPfMK85UM95vsapqQ== integrity sha512-aX+gFgA5GHcDi89KG5keey2zf0WfZk/HAQotEamsK2kbey+8yGKcson0hbK8E+v0NArlCJQCqMP161YhV6ZXLg==
"@types/normalize-package-data@^2.4.0": "@types/normalize-package-data@^2.4.0":
version "2.4.0" version "2.4.0"
@ -1490,10 +1495,10 @@
"@types/history" "*" "@types/history" "*"
"@types/react" "*" "@types/react" "*"
"@types/react-virtualized@^9.21.2": "@types/react-virtualized@^9.21.3":
version "9.21.2" version "9.21.3"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.2.tgz#c5e4293409593814c35466913e83fb856e2053d0" resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.3.tgz#79a44b870a4848cbc7cc04ff4bc06e5a10955262"
integrity sha512-Q6geJaDd8FlBw3ilD4ODferTyVtYAmDE3d7+GacfwN0jPt9rD9XkeuPjcHmyIwTrMXuLv1VIJmRxU9WQoQFBJw== integrity sha512-QhXeiVwXrshVAoq2Cy3SGZEDiFdeFfup2ciQya5RTgr5uycQ2alIKzLfy4X38UCrxonwxe8byk5q8fYV0U87Zg==
dependencies: dependencies:
"@types/prop-types" "*" "@types/prop-types" "*"
"@types/react" "*" "@types/react" "*"
@ -5173,11 +5178,12 @@ https-browserify@^1.0.0:
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=
husky@^3.0.0: husky@^3.0.1:
version "3.0.0" version "3.0.1"
resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.0.tgz#de63821a7049dc412b1afd753c259e2f6e227562" resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.1.tgz#06152c28e129622b05fa09c494209de8cf2dfb59"
integrity sha512-lKMEn7bRK+7f5eWPNGclDVciYNQt0GIkAQmhKl+uHP1qFzoN0h92kmH9HZ8PCwyVA2EQPD8KHf0FYWqnTxau+Q== integrity sha512-PXBv+iGKw23GHUlgELRlVX9932feFL407/wHFwtsGeArp0dDM4u+/QusSQwPKxmNgjpSL+ustbOdQ2jetgAZbA==
dependencies: dependencies:
chalk "^2.4.2"
cosmiconfig "^5.2.1" cosmiconfig "^5.2.1"
execa "^1.0.0" execa "^1.0.0"
get-stdin "^7.0.0" get-stdin "^7.0.0"
@ -5317,6 +5323,11 @@ indexof@0.0.1:
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10= integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
inflection@^1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416"
integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=
inflight@^1.0.4: inflight@^1.0.4:
version "1.0.6" version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@ -6676,11 +6687,16 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
lodash@^4.17.12, lodash@^4.17.14: lodash@^4.17.12:
version "4.17.14" version "4.17.14"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw== integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==
lodash@^4.17.15:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
log-symbols@^1.0.2: log-symbols@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
@ -8889,7 +8905,7 @@ react-dev-utils@^9.0.1:
strip-ansi "5.2.0" strip-ansi "5.2.0"
text-table "0.2.0" text-table "0.2.0"
react-dom@^16.4.2: react-dom@^16.8.0:
version "16.8.6" version "16.8.6"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f"
integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA== integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==
@ -9055,7 +9071,7 @@ react-virtualized@^9.21.1:
prop-types "^15.6.0" prop-types "^15.6.0"
react-lifecycles-compat "^3.0.4" react-lifecycles-compat "^3.0.4"
react@^16.4.2: react@^16.8.0:
version "16.8.6" version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw== integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==