ui(admin): add search and sort to ServersContainer

This commit is contained in:
Matthew Penner 2021-05-18 20:53:42 -06:00
parent ae88a01bea
commit bca2338863
16 changed files with 1097 additions and 1381 deletions

View file

@ -48,7 +48,7 @@ class ServerController extends ApplicationApiController
$servers = QueryBuilder::for(Server::query())
->allowedFilters(['uuid', 'name', 'image', 'external_id'])
->allowedSorts(['id', 'uuid'])
->allowedSorts(['id', 'uuid', 'owner_id', 'node_id', 'status'])
->paginate($perPage);
return $this->fractal->collection($servers)
@ -72,7 +72,7 @@ class ServerController extends ApplicationApiController
return $this->fractal->item($server)
->transformWith($this->getTransformer(ServerTransformer::class))
->respond(201);
->respond(Response::HTTP_CREATED);
}
/**

View file

@ -125,7 +125,9 @@ class ServerTransferController extends Controller
$server = $this->connection->transaction(function () use ($server, $transfer) {
$allocations = [$transfer->old_allocation];
if (!empty($transfer->old_additional_allocations)) {
array_push($allocations, $transfer->old_additional_allocations);
foreach ($transfer->old_additional_allocations as $allocation) {
$allocations[] = $allocation;
}
}
// Remove the old allocations for the server and re-assign the server to the new
@ -169,7 +171,9 @@ class ServerTransferController extends Controller
$allocations = [$transfer->new_allocation];
if (!empty($transfer->new_additional_allocations)) {
array_push($allocations, $transfer->new_additional_allocations);
foreach ($transfer->new_additional_allocations as $allocation) {
$allocations[] = $allocation;
}
}
Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]);

View file

@ -42,18 +42,61 @@ export const rawDataToServer = ({ attributes }: FractalResponseData): Server =>
},
});
export interface Filters {
uuid?: string;
name?: string;
image?: string;
/* eslint-disable camelcase */
external_id?: string;
/* eslint-enable camelcase */
}
interface ctx {
page: number;
setPage: (value: number | ((s: number) => number)) => void;
filters: Filters | null;
setFilters: (filters: Filters | null) => void;
sort: string | null;
setSort: (sort: string | null) => void;
sortDirection: boolean;
setSortDirection: (direction: boolean) => void;
}
export const Context = createContext<ctx>({ page: 1, setPage: () => 1 });
export const Context = createContext<ctx>({
page: 1,
setPage: () => 1,
filters: null,
setFilters: () => null,
sort: null,
setSort: () => null,
sortDirection: false,
setSortDirection: () => false,
});
export default (include: string[] = []) => {
const { page } = useContext(Context);
const { page, filters, sort, sortDirection } = useContext(Context);
return useSWR<PaginatedResult<Server>>([ 'servers', page ], async () => {
const { data } = await http.get('/api/application/servers', { params: { include: include.join(','), page } });
const params = {};
if (filters !== null) {
Object.keys(filters).forEach(key => {
// @ts-ignore
params['filter[' + key + ']'] = filters[key];
});
}
if (sort !== null) {
// @ts-ignore
params.sort = (sortDirection ? '-' : '') + sort;
}
return useSWR<PaginatedResult<Server>>([ 'servers', page, filters, sort, sortDirection ], async () => {
const { data } = await http.get('/api/application/servers', { params: { include: include.join(','), page, ...params } });
return ({
items: (data.data || []).map(rawDataToServer),

View file

@ -94,7 +94,7 @@ export interface PaginatedResult<T> {
pagination: PaginationDataSet;
}
interface PaginationDataSet {
export interface PaginationDataSet {
total: number;
count: number;
perPage: number;

View file

@ -1,4 +1,4 @@
import React, { useEffect, Suspense } from 'react';
import React, { lazy, useEffect, Suspense } from 'react';
import ReactGA from 'react-ga';
import { hot } from 'react-hot-loader/root';
import { Route, Router, Switch, useLocation } from 'react-router-dom';
@ -15,9 +15,8 @@ import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
import { history } from '@/components/history';
import { setupInterceptors } from '@/api/interceptors';
import TailwindGlobalStyles from '@/components/GlobalStyles';
import AdminRouter from '@/routers/AdminRouter';
// const ChunkedAdminRouter = lazy(() => import(/* webpackChunkName: "admin" */'@/routers/AdminRouter'));
const ChunkedAdminRouter = lazy(() => import(/* webpackChunkName: "admin" */'@/routers/AdminRouter'));
interface ExtendedWindow extends Window {
SiteConfiguration?: SiteSettings;
@ -94,7 +93,7 @@ const App = () => {
<Switch>
<Route path="/server/:id" component={ServerRouter}/>
<Route path="/auth" component={AuthenticationRouter}/>
<Route path="/admin" component={AdminRouter}/>
<Route path="/admin" component={ChunkedAdminRouter}/>
<Route path="/" component={DashboardRouter}/>
<Route path={'*'} component={NotFound}/>
</Switch>

View file

@ -1,26 +1,33 @@
import React from 'react';
import Input from '@/components/elements/Input';
import InputSpinner from '@/components/elements/InputSpinner';
import { debounce } from 'debounce';
import React, { useCallback, useState } from 'react';
import { TableCheckbox } from '@/components/admin/AdminCheckbox';
import Spinner from '@/components/elements/Spinner';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import { PaginatedResult } from '@/api/http';
import { PaginatedResult, PaginationDataSet } from '@/api/http';
export const TableHeader = ({ name }: { name?: string }) => {
export const TableHeader = ({ name, onClick, direction }: { name?: string, onClick?: (e: React.MouseEvent) => void, direction?: number | null }) => {
if (!name) {
return <th css={tw`px-6 py-2`}/>;
}
return (
<th css={tw`px-6 py-2`}>
<th css={tw`px-6 py-2`} onClick={onClick}>
<span css={tw`flex flex-row items-center cursor-pointer`}>
<span css={tw`text-xs font-medium tracking-wider uppercase text-neutral-300 whitespace-nowrap`}>{name}</span>
<span css={tw`text-xs font-medium tracking-wider uppercase text-neutral-300 whitespace-nowrap select-none`}>{name}</span>
{direction !== undefined ?
<div css={tw`ml-1`}>
<svg fill="none" viewBox="0 0 20 20" css={tw`w-4 h-4 text-neutral-400`}>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M13 7L10 4L7 7"/>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M7 13L10 16L13 13"/>
{(direction === null || direction === 1) ? <path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M13 7L10 4L7 7"/> : null}
{(direction === null || direction === 2) ? <path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M7 13L10 16L13 13"/> : null}
</svg>
</div>
:
null
}
</span>
</th>
);
@ -54,7 +61,7 @@ export const TableRow = ({ children }: { children: React.ReactNode }) => {
};
interface Props<T> {
data: PaginatedResult<T>;
data?: PaginatedResult<T>;
onPageSelect: (page: number) => void;
children: React.ReactNode;
@ -78,7 +85,20 @@ const PaginationArrow = styled.button`
}
`;
export function Pagination<T> ({ data: { pagination }, onPageSelect, children }: Props<T>) {
export function Pagination<T> ({ data, onPageSelect, children }: Props<T>) {
let pagination: PaginationDataSet;
if (data === undefined) {
pagination = {
total: 0,
count: 0,
perPage: 0,
currentPage: 1,
totalPages: 1,
};
} else {
pagination = data.pagination;
}
const setPage = (page: number) => {
if (page < 1 || page > pagination.totalPages) {
return;
@ -173,11 +193,27 @@ export const NoItems = () => {
interface Params {
checked: boolean;
onSelectAllClick: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSearch?: (query: string) => Promise<void>;
children: React.ReactNode;
}
export const ContentWrapper = ({ checked, onSelectAllClick, children }: Params) => {
export const ContentWrapper = ({ checked, onSelectAllClick, onSearch, children }: Params) => {
const [ loading, setLoading ] = useState(false);
const [ inputText, setInputText ] = useState('');
const search = useCallback(
debounce((query: string) => {
if (onSearch === undefined) {
return;
}
setLoading(true);
onSearch(query).then(() => setLoading(false));
}, 200),
[],
);
return (
<>
<div css={tw`flex flex-row items-center h-12 px-6`}>
@ -194,15 +230,19 @@ export const ContentWrapper = ({ checked, onSelectAllClick, children }: Params)
</svg>
</div>
{/* <div css={tw`flex flex-row items-center px-2 py-1 ml-auto rounded cursor-pointer bg-neutral-600`}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" css={tw`w-6 h-6 text-neutral-300`}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" css={tw`w-4 h-4 ml-1 text-neutral-200`}>
<path clipRule="evenodd" fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
</svg>
</div> */}
<div css={tw`flex flex-row items-center ml-auto`}>
<InputSpinner visible={loading}>
<Input
value={inputText}
css={tw`h-8`}
placeholder="Search..."
onChange={e => {
setInputText(e.currentTarget.value);
search(e.currentTarget.value);
}}
/>
</InputSpinner>
</div>
</div>
{children}

View file

@ -1,4 +1,3 @@
import CopyOnClick from '@/components/elements/CopyOnClick';
import React, { useContext, useEffect, useState } from 'react';
import getDatabases, { Context as DatabasesContext } from '@/api/admin/databases/getDatabases';
import FlashMessageRender from '@/components/FlashMessageRender';
@ -10,6 +9,7 @@ import AdminContentBlock from '@/components/admin/AdminContentBlock';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminTable, { TableBody, TableHead, TableHeader, TableRow, Pagination, Loading, NoItems, ContentWrapper } from '@/components/admin/AdminTable';
import Button from '@/components/elements/Button';
import CopyOnClick from '@/components/elements/CopyOnClick';
const RowCheckbox = ({ id }: { id: number}) => {
const isChecked = AdminContext.useStoreState(state => state.databases.selectedDatabases.indexOf(id) >= 0);

View file

@ -1,6 +1,8 @@
import LocationDeleteButton from '@/components/admin/locations/LocationDeleteButton';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router';
import tw from 'twin.macro';
import { useHistory, useRouteMatch } from 'react-router-dom';
import { useRouteMatch } from 'react-router-dom';
import { action, Action, Actions, createContextStore, useStoreActions } from 'easy-peasy';
import { Location } from '@/api/admin/locations/getLocations';
import getLocation from '@/api/admin/locations/getLocation';
@ -15,7 +17,6 @@ import Field from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { Form, Formik, FormikHelpers } from 'formik';
import updateLocation from '@/api/admin/locations/updateLocation';
import LocationDeleteButton from '@/components/admin/locations/LocationDeleteButton';
interface ctx {
location: Location | undefined;

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { useHistory, useRouteMatch } from 'react-router-dom';
import { useRouteMatch } from 'react-router-dom';
import { action, Action, Actions, createContextStore, useStoreActions } from 'easy-peasy';
import { Mount } from '@/api/admin/mounts/getMounts';
import getMount from '@/api/admin/mounts/getMount';
@ -15,7 +15,6 @@ import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { Field as FormikField, Form, Formik, FormikHelpers } from 'formik';
import MountDeleteButton from '@/components/admin/mounts/MountDeleteButton';
import Label from '@/components/elements/Label';
interface ctx {
@ -41,8 +40,6 @@ interface Values {
}
const EditInformationContainer = () => {
const history = useHistory();
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const mount = Context.useStoreState(state => state.mount);
@ -184,10 +181,10 @@ const EditInformationContainer = () => {
<div css={tw`w-full flex flex-row items-center mt-6`}>
<div css={tw`flex`}>
<MountDeleteButton
mountId={mount.id}
onDeleted={() => history.push('/admin/mounts')}
/>
{/* <MountDeleteButton */}
{/* mountId={mount.id} */}
{/* onDeleted={() => history.push('/admin/mounts')} */}
{/* /> */}
</div>
<div css={tw`flex ml-auto`}>

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { NavLink, useHistory, useRouteMatch } from 'react-router-dom';
import { NavLink, useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import Spinner from '@/components/elements/Spinner';
@ -20,7 +20,6 @@ import AdminTable, { ContentWrapper, NoItems, TableBody, TableHead, TableHeader,
import CopyOnClick from '@/components/elements/CopyOnClick';
import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
import NestDeleteButton from '@/components/admin/nests/NestDeleteButton';
interface ctx {
nest: Nest | undefined;
@ -61,8 +60,6 @@ interface Values {
}
const EditInformationContainer = () => {
const history = useHistory();
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const nest = Context.useStoreState(state => state.nest);
@ -125,10 +122,10 @@ const EditInformationContainer = () => {
<div css={tw`w-full flex flex-row items-center mt-6`}>
<div css={tw`flex`}>
<NestDeleteButton
nestId={nest.id}
onDeleted={() => history.push('/admin/nests')}
/>
{/* <NestDeleteButton */}
{/* nestId={nest.id} */}
{/* onDeleted={() => history.push('/admin/nests')} */}
{/* /> */}
</div>
<div css={tw`flex ml-auto`}>

View file

@ -98,7 +98,7 @@ export default () => {
<TableBody>
{
roles.map(role => (
<TableRow key={role.id}>
<TableRow key={role.id} css={role.id === roles[roles.length - 1].id ? tw`rounded-b-lg` : undefined}>
<td css={tw`pl-6`}>
<RowCheckbox id={role.id}/>
</td>

View file

@ -1,85 +0,0 @@
import React, { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { useRouteMatch } from 'react-router-dom';
import { action, Action, Actions, createContextStore, useStoreActions } from 'easy-peasy';
import { Server } from '@/api/admin/servers/getServers';
import getServer from '@/api/admin/servers/getServer';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ApplicationStore } from '@/state';
interface ctx {
server: Server | undefined;
setServer: Action<ctx, Server | undefined>;
}
export const Context = createContextStore<ctx>({
server: undefined,
setServer: action((state, payload) => {
state.server = payload;
}),
});
const ServerEditContainer = () => {
const match = useRouteMatch<{ id?: string }>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const [ loading, setLoading ] = useState(true);
const server = Context.useStoreState(state => state.server);
const setServer = Context.useStoreActions(actions => actions.setServer);
useEffect(() => {
clearFlashes('server');
getServer(Number(match.params?.id), [])
.then(server => setServer(server))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'server', error });
})
.then(() => setLoading(false));
}, []);
if (loading || server === undefined) {
return (
<AdminContentBlock>
<FlashMessageRender byKey={'server'} css={tw`mb-4`}/>
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '24rem' }}>
<Spinner size={'base'}/>
</div>
</AdminContentBlock>
);
}
return (
<AdminContentBlock title={'Server - ' + server.name}>
<div css={tw`w-full flex flex-row items-center mb-8`}>
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>{server.name}</h2>
{
(server.description || '').length < 1 ?
<p css={tw`text-base text-neutral-400`}>
<span css={tw`italic`}>No description</span>
</p>
:
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>{server.description}</p>
}
</div>
</div>
<FlashMessageRender byKey={'server'} css={tw`mb-4`}/>
</AdminContentBlock>
);
};
export default () => {
return (
<Context.Provider>
<ServerEditContainer/>
</Context.Provider>
);
};

View file

@ -1,3 +1,5 @@
import ServerRouter
from '@/components/admin/servers/ServerRouter';
import React, { useState } from 'react';
import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { State, useStoreState } from 'easy-peasy';
@ -20,7 +22,6 @@ import LocationsContainer from '@/components/admin/locations/LocationsContainer'
import LocationEditContainer from '@/components/admin/locations/LocationEditContainer';
import ServersContainer from '@/components/admin/servers/ServersContainer';
import NewServerContainer from '@/components/admin/servers/NewServerContainer';
import ServerEditContainer from '@/components/admin/servers/ServerEditContainer';
import UsersContainer from '@/components/admin/users/UsersContainer';
import NewUserContainer from '@/components/admin/users/NewUserContainer';
import UserEditContainer from '@/components/admin/users/UserEditContainer';
@ -233,7 +234,7 @@ const AdminRouter = ({ location, match }: RouteComponentProps) => {
<Route path={`${match.path}/servers/new`} component={NewServerContainer} exact/>
<Route
path={`${match.path}/servers/:id`}
component={ServerEditContainer}
component={ServerRouter}
/>
<Route path={`${match.path}/users`} component={UsersContainer} exact/>

2183
yarn.lock

File diff suppressed because it is too large Load diff