add sorting to tabular view

This commit is contained in:
Tao Bror Bojlén 2019-08-29 21:10:37 +01:00
parent 19b3a3806d
commit ee48bc8d10
No known key found for this signature in database
GPG key ID: C6EC7AAB905F9E6F
11 changed files with 217 additions and 47 deletions

View file

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added option to hide edges between instances if there are only mentions in one direction (off by default). - Added option to hide edges between instances if there are only mentions in one direction (off by default).
- Added note to neighbors tab to make it explicit that blocked instances may appear. - Added note to neighbors tab to make it explicit that blocked instances may appear.
- Added federation tab that shows federation restrictions (only available for some Pleroma instances). - Added federation tab that shows federation restrictions (only available for some Pleroma instances).
- Add tabular view of instances.
### Changed ### Changed

View file

@ -6,13 +6,39 @@ defmodule Backend.Api do
import Backend.Util import Backend.Util
import Ecto.Query import Ecto.Query
@spec get_instances(Integer.t() | nil) :: Scrivener.Page.t() @type instance_sort_field :: :name | :user_count | :status_count | :insularity
def get_instances(page \\ nil) do @type sort_direction :: :asc | :desc
@spec get_instances(Integer.t() | nil, instance_sort_field | nil, sort_direction | nil) ::
Scrivener.Page.t()
def get_instances(page \\ nil, sort_field \\ nil, sort_direction \\ nil) do
Instance Instance
|> where([i], not is_nil(i.type) and not i.opt_out) |> where([i], not is_nil(i.type) and not i.opt_out)
|> maybe_order_by(sort_field, sort_direction)
|> Repo.paginate(page: page) |> Repo.paginate(page: page)
end end
defp maybe_order_by(query, sort_field, sort_direction) do
cond do
sort_field == nil and sort_direction != nil ->
query
sort_field != nil and sort_direction == nil ->
query
|> order_by(desc: ^sort_field)
sort_direction == :asc ->
query
|> order_by(asc_nulls_last: ^sort_field)
sort_direction == :desc ->
query
|> order_by(desc_nulls_last: ^sort_field)
true ->
query
end
end
@spec get_instance(String.t()) :: Instance.t() | nil @spec get_instance(String.t()) :: Instance.t() | nil
def get_instance(domain) do def get_instance(domain) do
Instance Instance
@ -41,7 +67,8 @@ defmodule Backend.Api do
* the user count is > the threshold * the user count is > the threshold
* have x and y coordinates * have x and y coordinates
If `domain` is passed, then this function only returns nodes that are neighbors of that instance. If `domain` is passed, then this function only returns nodes that are neighbors of that
instance.
""" """
@spec list_nodes() :: [Instance.t()] @spec list_nodes() :: [Instance.t()]
def list_nodes(domain \\ nil) do def list_nodes(domain \\ nil) do

View file

@ -6,24 +6,54 @@ defmodule BackendWeb.InstanceController do
action_fallback(BackendWeb.FallbackController) action_fallback(BackendWeb.FallbackController)
# sobelow_skip ["DOS.StringToAtom"]
def index(conn, params) do def index(conn, params) do
page = Map.get(params, "page") page = Map.get(params, "page")
sort_field = Map.get(params, "sortField")
sort_direction = Map.get(params, "sortDirection")
%{ cond do
entries: instances, not Enum.member?([nil, "domain", "userCount", "statusCount", "insularity"], sort_field) ->
total_pages: total_pages, render(conn, "error.json", error: "Invalid sort field")
page_number: page_number,
total_entries: total_entries,
page_size: page_size
} = Api.get_instances(page)
render(conn, "index.json", not Enum.member?([nil, "asc", "desc"], sort_direction) ->
instances: instances, render(conn, "error.json", error: "Invalid sort direction")
total_pages: total_pages,
page_number: page_number, true ->
total_entries: total_entries, sort_field =
page_size: page_size if sort_field != nil do
) sort_field
|> Recase.to_snake()
|> String.to_atom()
else
nil
end
sort_direction =
if sort_direction != nil do
sort_direction
|> Recase.to_snake()
|> String.to_atom()
else
nil
end
%{
entries: instances,
total_pages: total_pages,
page_number: page_number,
total_entries: total_entries,
page_size: page_size
} = Api.get_instances(page, sort_field, sort_direction)
render(conn, "index.json",
instances: instances,
total_pages: total_pages,
page_number: page_number,
total_entries: total_entries,
page_size: page_size
)
end
end end
def show(conn, %{"id" => domain}) do def show(conn, %{"id" => domain}) do

View file

@ -20,8 +20,8 @@ defmodule BackendWeb.InstanceView do
end end
@doc """ @doc """
Used when rendering the index of all instances (the different from show.json is primarily that it does not Used when rendering the index of all instances (the difference from show.json is primarily that
include peers). it does not include peers).
""" """
def render("index_instance.json", %{instance: instance}) do def render("index_instance.json", %{instance: instance}) do
%{ %{
@ -63,6 +63,10 @@ defmodule BackendWeb.InstanceView do
%{name: instance.domain} %{name: instance.domain}
end end
def render("error.json", %{error: error}) do
%{error: error}
end
defp render_personal_instance(instance) do defp render_personal_instance(instance) do
%{ %{
name: instance.domain, name: instance.domain,

View file

@ -50,7 +50,10 @@ defmodule BackendWeb.InstanceControllerTest do
describe "update instance" do describe "update instance" do
setup [:create_instance] setup [:create_instance]
test "renders instance when data is valid", %{conn: conn, instance: %Instance{id: id} = instance} do test "renders instance when data is valid", %{
conn: conn,
instance: %Instance{id: id} = instance
} do
conn = put(conn, Routes.instance_path(conn, :update, instance), instance: @update_attrs) conn = put(conn, Routes.instance_path(conn, :update, instance), instance: @update_attrs)
assert %{"id" => ^id} = json_response(conn, 200)["data"] assert %{"id" => ^id} = json_response(conn, 200)["data"]

View file

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

View file

@ -1,4 +1,5 @@
import { Button, ButtonGroup, Code, HTMLTable, Intent, NonIdealState, Spinner } from "@blueprintjs/core"; import { Button, ButtonGroup, Code, HTMLTable, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { range, sortBy, sortedUniq, zip } from "lodash"; import { range, sortBy, sortedUniq, zip } from "lodash";
import * as numeral from "numeral"; import * as numeral from "numeral";
@ -7,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 } from "../../redux/types"; import { IAppState, IInstanceListResponse, IInstanceSort, SortField } from "../../redux/types";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
import { ErrorState } from "../molecules"; import { ErrorState } from "../molecules";
@ -21,19 +22,38 @@ const PaginationContainer = styled.div`
flex: 1; flex: 1;
align-items: center; align-items: center;
`; `;
const InstanceColumn = styled.th`
width: 15%;
`;
const ServerColumn = styled.th`
width: 20%;
`;
const VersionColumn = styled.th`
width: 20%;
`;
const UserCountColumn = styled.th`
width: 15%;
`;
const StatusCountColumn = styled.th`
width: 15%;
`;
const InsularityColumn = styled.th`
width: 15%;
`;
interface IInstanceTableProps { interface IInstanceTableProps {
loadError: boolean; loadError: boolean;
instancesResponse?: IInstanceListResponse; instancesResponse?: IInstanceListResponse;
instanceListSort: IInstanceSort;
isLoading: boolean; isLoading: boolean;
fetchInstances: (page?: number) => void; loadInstanceList: (page?: number, sort?: IInstanceSort) => void;
navigate: (path: string) => void; navigate: (path: string) => void;
} }
class InstanceTable extends React.PureComponent<IInstanceTableProps> { class InstanceTable extends React.PureComponent<IInstanceTableProps> {
public componentDidMount() { public componentDidMount() {
const { isLoading, instancesResponse, loadError } = this.props; const { isLoading, instancesResponse, loadError } = this.props;
if (!isLoading && !instancesResponse && !loadError) { if (!isLoading && !instancesResponse && !loadError) {
this.props.fetchInstances(); this.props.loadInstanceList();
} }
} }
@ -53,12 +73,44 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<StyledTable striped={true} bordered={true} interactive={true}> <StyledTable striped={true} bordered={true} interactive={true}>
<thead> <thead>
<tr> <tr>
<th>Instance</th> <InstanceColumn>
<th>Server type</th> Instance
<th>Version</th> <Button
<th>Users</th> minimal={true}
<th>Statuses</th> icon={this.getSortIcon("domain")}
<th>Insularity</th> onClick={this.sortByFactory("domain")}
intent={this.getSortIntent("domain")}
/>
</InstanceColumn>
<ServerColumn>Server type</ServerColumn>
<VersionColumn>Version</VersionColumn>
<UserCountColumn>
Users
<Button
minimal={true}
icon={this.getSortIcon("userCount")}
onClick={this.sortByFactory("userCount")}
intent={this.getSortIntent("userCount")}
/>
</UserCountColumn>
<StatusCountColumn>
Statuses
<Button
minimal={true}
icon={this.getSortIcon("statusCount")}
onClick={this.sortByFactory("statusCount")}
intent={this.getSortIntent("statusCount")}
/>
</StatusCountColumn>
<InsularityColumn>
Insularity
<Button
minimal={true}
icon={this.getSortIcon("insularity")}
onClick={this.sortByFactory("insularity")}
intent={this.getSortIntent("insularity")}
/>
</InsularityColumn>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -67,8 +119,8 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<td>{i.name}</td> <td>{i.name}</td>
<td>{i.type && <InstanceType type={i.type} />}</td> <td>{i.type && <InstanceType type={i.type} />}</td>
<td>{i.version && <Code>{i.version}</Code>}</td> <td>{i.version && <Code>{i.version}</Code>}</td>
<td>{i.userCount}</td> <td>{i.userCount && numeral.default(i.userCount).format("0,0")}</td>
<td>{i.statusCount}</td> <td>{i.statusCount && numeral.default(i.statusCount).format("0,0")}</td>
<td>{i.insularity && numeral.default(i.insularity).format("0.0%")}</td> <td>{i.insularity && numeral.default(i.insularity).format("0.0%")}</td>
</tr> </tr>
))} ))}
@ -84,7 +136,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<ButtonGroup> <ButtonGroup>
{zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage], idx) => { {zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage], idx) => {
if (page === undefined) { if (page === undefined) {
return; return null;
} }
const isCurrentPage = currentPage === page; const isCurrentPage = currentPage === page;
const isEndOfSection = nextPage !== undefined && page + 1 !== nextPage && page !== totalPages; const isEndOfSection = nextPage !== undefined && page + 1 !== nextPage && page !== totalPages;
@ -113,14 +165,44 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
); );
} }
private sortByFactory = (field: SortField) => () => {
const { instancesResponse, instanceListSort } = this.props;
const page = (instancesResponse && instancesResponse.pageNumber) || 1;
const nextSortDirection =
instanceListSort.field === field && instanceListSort.direction === "desc" ? "asc" : "desc";
this.props.loadInstanceList(page, { field, direction: nextSortDirection });
};
private loadPageFactory = (page: number) => () => { private loadPageFactory = (page: number) => () => {
this.props.fetchInstances(page); this.props.loadInstanceList(page);
}; };
private goToInstanceFactory = (domain: string) => () => { private goToInstanceFactory = (domain: string) => () => {
this.props.navigate(`/instance/${domain}`); this.props.navigate(`/instance/${domain}`);
}; };
private getSortIcon = (field: SortField) => {
const { instanceListSort } = this.props;
if (instanceListSort.field !== field) {
return IconNames.SORT;
} else if (instanceListSort.direction === "asc") {
return IconNames.SORT_ASC;
} else {
return IconNames.SORT_DESC;
}
};
private getSortIntent = (field: SortField) => {
const { instanceListSort } = this.props;
if (instanceListSort.field === field) {
return Intent.PRIMARY;
} else {
return Intent.NONE;
}
};
private getPagesToDisplay = (totalPages: number, currentPage: number) => { private getPagesToDisplay = (totalPages: number, currentPage: number) => {
if (totalPages < 10) { if (totalPages < 10) {
return range(1, totalPages + 1); return range(1, totalPages + 1);
@ -138,13 +220,14 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: IAppState) => {
return { return {
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) => ({
fetchInstances: (page?: number) => dispatch(loadInstanceList(page) as any), loadInstanceList: (page?: number, sort?: IInstanceSort) => dispatch(loadInstanceList(page, sort) as any),
navigate: (path: string) => dispatch(push(path)) navigate: (path: string) => dispatch(push(path))
}); });

View file

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

View file

@ -4,7 +4,7 @@ import { Dispatch } from "redux";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { ISearchFilter } from "../searchFilters"; import { ISearchFilter } from "../searchFilters";
import { getFromApi } from "../util"; import { getFromApi } from "../util";
import { ActionType, IAppState, IGraph, IInstanceDetails, ISearchResponse } from "./types"; import { ActionType, IAppState, IGraph, IInstanceDetails, IInstanceSort, ISearchResponse } from "./types";
// Instance details // Instance details
const requestInstanceDetails = (instanceName: string) => { const requestInstanceDetails = (instanceName: string) => {
@ -49,7 +49,8 @@ const graphLoadFailed = () => {
}; };
// Instance list // Instance list
const requestInstanceList = () => ({ const requestInstanceList = (sort?: IInstanceSort) => ({
payload: sort,
type: ActionType.REQUEST_INSTANCES type: ActionType.REQUEST_INSTANCES
}); });
const receiveInstanceList = (instances: IInstanceDetails[]) => ({ const receiveInstanceList = (instances: IInstanceDetails[]) => ({
@ -151,14 +152,19 @@ export const fetchGraph = () => {
}; };
}; };
export const loadInstanceList = (page?: number) => { export const loadInstanceList = (page?: number, sort?: IInstanceSort) => {
return (dispatch: Dispatch) => { return (dispatch: Dispatch, getState: () => IAppState) => {
dispatch(requestInstanceList()); sort = sort ? sort : getState().data.instanceListSort;
let params = ""; dispatch(requestInstanceList(sort));
const params: string[] = [];
if (!!page) { if (!!page) {
params += `page=${page}`; params.push(`page=${page}`);
} }
const path = !!params ? `instances?${params}` : "instances"; if (!!sort) {
params.push(`sortField=${sort.field}`);
params.push(`sortDirection=${sort.direction}`);
}
const path = !!params ? `instances?${params.join("&")}` : "instances";
return getFromApi(path) return getFromApi(path)
.then(instancesListResponse => dispatch(receiveInstanceList(instancesListResponse))) .then(instancesListResponse => dispatch(receiveInstanceList(instancesListResponse)))
.catch(() => dispatch(instanceListLoadFailed())); .catch(() => dispatch(instanceListLoadFailed()));

View file

@ -8,6 +8,7 @@ import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState }
const initialDataState: IDataState = { const initialDataState: IDataState = {
graphLoadError: false, graphLoadError: false,
instanceListLoadError: false, instanceListLoadError: false,
instanceListSort: { field: "userCount", direction: "desc" },
isLoadingGraph: false, isLoadingGraph: false,
isLoadingInstanceList: false isLoadingInstanceList: false
}; };
@ -35,6 +36,7 @@ const data = (state: IDataState = initialDataState, action: IAction): IDataState
return { return {
...state, ...state,
instanceListLoadError: false, instanceListLoadError: false,
instanceListSort: action.payload,
instancesResponse: undefined, instancesResponse: undefined,
isLoadingInstanceList: true isLoadingInstanceList: true
}; };

View file

@ -30,6 +30,13 @@ export interface IAction {
payload: any; payload: any;
} }
export type SortField = "domain" | "userCount" | "statusCount" | "insularity";
export type SortDirection = "asc" | "desc";
export interface IInstanceSort {
field: SortField;
direction: SortDirection;
}
export interface IPeer { export interface IPeer {
name: string; name: string;
} }
@ -129,6 +136,7 @@ export interface ICurrentInstanceState {
export interface IDataState { export interface IDataState {
graphResponse?: IGraphResponse; graphResponse?: IGraphResponse;
instancesResponse?: IInstanceListResponse; instancesResponse?: IInstanceListResponse;
instanceListSort: IInstanceSort;
isLoadingGraph: boolean; isLoadingGraph: boolean;
isLoadingInstanceList: boolean; isLoadingInstanceList: boolean;
graphLoadError: boolean; graphLoadError: boolean;