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
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.

View File

@ -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

View File

@ -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

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
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

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"},
{:corsica, "~> 1.1.2"},
{:sobelow, "~> 0.8", only: :dev},
{:gollum, "~> 0.3.2"}
{:gollum, "~> 0.3.2"},
{:paginator, "~> 0.6.0"}
]
end

View File

@ -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"},

View File

@ -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",

View File

@ -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>

View File

@ -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();

View File

@ -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;

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 ErrorState } from "./ErrorState";
export { default as FloatingResetButton } from "./FloatingResetButton";
export { default as SearchResult } from "./SearchResult";

View File

@ -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(

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 { 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>
);
}

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 Sidebar } from "./Sidebar";
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 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);

View File

@ -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;

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 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 { 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()));
};
};

View File

@ -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
});

View File

@ -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;
}

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> => {
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);

View File

@ -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==