move from tslint to eslint

This commit is contained in:
Tao Bojlén 2020-05-19 14:36:22 +01:00
parent fe3525a427
commit d05f737fc4
44 changed files with 1160 additions and 1219 deletions

View File

@ -1,7 +1,6 @@
{ {
"recommendations": [ "recommendations": [
"jakebecker.elixir-ls", "jakebecker.elixir-ls",
"ms-vscode.vscode-typescript-tslint-plugin",
"kevinmcgowan.typescriptimport", "kevinmcgowan.typescriptimport",
"msjsdiag.debugger-for-chrome" "msjsdiag.debugger-for-chrome"
] ]

4
frontend/.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
build
coverage

24
frontend/.eslintrc.js Normal file
View File

@ -0,0 +1,24 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
},
plugins: ["@typescript-eslint", "prettier"],
extends: [
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier/@typescript-eslint",
"prettier",
],
rules: {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"react/prop-types": 0,
"@typescript-eslint/no-non-null-assertion": 0
},
};

3
frontend/.prettierrc.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
printWidth: 100
}

View File

@ -6,7 +6,7 @@
"start": "NODE_ENV=development react-scripts start", "start": "NODE_ENV=development react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"typecheck": "tsc --noemit", "typecheck": "tsc --noemit",
"lint": "yarn typecheck && tslint -p tsconfig.json -c tslint.json \"src/**/*.{ts,tsx}\"", "lint": "yarn typecheck && yarn eslint src/ --ext .js,.jsx,.ts,.tsx",
"lint:fix": "yarn lint --fix", "lint:fix": "yarn lint --fix",
"pretty": "prettier --write \"src/**/*.{ts,tsx}\"", "pretty": "prettier --write \"src/**/*.{ts,tsx}\"",
"test": "yarn lint && react-scripts test --ci", "test": "yarn lint && react-scripts test --ci",
@ -54,11 +54,10 @@
"tippy.js": "^4.3.5" "tippy.js": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@blueprintjs/tslint-config": "^3.0.0",
"@types/classnames": "^2.2.9", "@types/classnames": "^2.2.9",
"@types/cytoscape": "^3.8.3", "@types/cytoscape": "^3.8.3",
"@types/inflection": "^1.5.28", "@types/inflection": "^1.5.28",
"@types/jest": "^25.2.2", "@types/jest": "^25.2.3",
"@types/lodash": "^4.14.151", "@types/lodash": "^4.14.151",
"@types/node": "^14.0.1", "@types/node": "^14.0.1",
"@types/numeral": "^0.0.28", "@types/numeral": "^0.0.28",
@ -68,12 +67,19 @@
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"@types/sanitize-html": "^1.23.0", "@types/sanitize-html": "^1.23.0",
"@types/styled-components": "5.1.0", "@types/styled-components": "5.1.0",
"@typescript-eslint/eslint-plugin": "^2.24.0",
"@typescript-eslint/parser": "^2.34.0",
"eslint-config-airbnb-typescript": "^7.2.1",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-react-hooks": "^4.0.2",
"husky": "^4.2.5", "husky": "^4.2.5",
"lint-staged": "^10.2.4", "lint-staged": "^10.2.4",
"prettier": "^2.0.5",
"react-axe": "^3.3.0", "react-axe": "^3.3.0",
"tslint": "^6.1.2",
"tslint-config-security": "^1.16.0",
"tslint-eslint-rules": "^5.4.0",
"typescript": "^3.9.2" "typescript": "^3.9.2"
}, },
"browserslist": [ "browserslist": [

View File

@ -4,26 +4,26 @@ import { Classes } from "@blueprintjs/core";
import { ConnectedRouter } from "connected-react-router"; import { ConnectedRouter } from "connected-react-router";
import { Route } from "react-router-dom"; import { Route } from "react-router-dom";
import { Nav } from "./components/organisms/"; import { Nav } from "./components/organisms";
import { import {
AboutScreen, AboutScreen,
AdminScreen, AdminScreen,
GraphScreen, GraphScreen,
LoginScreen, LoginScreen,
TableScreen, TableScreen,
VerifyLoginScreen VerifyLoginScreen,
} from "./components/screens/"; } from "./components/screens";
import { history } from "./index"; import { history } from "./index";
const AppRouter: React.FC = () => ( const AppRouter: React.FC = () => (
<ConnectedRouter history={history}> <ConnectedRouter history={history}>
<div className={`${Classes.DARK} App`}> <div className={`${Classes.DARK} App`}>
<Nav /> <Nav />
<Route path="/instances" exact={true} component={TableScreen} /> <Route path="/instances" exact component={TableScreen} />
<Route path="/about" exact={true} component={AboutScreen} /> <Route path="/about" exact component={AboutScreen} />
<Route path="/admin/login" exact={true} component={LoginScreen} /> <Route path="/admin/login" exact component={LoginScreen} />
<Route path="/admin/verify" exact={true} component={VerifyLoginScreen} /> <Route path="/admin/verify" exact component={VerifyLoginScreen} />
<Route path="/admin" exact={true} component={AdminScreen} /> <Route path="/admin" exact component={AdminScreen} />
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */} {/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
<GraphScreen /> <GraphScreen />
</div> </div>

View File

@ -11,7 +11,7 @@ const FloatingCardElement = styled(Card)`
z-index: 2; z-index: 2;
`; `;
const FloatingCard: React.FC<ICardProps> = props => ( const FloatingCard: React.FC<ICardProps> = (props) => (
<FloatingCardRow> <FloatingCardRow>
<FloatingCardElement elevation={Elevation.ONE} {...props} /> <FloatingCardElement elevation={Elevation.ONE} {...props} />
</FloatingCardRow> </FloatingCardRow>

View File

@ -7,11 +7,11 @@ const StyledSwitch = styled(Switch)`
margin: 0; margin: 0;
`; `;
interface IGraphHideEdgesButtonProps { interface GraphHideEdgesButtonProps {
isShowingEdges: boolean; isShowingEdges: boolean;
toggleEdges: () => void; toggleEdges: () => void;
} }
const GraphHideEdgesButton: React.FC<IGraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => ( const GraphHideEdgesButton: React.FC<GraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => (
<FloatingCard> <FloatingCard>
<StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} tabIndex={-1} /> <StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} tabIndex={-1} />
</FloatingCard> </FloatingCard>

View File

@ -6,9 +6,9 @@ import React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { FloatingCard, InstanceType } from "."; import { FloatingCard, InstanceType } from ".";
import { QUANTITATIVE_COLOR_SCHEME } from "../../constants"; import { QUANTITATIVE_COLOR_SCHEME } from "../../constants";
import { IColorScheme } from "../../types"; import { ColorScheme } from "../../types";
const ColorSchemeSelect = Select.ofType<IColorScheme>(); const ColorSchemeSelect = Select.ofType<ColorScheme>();
const StyledLi = styled.li` const StyledLi = styled.li`
margin-top: 2px; margin-top: 2px;
@ -27,12 +27,12 @@ const ColorBarContainer = styled.div`
flex-direction: column; flex-direction: column;
margin-right: 10px; margin-right: 10px;
`; `;
interface IColorBarProps { interface ColorBarProps {
color: string; color: string;
} }
const ColorBar = styled.div<IColorBarProps>` const ColorBar = styled.div<ColorBarProps>`
width: 10px; width: 10px;
background-color: ${props => props.color}; background-color: ${(props) => props.color};
flex: 1; flex: 1;
`; `;
const TextContainer = styled.div` const TextContainer = styled.div`
@ -41,13 +41,46 @@ const TextContainer = styled.div`
justify-content: space-between; justify-content: space-between;
`; `;
interface IGraphKeyProps { const renderItem: ItemRenderer<ColorScheme> = (colorScheme, { handleClick, modifiers }) => {
current?: IColorScheme; if (!modifiers.matchesPredicate) {
colorSchemes: IColorScheme[]; return null;
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map((v) => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color) => (
<ColorBar color={color} key={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
interface GraphKeyProps {
current?: ColorScheme;
colorSchemes: ColorScheme[];
ranges?: { [key: string]: [number, number] }; ranges?: { [key: string]: [number, number] };
onItemSelect: (colorScheme?: IColorScheme) => void; onItemSelect: (colorScheme?: ColorScheme) => void;
} }
const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => { const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => {
const unsetColorScheme = () => { const unsetColorScheme = () => {
onItemSelect(undefined); onItemSelect(undefined);
}; };
@ -76,13 +109,7 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onI
rightIcon={IconNames.CARET_DOWN} rightIcon={IconNames.CARET_DOWN}
tabIndex={-1} tabIndex={-1}
/> />
<Button <Button icon={IconNames.SMALL_CROSS} minimal onClick={unsetColorScheme} disabled={!current} tabIndex={-1} />
icon={IconNames.SMALL_CROSS}
minimal={true}
onClick={unsetColorScheme}
disabled={!current}
tabIndex={-1}
/>
</ColorSchemeSelect> </ColorSchemeSelect>
<br /> <br />
{!!current && !!key && ( {!!current && !!key && (
@ -95,38 +122,4 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onI
); );
}; };
const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map(v => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color, idx) => (
<ColorBar color={color} key={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
export default GraphKey; export default GraphKey;

View File

@ -2,10 +2,10 @@ import { Button } from "@blueprintjs/core";
import * as React from "react"; import * as React from "react";
import FloatingCard from "./FloatingCard"; import FloatingCard from "./FloatingCard";
interface IGraphResetButtonProps { interface GraphResetButtonProps {
onClick: () => void; onClick: () => void;
} }
const GraphResetButton: React.FC<IGraphResetButtonProps> = ({ onClick }) => ( const GraphResetButton: React.FC<GraphResetButtonProps> = ({ onClick }) => (
<FloatingCard> <FloatingCard>
<Button icon="compass" title="Reset graph view" onClick={onClick} tabIndex={-1} /> <Button icon="compass" title="Reset graph view" onClick={onClick} tabIndex={-1} />
</FloatingCard> </FloatingCard>

View File

@ -5,7 +5,7 @@ import { QUALITATIVE_COLOR_SCHEME } from "../../constants";
import { typeColorScheme } from "../../types"; import { typeColorScheme } from "../../types";
import { getTypeDisplayString } from "../../util"; import { getTypeDisplayString } from "../../util";
interface IInstanceTypeProps { interface InstanceTypeProps {
type: string; type: string;
colorAfterName?: boolean; colorAfterName?: boolean;
} }
@ -13,9 +13,9 @@ interface IInstanceTypeProps {
* By default, renders the color followed by the name of the instance type. * By default, renders the color followed by the name of the instance type.
* You can change this by passing `colorAfterName={true}`. * You can change this by passing `colorAfterName={true}`.
*/ */
const InstanceType: React.FC<IInstanceTypeProps> = ({ type, colorAfterName }) => { const InstanceType: React.FC<InstanceTypeProps> = ({ type, colorAfterName }) => {
const idx = typeColorScheme.values.indexOf(type); const idx = typeColorScheme.values.indexOf(type);
const name = " " + getTypeDisplayString(type); const name = ` ${getTypeDisplayString(type)}`;
return ( return (
<> <>
{!!colorAfterName && name} {!!colorAfterName && name}

View File

@ -11,19 +11,19 @@ const Backdrop = styled.div`
z-index: 3; z-index: 3;
`; `;
interface IContainerProps { interface ContainerProps {
fullWidth?: boolean; fullWidth?: boolean;
} }
const Container = styled.div<IContainerProps>` const Container = styled.div<ContainerProps>`
max-width: ${props => (props.fullWidth ? "100%" : "800px")}; max-width: ${(props) => (props.fullWidth ? "100%" : "800px")};
margin: auto; margin: auto;
padding: 2em; padding: 2em;
`; `;
interface IPageProps { interface PageProps {
fullWidth?: boolean; fullWidth?: boolean;
} }
const Page: React.FC<IPageProps> = ({ children, fullWidth }) => ( const Page: React.FC<PageProps> = ({ children, fullWidth }) => (
<Backdrop> <Backdrop>
<Container fullWidth={fullWidth}>{children}</Container> <Container fullWidth={fullWidth}>{children}</Container>
</Backdrop> </Backdrop>

View File

@ -10,9 +10,9 @@ import {
QUALITATIVE_COLOR_SCHEME, QUALITATIVE_COLOR_SCHEME,
QUANTITATIVE_COLOR_SCHEME, QUANTITATIVE_COLOR_SCHEME,
SEARCH_RESULT_COLOR, SEARCH_RESULT_COLOR,
SELECTED_NODE_COLOR SELECTED_NODE_COLOR,
} from "../../constants"; } from "../../constants";
import { IColorScheme } from "../../types"; import { ColorScheme } from "../../types";
import { getBuckets, getTypeDisplayString } from "../../util"; import { getBuckets, getTypeDisplayString } from "../../util";
const CytoscapeContainer = styled.div` const CytoscapeContainer = styled.div`
@ -21,8 +21,8 @@ const CytoscapeContainer = styled.div`
flex: 1; flex: 1;
`; `;
interface ICytoscapeProps { interface CytoscapeProps {
colorScheme?: IColorScheme; colorScheme?: ColorScheme;
currentNodeId: string | null; currentNodeId: string | null;
elements: cytoscape.ElementsDefinition; elements: cytoscape.ElementsDefinition;
hoveringOver?: string; hoveringOver?: string;
@ -32,10 +32,11 @@ interface ICytoscapeProps {
navigateToInstancePath?: (domain: string) => void; navigateToInstancePath?: (domain: string) => void;
navigateToRoot?: () => void; navigateToRoot?: () => void;
} }
class Cytoscape extends React.PureComponent<ICytoscapeProps> { class Cytoscape extends React.PureComponent<CytoscapeProps> {
private cy?: cytoscape.Core; private cy?: cytoscape.Core;
public componentDidMount() { public componentDidMount() {
// eslint-disable-next-line react/no-find-dom-node
const container = ReactDOM.findDOMNode(this); const container = ReactDOM.findDOMNode(this);
this.cy = cytoscape({ this.cy = cytoscape({
autoungrabify: true, autoungrabify: true,
@ -44,16 +45,16 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
hideEdgesOnViewport: true, hideEdgesOnViewport: true,
hideLabelsOnViewport: true, hideLabelsOnViewport: true,
layout: { layout: {
name: "preset" name: "preset",
}, },
maxZoom: 2, maxZoom: 2,
minZoom: 0.01, minZoom: 0.01,
pixelRatio: 1.0, pixelRatio: 1.0,
selectionType: "single" selectionType: "single",
}); });
// Setup node tooltip on hover // Setup node tooltip on hover
this.cy.nodes().forEach(n => { this.cy.nodes().forEach((n) => {
const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`; const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`;
const ref = (n as any).popperRef(); const ref = (n as any).popperRef();
const t = tippy(ref, { const t = tippy(ref, {
@ -61,12 +62,12 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
animation: "fade", animation: "fade",
content: tooltipContent, content: tooltipContent,
duration: 100, duration: 100,
trigger: "manual" trigger: "manual",
}); });
n.on("mouseover", e => { n.on("mouseover", () => {
(t as Instance).show(); (t as Instance).show();
}); });
n.on("mouseout", e => { n.on("mouseout", () => {
(t as Instance).hide(); (t as Instance).hide();
}); });
}); });
@ -78,25 +79,25 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
.style({ .style({
"curve-style": "haystack", // fast edges "curve-style": "haystack", // fast edges
"line-color": DEFAULT_NODE_COLOR, "line-color": DEFAULT_NODE_COLOR,
width: "mapData(weight, 0, 0.5, 1, 20)" width: "mapData(weight, 0, 0.5, 1, 20)",
}) })
.selector("node[label]") .selector("node[label]")
.style({ .style({
color: DEFAULT_NODE_COLOR, color: DEFAULT_NODE_COLOR,
"font-size": "mapData(size, 1, 6, 10, 100)", "font-size": "mapData(size, 1, 6, 10, 100)",
"min-zoomed-font-size": 16 "min-zoomed-font-size": 16,
}) })
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges .selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges
.style({ .style({
display: "none" display: "none",
}) })
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them .selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
.style({ .style({
width: 2 width: 2,
}); });
this.resetNodeColorScheme(style); // this function also called `update()` this.resetNodeColorScheme(style); // this function also called `update()`
this.cy.nodes().on("select", e => { this.cy.nodes().on("select", (e) => {
const instanceId = e.target.data("id"); const instanceId = e.target.data("id");
if (instanceId && instanceId !== this.props.currentNodeId) { if (instanceId && instanceId !== this.props.currentNodeId) {
if (this.props.navigateToInstancePath) { if (this.props.navigateToInstancePath) {
@ -110,21 +111,19 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
this.cy!.nodes().removeClass("hidden"); this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge"); this.cy!.edges().removeClass("thickEdge");
// Then hide everything except neighborhood // Then hide everything except neighborhood
this.cy!.nodes() this.cy!.nodes().diff(neighborhood).left.addClass("hidden");
.diff(neighborhood)
.left.addClass("hidden");
neighborhood.connectedEdges().addClass("thickEdge"); neighborhood.connectedEdges().addClass("thickEdge");
}); });
}); });
this.cy.nodes().on("unselect", e => { this.cy.nodes().on("unselect", () => {
this.cy!.batch(() => { this.cy!.batch(() => {
this.cy!.nodes().removeClass("hidden"); this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge"); this.cy!.edges().removeClass("thickEdge");
}); });
}); });
this.cy.on("click", e => { this.cy.on("click", (e) => {
// Clicking on the background should also deselect // Clicking on the background should also deselect
const target = e.target; const { target } = e;
if (!target || target === this.cy || target.isEdge()) { if (!target || target === this.cy || target.isEdge()) {
if (this.props.navigateToRoot) { if (this.props.navigateToRoot) {
// Go to the URL "/" // Go to the URL "/"
@ -136,7 +135,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
this.setNodeSelection(); this.setNodeSelection();
} }
public componentDidUpdate(prevProps: ICytoscapeProps) { public componentDidUpdate(prevProps: CytoscapeProps) {
this.setNodeSelection(prevProps.currentNodeId); this.setNodeSelection(prevProps.currentNodeId);
if (prevProps.colorScheme !== this.props.colorScheme) { if (prevProps.colorScheme !== this.props.colorScheme) {
this.updateColorScheme(); this.updateColorScheme();
@ -174,12 +173,12 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
if (currentNodeId) { if (currentNodeId) {
this.cy.zoom({ this.cy.zoom({
level: 0.2, level: 0.2,
position: this.cy.$id(currentNodeId).position() position: this.cy.$id(currentNodeId).position(),
}); });
} else { } else {
this.cy.zoom({ this.cy.zoom({
level: 0.2, level: 0.2,
position: { x: 0, y: 0 } position: { x: 0, y: 0 },
}); });
} }
} }
@ -221,7 +220,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
// quite good as it is, so... // quite good as it is, so...
height: "mapData(size, 1, 6, 20, 200)", height: "mapData(size, 1, 6, 20, 200)",
label: "data(id)", label: "data(id)",
width: "mapData(size, 1, 6, 20, 200)" width: "mapData(size, 1, 6, 20, 200)",
}); });
this.setNodeSearchColorScheme(style); this.setNodeSearchColorScheme(style);
@ -240,16 +239,16 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
"background-color": SEARCH_RESULT_COLOR, "background-color": SEARCH_RESULT_COLOR,
"border-color": SEARCH_RESULT_COLOR, "border-color": SEARCH_RESULT_COLOR,
"border-opacity": 0.7, "border-opacity": 0.7,
"border-width": 250 "border-width": 250,
}) })
.selector("node.hovered") .selector("node.hovered")
.style({ .style({
"border-color": HOVERED_NODE_COLOR, "border-color": HOVERED_NODE_COLOR,
"border-width": 1000 "border-width": 1000,
}) })
.selector("node:selected") .selector("node:selected")
.style({ .style({
"background-color": SELECTED_NODE_COLOR "background-color": SELECTED_NODE_COLOR,
}) })
.update(); .update();
}; };
@ -263,10 +262,11 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
if (!colorScheme) { if (!colorScheme) {
this.resetNodeColorScheme(); this.resetNodeColorScheme();
return; return;
} else if (colorScheme.type === "qualitative") { }
if (colorScheme.type === "qualitative") {
colorScheme.values.forEach((v, idx) => { colorScheme.values.forEach((v, idx) => {
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({ style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
"background-color": QUALITATIVE_COLOR_SCHEME[idx] "background-color": QUALITATIVE_COLOR_SCHEME[idx],
}); });
}); });
} else if (colorScheme.type === "quantitative") { } else if (colorScheme.type === "quantitative") {
@ -284,7 +284,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1]; const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`; const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
style = style.selector(selector).style({ style = style.selector(selector).style({
"background-color": color "background-color": color,
}); });
}); });
} }
@ -304,10 +304,10 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
} }
const { hoveringOver } = this.props; const { hoveringOver } = this.props;
if (!!prevHoveredId) { if (prevHoveredId) {
this.cy.$id(prevHoveredId).removeClass("hovered"); this.cy.$id(prevHoveredId).removeClass("hovered");
} }
if (!!hoveringOver) { if (hoveringOver) {
this.cy.$id(hoveringOver).addClass("hovered"); this.cy.$id(hoveringOver).addClass("hovered");
} }
}; };
@ -322,7 +322,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
this.cy!.nodes().removeClass("searchResult"); this.cy!.nodes().removeClass("searchResult");
if (!!searchResultIds && searchResultIds.length > 0) { if (!!searchResultIds && searchResultIds.length > 0) {
const currentResultSelector = searchResultIds.map(id => `node[id = "${id}"]`).join(", "); const currentResultSelector = searchResultIds.map((id) => `node[id = "${id}"]`).join(", ");
this.cy!.$(currentResultSelector).addClass("searchResult"); this.cy!.$(currentResultSelector).addClass("searchResult");
} }
}); });
@ -344,11 +344,11 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
/* Helper function to remove edges if source or target node is missing */ /* Helper function to remove edges if source or target node is missing */
private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => { private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => {
const domains = new Set(elements.nodes.map(n => n.data.id)); const domains = new Set(elements.nodes.map((n) => n.data.id));
const edges = elements.edges.filter(e => domains.has(e.data.source) && domains.has(e.data.target)); const edges = elements.edges.filter((e) => domains.has(e.data.source) && domains.has(e.data.target));
return { return {
edges, edges,
nodes: elements.nodes nodes: elements.nodes,
}; };
}; };
} }

View File

@ -2,11 +2,11 @@ import { NonIdealState } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import * as React from "react"; import * as React from "react";
interface IErrorStateProps { interface ErrorStateProps {
description?: string; description?: string;
} }
const ErrorState: React.FC<IErrorStateProps> = ({ description }) => ( const ErrorState: React.FC<ErrorStateProps> = ({ description }) => (
<NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} description={description} /> <NonIdealState icon={IconNames.ERROR} title="Something went wrong." description={description} />
); );
export default ErrorState; export default ErrorState;

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { IColorScheme } from "../../types"; import { ColorScheme } from "../../types";
import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms"; import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms";
const GraphToolsContainer = styled.div` const GraphToolsContainer = styled.div`
@ -11,35 +11,33 @@ const GraphToolsContainer = styled.div`
flex-direction: column; flex-direction: column;
`; `;
interface IGraphToolsProps { interface GraphToolsProps {
currentColorScheme?: IColorScheme; currentColorScheme?: ColorScheme;
colorSchemes: IColorScheme[]; colorSchemes: ColorScheme[];
isShowingEdges: boolean; isShowingEdges: boolean;
ranges?: { [key: string]: [number, number] }; ranges?: { [key: string]: [number, number] };
onColorSchemeSelect: (colorScheme?: IColorScheme) => void; onColorSchemeSelect: (colorScheme?: ColorScheme) => void;
onResetButtonClick: () => void; onResetButtonClick: () => void;
toggleEdges: () => void; toggleEdges: () => void;
} }
const GraphTools: React.FC<IGraphToolsProps> = ({ const GraphTools: React.FC<GraphToolsProps> = ({
currentColorScheme, currentColorScheme,
colorSchemes, colorSchemes,
isShowingEdges, isShowingEdges,
ranges, ranges,
onColorSchemeSelect, onColorSchemeSelect,
onResetButtonClick, onResetButtonClick,
toggleEdges toggleEdges,
}) => { }) => (
return ( <GraphToolsContainer>
<GraphToolsContainer> <GraphResetButton onClick={onResetButtonClick} />
<GraphResetButton onClick={onResetButtonClick} /> <GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} />
<GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} /> <GraphKey
<GraphKey current={currentColorScheme}
current={currentColorScheme} colorSchemes={colorSchemes}
colorSchemes={colorSchemes} onItemSelect={onColorSchemeSelect}
onItemSelect={onColorSchemeSelect} ranges={ranges}
ranges={ranges} />
/> </GraphToolsContainer>
</GraphToolsContainer> );
);
};
export default GraphTools; export default GraphTools;

View File

@ -4,7 +4,7 @@ import * as numeral from "numeral";
import React from "react"; import React from "react";
import sanitize from "sanitize-html"; import sanitize from "sanitize-html";
import styled from "styled-components"; import styled from "styled-components";
import { ISearchResultInstance } from "../../redux/types"; import { SearchResultInstance } from "../../redux/types";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
const StyledCard = styled(Card)` const StyledCard = styled(Card)`
@ -32,18 +32,18 @@ const StyledUserCount = styled.div`
const StyledDescription = styled.div` const StyledDescription = styled.div`
margin-top: 10px; margin-top: 10px;
`; `;
interface ISearchResultProps { interface SearchResultProps {
result: ISearchResultInstance; result: SearchResultInstance;
onClick: () => void; onClick: () => void;
onMouseEnter: () => void; onMouseEnter: () => void;
onMouseLeave: () => void; onMouseLeave: () => void;
} }
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => { const SearchResult: React.FC<SearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
let shortenedDescription; let shortenedDescription;
if (result.description) { if (result.description) {
shortenedDescription = result.description && sanitize(result.description); shortenedDescription = result.description && sanitize(result.description);
if (shortenedDescription.length > 100) { if (shortenedDescription.length > 100) {
shortenedDescription = shortenedDescription.substring(0, 100) + "..."; shortenedDescription = `${shortenedDescription.substring(0, 100)}...`;
} }
} }
@ -59,7 +59,7 @@ const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEn
return ( return (
<StyledCard <StyledCard
elevation={Elevation.ONE} elevation={Elevation.ONE}
interactive={true} interactive
key={result.name} key={result.name}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}

View File

@ -1,12 +1,12 @@
import { Classes, H3 } from "@blueprintjs/core"; import { Classes, H3 } from "@blueprintjs/core";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { IFederationRestrictions } from "../../redux/types"; import { FederationRestrictions } from "../../redux/types";
const maybeGetList = (domains?: string[]) => const maybeGetList = (domains?: string[]) =>
domains && ( domains && (
<ul> <ul>
{domains.sort().map(domain => ( {domains.sort().map((domain) => (
<li key={domain}> <li key={domain}>
<Link to={`/instance/${domain}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button"> <Link to={`/instance/${domain}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
{domain} {domain}
@ -16,10 +16,10 @@ const maybeGetList = (domains?: string[]) =>
</ul> </ul>
); );
interface IFederationTabProps { interface FederationTabProps {
restrictions?: IFederationRestrictions; restrictions?: FederationRestrictions;
} }
const FederationTab: React.FC<IFederationTabProps> = ({ restrictions }) => { const FederationTab: React.FC<FederationTabProps> = ({ restrictions }) => {
if (!restrictions) { if (!restrictions) {
return null; return null;
} }

View File

@ -6,33 +6,33 @@ import { push } from "connected-react-router";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { fetchGraph } from "../../redux/actions"; import { fetchGraph } from "../../redux/actions";
import { IAppState, IGraphResponse } from "../../redux/types"; import { AppState, GraphResponse } from "../../redux/types";
import { colorSchemes, IColorScheme } from "../../types"; import { colorSchemes, ColorScheme } from "../../types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, GraphTools } from "../molecules/"; import { Cytoscape, ErrorState, GraphTools } from "../molecules";
const GraphDiv = styled.div` const GraphDiv = styled.div`
flex: 2; flex: 2;
`; `;
interface IGraphProps { interface GraphProps {
currentInstanceName: string | null; currentInstanceName: string | null;
fetchGraph: () => void; fetchGraph: () => void;
graphResponse?: IGraphResponse; graphResponse?: GraphResponse;
graphLoadError: boolean; graphLoadError: boolean;
hoveringOverResult?: string; hoveringOverResult?: string;
isLoadingGraph: boolean; isLoadingGraph: boolean;
searchResultDomains: string[]; searchResultDomains: string[];
navigate: (path: string) => void; navigate: (path: string) => void;
} }
interface IGraphState { interface GraphState {
colorScheme?: IColorScheme; colorScheme?: ColorScheme;
isShowingEdges: boolean; isShowingEdges: boolean;
} }
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> { class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
private cytoscapeComponent: React.RefObject<Cytoscape>; private cytoscapeComponent: React.RefObject<Cytoscape>;
public constructor(props: IGraphProps) { public constructor(props: GraphProps) {
super(props); super(props);
this.cytoscapeComponent = React.createRef(); this.cytoscapeComponent = React.createRef();
this.state = { colorScheme: undefined, isShowingEdges: true }; this.state = { colorScheme: undefined, isShowingEdges: true };
@ -76,7 +76,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
); );
} }
return <GraphDiv aria-hidden={true}>{content}</GraphDiv>; return <GraphDiv aria-hidden>{content}</GraphDiv>;
} }
private loadGraph = () => { private loadGraph = () => {
@ -95,7 +95,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
this.setState({ isShowingEdges: !this.state.isShowingEdges }); this.setState({ isShowingEdges: !this.state.isShowingEdges });
}; };
private setColorScheme = (colorScheme?: IColorScheme) => { private setColorScheme = (colorScheme?: ColorScheme) => {
this.setState({ colorScheme }); this.setState({ colorScheme });
}; };
@ -107,7 +107,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
this.props.navigate("/"); this.props.navigate("/");
}; };
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
@ -115,15 +115,12 @@ const mapStateToProps = (state: IAppState) => {
graphResponse: state.data.graphResponse, graphResponse: state.data.graphResponse,
hoveringOverResult: state.search.hoveringOverResult, hoveringOverResult: state.search.hoveringOverResult,
isLoadingGraph: state.data.isLoadingGraph, isLoadingGraph: state.data.isLoadingGraph,
searchResultDomains: state.search.results.map(r => r.name) searchResultDomains: state.search.results.map((r) => r.name),
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any), fetchGraph: () => dispatch(fetchGraph() as any),
navigate: (path: string) => dispatch(push(path)) navigate: (path: string) => dispatch(push(path)),
}); });
const Graph = connect( const Graph = connect(mapStateToProps, mapDispatchToProps)(GraphImpl);
mapStateToProps,
mapDispatchToProps
)(GraphImpl);
export default Graph; export default Graph;

View File

@ -8,7 +8,7 @@ import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { loadInstanceList } from "../../redux/actions"; import { loadInstanceList } from "../../redux/actions";
import { IAppState, IInstanceListResponse, IInstanceSort, SortField } from "../../redux/types"; import { AppState, InstanceListResponse, InstanceSort, SortField, InstanceDetails } from "../../redux/types";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
import { ErrorState } from "../molecules"; import { ErrorState } from "../molecules";
@ -41,15 +41,15 @@ const InsularityColumn = styled.th`
width: 15%; width: 15%;
`; `;
interface IInstanceTableProps { interface InstanceTableProps {
loadError: boolean; loadError: boolean;
instancesResponse?: IInstanceListResponse; instancesResponse?: InstanceListResponse;
instanceListSort: IInstanceSort; instanceListSort: InstanceSort;
isLoading: boolean; isLoading: boolean;
loadInstanceList: (page?: number, sort?: IInstanceSort) => void; loadInstanceList: (page?: number, sort?: InstanceSort) => void;
navigate: (path: string) => void; navigate: (path: string) => void;
} }
class InstanceTable extends React.PureComponent<IInstanceTableProps> { class InstanceTable extends React.PureComponent<InstanceTableProps> {
public componentDidMount() { public componentDidMount() {
const { isLoading, instancesResponse, loadError } = this.props; const { isLoading, instancesResponse, loadError } = this.props;
if (!isLoading && !instancesResponse && !loadError) { if (!isLoading && !instancesResponse && !loadError) {
@ -61,22 +61,23 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const { isLoading, instancesResponse, loadError } = this.props; const { isLoading, instancesResponse, loadError } = this.props;
if (loadError) { if (loadError) {
return <ErrorState />; return <ErrorState />;
} else if (isLoading || !instancesResponse) { }
if (isLoading || !instancesResponse) {
return <NonIdealState icon={<Spinner />} />; return <NonIdealState icon={<Spinner />} />;
} }
const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse!; const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse;
const pagesToDisplay = this.getPagesToDisplay(totalPages, currentPage); const pagesToDisplay = this.getPagesToDisplay(totalPages, currentPage);
return ( return (
<> <>
<StyledTable striped={true} bordered={true} interactive={true}> <StyledTable striped bordered interactive>
<thead> <thead>
<tr> <tr>
<InstanceColumn> <InstanceColumn>
Instance Instance
<Button <Button
minimal={true} minimal
icon={this.getSortIcon("domain")} icon={this.getSortIcon("domain")}
onClick={this.sortByFactory("domain")} onClick={this.sortByFactory("domain")}
intent={this.getSortIntent("domain")} intent={this.getSortIntent("domain")}
@ -87,7 +88,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<UserCountColumn> <UserCountColumn>
Users Users
<Button <Button
minimal={true} minimal
icon={this.getSortIcon("userCount")} icon={this.getSortIcon("userCount")}
onClick={this.sortByFactory("userCount")} onClick={this.sortByFactory("userCount")}
intent={this.getSortIntent("userCount")} intent={this.getSortIntent("userCount")}
@ -96,7 +97,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<StatusCountColumn> <StatusCountColumn>
Statuses Statuses
<Button <Button
minimal={true} minimal
icon={this.getSortIcon("statusCount")} icon={this.getSortIcon("statusCount")}
onClick={this.sortByFactory("statusCount")} onClick={this.sortByFactory("statusCount")}
intent={this.getSortIntent("statusCount")} intent={this.getSortIntent("statusCount")}
@ -105,7 +106,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<InsularityColumn> <InsularityColumn>
Insularity Insularity
<Button <Button
minimal={true} minimal
icon={this.getSortIcon("insularity")} icon={this.getSortIcon("insularity")}
onClick={this.sortByFactory("insularity")} onClick={this.sortByFactory("insularity")}
intent={this.getSortIntent("insularity")} intent={this.getSortIntent("insularity")}
@ -114,7 +115,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{instances.map(i => ( {instances.map((i: InstanceDetails) => (
<tr key={i.name} onClick={this.goToInstanceFactory(i.name)}> <tr key={i.name} onClick={this.goToInstanceFactory(i.name)}>
<td>{i.name}</td> <td>{i.name}</td>
<td>{i.type && <InstanceType type={i.type} />}</td> <td>{i.type && <InstanceType type={i.type} />}</td>
@ -134,7 +135,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
</p> </p>
<ButtonGroup> <ButtonGroup>
{zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage], idx) => { {zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage]) => {
if (page === undefined) { if (page === undefined) {
return null; return null;
} }
@ -152,8 +153,8 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
{page} {page}
</Button> </Button>
{isEndOfSection && ( {isEndOfSection && (
<Button disabled={true} key={"..."}> <Button disabled key="...">
{"..."} ...
</Button> </Button>
)} )}
</> </>
@ -187,20 +188,19 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const { instanceListSort } = this.props; const { instanceListSort } = this.props;
if (instanceListSort.field !== field) { if (instanceListSort.field !== field) {
return IconNames.SORT; return IconNames.SORT;
} else if (instanceListSort.direction === "asc") {
return IconNames.SORT_ASC;
} else {
return IconNames.SORT_DESC;
} }
if (instanceListSort.direction === "asc") {
return IconNames.SORT_ASC;
}
return IconNames.SORT_DESC;
}; };
private getSortIntent = (field: SortField) => { private getSortIntent = (field: SortField) => {
const { instanceListSort } = this.props; const { instanceListSort } = this.props;
if (instanceListSort.field === field) { if (instanceListSort.field === field) {
return Intent.PRIMARY; return Intent.PRIMARY;
} else {
return Intent.NONE;
} }
return Intent.NONE;
}; };
private getPagesToDisplay = (totalPages: number, currentPage: number) => { private getPagesToDisplay = (totalPages: number, currentPage: number) => {
@ -214,24 +214,19 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const pagesToDisplay = firstPages.concat(surroundingPages).concat(lastPages); const pagesToDisplay = firstPages.concat(surroundingPages).concat(lastPages);
return sortedUniq(sortBy(pagesToDisplay, n => n)); return sortedUniq(sortBy(pagesToDisplay, (n) => n));
}; };
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => ({
return { instanceListSort: state.data.instanceListSort,
instanceListSort: state.data.instanceListSort, instancesResponse: state.data.instancesResponse,
instancesResponse: state.data.instancesResponse, isLoading: state.data.isLoadingInstanceList,
isLoading: state.data.isLoadingInstanceList, loadError: state.data.instanceListLoadError,
loadError: state.data.instanceListLoadError });
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
loadInstanceList: (page?: number, sort?: IInstanceSort) => dispatch(loadInstanceList(page, sort) as any), loadInstanceList: (page?: number, sort?: InstanceSort) => dispatch(loadInstanceList(page, sort) as any),
navigate: (path: string) => dispatch(push(path)) navigate: (path: string) => dispatch(push(path)),
}); });
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(InstanceTable);
mapStateToProps,
mapDispatchToProps
)(InstanceTable);

View File

@ -1,21 +1,19 @@
import * as React from "react"; import * as React from "react";
import { Alignment, Navbar } from "@blueprintjs/core"; import { Alignment, Navbar, Classes } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import { Classes } from "@blueprintjs/core";
import { match, NavLink } from "react-router-dom"; import { match, NavLink } from "react-router-dom";
import { IInstanceDomainPath } from "../../constants"; import { InstanceDomainPath } from "../../constants";
interface INavState { interface NavState {
aboutIsOpen: boolean; aboutIsOpen: boolean;
} }
const graphIsActive = (currMatch: match<IInstanceDomainPath>, location: Location) => { const graphIsActive = (currMatch: match<InstanceDomainPath>, location: Location) =>
return location.pathname === "/" || location.pathname.startsWith("/instance/"); location.pathname === "/" || location.pathname.startsWith("/instance/");
};
class Nav extends React.Component<{}, INavState> { class Nav extends React.Component<{}, NavState> {
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.state = { aboutIsOpen: false }; this.state = { aboutIsOpen: false };
@ -23,7 +21,7 @@ class Nav extends React.Component<{}, INavState> {
public render() { public render() {
return ( return (
<Navbar fixedToTop={true}> <Navbar fixedToTop>
<Navbar.Group align={Alignment.LEFT}> <Navbar.Group align={Alignment.LEFT}>
<Navbar.Heading>fediverse.space</Navbar.Heading> <Navbar.Heading>fediverse.space</Navbar.Heading>
<Navbar.Divider /> <Navbar.Divider />
@ -46,7 +44,7 @@ class Nav extends React.Component<{}, INavState> {
to="/about" to="/about"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`} className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
activeClassName={Classes.INTENT_PRIMARY} activeClassName={Classes.INTENT_PRIMARY}
exact={true} exact
> >
About About
</NavLink> </NavLink>

View File

@ -3,7 +3,7 @@ import { IconNames } from "@blueprintjs/icons";
import React, { MouseEvent } from "react"; import React, { MouseEvent } from "react";
import styled from "styled-components"; import styled from "styled-components";
import { INSTANCE_TYPES } from "../../constants"; import { INSTANCE_TYPES } from "../../constants";
import { getSearchFilterDisplayValue, ISearchFilter } from "../../searchFilters"; import { getSearchFilterDisplayValue, SearchFilter } from "../../searchFilters";
import { getTypeDisplayString } from "../../util"; import { getTypeDisplayString } from "../../util";
const SearchFilterContainer = styled.div` const SearchFilterContainer = styled.div`
@ -19,30 +19,30 @@ const StyledTag = styled(Tag)`
margin-left: 5px; margin-left: 5px;
`; `;
interface ISearchFiltersProps { interface SearchFiltersProps {
selectedFilters: ISearchFilter[]; selectedFilters: SearchFilter[];
selectFilter: (filter: ISearchFilter) => void; selectFilter: (filter: SearchFilter) => void;
deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void; deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void;
} }
const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => { const SearchFilters: React.FC<SearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => {
const hasInstanceTypeFilter = selectedFilters.some(sf => sf.field === "type"); const hasInstanceTypeFilter = selectedFilters.some((sf) => sf.field === "type");
const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => { const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => {
const field = "type"; const field = "type";
const relation = "eq"; const relation = "eq";
const value = e.currentTarget.innerText.toLowerCase().replace(" ", ""); const value = e.currentTarget.innerText.toLowerCase().replace(" ", "");
const filter: ISearchFilter = { const filter: SearchFilter = {
displayValue: getSearchFilterDisplayValue(field, relation, value), displayValue: getSearchFilterDisplayValue(field, relation, value),
field, field,
relation, relation,
value value,
}; };
selectFilter(filter); selectFilter(filter);
}; };
const renderMenu = () => ( const renderMenu = () => (
<Menu> <Menu>
<MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}> <MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}>
{INSTANCE_TYPES.map(t => ( {INSTANCE_TYPES.map((t) => (
<MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} /> <MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} />
))} ))}
</MenuItem> </MenuItem>
@ -51,15 +51,15 @@ const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectF
return ( return (
<SearchFilterContainer> <SearchFilterContainer>
<TagContainer> <TagContainer>
{selectedFilters.map(filter => ( {selectedFilters.map((filter) => (
<StyledTag key={filter.displayValue} minimal={true} onRemove={deselectFilter}> <StyledTag key={filter.displayValue} minimal onRemove={deselectFilter}>
{filter.displayValue} {filter.displayValue}
</StyledTag> </StyledTag>
))} ))}
</TagContainer> </TagContainer>
<Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}> <Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}>
<Button minimal={true} icon={IconNames.FILTER}> <Button minimal icon={IconNames.FILTER}>
{"Add filter"} Add filter
</Button> </Button>
</Popover> </Popover>
</SearchFilterContainer> </SearchFilterContainer>

View File

@ -17,11 +17,9 @@ const StyledCard = styled(Card)`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`; `;
const SidebarContainer: React.FC = ({ children }) => { const SidebarContainer: React.FC = ({ children }) => (
return ( <RightDiv>
<RightDiv> <StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard> </RightDiv>
</RightDiv> );
);
};
export default SidebarContainer; export default SidebarContainer;

View File

@ -2,9 +2,9 @@ import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
// import appsignalLogo from "../../assets/appsignal.svg"; // import appsignalLogo from "../../assets/appsignal.svg";
import gitlabLogo from "../../assets/gitlab.png"; import * as gitlabLogo from "../../assets/gitlab.png";
import nlnetLogo from "../../assets/nlnet.png"; import * as nlnetLogo from "../../assets/nlnet.png";
import { Page } from "../atoms/"; import { Page } from "../atoms";
const SponsorContainer = styled.div` const SponsorContainer = styled.div`
margin-bottom: 20px; margin-bottom: 20px;
@ -36,10 +36,11 @@ const AboutScreen: React.FC = () => (
<br /> <br />
<H2>FAQ</H2> <H2>FAQ</H2>
<H4>Why can't I see details about my instance?</H4> <H4>Why can&apos;t I see details about my instance?</H4>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo. fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo.
Instances with 10 or fewer users won't be scraped -- it's a tool for understanding communities, not individuals. Instances with 10 or fewer users won&apos;t be scraped -- it&apos;s a tool for understanding communities, not
individuals.
</p> </p>
<H4> <H4>

View File

@ -17,7 +17,7 @@ const ButtonContainer = styled.div`
justify-content: space-between; justify-content: space-between;
`; `;
interface IAdminSettings { interface AdminSettings {
domain: string; domain: string;
optIn: boolean; optIn: boolean;
optOut: boolean; optOut: boolean;
@ -25,26 +25,26 @@ interface IAdminSettings {
statusCount: number; statusCount: number;
} }
interface IAdminScreenProps { interface AdminScreenProps {
navigate: (path: string) => void; navigate: (path: string) => void;
} }
interface IAdminScreenState { interface AdminScreenState {
settings?: IAdminSettings; settings?: AdminSettings;
isUpdating: boolean; isUpdating: boolean;
} }
class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenState> { class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState> {
private authToken = getAuthToken(); private authToken = getAuthToken();
public constructor(props: IAdminScreenProps) { public constructor(props: AdminScreenProps) {
super(props); super(props);
this.state = { isUpdating: false }; this.state = { isUpdating: false };
} }
public componentDidMount() { public componentDidMount() {
// Load instance settings from server // Load instance settings from server
if (!!this.authToken) { if (this.authToken) {
getFromApi(`admin`, this.authToken!) getFromApi(`admin`, this.authToken)
.then(response => { .then((response) => {
this.setState({ settings: response }); this.setState({ settings: response });
}) })
.catch(() => { .catch(() => {
@ -52,7 +52,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
icon: IconNames.ERROR, icon: IconNames.ERROR,
intent: Intent.DANGER, intent: Intent.DANGER,
message: "Failed to load settings.", message: "Failed to load settings.",
timeout: 0 timeout: 0,
}); });
unsetAuthToken(); unsetAuthToken();
}); });
@ -78,7 +78,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
<Switch <Switch
id="opt-in-switch" id="opt-in-switch"
checked={!!settings.optIn} checked={!!settings.optIn}
large={true} large
label="Opt in" label="Opt in"
disabled={!!isUpdating} disabled={!!isUpdating}
onChange={this.updateOptIn} onChange={this.updateOptIn}
@ -89,7 +89,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
<Switch <Switch
id="opt-out-switch" id="opt-out-switch"
checked={!!settings.optOut} checked={!!settings.optOut}
large={true} large
label="Opt out" label="Opt out"
disabled={!!isUpdating} disabled={!!isUpdating}
onChange={this.updateOptOut} onChange={this.updateOptOut}
@ -116,9 +116,9 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
} }
private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => { private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => {
const settings = this.state.settings as IAdminSettings; const settings = this.state.settings as AdminSettings;
const optIn = e.currentTarget.checked; const optIn = e.currentTarget.checked;
let optOut = settings.optOut; let { optOut } = settings;
if (optIn) { if (optIn) {
optOut = false; optOut = false;
} }
@ -126,9 +126,9 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
}; };
private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => { private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => {
const settings = this.state.settings as IAdminSettings; const settings = this.state.settings as AdminSettings;
const optOut = e.currentTarget.checked; const optOut = e.currentTarget.checked;
let optIn = settings.optIn; let { optIn } = settings;
if (optOut) { if (optOut) {
optIn = false; optIn = false;
} }
@ -140,15 +140,15 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
this.setState({ isUpdating: true }); this.setState({ isUpdating: true });
const body = { const body = {
optIn: this.state.settings!.optIn, optIn: this.state.settings!.optIn,
optOut: this.state.settings!.optOut optOut: this.state.settings!.optOut,
}; };
postToApi(`admin`, body, this.authToken!) postToApi(`admin`, body, this.authToken!)
.then(response => { .then((response) => {
this.setState({ settings: response, isUpdating: false }); this.setState({ settings: response, isUpdating: false });
AppToaster.show({ AppToaster.show({
icon: IconNames.TICK, icon: IconNames.TICK,
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
message: "Successfully updated settings." message: "Successfully updated settings.",
}); });
}) })
.catch(() => { .catch(() => {
@ -161,16 +161,13 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
unsetAuthToken(); unsetAuthToken();
AppToaster.show({ AppToaster.show({
icon: IconNames.LOG_OUT, icon: IconNames.LOG_OUT,
message: "Logged out." message: "Logged out.",
}); });
this.props.navigate("/admin/login"); this.props.navigate("/admin/login");
}; };
} }
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
navigate: (path: string) => dispatch(push(path)) navigate: (path: string) => dispatch(push(path)),
}); });
export default connect( export default connect(undefined, mapDispatchToProps)(AdminScreen);
undefined,
mapDispatchToProps
)(AdminScreen);

View File

@ -7,9 +7,9 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
import { InstanceScreen, SearchScreen } from "."; import { InstanceScreen, SearchScreen } from ".";
import { INSTANCE_DOMAIN_PATH } from "../../constants"; import { INSTANCE_DOMAIN_PATH } from "../../constants";
import { loadInstance } from "../../redux/actions"; import { loadInstance } from "../../redux/actions";
import { IAppState } from "../../redux/types"; import { AppState } from "../../redux/types";
import { domainMatchSelector, isSmallScreen } from "../../util"; import { domainMatchSelector, isSmallScreen } from "../../util";
import { Graph, SidebarContainer } from "../organisms/"; import { Graph, SidebarContainer } from "../organisms";
const GraphContainer = styled.div` const GraphContainer = styled.div`
display: flex; display: flex;
@ -24,13 +24,13 @@ const FullDiv = styled.div`
right: 0; right: 0;
`; `;
interface IGraphScreenProps extends RouteComponentProps { interface GraphScreenProps extends RouteComponentProps {
currentInstanceName: string | null; currentInstanceName: string | null;
pathname: string; pathname: string;
graphLoadError: boolean; graphLoadError: boolean;
loadInstance: (domain: string | null) => void; loadInstance: (domain: string | null) => void;
} }
interface IGraphScreenState { interface GraphScreenState {
hasBeenViewed: boolean; hasBeenViewed: boolean;
} }
/** /**
@ -41,8 +41,8 @@ interface IGraphScreenState {
* However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the * However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the
* graph since it slows down everything else! * graph since it slows down everything else!
*/ */
class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenState> { class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState> {
public constructor(props: IGraphScreenProps) { public constructor(props: GraphScreenProps) {
super(props); super(props);
this.state = { hasBeenViewed: false }; this.state = { hasBeenViewed: false };
} }
@ -56,7 +56,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
this.loadCurrentInstance(); this.loadCurrentInstance();
} }
public componentDidUpdate(prevProps: IGraphScreenProps) { public componentDidUpdate(prevProps: GraphScreenProps) {
this.setHasBeenViewed(); this.setHasBeenViewed();
this.loadCurrentInstance(prevProps.currentInstanceName); this.loadCurrentInstance(prevProps.currentInstanceName);
} }
@ -72,7 +72,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
} }
}; };
private renderRoutes = ({ location }: RouteComponentProps) => ( private renderRoutes = () => (
<FullDiv> <FullDiv>
<GraphContainer> <GraphContainer>
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */} {/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
@ -80,7 +80,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
<SidebarContainer> <SidebarContainer>
<Switch> <Switch>
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} /> <Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
<Route exact={true} path="/" component={SearchScreen} /> <Route exact path="/" component={SearchScreen} />
</Switch> </Switch>
</SidebarContainer> </SidebarContainer>
</GraphContainer> </GraphContainer>
@ -94,19 +94,16 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
}; };
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
graphLoadError: state.data.graphLoadError, graphLoadError: state.data.graphLoadError,
pathname: state.router.location.pathname pathname: state.router.location.pathname,
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any) loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any),
}); });
const GraphScreen = connect( const GraphScreen = connect(mapStateToProps, mapDispatchToProps)(GraphScreenImpl);
mapStateToProps,
mapDispatchToProps
)(GraphScreenImpl);
export default withRouter(GraphScreen); export default withRouter(GraphScreen);

View File

@ -20,7 +20,7 @@ import {
Spinner, Spinner,
Tab, Tab,
Tabs, Tabs,
Tooltip Tooltip,
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
@ -28,10 +28,10 @@ import { push } from "connected-react-router";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { IAppState, IGraph, IGraphResponse, IInstanceDetails } from "../../redux/types"; import { AppState, Graph, GraphResponse, InstanceDetails, Peer } from "../../redux/types";
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util"; import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
import { Cytoscape, ErrorState } from "../molecules/"; import { Cytoscape, ErrorState } from "../molecules";
import { FederationTab } from "../organisms"; import { FederationTab } from "../organisms";
const InstanceScreenContainer = styled.div` const InstanceScreenContainer = styled.div`
@ -82,25 +82,25 @@ const StyledGraphContainer = styled.div`
flex-direction: column; flex-direction: column;
margin-bottom: 10px; margin-bottom: 10px;
`; `;
interface IInstanceScreenProps { interface InstanceScreenProps {
graph?: IGraph; graph?: Graph;
instanceName: string | null; instanceName: string | null;
instanceLoadError: boolean; instanceLoadError: boolean;
instanceDetails: IInstanceDetails | null; instanceDetails: InstanceDetails | null;
isLoadingInstanceDetails: boolean; isLoadingInstanceDetails: boolean;
navigateToRoot: () => void; navigateToRoot: () => void;
navigateToInstance: (domain: string) => void; navigateToInstance: (domain: string) => void;
} }
interface IInstanceScreenState { interface InstanceScreenState {
neighbors?: string[]; neighbors?: string[];
isProcessingNeighbors: boolean; isProcessingNeighbors: boolean;
// Local (neighborhood) graph. Used only on small screens (mobile devices). // Local (neighborhood) graph. Used only on small screens (mobile devices).
isLoadingLocalGraph: boolean; isLoadingLocalGraph: boolean;
localGraph?: IGraph; localGraph?: Graph;
localGraphLoadError?: boolean; localGraphLoadError?: boolean;
} }
class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> { class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, InstanceScreenState> {
public constructor(props: IInstanceScreenProps) { public constructor(props: InstanceScreenProps) {
super(props); super(props);
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false }; this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
} }
@ -116,9 +116,9 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
!this.props.instanceDetails.status !this.props.instanceDetails.status
) { ) {
content = <ErrorState />; content = <ErrorState />;
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) { } else if (this.props.instanceDetails.status.toLowerCase().includes("personal instance")) {
content = this.renderPersonalInstanceErrorState(); content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) { } else if (this.props.instanceDetails.status.toLowerCase().includes("robots.txt")) {
content = this.renderRobotsTxtState(); content = this.renderRobotsTxtState();
} else if (this.props.instanceDetails.status !== "success") { } else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState(); content = this.renderMissingDataState();
@ -130,7 +130,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
<HeadingContainer> <HeadingContainer>
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2> <StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}> <StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} /> <AnchorButton icon={IconNames.LINK} minimal onClick={this.openInstanceLink} />
</StyledHeadingTooltip> </StyledHeadingTooltip>
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} /> <StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
</HeadingContainer> </HeadingContainer>
@ -145,7 +145,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
this.processEdgesToFindNeighbors(); this.processEdgesToFindNeighbors();
} }
public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) { public componentDidUpdate(prevProps: InstanceScreenProps, prevState: InstanceScreenState) {
const isNewInstance = prevProps.instanceName !== this.props.instanceName; const isNewInstance = prevProps.instanceName !== this.props.instanceName;
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors; const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors;
const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph; const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph;
@ -164,10 +164,13 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
} }
this.setState({ isProcessingNeighbors: true }); this.setState({ isProcessingNeighbors: true });
const graphToUse = !!graph ? graph : localGraph; const graphToUse = graph || localGraph;
const edges = graphToUse!.edges.filter(e => [e.data.source, e.data.target].indexOf(instanceName!) > -1); if (!graphToUse) {
return;
}
const edges = graphToUse.edges.filter((e) => [e.data.source, e.data.target].includes(instanceName));
const neighbors: any[] = []; const neighbors: any[] = [];
edges.forEach(e => { edges.forEach((e) => {
if (e.data.source === instanceName) { if (e.data.source === instanceName) {
neighbors.push({ neighbor: e.data.target, weight: e.data.weight }); neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
} else { } else {
@ -183,39 +186,38 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
} }
this.setState({ isLoadingLocalGraph: true }); this.setState({ isLoadingLocalGraph: true });
getFromApi(`graph/${this.props.instanceName}`) getFromApi(`graph/${this.props.instanceName}`)
.then((response: IGraphResponse) => { .then((response: GraphResponse) => {
// We do some processing of edges here to make sure that every edge's source and target are in the neighborhood // We do some processing of edges here to make sure that every edge's source and target are in the neighborhood
// We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL // We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL
// queries. // queries.
// TODO: think more about moving the backend to a graph database that would make this easier. // TODO: think more about moving the backend to a graph database that would make this easier.
const graph = response.graph; const { graph } = response;
const nodeIds = new Set(graph.nodes.map(n => n.data.id)); const nodeIds = new Set(graph.nodes.map((n) => n.data.id));
const edges = graph.edges.filter(e => nodeIds.has(e.data.source) && nodeIds.has(e.data.target)); const edges = graph.edges.filter((e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } }); this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } });
}) })
.catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true })); .catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true }));
}; };
private renderTabs = () => { private renderTabs = () => {
const { instanceDetails } = this.props;
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0; const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
const federationRestrictions = this.props.instanceDetails && this.props.instanceDetails.federationRestrictions; const federationRestrictions = instanceDetails && instanceDetails.federationRestrictions;
const hasLocalGraph = const hasLocalGraph =
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0; !!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
const insularCallout = const insularCallout =
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? ( this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? (
<StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance"> <StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance">
<p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p> <p>This instance doesn&apos;t have any neighbors that we know of, so it&apos;s hidden from the graph.</p>
</StyledCallout> </StyledCallout>
) : ( ) : undefined;
undefined
);
return ( return (
<> <>
{insularCallout} {insularCallout}
{this.maybeRenderLocalGraph()} {this.maybeRenderLocalGraph()}
<StyledTabs> <StyledTabs>
{this.props.instanceDetails!.description && ( {instanceDetails && instanceDetails.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} /> <Tab id="description" title="Description" panel={this.renderDescription()} />
)} )}
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />} {this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
@ -232,7 +234,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
<StyledLinkToFdNetwork> <StyledLinkToFdNetwork>
<AnchorButton <AnchorButton
href={`https://fediverse.network/${this.props.instanceName}`} href={`https://fediverse.network/${this.props.instanceName}`}
minimal={true} minimal
rightIcon={IconNames.SHARE} rightIcon={IconNames.SHARE}
target="_blank" target="_blank"
text="See more statistics at fediverse.network" text="See more statistics at fediverse.network"
@ -244,18 +246,17 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
private maybeRenderLocalGraph = () => { private maybeRenderLocalGraph = () => {
const { localGraph } = this.state; const { localGraph } = this.state;
const hasLocalGraph = const hasLocalGraph = !!localGraph && localGraph.nodes.length > 0 && localGraph.edges.length > 0;
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0; if (!hasLocalGraph || !localGraph) {
if (!hasLocalGraph) {
return; return;
} }
return ( return (
<StyledGraphContainer aria-hidden={true}> <StyledGraphContainer aria-hidden>
<Cytoscape <Cytoscape
elements={localGraph!} elements={localGraph}
currentNodeId={this.props.instanceName} currentNodeId={this.props.instanceName}
navigateToInstancePath={this.props.navigateToInstance} navigateToInstancePath={this.props.navigateToInstance}
showEdges={true} showEdges
/> />
<Divider /> <Divider />
</StyledGraphContainer> </StyledGraphContainer>
@ -268,7 +269,11 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
}; };
private renderDescription = () => { private renderDescription = () => {
const description = this.props.instanceDetails!.description; const { instanceDetails } = this.props;
if (!instanceDetails) {
return;
}
const { description } = instanceDetails;
if (!description) { if (!description) {
return; return;
} }
@ -288,10 +293,10 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
insularity, insularity,
type, type,
statusesPerDay, statusesPerDay,
statusesPerUserPerDay statusesPerUserPerDay,
} = this.props.instanceDetails; } = this.props.instanceDetails;
return ( return (
<StyledHTMLTable small={true} striped={true}> <StyledHTMLTable small striped>
<tbody> <tbody>
<tr> <tr>
<td>Version</td> <td>Version</td>
@ -299,7 +304,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td>Instance type</td> <td>Instance type</td>
<td>{(type && <InstanceType type={type} colorAfterName={true} />) || "Unknown"}</td> <td>{(type && <InstanceType type={type} colorAfterName />) || "Unknown"}</td>
</tr> </tr>
<tr> <tr>
<td>Users</td> <td>Users</td>
@ -311,7 +316,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td> <td>
Insularity{" "} Insularity
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -330,7 +336,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td> <td>
Statuses / day{" "} Statuses / day
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -349,7 +356,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td> <td>
Statuses / person / day{" "} Statuses / person / day
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -372,7 +380,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td>Last updated</td> <td>Last updated</td>
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td> <td>{moment(`${lastUpdated}Z`).fromNow() || "Unknown"}</td>
</tr> </tr>
</tbody> </tbody>
</StyledHTMLTable> </StyledHTMLTable>
@ -412,7 +420,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
would mean that every single status on {this.props.instanceName} contained a mention of someone on the other would mean that every single status on {this.props.instanceName} contained a mention of someone on the other
instance, and vice versa. instance, and vice versa.
</p> </p>
<StyledHTMLTable small={true} striped={true} interactive={false}> <StyledHTMLTable small striped interactive={false}>
<thead> <thead>
<tr> <tr>
<th>Instance</th> <th>Instance</th>
@ -426,11 +434,15 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
}; };
private renderPeers = () => { private renderPeers = () => {
const peers = this.props.instanceDetails!.peers; const { instanceDetails } = this.props;
if (!instanceDetails) {
return;
}
const { peers } = instanceDetails;
if (!peers || peers.length === 0) { if (!peers || peers.length === 0) {
return; return;
} }
const peerRows = peers.map(instance => ( const peerRows = peers.map((instance: Peer) => (
<tr key={instance.name}> <tr key={instance.name}>
<td> <td>
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button"> <Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
@ -444,7 +456,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
<p className={Classes.TEXT_MUTED}> <p className={Classes.TEXT_MUTED}>
All the instances, past and present, that {this.props.instanceName} knows about. All the instances, past and present, that {this.props.instanceName} knows about.
</p> </p>
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table"> <StyledHTMLTable small striped interactive={false} className="fediverse-sidebar-table">
<tbody>{peerRows}</tbody> <tbody>{peerRows}</tbody>
</StyledHTMLTable> </StyledHTMLTable>
</div> </div>
@ -453,71 +465,62 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />; private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
private renderPersonalInstanceErrorState = () => { private renderPersonalInstanceErrorState = () => (
return ( <NonIdealState
<NonIdealState icon={IconNames.BLOCKED_PERSON}
icon={IconNames.BLOCKED_PERSON} title="No data"
title="No data" description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in." action={
action={ <Link to="/admin" className={Classes.BUTTON} role="button">
<Link to={"/admin"} className={Classes.BUTTON} role="button"> Opt in
{"Opt in"} </Link>
</Link> }
} />
/> );
);
};
private renderMissingDataState = () => { private renderMissingDataState = () => (
return ( <>
<> <NonIdealState
<NonIdealState icon={IconNames.ERROR}
icon={IconNames.ERROR} title="No data"
title="No data" description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet." />
/> <span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}> {this.props.instanceDetails && this.props.instanceDetails.status}
{this.props.instanceDetails && this.props.instanceDetails.status} </span>
</>
);
private renderRobotsTxtState = () => (
<NonIdealState
icon={
<span role="img" aria-label="robot">
🤖
</span> </span>
</> }
); title="No data"
}; description="This instance was not crawled because its robots.txt did not allow us to."
/>
private renderRobotsTxtState = () => { );
return (
<NonIdealState
icon={
<span role="img" aria-label="robot">
🤖
</span>
}
title="No data"
description="This instance was not crawled because its robots.txt did not allow us to."
/>
);
};
private openInstanceLink = () => { private openInstanceLink = () => {
window.open("https://" + this.props.instanceName, "_blank"); window.open(`https://${this.props.instanceName}`, "_blank");
}; };
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
graph: state.data.graphResponse && state.data.graphResponse.graph, graph: state.data.graphResponse && state.data.graphResponse.graph,
instanceDetails: state.currentInstance.currentInstanceDetails, instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error, instanceLoadError: state.currentInstance.error,
instanceName: match && match.params.domain, instanceName: match && match.params.domain,
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails,
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)), navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
navigateToRoot: () => dispatch(push("/")) navigateToRoot: () => dispatch(push("/")),
}); });
const InstanceScreen = connect( const InstanceScreen = connect(mapStateToProps, mapDispatchToProps)(InstanceScreenImpl);
mapStateToProps,
mapDispatchToProps
)(InstanceScreenImpl);
export default InstanceScreen; export default InstanceScreen;

View File

@ -8,11 +8,11 @@ import { getAuthToken, getFromApi, postToApi } from "../../util";
import { Page } from "../atoms"; import { Page } from "../atoms";
import { ErrorState } from "../molecules"; import { ErrorState } from "../molecules";
interface IFormContainerProps { interface FormContainerProps {
error: boolean; error: boolean;
} }
const FormContainer = styled.div<IFormContainerProps>` const FormContainer = styled.div<FormContainerProps>`
${props => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")} ${(props) => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")}
`; `;
const LoginTypeContainer = styled.div` const LoginTypeContainer = styled.div`
display: flex; display: flex;
@ -31,23 +31,28 @@ const StyledIcon = styled(Icon)`
margin-bottom: 10px; margin-bottom: 10px;
`; `;
interface ILoginTypes { interface LoginTypes {
domain: string; domain: string;
email?: string; email?: string;
fediverseAccount?: string; fediverseAccount?: string;
} }
interface ILoginScreenState { interface LoginScreenState {
domain: string; domain: string;
isGettingLoginTypes: boolean; isGettingLoginTypes: boolean;
isSendingLoginRequest: boolean; isSendingLoginRequest: boolean;
loginTypes?: ILoginTypes; loginTypes?: LoginTypes;
selectedLoginType?: "email" | "fediverseAccount"; selectedLoginType?: "email" | "fediverseAccount";
error: boolean; error: boolean;
} }
class LoginScreen extends React.PureComponent<{}, ILoginScreenState> { class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
public constructor(props: any) { public constructor(props: any) {
super(props); super(props);
this.state = { domain: "", error: false, isGettingLoginTypes: false, isSendingLoginRequest: false }; this.state = {
domain: "",
error: false,
isGettingLoginTypes: false,
isSendingLoginRequest: false,
};
} }
public render() { public render() {
@ -59,13 +64,13 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state; const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state;
let content; let content;
if (!!error) { if (error) {
content = ( content = (
<ErrorState description="This could be because the instance is down. If not, please reload the page and try again." /> <ErrorState description="This could be because the instance is down. If not, please reload the page and try again." />
); );
} else if (!!selectedLoginType && !isSendingLoginRequest) { } else if (!!selectedLoginType && !isSendingLoginRequest) {
content = this.renderPostLogin(); content = this.renderPostLogin();
} else if (!!loginTypes) { } else if (loginTypes) {
content = this.renderChooseLoginType(); content = this.renderChooseLoginType();
} else { } else {
content = this.renderChooseInstance(); content = this.renderChooseInstance();
@ -78,7 +83,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
You must be the instance admin to manage how fediverse.space interacts with your instance. You must be the instance admin to manage how fediverse.space interacts with your instance.
</p> </p>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
It's currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a It&apos;s currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a
direct message, your instance must federate with mastodon.social and vice versa. direct message, your instance must federate with mastodon.social and vice versa.
</p> </p>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
@ -95,7 +100,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
const onButtonClick = () => this.getLoginTypes(); const onButtonClick = () => this.getLoginTypes();
return ( return (
<form onSubmit={this.getLoginTypes}> <form onSubmit={this.getLoginTypes}>
<FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline={true}> <FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline>
<InputGroup <InputGroup
disabled={isGettingLoginTypes} disabled={isGettingLoginTypes}
id="domain-input" id="domain-input"
@ -104,7 +109,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
rightElement={ rightElement={
<Button <Button
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
minimal={true} minimal
rightIcon={IconNames.ARROW_RIGHT} rightIcon={IconNames.ARROW_RIGHT}
title="submit" title="submit"
loading={isGettingLoginTypes} loading={isGettingLoginTypes}
@ -130,18 +135,13 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
<H4>Choose an authentication method</H4> <H4>Choose an authentication method</H4>
<LoginTypeContainer> <LoginTypeContainer>
{loginTypes.email && ( {loginTypes.email && (
<LoginTypeButton <LoginTypeButton large icon={IconNames.ENVELOPE} onClick={loginWithEmail} loading={!!isSendingLoginRequest}>
large={true}
icon={IconNames.ENVELOPE}
onClick={loginWithEmail}
loading={!!isSendingLoginRequest}
>
{`Email ${loginTypes.email}`} {`Email ${loginTypes.email}`}
</LoginTypeButton> </LoginTypeButton>
)} )}
{loginTypes.fediverseAccount && ( {loginTypes.fediverseAccount && (
<LoginTypeButton <LoginTypeButton
large={true} large
icon={IconNames.GLOBE_NETWORK} icon={IconNames.GLOBE_NETWORK}
onClick={loginWithDm} onClick={loginWithDm}
loading={!!isSendingLoginRequest} loading={!!isSendingLoginRequest}
@ -175,7 +175,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
}; };
private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => { private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => {
if (!!e) { if (e) {
e.preventDefault(); e.preventDefault();
} }
this.setState({ isGettingLoginTypes: true }); this.setState({ isGettingLoginTypes: true });
@ -184,8 +184,8 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
domain = domain.slice(8); domain = domain.slice(8);
} }
getFromApi(`admin/login/${domain.trim()}`) getFromApi(`admin/login/${domain.trim()}`)
.then(response => { .then((response) => {
if (!!response.error) { if (response.error) {
// Go to catch() below // Go to catch() below
throw new Error(response.error); throw new Error(response.error);
} else { } else {
@ -196,7 +196,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
AppToaster.show({ AppToaster.show({
icon: IconNames.ERROR, icon: IconNames.ERROR,
intent: Intent.DANGER, intent: Intent.DANGER,
message: err.message message: err.message,
}); });
this.setState({ isGettingLoginTypes: false }); this.setState({ isGettingLoginTypes: false });
}); });
@ -205,7 +205,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
private login = (type: "email" | "fediverseAccount") => { private login = (type: "email" | "fediverseAccount") => {
this.setState({ isSendingLoginRequest: true, selectedLoginType: type }); this.setState({ isSendingLoginRequest: true, selectedLoginType: type });
postToApi("admin/login", { domain: this.state.loginTypes!.domain, type }) postToApi("admin/login", { domain: this.state.loginTypes!.domain, type })
.then(response => { .then((response) => {
if ("error" in response || "errors" in response) { if ("error" in response || "errors" in response) {
// Go to catch() below // Go to catch() below
throw new Error(); throw new Error();

View File

@ -7,20 +7,20 @@ import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { setResultHover, updateSearch } from "../../redux/actions"; import { setResultHover, updateSearch } from "../../redux/actions";
import { IAppState, ISearchResultInstance } from "../../redux/types"; import { AppState, SearchResultInstance } from "../../redux/types";
import { ISearchFilter } from "../../searchFilters"; import { SearchFilter } from "../../searchFilters";
import { isSmallScreen } from "../../util"; import { isSmallScreen } from "../../util";
import { SearchResult } from "../molecules"; import { SearchResult } from "../molecules";
import { SearchFilters } from "../organisms"; import { SearchFilters } from "../organisms";
interface ISearchBarContainerProps { interface SearchBarContainerProps {
hasSearchResults: boolean; hasSearchResults: boolean;
hasError: boolean; hasError: boolean;
} }
const SearchBarContainer = styled.div<ISearchBarContainerProps>` const SearchBarContainer = styled.div<SearchBarContainerProps>`
width: 80%; width: 80%;
text-align: center; text-align: center;
margin: ${props => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")}; margin: ${(props) => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
align-self: center; align-self: center;
`; `;
const SearchResults = styled.div` const SearchResults = styled.div`
@ -38,22 +38,22 @@ const CalloutContainer = styled.div`
text-align: left; text-align: left;
`; `;
interface ISearchScreenProps { interface SearchScreenProps {
error: boolean; error: boolean;
isLoadingResults: boolean; isLoadingResults: boolean;
query: string; query: string;
hasMoreResults: boolean; hasMoreResults: boolean;
results: ISearchResultInstance[]; results: SearchResultInstance[];
handleSearch: (query: string, filters: ISearchFilter[]) => void; handleSearch: (query: string, filters: SearchFilter[]) => void;
navigateToInstance: (domain: string) => void; navigateToInstance: (domain: string) => void;
setIsHoveringOver: (domain?: string) => void; setIsHoveringOver: (domain?: string) => void;
} }
interface ISearchScreenState { interface SearchScreenState {
currentQuery: string; currentQuery: string;
searchFilters: ISearchFilter[]; searchFilters: SearchFilter[];
} }
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> { class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenState> {
public constructor(props: ISearchScreenProps) { public constructor(props: SearchScreenProps) {
super(props); super(props);
this.state = { currentQuery: "", searchFilters: [] }; this.state = { currentQuery: "", searchFilters: [] };
} }
@ -81,7 +81,7 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
} else if (!!results && results.length > 0) { } else if (!!results && results.length > 0) {
content = ( content = (
<SearchResults> <SearchResults>
{results.map(result => ( {results.map((result) => (
<SearchResult <SearchResult
result={result} result={result}
key={result.name} key={result.name}
@ -92,7 +92,7 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
))} ))}
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />} {isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
{!isLoadingResults && hasMoreResults && ( {!isLoadingResults && hasMoreResults && (
<Button onClick={this.search} minimal={true}> <Button onClick={this.search} minimal>
Load more results Load more results
</Button> </Button>
)} )}
@ -104,13 +104,11 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
if (isLoadingResults) { if (isLoadingResults) {
rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />; rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />;
} else if (query || error) { } else if (query || error) {
rightSearchBarElement = ( rightSearchBarElement = <Button minimal icon={IconNames.CROSS} onClick={this.clearQuery} aria-label="Search" />;
<Button minimal={true} icon={IconNames.CROSS} onClick={this.clearQuery} aria-label="Search" />
);
} else { } else {
rightSearchBarElement = ( rightSearchBarElement = (
<Button <Button
minimal={true} minimal
icon={IconNames.ARROW_RIGHT} icon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
onClick={this.search} onClick={this.search}
@ -127,7 +125,7 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
<InputGroup <InputGroup
leftIcon={IconNames.SEARCH} leftIcon={IconNames.SEARCH}
rightElement={rightSearchBarElement} rightElement={rightSearchBarElement}
large={true} large
placeholder="Search instance names and descriptions" placeholder="Search instance names and descriptions"
aria-label="Search instance names and descriptions" aria-label="Search instance names and descriptions"
type="search" type="search"
@ -164,10 +162,10 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
this.setState({ currentQuery: "" }, () => this.props.handleSearch("", [])); this.setState({ currentQuery: "" }, () => this.props.handleSearch("", []));
}; };
private selectSearchFilter = (filter: ISearchFilter) => { private selectSearchFilter = (filter: SearchFilter) => {
const { searchFilters } = this.state; const { searchFilters } = this.state;
// Don't add the same filters twice // Don't add the same filters twice
if (searchFilters.some(sf => isEqual(sf, filter))) { if (searchFilters.some((sf) => isEqual(sf, filter))) {
return; return;
} }
this.setState({ searchFilters: [...searchFilters, filter] }, this.search); this.setState({ searchFilters: [...searchFilters, filter] }, this.search);
@ -176,9 +174,9 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => { private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => {
const { searchFilters } = this.state; const { searchFilters } = this.state;
const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", ""); const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", "");
if (!!displayValueToRemove) { if (displayValueToRemove) {
this.setState( this.setState(
{ searchFilters: searchFilters.filter(sf => sf.displayValue !== displayValueToRemove) }, { searchFilters: searchFilters.filter((sf) => sf.displayValue !== displayValueToRemove) },
this.search this.search
); );
} }
@ -207,19 +205,16 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
); );
} }
const mapStateToProps = (state: IAppState) => ({ const mapStateToProps = (state: AppState) => ({
error: state.search.error, error: state.search.error,
hasMoreResults: !!state.search.next, hasMoreResults: !!state.search.next,
isLoadingResults: state.search.isLoadingResults, isLoadingResults: state.search.isLoadingResults,
query: state.search.query, query: state.search.query,
results: state.search.results results: state.search.results,
}); });
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
handleSearch: (query: string, filters: ISearchFilter[]) => dispatch(updateSearch(query, filters) as any), handleSearch: (query: string, filters: SearchFilter[]) => dispatch(updateSearch(query, filters) as any),
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)), navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain)) setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain)),
}); });
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(SearchScreen);
mapStateToProps,
mapDispatchToProps
)(SearchScreen);

View File

@ -6,8 +6,8 @@ import { InstanceTable } from "../organisms";
class TableScreen extends React.PureComponent { class TableScreen extends React.PureComponent {
public render() { public render() {
return ( return (
<Page fullWidth={true}> <Page fullWidth>
<H1>{"Instances"}</H1> <H1>Instances</H1>
<InstanceTable /> <InstanceTable />
</Page> </Page>
); );

View File

@ -1,20 +1,20 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Redirect } from "react-router"; import { Redirect } from "react-router";
import { IAppState } from "../../redux/types"; import { AppState } from "../../redux/types";
import { setAuthToken } from "../../util"; import { setAuthToken } from "../../util";
import { Page } from "../atoms"; import { Page } from "../atoms";
interface IVerifyLoginScreenProps { interface VerifyLoginScreenProps {
search: string; search: string;
} }
const VerifyLoginScreen: React.FC<IVerifyLoginScreenProps> = ({ search }) => { const VerifyLoginScreen: React.FC<VerifyLoginScreenProps> = ({ search }) => {
const [didSaveToken, setDidSaveToken] = useState(false); const [didSaveToken, setDidSaveToken] = useState(false);
const token = new URLSearchParams(search).get("token"); const token = new URLSearchParams(search).get("token");
useEffect(() => { useEffect(() => {
// Save the auth token // Save the auth token
if (!!token) { if (token) {
setAuthToken(token); setAuthToken(token);
setDidSaveToken(true); setDidSaveToken(true);
} }
@ -22,15 +22,14 @@ const VerifyLoginScreen: React.FC<IVerifyLoginScreenProps> = ({ search }) => {
if (!token) { if (!token) {
return <Redirect to="/admin/login" />; return <Redirect to="/admin/login" />;
} else if (!didSaveToken) { }
if (!didSaveToken) {
return <Page />; return <Page />;
} }
return <Redirect to="/admin" />; return <Redirect to="/admin" />;
}; };
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => ({
return { search: state.router.location.search,
search: state.router.location.search });
};
};
export default connect(mapStateToProps)(VerifyLoginScreen); export default connect(mapStateToProps)(VerifyLoginScreen);

View File

@ -21,7 +21,7 @@ export const QUALITATIVE_COLOR_SCHEME = [
"#0E5A8A", "#0E5A8A",
"#0A6640", "#0A6640",
"#A66321", "#A66321",
"#A82A2A" "#A82A2A",
]; ];
// From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes // From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes
@ -35,11 +35,11 @@ export const QUANTITATIVE_COLOR_SCHEME = [
"#C15B3F", "#C15B3F",
"#B64C2F", "#B64C2F",
"#AA3C1F", "#AA3C1F",
"#9E2B0E" "#9E2B0E",
]; ];
export const INSTANCE_DOMAIN_PATH = "/instance/:domain"; export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface IInstanceDomainPath { export interface InstanceDomainPath {
domain: string; domain: string;
} }
@ -55,5 +55,5 @@ export const INSTANCE_TYPES = [
"friendica", "friendica",
"hubzilla", "hubzilla",
"plume", "plume",
"wordpress" "wordpress",
]; ];

View File

@ -25,8 +25,7 @@ FocusStyleManager.onlyShowFocusOnTabs();
export const history = createBrowserHistory(); export const history = createBrowserHistory();
// Initialize redux // Initialize redux
// @ts-ignore const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore( const store = createStore(
createRootReducer(history), createRootReducer(history),
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk)) composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))

View File

@ -1 +1 @@
/// <reference types="react-scripts" /> // / <reference types="react-scripts" />

View File

@ -2,171 +2,145 @@ import { isEqual } from "lodash";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { ISearchFilter } from "../searchFilters"; import { SearchFilter } from "../searchFilters";
import { getFromApi } from "../util"; import { getFromApi } from "../util";
import { ActionType, IAppState, IGraph, IInstanceDetails, IInstanceSort, ISearchResponse } from "./types"; import { ActionType, AppState, Graph, InstanceDetails, InstanceSort, SearchResponse } from "./types";
// Instance details // Instance details
const requestInstanceDetails = (instanceName: string) => { const requestInstanceDetails = (instanceName: string) => ({
return { payload: instanceName,
payload: instanceName, type: ActionType.REQUEST_INSTANCE_DETAILS,
type: ActionType.REQUEST_INSTANCE_DETAILS });
}; const receiveInstanceDetails = (instanceDetails: InstanceDetails) => ({
}; payload: instanceDetails,
const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => { type: ActionType.RECEIVE_INSTANCE_DETAILS,
return { });
payload: instanceDetails, const instanceLoadFailed = () => ({
type: ActionType.RECEIVE_INSTANCE_DETAILS type: ActionType.INSTANCE_LOAD_ERROR,
}; });
}; const deselectInstance = () => ({
const instanceLoadFailed = () => { type: ActionType.DESELECT_INSTANCE,
return { });
type: ActionType.INSTANCE_LOAD_ERROR
};
};
const deselectInstance = () => {
return {
type: ActionType.DESELECT_INSTANCE
};
};
// Graph // Graph
const requestGraph = () => { const requestGraph = () => ({
return { type: ActionType.REQUEST_GRAPH,
type: ActionType.REQUEST_GRAPH });
}; const receiveGraph = (graph: Graph) => ({
}; payload: graph,
const receiveGraph = (graph: IGraph) => { type: ActionType.RECEIVE_GRAPH,
return { });
payload: graph, const graphLoadFailed = () => ({
type: ActionType.RECEIVE_GRAPH type: ActionType.GRAPH_LOAD_ERROR,
}; });
};
const graphLoadFailed = () => {
return {
type: ActionType.GRAPH_LOAD_ERROR
};
};
// Instance list // Instance list
const requestInstanceList = (sort?: IInstanceSort) => ({ const requestInstanceList = (sort?: InstanceSort) => ({
payload: sort, payload: sort,
type: ActionType.REQUEST_INSTANCES type: ActionType.REQUEST_INSTANCES,
}); });
const receiveInstanceList = (instances: IInstanceDetails[]) => ({ const receiveInstanceList = (instances: InstanceDetails[]) => ({
payload: instances, payload: instances,
type: ActionType.RECEIVE_INSTANCES type: ActionType.RECEIVE_INSTANCES,
}); });
const instanceListLoadFailed = () => ({ const instanceListLoadFailed = () => ({
type: ActionType.INSTANCE_LIST_LOAD_ERROR type: ActionType.INSTANCE_LIST_LOAD_ERROR,
}); });
// Search // Search
const requestSearchResult = (query: string, filters: ISearchFilter[]) => { const requestSearchResult = (query: string, filters: SearchFilter[]) => ({
return { payload: { query, filters },
payload: { query, filters }, type: ActionType.REQUEST_SEARCH_RESULTS,
type: ActionType.REQUEST_SEARCH_RESULTS });
}; const receiveSearchResults = (result: SearchResponse) => ({
}; payload: result,
const receiveSearchResults = (result: ISearchResponse) => { type: ActionType.RECEIVE_SEARCH_RESULTS,
return { });
payload: result, const searchFailed = () => ({
type: ActionType.RECEIVE_SEARCH_RESULTS type: ActionType.SEARCH_RESULTS_ERROR,
}; });
};
const searchFailed = () => {
return {
type: ActionType.SEARCH_RESULTS_ERROR
};
};
const resetSearch = () => { const resetSearch = () => ({
return { type: ActionType.RESET_SEARCH,
type: ActionType.RESET_SEARCH });
};
};
export const setResultHover = (domain?: string) => { export const setResultHover = (domain?: string) => ({
return { payload: domain,
payload: domain, type: ActionType.SET_SEARCH_RESULT_HOVER,
type: ActionType.SET_SEARCH_RESULT_HOVER });
};
};
/** Async actions: https://redux.js.org/advanced/asyncactions */ /** Async actions: https://redux.js.org/advanced/asyncactions */
export const loadInstance = (instanceName: string | null) => { export const loadInstance = (instanceName: string | null) => (dispatch: Dispatch, getState: () => AppState) => {
return (dispatch: Dispatch, getState: () => IAppState) => { if (!instanceName) {
if (!instanceName) { dispatch(deselectInstance());
dispatch(deselectInstance()); if (getState().router.location.pathname.startsWith("/instance/")) {
if (getState().router.location.pathname.startsWith("/instance/")) { dispatch(push("/"));
dispatch(push("/"));
}
return;
} }
dispatch(requestInstanceDetails(instanceName)); return;
return getFromApi("instances/" + instanceName) }
.then(details => dispatch(receiveInstanceDetails(details))) dispatch(requestInstanceDetails(instanceName));
.catch(() => dispatch(instanceLoadFailed())); return getFromApi(`instances/${instanceName}`)
}; .then((details) => dispatch(receiveInstanceDetails(details)))
.catch(() => dispatch(instanceLoadFailed()));
}; };
export const updateSearch = (query: string, filters: ISearchFilter[]) => { export const updateSearch = (query: string, filters: SearchFilter[]) => (
return (dispatch: Dispatch, getState: () => IAppState) => { dispatch: Dispatch,
query = query.trim(); getState: () => AppState
) => {
query = query.trim();
if (!query) { if (!query) {
dispatch(resetSearch()); dispatch(resetSearch());
return; return;
} }
const prevQuery = getState().search.query; const prevQuery = getState().search.query;
const prevFilters = getState().search.filters; const prevFilters = getState().search.filters;
const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters); const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters);
const next = getState().search.next; const { next } = getState().search;
let url = `search/?query=${query}`; let url = `search/?query=${query}`;
if (!isNewQuery && next) { if (!isNewQuery && next) {
url += `&after=${next}`; url += `&after=${next}`;
} }
// Add filters // Add filters
// The format is e.g. type_eq=mastodon or user_count_gt=1000 // The format is e.g. type_eq=mastodon or user_count_gt=1000
filters.forEach(filter => { filters.forEach((filter) => {
url += `&${filter.field}_${filter.relation}=${filter.value}`; url += `&${filter.field}_${filter.relation}=${filter.value}`;
}); });
dispatch(requestSearchResult(query, filters)); dispatch(requestSearchResult(query, filters));
return getFromApi(url) return getFromApi(url)
.then(result => dispatch(receiveSearchResults(result))) .then((result) => dispatch(receiveSearchResults(result)))
.catch(() => dispatch(searchFailed())); .catch(() => dispatch(searchFailed()));
};
}; };
export const fetchGraph = () => { export const fetchGraph = () => (dispatch: Dispatch) => {
return (dispatch: Dispatch) => { dispatch(requestGraph());
dispatch(requestGraph()); return getFromApi("graph")
return getFromApi("graph") .then((graph) => dispatch(receiveGraph(graph)))
.then(graph => dispatch(receiveGraph(graph))) .catch(() => dispatch(graphLoadFailed()));
.catch(() => dispatch(graphLoadFailed()));
};
}; };
export const loadInstanceList = (page?: number, sort?: IInstanceSort) => { export const loadInstanceList = (page?: number, sort?: InstanceSort) => (
return (dispatch: Dispatch, getState: () => IAppState) => { dispatch: Dispatch,
sort = sort ? sort : getState().data.instanceListSort; getState: () => AppState
dispatch(requestInstanceList(sort)); ) => {
const params: string[] = []; sort = sort || getState().data.instanceListSort;
if (!!page) { dispatch(requestInstanceList(sort));
params.push(`page=${page}`); const params: string[] = [];
} if (page) {
if (!!sort) { params.push(`page=${page}`);
params.push(`sortField=${sort.field}`); }
params.push(`sortDirection=${sort.direction}`); if (sort) {
} params.push(`sortField=${sort.field}`);
const path = !!params ? `instances?${params.join("&")}` : "instances"; params.push(`sortDirection=${sort.direction}`);
return getFromApi(path) }
.then(instancesListResponse => dispatch(receiveInstanceList(instancesListResponse))) const path = params ? `instances?${params.join("&")}` : "instances";
.catch(() => dispatch(instanceListLoadFailed())); return getFromApi(path)
}; .then((instancesListResponse) => dispatch(receiveInstanceList(instancesListResponse)))
.catch(() => dispatch(instanceListLoadFailed()));
}; };

View File

@ -3,34 +3,34 @@ import { isEqual } from "lodash";
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import { History } from "history"; import { History } from "history";
import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types"; import { ActionType, Action, CurrentInstanceState, DataState, SearchState } from "./types";
const initialDataState: IDataState = { const initialDataState: DataState = {
graphLoadError: false, graphLoadError: false,
instanceListLoadError: false, instanceListLoadError: false,
instanceListSort: { field: "userCount", direction: "desc" }, instanceListSort: { field: "userCount", direction: "desc" },
isLoadingGraph: false, isLoadingGraph: false,
isLoadingInstanceList: false isLoadingInstanceList: false,
}; };
const data = (state: IDataState = initialDataState, action: IAction): IDataState => { const data = (state: DataState = initialDataState, action: Action): DataState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_GRAPH: case ActionType.REQUEST_GRAPH:
return { return {
...state, ...state,
graphResponse: undefined, graphResponse: undefined,
isLoadingGraph: true isLoadingGraph: true,
}; };
case ActionType.RECEIVE_GRAPH: case ActionType.RECEIVE_GRAPH:
return { return {
...state, ...state,
graphResponse: action.payload, graphResponse: action.payload,
isLoadingGraph: false isLoadingGraph: false,
}; };
case ActionType.GRAPH_LOAD_ERROR: case ActionType.GRAPH_LOAD_ERROR:
return { return {
...state, ...state,
graphLoadError: true, graphLoadError: true,
isLoadingGraph: false isLoadingGraph: false,
}; };
case ActionType.REQUEST_INSTANCES: case ActionType.REQUEST_INSTANCES:
return { return {
@ -38,71 +38,71 @@ const data = (state: IDataState = initialDataState, action: IAction): IDataState
instanceListLoadError: false, instanceListLoadError: false,
instanceListSort: action.payload, instanceListSort: action.payload,
instancesResponse: undefined, instancesResponse: undefined,
isLoadingInstanceList: true isLoadingInstanceList: true,
}; };
case ActionType.RECEIVE_INSTANCES: case ActionType.RECEIVE_INSTANCES:
return { return {
...state, ...state,
instancesResponse: action.payload, instancesResponse: action.payload,
isLoadingInstanceList: false isLoadingInstanceList: false,
}; };
case ActionType.INSTANCE_LIST_LOAD_ERROR: case ActionType.INSTANCE_LIST_LOAD_ERROR:
return { return {
...state, ...state,
instanceListLoadError: true, instanceListLoadError: true,
isLoadingInstanceList: false isLoadingInstanceList: false,
}; };
default: default:
return state; return state;
} }
}; };
const initialCurrentInstanceState: ICurrentInstanceState = { const initialCurrentInstanceState: CurrentInstanceState = {
currentInstanceDetails: null, currentInstanceDetails: null,
error: false, error: false,
isLoadingInstanceDetails: false isLoadingInstanceDetails: false,
}; };
const currentInstance = (state = initialCurrentInstanceState, action: IAction): ICurrentInstanceState => { const currentInstance = (state = initialCurrentInstanceState, action: Action): CurrentInstanceState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_INSTANCE_DETAILS: case ActionType.REQUEST_INSTANCE_DETAILS:
return { return {
...state, ...state,
error: false, error: false,
isLoadingInstanceDetails: true isLoadingInstanceDetails: true,
}; };
case ActionType.RECEIVE_INSTANCE_DETAILS: case ActionType.RECEIVE_INSTANCE_DETAILS:
return { return {
...state, ...state,
currentInstanceDetails: action.payload, currentInstanceDetails: action.payload,
error: false, error: false,
isLoadingInstanceDetails: false isLoadingInstanceDetails: false,
}; };
case ActionType.DESELECT_INSTANCE: case ActionType.DESELECT_INSTANCE:
return { return {
...state, ...state,
currentInstanceDetails: null, currentInstanceDetails: null,
error: false error: false,
}; };
case ActionType.INSTANCE_LOAD_ERROR: case ActionType.INSTANCE_LOAD_ERROR:
return { return {
...state, ...state,
error: true, error: true,
isLoadingInstanceDetails: false isLoadingInstanceDetails: false,
}; };
default: default:
return state; return state;
} }
}; };
const initialSearchState: ISearchState = { const initialSearchState: SearchState = {
error: false, error: false,
filters: [], filters: [],
isLoadingResults: false, isLoadingResults: false,
next: "", next: "",
query: "", query: "",
results: [] results: [],
}; };
const search = (state = initialSearchState, action: IAction): ISearchState => { const search = (state = initialSearchState, action: Action): SearchState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_SEARCH_RESULTS: case ActionType.REQUEST_SEARCH_RESULTS:
const { query, filters } = action.payload; const { query, filters } = action.payload;
@ -114,7 +114,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
isLoadingResults: true, isLoadingResults: true,
next: isNewQuery ? "" : state.next, next: isNewQuery ? "" : state.next,
query, query,
results: isNewQuery ? [] : state.results results: isNewQuery ? [] : state.results,
}; };
case ActionType.RECEIVE_SEARCH_RESULTS: case ActionType.RECEIVE_SEARCH_RESULTS:
return { return {
@ -122,7 +122,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
error: false, error: false,
isLoadingResults: false, isLoadingResults: false,
next: action.payload.next, next: action.payload.next,
results: state.results.concat(action.payload.results) results: state.results.concat(action.payload.results),
}; };
case ActionType.SEARCH_RESULTS_ERROR: case ActionType.SEARCH_RESULTS_ERROR:
return { ...initialSearchState, error: true }; return { ...initialSearchState, error: true };
@ -131,7 +131,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
case ActionType.SET_SEARCH_RESULT_HOVER: case ActionType.SET_SEARCH_RESULT_HOVER:
return { return {
...state, ...state,
hoveringOverResult: action.payload hoveringOverResult: action.payload,
}; };
default: default:
return state; return state;
@ -141,8 +141,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
export default (history: History) => export default (history: History) =>
combineReducers({ combineReducers({
router: connectRouter(history), router: connectRouter(history),
// tslint:disable-next-line:object-literal-sort-keys
currentInstance, currentInstance,
data, data,
search search,
}); });

View File

@ -1,5 +1,5 @@
import { RouterState } from "connected-react-router"; import { RouterState } from "connected-react-router";
import { ISearchFilter } from "../searchFilters"; import { SearchFilter } from "../searchFilters";
export enum ActionType { export enum ActionType {
// Instance details // Instance details
@ -22,33 +22,33 @@ export enum ActionType {
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR", SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
RESET_SEARCH = "RESET_SEARCH", RESET_SEARCH = "RESET_SEARCH",
// Search -- hovering over results // Search -- hovering over results
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER" SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER",
} }
export interface IAction { export interface Action {
type: ActionType; type: ActionType;
payload: any; payload: any;
} }
export type SortField = "domain" | "userCount" | "statusCount" | "insularity"; export type SortField = "domain" | "userCount" | "statusCount" | "insularity";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export interface IInstanceSort { export interface InstanceSort {
field: SortField; field: SortField;
direction: SortDirection; direction: SortDirection;
} }
export interface IPeer { export interface Peer {
name: string; name: string;
} }
export interface ISearchResultInstance { export interface SearchResultInstance {
name: string; name: string;
description?: string; description?: string;
userCount?: number; userCount?: number;
type?: string; type?: string;
} }
export interface IFederationRestrictions { export interface FederationRestrictions {
reportRemoval?: string[]; reportRemoval?: string[];
reject?: string[]; reject?: string[];
mediaRemoval?: string[]; mediaRemoval?: string[];
@ -59,7 +59,7 @@ export interface IFederationRestrictions {
accept?: string[]; accept?: string[];
} }
export interface IInstanceDetails { export interface InstanceDetails {
name: string; name: string;
description?: string; description?: string;
version?: string; version?: string;
@ -67,8 +67,8 @@ export interface IInstanceDetails {
insularity?: number; insularity?: number;
statusCount?: number; statusCount?: number;
domainCount?: number; domainCount?: number;
peers?: IPeer[]; peers?: Peer[];
federationRestrictions: IFederationRestrictions; federationRestrictions: FederationRestrictions;
lastUpdated?: string; lastUpdated?: string;
status: string; status: string;
type?: string; type?: string;
@ -76,7 +76,7 @@ export interface IInstanceDetails {
statusesPerUserPerDay?: number; statusesPerUserPerDay?: number;
} }
interface IGraphNode { interface GraphNode {
data: { data: {
id: string; id: string;
label: string; label: string;
@ -88,7 +88,7 @@ interface IGraphNode {
}; };
} }
interface IGraphEdge { interface GraphEdge {
data: { data: {
source: string; source: string;
target: string; target: string;
@ -97,65 +97,65 @@ interface IGraphEdge {
}; };
} }
interface IGraphMetadata { interface GraphMetadata {
ranges: { [key: string]: [number, number] }; ranges: { [key: string]: [number, number] };
} }
export interface IGraph { export interface Graph {
nodes: IGraphNode[]; nodes: GraphNode[];
edges: IGraphEdge[]; edges: GraphEdge[];
} }
export interface IGraphResponse { export interface GraphResponse {
graph: IGraph; graph: Graph;
metadata: IGraphMetadata; metadata: GraphMetadata;
} }
export interface ISearchResponse { export interface SearchResponse {
results: ISearchResultInstance[]; results: SearchResultInstance[];
next: string | null; next: string | null;
} }
export interface IInstanceListResponse { export interface InstanceListResponse {
pageNumber: number; pageNumber: number;
totalPages: number; totalPages: number;
totalEntries: number; totalEntries: number;
pageSize: number; pageSize: number;
instances: IInstanceDetails[]; instances: InstanceDetails[];
} }
// Redux state // Redux state
// The current instance name is stored in the URL. See state -> router -> location // The current instance name is stored in the URL. See state -> router -> location
export interface ICurrentInstanceState { export interface CurrentInstanceState {
currentInstanceDetails: IInstanceDetails | null; currentInstanceDetails: InstanceDetails | null;
isLoadingInstanceDetails: boolean; isLoadingInstanceDetails: boolean;
error: boolean; error: boolean;
} }
export interface IDataState { export interface DataState {
graphResponse?: IGraphResponse; graphResponse?: GraphResponse;
instancesResponse?: IInstanceListResponse; instancesResponse?: InstanceListResponse;
instanceListSort: IInstanceSort; instanceListSort: InstanceSort;
isLoadingGraph: boolean; isLoadingGraph: boolean;
isLoadingInstanceList: boolean; isLoadingInstanceList: boolean;
graphLoadError: boolean; graphLoadError: boolean;
instanceListLoadError: boolean; instanceListLoadError: boolean;
} }
export interface ISearchState { export interface SearchState {
error: boolean; error: boolean;
isLoadingResults: boolean; isLoadingResults: boolean;
next: string; next: string;
query: string; query: string;
results: ISearchResultInstance[]; results: SearchResultInstance[];
filters: ISearchFilter[]; filters: SearchFilter[];
hoveringOverResult?: string; hoveringOverResult?: string;
} }
export interface IAppState { export interface AppState {
router: RouterState; router: RouterState;
currentInstance: ICurrentInstanceState; currentInstance: CurrentInstanceState;
data: IDataState; data: DataState;
search: ISearchState; search: SearchState;
} }

View File

@ -1,8 +1,8 @@
type ISearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte"; type SearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte";
export interface ISearchFilter { export interface SearchFilter {
// The ES field to filter on // The ES field to filter on
field: string; field: string;
relation: ISearchFilterRelation; relation: SearchFilterRelation;
// The value we want to filter to // The value we want to filter to
value: string; value: string;
// Human-meaningful text that we're showing in the UI // Human-meaningful text that we're showing in the UI
@ -10,17 +10,19 @@ export interface ISearchFilter {
} }
// Maps to translate this to user-friendly text // Maps to translate this to user-friendly text
type SearchFilterField = "type" | "user_count";
const searchFilterFieldTranslations = { const searchFilterFieldTranslations = {
type: "Instance type", type: "Instance type",
user_count: "User count" // eslint-disable-next-line @typescript-eslint/camelcase
user_count: "User count",
}; };
const searchFilterRelationTranslations = { const searchFilterRelationTranslations = {
eq: "=", eq: "=",
gt: ">", gt: ">",
gte: ">=", gte: ">=",
lt: "<", lt: "<",
lte: "<=" lte: "<=",
}; };
export const getSearchFilterDisplayValue = (field: string, relation: ISearchFilterRelation, value: string) => export const getSearchFilterDisplayValue = (field: SearchFilterField, relation: SearchFilterRelation, value: string) =>
`${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`; `${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`;

View File

@ -2,5 +2,5 @@ import { Position, Toaster } from "@blueprintjs/core";
export default Toaster.create({ export default Toaster.create({
className: "app-toaster", className: "app-toaster",
position: Position.TOP position: Position.TOP,
}); });

View File

@ -1,6 +1,6 @@
import { INSTANCE_TYPES } from "./constants"; import { INSTANCE_TYPES } from "./constants";
interface IColorSchemeBase { interface ColorSchemeBase {
// The name of the coloring, e.g. "Instance type" // The name of the coloring, e.g. "Instance type"
name: string; name: string;
// The name of the key in a cytoscape node's `data` field to color by. // The name of the key in a cytoscape node's `data` field to color by.
@ -9,30 +9,30 @@ interface IColorSchemeBase {
description?: string; description?: string;
type: "qualitative" | "quantitative"; type: "qualitative" | "quantitative";
} }
interface IQualitativeColorScheme extends IColorSchemeBase { interface QualitativeColorScheme extends ColorSchemeBase {
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"]. // The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
values: string[]; values: string[];
type: "qualitative"; type: "qualitative";
} }
interface IQuantitativeColorScheme extends IColorSchemeBase { interface QuantitativeColorScheme extends ColorSchemeBase {
type: "quantitative"; type: "quantitative";
exponential: boolean; exponential: boolean;
} }
export type IColorScheme = IQualitativeColorScheme | IQuantitativeColorScheme; export type ColorScheme = QualitativeColorScheme | QuantitativeColorScheme;
export const typeColorScheme: IQualitativeColorScheme = { export const typeColorScheme: QualitativeColorScheme = {
cytoscapeDataKey: "type", cytoscapeDataKey: "type",
name: "Instance type", name: "Instance type",
type: "qualitative", type: "qualitative",
values: INSTANCE_TYPES values: INSTANCE_TYPES,
}; };
export const activityColorScheme: IQuantitativeColorScheme = { export const activityColorScheme: QuantitativeColorScheme = {
cytoscapeDataKey: "statusesPerDay", cytoscapeDataKey: "statusesPerDay",
description: "The average number of statuses posted per day. This is an exponential scale.", description: "The average number of statuses posted per day. This is an exponential scale.",
exponential: true, exponential: true,
name: "Activity", name: "Activity",
type: "quantitative" type: "quantitative",
}; };
export const colorSchemes: IColorScheme[] = [typeColorScheme, activityColorScheme]; export const colorSchemes: ColorScheme[] = [typeColorScheme, activityColorScheme];

1
frontend/src/typings/globals.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "*.png";

View File

@ -1,41 +1,39 @@
import { createMatchSelector } from "connected-react-router"; import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch"; import fetch from "cross-fetch";
import { range } from "lodash"; import { range } from "lodash";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants"; import { DESKTOP_WIDTH_THRESHOLD, InstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { IAppState } from "./redux/types"; import { AppState } from "./redux/types";
let API_ROOT = "http://localhost:4000/api/"; let API_ROOT = "http://localhost:4000/api/";
if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) { if (["true", true, 1, "1"].includes(process.env.REACT_APP_STAGING || "")) {
API_ROOT = "https://phoenix.api-develop.fediverse.space/api/"; API_ROOT = "https://phoenix.api-develop.fediverse.space/api/";
} else if (process.env.NODE_ENV === "production") { } else if (process.env.NODE_ENV === "production") {
API_ROOT = "https://phoenix.api.fediverse.space/api/"; API_ROOT = "https://phoenix.api.fediverse.space/api/";
} }
export const getFromApi = (path: string, token?: string): Promise<any> => { export const getFromApi = (path: string, token?: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/"; const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`;
const headers = token ? { token } : undefined; const headers = token ? { token } : undefined;
return fetch(encodeURI(domain + path), { return fetch(encodeURI(domain + path), {
headers headers,
}).then(response => response.json()); }).then((response) => response.json());
}; };
export const postToApi = (path: string, body: any, token?: string): Promise<any> => { export const postToApi = (path: string, body: any, token?: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/"; const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`;
const defaultHeaders = { const defaultHeaders = {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json" "Content-Type": "application/json",
}; };
const headers = token ? { ...defaultHeaders, token } : defaultHeaders; const headers = token ? { ...defaultHeaders, token } : defaultHeaders;
return fetch(encodeURI(domain + path), { return fetch(encodeURI(domain + path), {
body: JSON.stringify(body), body: JSON.stringify(body),
headers, headers,
method: "POST" method: "POST",
}).then(response => { }).then((response) => response.json());
return response.json();
});
}; };
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH); export const domainMatchSelector = createMatchSelector<AppState, InstanceDomainPath>(INSTANCE_DOMAIN_PATH);
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD; export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
@ -49,28 +47,25 @@ export const unsetAuthToken = () => {
sessionStorage.removeItem("adminToken"); sessionStorage.removeItem("adminToken");
}; };
export const getAuthToken = () => { export const getAuthToken = () => sessionStorage.getItem("adminToken");
return sessionStorage.getItem("adminToken");
};
export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => { export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => {
if (exponential) { if (exponential) {
const logSpace = range(steps).map(i => Math.E ** i); const logSpace = range(steps).map((i) => Math.E ** i);
// Scale the log space to the linear range // Scale the log space to the linear range
const logRange = logSpace[logSpace.length - 1] - logSpace[0]; const logRange = logSpace[logSpace.length - 1] - logSpace[0];
const linearRange = max - min; const linearRange = max - min;
const scalingFactor = linearRange / logRange; const scalingFactor = linearRange / logRange;
const translation = min - logSpace[0]; const translation = min - logSpace[0];
return logSpace.map(i => (i + translation) * scalingFactor); return logSpace.map((i) => (i + translation) * scalingFactor);
} else {
// Linear
const bucketSize = (max - min) / steps;
return range(min, max, bucketSize);
} }
// Linear
const bucketSize = (max - min) / steps;
return range(min, max, bucketSize);
}; };
const typeToDisplay = { const typeToDisplay: { [field: string]: string } = {
gnusocial: "GNU Social" gnusocial: "GNU Social",
}; };
export const getTypeDisplayString = (key: string) => { export const getTypeDisplayString = (key: string) => {
if (key in typeToDisplay) { if (key in typeToDisplay) {

View File

@ -1,40 +1,32 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"jsx": "preserve",
"lib": [
"es2015",
"dom"
],
"module": "esnext",
"moduleResolution": "node",
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"outDir": "build",
"rootDir": "src",
"sourceMap": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"target": "es5", "target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"jsx": "react",
"typeRoots": [ "typeRoots": [
"./node_modules/@types", "./node_modules/@types",
"./src/typings" "./src/typings"
], ],
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}, },
"exclude": [
"node_modules",
"build"
],
"include": [ "include": [
"src" "src"
] ]

View File

@ -1,11 +0,0 @@
{
"extends": [
"tslint:recommended",
"tslint-eslint-rules",
"tslint-react",
"@blueprintjs/tslint-config/blueprint-rules",
"tslint-config-prettier",
"tslint-config-security"
],
"exclude": ["**/*.css"]
}

File diff suppressed because it is too large Load Diff