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 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).
- Add tabular view of instances.
### Changed

View file

@ -6,13 +6,39 @@ defmodule Backend.Api do
import Backend.Util
import Ecto.Query
@spec get_instances(Integer.t() | nil) :: Scrivener.Page.t()
def get_instances(page \\ nil) do
@type instance_sort_field :: :name | :user_count | :status_count | :insularity
@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
|> where([i], not is_nil(i.type) and not i.opt_out)
|> maybe_order_by(sort_field, sort_direction)
|> Repo.paginate(page: page)
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
def get_instance(domain) do
Instance
@ -41,7 +67,8 @@ defmodule Backend.Api do
* the user count is > the threshold
* 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()]
def list_nodes(domain \\ nil) do

View file

@ -6,8 +6,37 @@ defmodule BackendWeb.InstanceController do
action_fallback(BackendWeb.FallbackController)
# sobelow_skip ["DOS.StringToAtom"]
def index(conn, params) do
page = Map.get(params, "page")
sort_field = Map.get(params, "sortField")
sort_direction = Map.get(params, "sortDirection")
cond do
not Enum.member?([nil, "domain", "userCount", "statusCount", "insularity"], sort_field) ->
render(conn, "error.json", error: "Invalid sort field")
not Enum.member?([nil, "asc", "desc"], sort_direction) ->
render(conn, "error.json", error: "Invalid sort direction")
true ->
sort_field =
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,
@ -15,7 +44,7 @@ defmodule BackendWeb.InstanceController do
page_number: page_number,
total_entries: total_entries,
page_size: page_size
} = Api.get_instances(page)
} = Api.get_instances(page, sort_field, sort_direction)
render(conn, "index.json",
instances: instances,
@ -25,6 +54,7 @@ defmodule BackendWeb.InstanceController do
page_size: page_size
)
end
end
def show(conn, %{"id" => domain}) do
instance = Cache.get_instance_with_relationships(domain)

View file

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

View file

@ -50,7 +50,10 @@ defmodule BackendWeb.InstanceControllerTest do
describe "update instance" do
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)
assert %{"id" => ^id} = json_response(conn, 200)["data"]

View file

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

View file

@ -1,4 +1,5 @@
import { Button, ButtonGroup, Code, HTMLTable, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router";
import { range, sortBy, sortedUniq, zip } from "lodash";
import * as numeral from "numeral";
@ -7,7 +8,7 @@ import { connect } from "react-redux";
import { Dispatch } from "redux";
import styled from "styled-components";
import { loadInstanceList } from "../../redux/actions";
import { IAppState, IInstanceListResponse } from "../../redux/types";
import { IAppState, IInstanceListResponse, IInstanceSort, SortField } from "../../redux/types";
import { InstanceType } from "../atoms";
import { ErrorState } from "../molecules";
@ -21,19 +22,38 @@ const PaginationContainer = styled.div`
flex: 1;
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 {
loadError: boolean;
instancesResponse?: IInstanceListResponse;
instanceListSort: IInstanceSort;
isLoading: boolean;
fetchInstances: (page?: number) => void;
loadInstanceList: (page?: number, sort?: IInstanceSort) => void;
navigate: (path: string) => void;
}
class InstanceTable extends React.PureComponent<IInstanceTableProps> {
public componentDidMount() {
const { isLoading, instancesResponse, loadError } = this.props;
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}>
<thead>
<tr>
<th>Instance</th>
<th>Server type</th>
<th>Version</th>
<th>Users</th>
<th>Statuses</th>
<th>Insularity</th>
<InstanceColumn>
Instance
<Button
minimal={true}
icon={this.getSortIcon("domain")}
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>
</thead>
<tbody>
@ -67,8 +119,8 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<td>{i.name}</td>
<td>{i.type && <InstanceType type={i.type} />}</td>
<td>{i.version && <Code>{i.version}</Code>}</td>
<td>{i.userCount}</td>
<td>{i.statusCount}</td>
<td>{i.userCount && numeral.default(i.userCount).format("0,0")}</td>
<td>{i.statusCount && numeral.default(i.statusCount).format("0,0")}</td>
<td>{i.insularity && numeral.default(i.insularity).format("0.0%")}</td>
</tr>
))}
@ -84,7 +136,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<ButtonGroup>
{zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage], idx) => {
if (page === undefined) {
return;
return null;
}
const isCurrentPage = currentPage === page;
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) => () => {
this.props.fetchInstances(page);
this.props.loadInstanceList(page);
};
private goToInstanceFactory = (domain: string) => () => {
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) => {
if (totalPages < 10) {
return range(1, totalPages + 1);
@ -138,13 +220,14 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const mapStateToProps = (state: IAppState) => {
return {
instanceListSort: state.data.instanceListSort,
instancesResponse: state.data.instancesResponse,
isLoading: state.data.isLoadingInstanceList,
loadError: state.data.instanceListLoadError
};
};
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))
});

View file

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

View file

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

View file

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

View file

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