Refactor graph sidebar, move to backend search
This commit is contained in:
parent
3ebab9d452
commit
6673a24466
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"elixirLS.projectDir": "backend/"
|
||||
}
|
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -1,38 +1,61 @@
|
|||
# Changelog
|
||||
|
||||
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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- You no longer have to zoom completely in to see labels.
|
||||
- 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
|
||||
|
||||
### Removed
|
||||
|
||||
### Fixed
|
||||
|
||||
- Previously, direct links to /about would return a 404 on Netlify's infrastructure. Now it's fixed.
|
||||
|
||||
### Security
|
||||
|
||||
## [2.0.0] - 2019-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
- The crawler now respects robots.txt.
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the frontend graph from Sigma.js to Cytoscape.js.
|
||||
- To improve performance, instances with no neighbors are no longer shown on the graph.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- The /api/v1 endpoint no longer exists; now there's a new /api.
|
||||
|
||||
### Security
|
||||
|
||||
- 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).
|
||||
|
||||
## [1.0.0] - 2018-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
|
|
|
@ -52,4 +52,16 @@ defmodule Backend.Api do
|
|||
)
|
||||
|> Repo.all()
|
||||
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
|
||||
|
|
|
@ -3,6 +3,8 @@ defmodule Backend.Repo do
|
|||
otp_app: :backend,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
|
||||
use Paginator
|
||||
|
||||
def init(_type, config) do
|
||||
{:ok, Keyword.put(config, :url, System.get_env("DATABASE_URL"))}
|
||||
end
|
||||
|
|
13
backend/lib/backend_web/controllers/search_controller.ex
Normal file
13
backend/lib/backend_web/controllers/search_controller.ex
Normal 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
|
|
@ -2,13 +2,14 @@ defmodule BackendWeb.Router do
|
|||
use BackendWeb, :router
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
plug(:accepts, ["json"])
|
||||
end
|
||||
|
||||
scope "/api", BackendWeb do
|
||||
pipe_through :api
|
||||
pipe_through(:api)
|
||||
|
||||
resources "/instances", InstanceController, only: [:index, :show]
|
||||
resources "/graph", GraphController, only: [:index]
|
||||
resources("/instances", InstanceController, only: [:index, :show])
|
||||
resources("/graph", GraphController, only: [:index])
|
||||
resources("/search", SearchController, only: [:index])
|
||||
end
|
||||
end
|
||||
|
|
20
backend/lib/backend_web/views/search_view.ex
Normal file
20
backend/lib/backend_web/views/search_view.ex
Normal 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
|
|
@ -47,7 +47,8 @@ defmodule Backend.MixProject do
|
|||
{:quantum, "~> 2.3"},
|
||||
{:corsica, "~> 1.1.2"},
|
||||
{:sobelow, "~> 0.8", only: :dev},
|
||||
{:gollum, "~> 0.3.2"}
|
||||
{:gollum, "~> 0.3.2"},
|
||||
{:paginator, "~> 0.6.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
|
||||
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "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"},
|
||||
"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"},
|
||||
|
|
|
@ -36,12 +36,13 @@
|
|||
"cross-fetch": "^3.0.4",
|
||||
"cytoscape": "^3.8.1",
|
||||
"cytoscape-popper": "^1.0.4",
|
||||
"lodash": "^4.17.14",
|
||||
"inflection": "^1.12.0",
|
||||
"lodash": "^4.17.15",
|
||||
"moment": "^2.22.2",
|
||||
"normalize.css": "^8.0.0",
|
||||
"numeral": "^2.0.6",
|
||||
"react": "^16.4.2",
|
||||
"react-dom": "^16.4.2",
|
||||
"react": "^16.8.0",
|
||||
"react-dom": "^16.8.0",
|
||||
"react-redux": "^7.1.0",
|
||||
"react-router-dom": "^5.0.1",
|
||||
"react-scripts": "^3.0.1",
|
||||
|
@ -56,19 +57,20 @@
|
|||
"devDependencies": {
|
||||
"@blueprintjs/tslint-config": "^1.8.1",
|
||||
"@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/lodash": "^4.14.136",
|
||||
"@types/node": "^12.6.2",
|
||||
"@types/node": "^12.6.8",
|
||||
"@types/numeral": "^0.0.25",
|
||||
"@types/react": "^16.8.23",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/react-redux": "^7.1.1",
|
||||
"@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/styled-components": "4.1.18",
|
||||
"husky": "^3.0.0",
|
||||
"husky": "^3.0.1",
|
||||
"lint-staged": "^9.2.0",
|
||||
"react-axe": "^3.2.0",
|
||||
"tslint": "^5.18.0",
|
||||
|
|
|
@ -4,10 +4,10 @@ import { Button, Classes, Dialog } from "@blueprintjs/core";
|
|||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
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 { 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";
|
||||
|
||||
interface IAppLocalState {
|
||||
|
@ -24,13 +24,9 @@ export class AppRouter extends React.Component<{}, IAppLocalState> {
|
|||
<ConnectedRouter history={history}>
|
||||
<div className={`${Classes.DARK} App`}>
|
||||
<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} />
|
||||
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
|
||||
<GraphScreen />
|
||||
{this.renderMobileDialog()}
|
||||
</div>
|
||||
</ConnectedRouter>
|
||||
|
|
|
@ -154,7 +154,7 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
|
||||
public resetGraphPosition() {
|
||||
if (!this.cy) {
|
||||
return;
|
||||
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||
}
|
||||
const { currentNodeId } = this.props;
|
||||
if (currentNodeId) {
|
||||
|
@ -175,7 +175,7 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
*/
|
||||
private setNodeSelection = (prevNodeId?: string | null) => {
|
||||
if (!this.cy) {
|
||||
return;
|
||||
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||
}
|
||||
if (prevNodeId) {
|
||||
this.cy.$id(prevNodeId).unselect();
|
||||
|
|
|
@ -7,7 +7,7 @@ interface IFloatingResetButtonProps {
|
|||
}
|
||||
const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
|
||||
<FloatingCard>
|
||||
<Button icon="compass" onClick={onClick} />
|
||||
<Button icon="compass" title="Reset graph view" onClick={onClick} />
|
||||
</FloatingCard>
|
||||
);
|
||||
export default FloatingResetButton;
|
||||
|
|
48
frontend/src/components/molecules/SearchResult.tsx
Normal file
48
frontend/src/components/molecules/SearchResult.tsx
Normal 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;
|
|
@ -1,3 +1,4 @@
|
|||
export { default as Cytoscape } from "./Cytoscape";
|
||||
export { default as ErrorState } from "./ErrorState";
|
||||
export { default as FloatingResetButton } from "./FloatingResetButton";
|
||||
export { default as SearchResult } from "./SearchResult";
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { NonIdealState, Spinner } from "@blueprintjs/core";
|
||||
import { push } from "connected-react-router";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { fetchGraph } from "../../redux/actions";
|
||||
import { IAppState, IGraph } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { Cytoscape, ErrorState, FloatingResetButton } from "../molecules/";
|
||||
|
||||
const GraphDiv = styled.div`
|
||||
flex: 3;
|
||||
flex: 2;
|
||||
`;
|
||||
|
||||
// TODO: merge this component with Cytoscape.tsx
|
||||
interface IGraphProps {
|
||||
graph?: IGraph;
|
||||
currentInstanceName: string | null;
|
||||
fetchGraph: () => void;
|
||||
graph?: IGraph;
|
||||
graphLoadError: boolean;
|
||||
isLoadingGraph: boolean;
|
||||
navigate: (path: string) => void;
|
||||
}
|
||||
class GraphImpl extends React.Component<IGraphProps> {
|
||||
|
@ -26,25 +30,40 @@ class GraphImpl extends React.Component<IGraphProps> {
|
|||
this.cytoscapeComponent = React.createRef();
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.loadGraph();
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (!this.props.graph) {
|
||||
return <ErrorState />;
|
||||
let content;
|
||||
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 (
|
||||
<GraphDiv>
|
||||
<Cytoscape
|
||||
currentNodeId={this.props.currentInstanceName}
|
||||
elements={this.props.graph}
|
||||
navigateToInstancePath={this.navigateToInstancePath}
|
||||
navigateToRoot={this.navigateToRoot}
|
||||
ref={this.cytoscapeComponent}
|
||||
/>
|
||||
<FloatingResetButton onClick={this.resetGraphPosition} />
|
||||
</GraphDiv>
|
||||
);
|
||||
return <GraphDiv>{content}</GraphDiv>;
|
||||
}
|
||||
|
||||
private loadGraph = () => {
|
||||
if (!this.props.isLoadingGraph && !this.props.graphLoadError) {
|
||||
this.props.fetchGraph();
|
||||
}
|
||||
};
|
||||
|
||||
private resetGraphPosition = () => {
|
||||
if (this.cytoscapeComponent.current) {
|
||||
this.cytoscapeComponent.current.resetGraphPosition();
|
||||
|
@ -63,10 +82,13 @@ const mapStateToProps = (state: IAppState) => {
|
|||
const match = domainMatchSelector(state);
|
||||
return {
|
||||
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) => ({
|
||||
fetchGraph: () => dispatch(fetchGraph() as any),
|
||||
navigate: (path: string) => dispatch(push(path))
|
||||
});
|
||||
const Graph = connect(
|
||||
|
|
|
@ -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;
|
|
@ -5,7 +5,6 @@ import { IconNames } from "@blueprintjs/icons";
|
|||
|
||||
import { Classes } from "@blueprintjs/core";
|
||||
import { match, NavLink } from "react-router-dom";
|
||||
import { InstanceSearch } from ".";
|
||||
import { IInstanceDomainPath } from "../../constants";
|
||||
|
||||
interface INavState {
|
||||
|
@ -45,9 +44,6 @@ class Nav extends React.Component<{}, INavState> {
|
|||
About
|
||||
</NavLink>
|
||||
</Navbar.Group>
|
||||
<Navbar.Group align={Alignment.RIGHT}>
|
||||
<InstanceSearch />
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
|
27
frontend/src/components/organisms/SidebarContainer.tsx
Normal file
27
frontend/src/components/organisms/SidebarContainer.tsx
Normal 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;
|
|
@ -1,4 +1,3 @@
|
|||
export { default as Graph } from "./Graph";
|
||||
export { default as Sidebar } from "./Sidebar";
|
||||
export { default as Nav } from "./Nav";
|
||||
export { default as InstanceSearch } from "./InstanceSearch";
|
||||
export { default as SidebarContainer } from "./SidebarContainer";
|
||||
|
|
|
@ -3,13 +3,13 @@ import { connect } from "react-redux";
|
|||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { NonIdealState, Spinner } from "@blueprintjs/core";
|
||||
|
||||
import { fetchGraph, fetchInstances, loadInstance } from "../../redux/actions";
|
||||
import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
|
||||
import { InstanceScreen, SearchScreen } from ".";
|
||||
import { INSTANCE_DOMAIN_PATH } from "../../constants";
|
||||
import { loadInstance } from "../../redux/actions";
|
||||
import { IAppState } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { ErrorState } from "../molecules/";
|
||||
import { Graph, Sidebar } from "../organisms/";
|
||||
import { Graph, SidebarContainer } from "../organisms/";
|
||||
|
||||
const GraphContainer = styled.div`
|
||||
display: flex;
|
||||
|
@ -24,39 +24,22 @@ const FullDiv = styled.div`
|
|||
right: 0;
|
||||
`;
|
||||
|
||||
interface IGraphScreenProps {
|
||||
interface IGraphScreenProps extends RouteComponentProps {
|
||||
currentInstanceName: string | null;
|
||||
pathname: string;
|
||||
isLoadingGraph: boolean;
|
||||
isLoadingInstances: boolean;
|
||||
graphLoadError: boolean;
|
||||
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.
|
||||
* It also handles changing and animating the screen shown in the sidebar.
|
||||
*/
|
||||
class GraphScreenImpl extends React.Component<IGraphScreenProps> {
|
||||
public render() {
|
||||
let content;
|
||||
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>;
|
||||
return <Route render={this.renderRoutes} />;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.loadInstancesAndGraph();
|
||||
this.loadCurrentInstance();
|
||||
}
|
||||
|
||||
|
@ -64,45 +47,40 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps> {
|
|||
this.loadCurrentInstance(prevProps.currentInstanceName);
|
||||
}
|
||||
|
||||
private loadInstancesAndGraph = () => {
|
||||
if (!this.props.isLoadingGraph && !this.props.graphLoadError) {
|
||||
this.props.fetchGraph();
|
||||
}
|
||||
if (!this.props.isLoadingInstances && !this.props.graphLoadError) {
|
||||
this.props.fetchInstances();
|
||||
}
|
||||
};
|
||||
private renderRoutes = ({ location }: RouteComponentProps) => (
|
||||
<FullDiv>
|
||||
<GraphContainer>
|
||||
<Graph />
|
||||
<SidebarContainer>
|
||||
<Switch>
|
||||
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
|
||||
<Route exact={true} path="/" component={SearchScreen} />
|
||||
</Switch>
|
||||
</SidebarContainer>
|
||||
</GraphContainer>
|
||||
</FullDiv>
|
||||
);
|
||||
|
||||
private loadCurrentInstance = (prevInstanceName?: string | null) => {
|
||||
if (prevInstanceName !== 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 match = domainMatchSelector(state);
|
||||
return {
|
||||
currentInstanceName: match && match.params.domain,
|
||||
graph: state.data.graph,
|
||||
graphLoadError: state.data.error,
|
||||
instances: state.data.instances,
|
||||
isLoadingGraph: state.data.isLoadingGraph,
|
||||
isLoadingInstances: state.data.isLoadingInstances,
|
||||
pathname: state.router.location.pathname
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
fetchGraph: () => dispatch(fetchGraph() as any),
|
||||
fetchInstances: () => dispatch(fetchInstances() as any),
|
||||
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any)
|
||||
});
|
||||
const GraphScreen = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(GraphScreenImpl);
|
||||
export default GraphScreen;
|
||||
export default withRouter(GraphScreen);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { orderBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import * as numeral from "numeral";
|
||||
import * as React from "react";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import sanitize from "sanitize-html";
|
||||
|
||||
|
@ -9,114 +9,122 @@ import {
|
|||
AnchorButton,
|
||||
Button,
|
||||
Callout,
|
||||
Card,
|
||||
Classes,
|
||||
Code,
|
||||
Divider,
|
||||
Elevation,
|
||||
H2,
|
||||
H4,
|
||||
HTMLTable,
|
||||
Icon,
|
||||
NonIdealState,
|
||||
Position,
|
||||
Spinner,
|
||||
Tab,
|
||||
Tabs,
|
||||
Tooltip
|
||||
} from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
import { push } from "connected-react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { IAppState, IGraph, IInstanceDetails } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { ErrorState } from "../molecules/";
|
||||
import { FullDiv } from "../styled-components";
|
||||
|
||||
interface IClosedProp {
|
||||
closed?: boolean;
|
||||
}
|
||||
const SidebarContainer = styled.div<IClosedProp>`
|
||||
position: fixed;
|
||||
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 InstanceScreenContainer = styled.div`
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
`;
|
||||
const StyledCard = styled(Card)`
|
||||
min-height: 100%;
|
||||
const HeadingContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledButton = styled(Button)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -40px;
|
||||
z-index: 20;
|
||||
transition-property: all;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
const StyledHeadingH2 = styled(H2)`
|
||||
margin: 0;
|
||||
`;
|
||||
const StyledCloseButton = styled(Button)`
|
||||
justify-self: flex-end;
|
||||
`;
|
||||
const StyledHeadingTooltip = styled(Tooltip)`
|
||||
margin-left: 5px;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
const StyledHTMLTable = styled(HTMLTable)`
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledLinkToFdNetwork = styled.div`
|
||||
margin-top: 3em;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
`;
|
||||
|
||||
interface ISidebarProps {
|
||||
const StyledTabs = styled(Tabs)`
|
||||
width: 100%;
|
||||
`;
|
||||
interface IInstanceScreenProps {
|
||||
graph?: IGraph;
|
||||
instanceName: string | null;
|
||||
instanceLoadError: boolean;
|
||||
instanceDetails: IInstanceDetails | null;
|
||||
isLoadingInstanceDetails: boolean;
|
||||
navigateToRoot: () => void;
|
||||
}
|
||||
interface ISidebarState {
|
||||
isOpen: boolean;
|
||||
|
||||
interface IInstanceScreenState {
|
||||
neighbors?: string[];
|
||||
isProcessingNeighbors: boolean;
|
||||
}
|
||||
class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
||||
constructor(props: ISidebarProps) {
|
||||
class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> {
|
||||
public constructor(props: IInstanceScreenProps) {
|
||||
super(props);
|
||||
const isOpen = window.innerWidth >= 900 ? true : false;
|
||||
this.state = { isOpen, isProcessingNeighbors: false };
|
||||
this.state = { 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() {
|
||||
this.processEdgesToFindNeighbors();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: ISidebarProps, prevState: ISidebarState) {
|
||||
public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) {
|
||||
if (prevProps.instanceName !== this.props.instanceName) {
|
||||
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 = () => {
|
||||
const { graph, instanceName } = this.props;
|
||||
if (!graph || !instanceName) {
|
||||
|
@ -135,63 +143,38 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
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 = () => {
|
||||
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
|
||||
|
||||
const insularCallout = hasNeighbors ? (
|
||||
undefined
|
||||
) : (
|
||||
<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>
|
||||
</Callout>
|
||||
);
|
||||
const insularCallout =
|
||||
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors ? (
|
||||
<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>
|
||||
</Callout>
|
||||
) : (
|
||||
undefined
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
{insularCallout}
|
||||
<Tabs>
|
||||
<StyledTabs>
|
||||
{this.props.instanceDetails!.description && (
|
||||
<Tab id="description" title="Description" panel={this.renderDescription()} />
|
||||
)}
|
||||
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
|
||||
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
|
||||
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
|
||||
</Tabs>
|
||||
</StyledTabs>
|
||||
<StyledLinkToFdNetwork>
|
||||
<a
|
||||
<AnchorButton
|
||||
href={`https://fediverse.network/${this.props.instanceName}`}
|
||||
minimal={true}
|
||||
rightIcon={IconNames.SHARE}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${Classes.BUTTON} bp3-icon-${IconNames.LINK}`}
|
||||
>
|
||||
See more statistics at fediverse.network
|
||||
</a>
|
||||
text="See more statistics at fediverse.network"
|
||||
/>
|
||||
</StyledLinkToFdNetwork>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -200,29 +183,6 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
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 = () => {
|
||||
const description = this.props.instanceDetails!.description;
|
||||
if (!description) {
|
||||
|
@ -237,51 +197,49 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
}
|
||||
const { version, userCount, statusCount, domainCount, lastUpdated, insularity } = this.props.instanceDetails;
|
||||
return (
|
||||
<div>
|
||||
<StyledHTMLTable small={true} striped={true}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Version</td>
|
||||
<td>{<Code>{version}</Code> || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Users</td>
|
||||
<td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Statuses</td>
|
||||
<td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Insularity{" "}
|
||||
<Tooltip
|
||||
content={
|
||||
<span>
|
||||
The percentage of mentions that are directed
|
||||
<br />
|
||||
toward users on the same instance.
|
||||
</span>
|
||||
}
|
||||
position={Position.TOP}
|
||||
className={Classes.DARK}
|
||||
>
|
||||
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Known peers</td>
|
||||
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last updated</td>
|
||||
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</StyledHTMLTable>
|
||||
</div>
|
||||
<StyledHTMLTable small={true} striped={true}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Version</td>
|
||||
<td>{<Code>{version}</Code> || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Users</td>
|
||||
<td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Statuses</td>
|
||||
<td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Insularity{" "}
|
||||
<Tooltip
|
||||
content={
|
||||
<span>
|
||||
The percentage of mentions that are directed
|
||||
<br />
|
||||
toward users on the same instance.
|
||||
</span>
|
||||
}
|
||||
position={Position.TOP}
|
||||
className={Classes.DARK}
|
||||
>
|
||||
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Known peers</td>
|
||||
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last updated</td>
|
||||
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</StyledHTMLTable>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -369,34 +327,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
);
|
||||
};
|
||||
|
||||
private renderLoadingState = () => {
|
||||
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 renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
|
||||
|
||||
private renderPersonalInstanceErrorState = () => {
|
||||
return (
|
||||
|
@ -415,7 +346,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
|
||||
private renderMissingDataState = () => {
|
||||
return (
|
||||
<FullDiv>
|
||||
<>
|
||||
<NonIdealState
|
||||
icon={IconNames.ERROR}
|
||||
title="No data"
|
||||
|
@ -424,7 +355,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
|
||||
{this.props.instanceDetails && this.props.instanceDetails.status}
|
||||
</span>
|
||||
</FullDiv>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -457,5 +388,11 @@ const mapStateToProps = (state: IAppState) => {
|
|||
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
|
||||
};
|
||||
};
|
||||
const Sidebar = connect(mapStateToProps)(SidebarImpl);
|
||||
export default Sidebar;
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
navigateToRoot: () => dispatch(push("/"))
|
||||
});
|
||||
const InstanceScreen = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(InstanceScreenImpl);
|
||||
export default InstanceScreen;
|
136
frontend/src/components/screens/SearchScreen.tsx
Normal file
136
frontend/src/components/screens/SearchScreen.tsx
Normal 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);
|
|
@ -1,2 +1,4 @@
|
|||
export { default as AboutScreen } from "./AboutScreen";
|
||||
export { default as GraphScreen } from "./GraphScreen";
|
||||
export { default as SearchScreen } from "./SearchScreen";
|
||||
export { default as InstanceScreen } from "./InstanceScreen";
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
import styled from "styled-components";
|
||||
|
||||
export const FullDiv = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
|
@ -2,76 +2,77 @@ import { Dispatch } from "redux";
|
|||
|
||||
import { push } from "connected-react-router";
|
||||
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) => {
|
||||
return {
|
||||
payload: instanceName,
|
||||
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 = () => {
|
||||
return {
|
||||
type: ActionType.DESELECT_INSTANCE
|
||||
};
|
||||
};
|
||||
|
||||
export const requestInstances = () => {
|
||||
return {
|
||||
type: ActionType.REQUEST_INSTANCES
|
||||
};
|
||||
};
|
||||
|
||||
export const receiveInstances = (instances: IInstance[]) => {
|
||||
return {
|
||||
payload: instances,
|
||||
type: ActionType.RECEIVE_INSTANCES
|
||||
};
|
||||
};
|
||||
export const requestGraph = () => {
|
||||
// Graph
|
||||
const requestGraph = () => {
|
||||
return {
|
||||
type: ActionType.REQUEST_GRAPH
|
||||
};
|
||||
};
|
||||
|
||||
export const receiveGraph = (graph: IGraph) => {
|
||||
const receiveGraph = (graph: IGraph) => {
|
||||
return {
|
||||
payload: graph,
|
||||
type: ActionType.RECEIVE_GRAPH
|
||||
};
|
||||
};
|
||||
|
||||
const graphLoadFailed = () => {
|
||||
return {
|
||||
type: ActionType.GRAPH_LOAD_ERROR
|
||||
};
|
||||
};
|
||||
|
||||
const instanceLoadFailed = () => {
|
||||
// Search
|
||||
const requestSearchResult = (query: string) => {
|
||||
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 {
|
||||
payload: instanceDetails,
|
||||
type: ActionType.RECEIVE_INSTANCE_DETAILS
|
||||
type: ActionType.RESET_SEARCH
|
||||
};
|
||||
};
|
||||
|
||||
/** 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) => {
|
||||
return (dispatch: Dispatch, getState: () => IAppState) => {
|
||||
if (!instanceName) {
|
||||
|
@ -84,7 +85,26 @@ export const loadInstance = (instanceName: string | null) => {
|
|||
dispatch(requestInstanceDetails(instanceName));
|
||||
return getFromApi("instances/" + instanceName)
|
||||
.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());
|
||||
return getFromApi("graph")
|
||||
.then(graph => dispatch(receiveGraph(graph)))
|
||||
.catch(e => dispatch(graphLoadFailed()));
|
||||
.catch(() => dispatch(graphLoadFailed()));
|
||||
};
|
||||
};
|
||||
|
|
|
@ -2,27 +2,14 @@ import { connectRouter } from "connected-react-router";
|
|||
import { combineReducers } from "redux";
|
||||
|
||||
import { History } from "history";
|
||||
import { ActionType, IAction, ICurrentInstanceState, IDataState } from "./types";
|
||||
import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types";
|
||||
|
||||
const initialDataState = {
|
||||
error: false,
|
||||
isLoadingGraph: false,
|
||||
isLoadingInstances: false
|
||||
isLoadingGraph: false
|
||||
};
|
||||
const data = (state: IDataState = initialDataState, action: IAction) => {
|
||||
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:
|
||||
return {
|
||||
...state,
|
||||
|
@ -38,8 +25,7 @@ const data = (state: IDataState = initialDataState, action: IAction) => {
|
|||
return {
|
||||
...state,
|
||||
error: true,
|
||||
isLoadingGraph: false,
|
||||
isLoadingInstances: false
|
||||
isLoadingGraph: false
|
||||
};
|
||||
default:
|
||||
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) =>
|
||||
combineReducers({
|
||||
router: connectRouter(history),
|
||||
// tslint:disable-next-line:object-literal-sort-keys
|
||||
currentInstance,
|
||||
data
|
||||
data,
|
||||
search
|
||||
});
|
||||
|
|
|
@ -1,15 +1,23 @@
|
|||
import { RouterState } from "connected-react-router";
|
||||
|
||||
export enum ActionType {
|
||||
// Instance details
|
||||
REQUEST_INSTANCE_DETAILS = "REQUEST_INSTANCE_DETAILS",
|
||||
REQUEST_INSTANCES = "REQUEST_INSTANCES",
|
||||
RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
|
||||
RECEIVE_INSTANCE_DETAILS = "RECEIVE_INSTANCE_DETAILS",
|
||||
INSTANCE_LOAD_ERROR = "INSTANCE_LOAD_ERROR",
|
||||
// Graph
|
||||
REQUEST_GRAPH = "REQUEST_GRAPH",
|
||||
RECEIVE_GRAPH = "RECEIVE_GRAPH",
|
||||
RECEIVE_INSTANCE_DETAILS = "RECEIVE_INSTANCE_DETAILS",
|
||||
DESELECT_INSTANCE = "DESELECT_INSTANCE",
|
||||
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 {
|
||||
|
@ -21,6 +29,12 @@ export interface IInstance {
|
|||
name: string;
|
||||
}
|
||||
|
||||
export interface ISearchResultInstance {
|
||||
name: string;
|
||||
description?: string;
|
||||
userCount?: number;
|
||||
}
|
||||
|
||||
export interface IInstanceDetails {
|
||||
name: string;
|
||||
description?: string;
|
||||
|
@ -60,6 +74,11 @@ export interface IGraph {
|
|||
edges: IGraphEdge[];
|
||||
}
|
||||
|
||||
export interface ISearchResponse {
|
||||
results: ISearchResultInstance[];
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
// Redux state
|
||||
|
||||
// The current instance name is stored in the URL. See state -> router -> location
|
||||
|
@ -70,15 +89,22 @@ export interface ICurrentInstanceState {
|
|||
}
|
||||
|
||||
export interface IDataState {
|
||||
instances?: IInstance[];
|
||||
graph?: IGraph;
|
||||
isLoadingInstances: boolean;
|
||||
isLoadingGraph: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export interface ISearchState {
|
||||
error: boolean;
|
||||
isLoadingResults: boolean;
|
||||
next: string;
|
||||
query: string;
|
||||
results: ISearchResultInstance[];
|
||||
}
|
||||
|
||||
export interface IAppState {
|
||||
router: RouterState;
|
||||
currentInstance: ICurrentInstanceState;
|
||||
data: IDataState;
|
||||
search: ISearchState;
|
||||
}
|
||||
|
|
|
@ -12,8 +12,7 @@ if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
|
|||
|
||||
export const getFromApi = (path: string): Promise<any> => {
|
||||
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
|
||||
path = path.endsWith("/") ? path : path + "/";
|
||||
return fetch(domain + path).then(response => response.json());
|
||||
return fetch(encodeURI(domain + path)).then(response => response.json());
|
||||
};
|
||||
|
||||
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
|
||||
|
|
|
@ -1318,10 +1318,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.9.tgz#d868b6febb02666330410fe7f58f3c4b8258be7b"
|
||||
integrity sha512-MNl+rT5UmZeilaPxAVs6YaPC2m6aA8rofviZbhbxpPpl61uKodfdQVsBtgJGTqGizEf02oW3tsVe7FYB8kK14A==
|
||||
|
||||
"@types/cytoscape@^3.4.3":
|
||||
version "3.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.4.3.tgz#8b9353154dc895231cd344ed1c7eff2d1391c103"
|
||||
integrity sha512-uADb/vBj/xTeNNRvtYlzPz1rftMR4Jf6ipq4jqKfYibMZ173sAbdFM3Fl2fPbGfP28CWJpqhcpHp4+NUq3Ma4g==
|
||||
"@types/cytoscape@^3.8.0":
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.8.0.tgz#334006612fc6285dac83ee3665132743d7651f58"
|
||||
integrity sha512-8TJL7HuMEgjQRCcUC3xKenb7Y6Ra3ZJ3LvYDlpxlt5LlAatzRyTBtIuE1JOdRelqAla8r87XJUzTgi92mlUlQQ==
|
||||
|
||||
"@types/dom4@^2.0.1":
|
||||
version "2.0.1"
|
||||
|
@ -1376,6 +1376,11 @@
|
|||
"@types/domutils" "*"
|
||||
"@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":
|
||||
version "2.0.1"
|
||||
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"
|
||||
integrity sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ==
|
||||
|
||||
"@types/node@^12.6.2":
|
||||
version "12.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.2.tgz#a5ccec6abb6060d5f20d256fb03ed743e9774999"
|
||||
integrity sha512-gojym4tX0FWeV2gsW4Xmzo5wxGjXGm550oVUII7f7G5o4BV6c7DBdiG1RRQd+y1bvqRyYtPfMK85UM95vsapqQ==
|
||||
"@types/node@^12.6.8":
|
||||
version "12.6.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.8.tgz#e469b4bf9d1c9832aee4907ba8a051494357c12c"
|
||||
integrity sha512-aX+gFgA5GHcDi89KG5keey2zf0WfZk/HAQotEamsK2kbey+8yGKcson0hbK8E+v0NArlCJQCqMP161YhV6ZXLg==
|
||||
|
||||
"@types/normalize-package-data@^2.4.0":
|
||||
version "2.4.0"
|
||||
|
@ -1490,10 +1495,10 @@
|
|||
"@types/history" "*"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-virtualized@^9.21.2":
|
||||
version "9.21.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.2.tgz#c5e4293409593814c35466913e83fb856e2053d0"
|
||||
integrity sha512-Q6geJaDd8FlBw3ilD4ODferTyVtYAmDE3d7+GacfwN0jPt9rD9XkeuPjcHmyIwTrMXuLv1VIJmRxU9WQoQFBJw==
|
||||
"@types/react-virtualized@^9.21.3":
|
||||
version "9.21.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.3.tgz#79a44b870a4848cbc7cc04ff4bc06e5a10955262"
|
||||
integrity sha512-QhXeiVwXrshVAoq2Cy3SGZEDiFdeFfup2ciQya5RTgr5uycQ2alIKzLfy4X38UCrxonwxe8byk5q8fYV0U87Zg==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
"@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"
|
||||
integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=
|
||||
|
||||
husky@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.0.tgz#de63821a7049dc412b1afd753c259e2f6e227562"
|
||||
integrity sha512-lKMEn7bRK+7f5eWPNGclDVciYNQt0GIkAQmhKl+uHP1qFzoN0h92kmH9HZ8PCwyVA2EQPD8KHf0FYWqnTxau+Q==
|
||||
husky@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.1.tgz#06152c28e129622b05fa09c494209de8cf2dfb59"
|
||||
integrity sha512-PXBv+iGKw23GHUlgELRlVX9932feFL407/wHFwtsGeArp0dDM4u+/QusSQwPKxmNgjpSL+ustbOdQ2jetgAZbA==
|
||||
dependencies:
|
||||
chalk "^2.4.2"
|
||||
cosmiconfig "^5.2.1"
|
||||
execa "^1.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"
|
||||
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:
|
||||
version "1.0.6"
|
||||
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"
|
||||
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
|
||||
|
||||
lodash@^4.17.12, lodash@^4.17.14:
|
||||
lodash@^4.17.12:
|
||||
version "4.17.14"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
|
||||
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:
|
||||
version "1.0.2"
|
||||
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"
|
||||
text-table "0.2.0"
|
||||
|
||||
react-dom@^16.4.2:
|
||||
react-dom@^16.8.0:
|
||||
version "16.8.6"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f"
|
||||
integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==
|
||||
|
@ -9055,7 +9071,7 @@ react-virtualized@^9.21.1:
|
|||
prop-types "^15.6.0"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
react@^16.4.2:
|
||||
react@^16.8.0:
|
||||
version "16.8.6"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
|
||||
integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==
|
||||
|
|
Loading…
Reference in a new issue