ui(admin): make all tables searchable and sortable

This commit is contained in:
Matthew Penner 2021-07-14 16:43:59 -06:00
parent 8f8d66584d
commit c0e9f1adee
27 changed files with 968 additions and 229 deletions

View File

@ -38,12 +38,13 @@ class EggController extends ApplicationApiController
*/
public function index(GetEggsRequest $request, Nest $nest): array
{
$perPage = $request->query('per_page', 0);
$perPage = $request->query('per_page', 10);
if ($perPage > 100) {
throw new QueryValueOutOfRangeHttpException('per_page', 1, 100);
}
$eggs = QueryBuilder::for(Egg::query())
->where('nest_id', '=', $nest->id)
->allowedFilters(['id', 'name', 'author'])
->allowedSorts(['id', 'name', 'author']);
if ($perPage > 0) {

View File

@ -9,7 +9,6 @@ use Spatie\QueryBuilder\QueryBuilder;
use Pterodactyl\Services\Locations\LocationUpdateService;
use Pterodactyl\Services\Locations\LocationCreationService;
use Pterodactyl\Services\Locations\LocationDeletionService;
use Pterodactyl\Contracts\Repository\LocationRepositoryInterface;
use Pterodactyl\Transformers\Api\Application\LocationTransformer;
use Pterodactyl\Exceptions\Http\QueryValueOutOfRangeHttpException;
use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController;
@ -24,7 +23,6 @@ class LocationController extends ApplicationApiController
private LocationCreationService $creationService;
private LocationDeletionService $deletionService;
private LocationUpdateService $updateService;
private LocationRepositoryInterface $repository;
/**
* LocationController constructor.
@ -32,15 +30,13 @@ class LocationController extends ApplicationApiController
public function __construct(
LocationCreationService $creationService,
LocationDeletionService $deletionService,
LocationUpdateService $updateService,
LocationRepositoryInterface $repository
LocationUpdateService $updateService
) {
parent::__construct();
$this->creationService = $creationService;
$this->deletionService = $deletionService;
$this->updateService = $updateService;
$this->repository = $repository;
}
/**
@ -57,7 +53,7 @@ class LocationController extends ApplicationApiController
$locations = QueryBuilder::for(Location::query())
->allowedFilters(['short', 'long'])
->allowedSorts(['id'])
->allowedSorts(['id', 'short', 'long'])
->paginate($perPage);
return $this->fractal->collection($locations)

View File

@ -40,8 +40,8 @@ class MountController extends ApplicationApiController
}
$mounts = QueryBuilder::for(Mount::query())
->allowedFilters(['name', 'host'])
->allowedSorts(['id', 'name', 'host'])
->allowedFilters(['id', 'name', 'source', 'target'])
->allowedSorts(['id', 'name', 'source', 'target'])
->paginate($perPage);
return $this->fractal->collection($mounts)

View File

@ -50,7 +50,7 @@ class NestController extends ApplicationApiController
*/
public function index(GetNestsRequest $request): array
{
$perPage = $request->query('per_page', 0);
$perPage = $request->query('per_page', 10);
if ($perPage > 100) {
throw new QueryValueOutOfRangeHttpException('per_page', 1, 100);
}

View File

@ -3,9 +3,13 @@
namespace Pterodactyl\Http\Controllers\Api\Application\Roles;
use Illuminate\Http\Response;
use Pterodactyl\Models\Location;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\AdminRole;
use Spatie\QueryBuilder\QueryBuilder;
use Pterodactyl\Transformers\Api\Application\LocationTransformer;
use Pterodactyl\Transformers\Api\Application\AdminRoleTransformer;
use Pterodactyl\Exceptions\Http\QueryValueOutOfRangeHttpException;
use Pterodactyl\Http\Requests\Api\Application\Roles\GetRoleRequest;
use Pterodactyl\Http\Requests\Api\Application\Roles\GetRolesRequest;
use Pterodactyl\Http\Requests\Api\Application\Roles\StoreRoleRequest;
@ -30,7 +34,17 @@ class RoleController extends ApplicationApiController
*/
public function index(GetRolesRequest $request): array
{
return $this->fractal->collection(AdminRole::all())
$perPage = $request->query('per_page', 10);
if ($perPage < 1 || $perPage > 100) {
throw new QueryValueOutOfRangeHttpException('per_page', 1, 100);
}
$roles = QueryBuilder::for(AdminRole::query())
->allowedFilters(['id', 'name'])
->allowedSorts(['id', 'name'])
->paginate($perPage);
return $this->fractal->collection($roles)
->transformWith($this->getTransformer(AdminRoleTransformer::class))
->toArray();
}

View File

@ -19,7 +19,7 @@ use Pterodactyl\Http\Requests\Api\Application\Users\DeleteUserRequest;
use Pterodactyl\Http\Requests\Api\Application\Users\UpdateUserRequest;
use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController;
class UserController extends ApplicationApiController
class UserController extends ApplicationApiController
{
private UserRepositoryInterface $repository;
private UserCreationService $creationService;
@ -58,8 +58,8 @@ class UserController extends ApplicationApiController
}
$users = QueryBuilder::for(User::query())
->allowedFilters(['email', 'uuid', 'username', 'external_id'])
->allowedSorts(['id', 'uuid'])
->allowedFilters(['id', 'uuid', 'username', 'email', 'first_name', 'last_name', 'external_id'])
->allowedSorts(['id', 'uuid', 'username', 'email', 'admin_role_id'])
->paginate($perPage);
return $this->fractal->collection($users)

View File

@ -28,18 +28,58 @@ export const rawDataToDatabase = ({ attributes }: FractalResponseData): Database
getAddress: () => `${attributes.host}:${attributes.port}`,
});
export interface Filters {
id?: string;
name?: string;
host?: string;
}
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<Database>>([ 'databases', page ], async () => {
const { data } = await http.get('/api/application/databases', { 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<Database>>([ 'databases', page, filters, sort, sortDirection ], async () => {
const { data } = await http.get('/api/application/databases', { params: { include: include.join(','), page, ...params } });
return ({
items: (data.data || []).map(rawDataToDatabase),

View File

@ -18,18 +18,58 @@ export const rawDataToLocation = ({ attributes }: FractalResponseData): Location
updatedAt: new Date(attributes.updated_at),
});
export interface Filters {
id?: string;
short?: string;
long?: string;
}
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<Location>>([ 'locations', page ], async () => {
const { data } = await http.get('/api/application/locations', { 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<Location>>([ 'locations', page, filters, sort, sortDirection ], async () => {
const { data } = await http.get('/api/application/locations', { params: { include: include.join(','), page, ...params } });
return ({
items: (data.data || []).map(rawDataToLocation),

View File

@ -43,18 +43,59 @@ export const rawDataToMount = ({ attributes }: FractalResponseData): Mount => ({
},
});
export interface Filters {
id?: string;
name?: string;
source?: string;
target?: string;
}
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<Mount>>([ 'mounts', page ], async () => {
const { data } = await http.get('/api/application/mounts', { 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<Mount>>([ 'mounts', page, filters, sort, sortDirection ], async () => {
const { data } = await http.get('/api/application/mounts', { params: { include: include.join(','), page, ...params } });
return ({
items: (data.data || []).map(rawDataToMount),

View File

@ -1,10 +1,63 @@
import http from '@/api/http';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
import { createContext, useContext } from 'react';
import useSWR from 'swr';
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
export default (nestId: number): Promise<Egg[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/application/nests/${nestId}/eggs`)
.then(({ data }) => resolve((data.data || []).map(rawDataToEgg)))
.catch(reject);
export interface Filters {
id?: string;
name?: string;
}
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,
filters: null,
setFilters: () => null,
sort: null,
setSort: () => null,
sortDirection: false,
setSortDirection: () => false,
});
export default (nestId: number, include: string[] = []) => {
const { page, filters, sort, sortDirection } = useContext(Context);
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<Egg>>([ nestId, 'eggs', page, filters, sort, sortDirection ], async () => {
const { data } = await http.get(`/api/application/nests/${nestId}/eggs`, { params: { include: include.join(','), page, ...params } });
return ({
items: (data.data || []).map(rawDataToEgg),
pagination: getPaginationSet(data.meta.pagination),
});
});
};

View File

@ -31,24 +31,57 @@ export const rawDataToNest = ({ attributes }: FractalResponseData): Nest => ({
},
});
export interface Filters {
id?: string;
name?: string;
}
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<Nest>>([ 'nests', page ], async () => {
const { data } = await http.get('/api/application/nests', {
params: {
include: include.join(','),
per_page: 10,
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<Nest>>([ 'nests', page, filters, sort, sortDirection ], async () => {
const { data } = await http.get('/api/application/nests', { params: { include: include.join(','), page, ...params } });
return ({
items: (data.data || []).map(rawDataToNest),

View File

@ -1,5 +1,4 @@
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
import http, { FractalResponseData } from '@/api/http';
export interface Allocation {
id: number;
@ -7,17 +6,22 @@ export interface Allocation {
alias: string | null;
port: number;
notes: string | null;
isDefault: boolean;
assigned: boolean;
}
export default (uuid: string): Promise<[ Allocation, string[] ]> => {
export const rawDataToAllocation = (data: FractalResponseData): Allocation => ({
id: data.attributes.id,
ip: data.attributes.ip,
alias: data.attributes.ip_alias,
port: data.attributes.port,
notes: data.attributes.notes,
assigned: data.attributes.assigned,
});
export default (uuid: string): Promise<Allocation[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/application/allocations/${uuid}`)
.then(({ data }) => resolve([
rawDataToServerAllocation(data),
// eslint-disable-next-line camelcase
data.meta?.is_allocation_owner ? [ '*' ] : (data.meta?.user_permissions || []),
]))
http.get(`/api/application/nodes/${uuid}/allocations`)
.then(({ data }) => resolve((data.data || []).map(rawDataToAllocation)))
.catch(reject);
});
};

View File

@ -68,6 +68,7 @@ export const rawDataToNode = ({ attributes }: FractalResponseData): Node => ({
});
export interface Filters {
id?: string;
uuid?: string;
name?: string;
image?: string;

View File

@ -1,4 +1,6 @@
import http, { FractalResponseData } from '@/api/http';
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
import { createContext, useContext } from 'react';
import useSWR from 'swr';
export interface Role {
id: number;
@ -12,10 +14,61 @@ export const rawDataToRole = ({ attributes }: FractalResponseData): Role => ({
description: attributes.description,
});
export default (include: string[] = []): Promise<Role[]> => {
return new Promise((resolve, reject) => {
http.get('/api/application/roles', { params: { include: include.join(',') } })
.then(({ data }) => resolve((data.data || []).map(rawDataToRole)))
.catch(reject);
export interface Filters {
id?: string;
name?: string;
}
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,
filters: null,
setFilters: () => null,
sort: null,
setSort: () => null,
sortDirection: false,
setSortDirection: () => false,
});
export default (include: string[] = []) => {
const { page, filters, sort, sortDirection } = useContext(Context);
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<Role>>([ 'roles', page, filters, sort, sortDirection ], async () => {
const { data } = await http.get('/api/application/roles', { params: { include: include.join(','), page, ...params } });
return ({
items: (data.data || []).map(rawDataToRole),
pagination: getPaginationSet(data.meta.pagination),
});
});
};

View File

@ -100,6 +100,7 @@ export const rawDataToServer = ({ attributes }: FractalResponseData): Server =>
});
export interface Filters {
id?: string;
uuid?: string;
name?: string;
image?: string;

View File

@ -36,18 +36,61 @@ export const rawDataToUser = ({ attributes }: FractalResponseData): User => ({
updatedAt: new Date(attributes.updated_at),
});
export interface Filters {
id?: string;
uuid?: string;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
}
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<User>>([ 'users', page ], async () => {
const { data } = await http.get('/api/application/users', { 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<User>>([ 'users', page, filters, sort, sortDirection ], async () => {
const { data } = await http.get('/api/application/users', { params: { include: include.join(','), page, ...params } });
return ({
items: (data.data || []).map(rawDataToUser),

View File

@ -1,5 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import getDatabases, { Context as DatabasesContext } from '@/api/admin/databases/getDatabases';
import getDatabases, { Context as DatabasesContext, Filters } from '@/api/admin/databases/getDatabases';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
@ -34,7 +34,7 @@ const RowCheckbox = ({ id }: { id: number}) => {
const DatabasesContainer = () => {
const match = useRouteMatch();
const { page, setPage } = useContext(DatabasesContext);
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(DatabasesContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: databases, error, isValidating } = getDatabases();
@ -56,6 +56,17 @@ const DatabasesContainer = () => {
setSelectedDatabases(e.currentTarget.checked ? (databases?.items?.map(database => database.id) || []) : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve) => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ id: query, name: query, host: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedDatabases([]);
}, [ page ]);
@ -89,15 +100,16 @@ const DatabasesContainer = () => {
<ContentWrapper
checked={selectedDatabasesLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={databases} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'}/>
<TableHeader name={'Name'}/>
<TableHeader name={'ID'} direction={sort === 'id' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('id')}/>
<TableHeader name={'Name'} direction={sort === 'name' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('name')}/>
<TableHeader name={'Address'}/>
<TableHeader name={'Username'}/>
<TableHeader name={'Username'} direction={sort === 'username' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('username')}/>
</TableHead>
<TableBody>
@ -143,9 +155,21 @@ const DatabasesContainer = () => {
export default () => {
const [ page, setPage ] = useState<number>(1);
const [ filters, setFilters ] = useState<Filters | null>(null);
const [ sort, setSortState ] = useState<string | null>(null);
const [ sortDirection, setSortDirection ] = useState<boolean>(false);
const setSort = (newSort: string | null) => {
if (sort === newSort) {
setSortDirection(!sortDirection);
} else {
setSortState(newSort);
setSortDirection(false);
}
};
return (
<DatabasesContext.Provider value={{ page, setPage }}>
<DatabasesContext.Provider value={{ page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection }}>
<DatabasesContainer/>
</DatabasesContext.Provider>
);

View File

@ -1,5 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import getLocations, { Context as LocationsContext } from '@/api/admin/locations/getLocations';
import getLocations, { Context as LocationsContext, Filters } from '@/api/admin/locations/getLocations';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
@ -34,7 +34,7 @@ const RowCheckbox = ({ id }: { id: number}) => {
const LocationsContainer = () => {
const match = useRouteMatch();
const { page, setPage } = useContext(LocationsContext);
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(LocationsContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: locations, error, isValidating } = getLocations();
@ -56,6 +56,17 @@ const LocationsContainer = () => {
setSelectedLocations(e.currentTarget.checked ? (locations?.items?.map(location => location.id) || []) : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve) => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ short: query, long: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedLocations([]);
}, [ page ]);
@ -85,14 +96,15 @@ const LocationsContainer = () => {
<ContentWrapper
checked={selectedLocationsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={locations} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'}/>
<TableHeader name={'Short Name'}/>
<TableHeader name={'Long Name'}/>
<TableHeader name={'ID'} direction={sort === 'id' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('id')}/>
<TableHeader name={'Short Name'} direction={sort === 'short' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('short')}/>
<TableHeader name={'Long Name'} direction={sort === 'long' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('long')}/>
</TableHead>
<TableBody>
@ -132,9 +144,21 @@ const LocationsContainer = () => {
export default () => {
const [ page, setPage ] = useState<number>(1);
const [ filters, setFilters ] = useState<Filters | null>(null);
const [ sort, setSortState ] = useState<string | null>(null);
const [ sortDirection, setSortDirection ] = useState<boolean>(false);
const setSort = (newSort: string | null) => {
if (sort === newSort) {
setSortDirection(!sortDirection);
} else {
setSortState(newSort);
setSortDirection(false);
}
};
return (
<LocationsContext.Provider value={{ page, setPage }}>
<LocationsContext.Provider value={{ page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection }}>
<LocationsContainer/>
</LocationsContext.Provider>
);

View File

@ -1,5 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import getMounts, { Context as MountsContext } from '@/api/admin/mounts/getMounts';
import getMounts, { Context as MountsContext, Filters } from '@/api/admin/mounts/getMounts';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
@ -34,7 +34,7 @@ const RowCheckbox = ({ id }: { id: number}) => {
const MountsContainer = () => {
const match = useRouteMatch();
const { page, setPage } = useContext(MountsContext);
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(MountsContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: mounts, error, isValidating } = getMounts();
@ -56,6 +56,17 @@ const MountsContainer = () => {
setSelectedMounts(e.currentTarget.checked ? (mounts?.items?.map(mount => mount.id) || []) : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve) => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ id: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedMounts([]);
}, [ page ]);
@ -87,15 +98,16 @@ const MountsContainer = () => {
<ContentWrapper
checked={selectedMountsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={mounts} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'}/>
<TableHeader name={'Name'}/>
<TableHeader name={'Source Path'}/>
<TableHeader name={'Target Path'}/>
<TableHeader name={'ID'} direction={sort === 'id' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('id')}/>
<TableHeader name={'Name'} direction={sort === 'name' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('name')}/>
<TableHeader name={'Source Path'} direction={sort === 'source' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('source')}/>
<TableHeader name={'Target Path'} direction={sort === 'target' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('target')}/>
<th css={tw`px-6 py-2`}/>
<th css={tw`px-6 py-2`}/>
</TableHead>
@ -171,9 +183,21 @@ const MountsContainer = () => {
export default () => {
const [ page, setPage ] = useState<number>(1);
const [ filters, setFilters ] = useState<Filters | null>(null);
const [ sort, setSortState ] = useState<string | null>(null);
const [ sortDirection, setSortDirection ] = useState<boolean>(false);
const setSort = (newSort: string | null) => {
if (sort === newSort) {
setSortDirection(!sortDirection);
} else {
setSortState(newSort);
setSortDirection(false);
}
};
return (
<MountsContext.Provider value={{ page, setPage }}>
<MountsContext.Provider value={{ page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection }}>
<MountsContainer/>
</MountsContext.Provider>
);

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router';
import { NavLink, useRouteMatch } from 'react-router-dom';
import { useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import Spinner from '@/components/elements/Spinner';
@ -16,12 +16,11 @@ import { ApplicationStore } from '@/state';
import { action, Action, Actions, createContextStore, useStoreActions } from 'easy-peasy';
import { Form, Formik, FormikHelpers } from 'formik';
import AdminBox from '@/components/admin/AdminBox';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminTable, { ContentWrapper, NoItems, TableBody, TableHead, TableHeader, TableRow } from '@/components/admin/AdminTable';
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';
import NestEggTable from '@/components/admin/nests/NestEggTable';
interface ctx {
nest: Nest | undefined;
@ -198,28 +197,8 @@ const ViewDetailsContainer = () => {
);
};
const RowCheckbox = ({ id }: { id: number }) => {
const isChecked = Context.useStoreState(state => state.selectedEggs.indexOf(id) >= 0);
const appendSelectedEggs = Context.useStoreActions(actions => actions.appendSelectedEggs);
const removeSelectedEggs = Context.useStoreActions(actions => actions.removeSelectedEggs);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedEggs(id);
} else {
removeSelectedEggs(id);
}
}}
/>
);
};
const NestEditContainer = () => {
const match = useRouteMatch<{ nestId?: string }>();
const match = useRouteMatch<{ nestId: string }>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const [ loading, setLoading ] = useState(true);
@ -227,13 +206,10 @@ const NestEditContainer = () => {
const nest = Context.useStoreState(state => state.nest);
const setNest = Context.useStoreActions(actions => actions.setNest);
const setSelectedEggs = Context.useStoreActions(actions => actions.setSelectedEggs);
const selectedEggsLength = Context.useStoreState(state => state.selectedEggs.length);
useEffect(() => {
clearFlashes('nest');
getNest(Number(match.params?.nestId), [ 'eggs' ])
getNest(Number(match.params.nestId), [ 'eggs' ])
.then(nest => setNest(nest))
.catch(error => {
console.error(error);
@ -254,12 +230,6 @@ const NestEditContainer = () => {
);
}
const length = nest.relations.eggs?.length || 0;
const onSelectAllClick = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedEggs(e.currentTarget.checked ? (nest.relations.eggs?.map(egg => egg.id) || []) : []);
};
return (
<AdminContentBlock title={'Nests - ' + nest.name}>
<div css={tw`w-full flex flex-row items-center mb-8`}>
@ -289,52 +259,7 @@ const NestEditContainer = () => {
<ViewDetailsContainer/>
</div>
<AdminTable>
{ length < 1 ?
<NoItems/>
:
<ContentWrapper
checked={selectedEggsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'}/>
<TableHeader name={'Name'}/>
<TableHeader name={'Description'}/>
</TableHead>
<TableBody>
{
nest.relations.eggs?.map(egg => (
<TableRow key={egg.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={egg.id}/>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={egg.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>{egg.id}</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink to={`${match.url}/eggs/${egg.id}`} css={tw`text-primary-400 hover:text-primary-300`}>
{egg.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>{egg.description}</td>
</TableRow>
))
}
</TableBody>
</table>
</div>
</ContentWrapper>
}
</AdminTable>
<NestEggTable/>
</AdminContentBlock>
);
};

View File

@ -0,0 +1,147 @@
import CopyOnClick from '@/components/elements/CopyOnClick';
import React, { useContext, useEffect, useState } from 'react';
import getEggs, { Context as EggsContext, Filters } from '@/api/admin/nests/getEggs';
import useFlash from '@/plugins/useFlash';
import { NavLink, useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminTable, { TableBody, TableHead, TableHeader, TableRow, Pagination, Loading, NoItems, ContentWrapper } from '@/components/admin/AdminTable';
import { Context } from '@/components/admin/nests/NestEditContainer';
const RowCheckbox = ({ id }: { id: number}) => {
const isChecked = Context.useStoreState(state => state.selectedEggs.indexOf(id) >= 0);
const appendSelectedEggs = Context.useStoreActions(actions => actions.appendSelectedEggs);
const removeSelectedEggs = Context.useStoreActions(actions => actions.removeSelectedEggs);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedEggs(id);
} else {
removeSelectedEggs(id);
}
}}
/>
);
};
const EggsTable = () => {
const match = useRouteMatch<{ nestId: string }>();
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(EggsContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: eggs, error, isValidating } = getEggs(Number(match.params.nestId));
useEffect(() => {
if (!error) {
clearFlashes('nests');
return;
}
clearAndAddHttpError({ key: 'nests', error });
}, [ error ]);
const length = eggs?.items?.length || 0;
const setSelectedEggs = Context.useStoreActions(actions => actions.setSelectedEggs);
const selectedEggsLength = Context.useStoreState(state => state.selectedEggs.length);
const onSelectAllClick = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedEggs(e.currentTarget.checked ? (eggs?.items?.map(nest => nest.id) || []) : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve) => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedEggs([]);
}, [ page ]);
return (
<AdminTable>
{ eggs === undefined || (error && isValidating) ?
<Loading/>
:
length < 1 ?
<NoItems/>
:
<ContentWrapper
checked={selectedEggsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={eggs} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'} direction={sort === 'id' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('id')}/>
<TableHeader name={'Name'} direction={sort === 'name' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('name')}/>
<TableHeader name={'Description'}/>
</TableHead>
<TableBody>
{
eggs.items.map(egg => (
<TableRow key={egg.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={egg.id}/>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={egg.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>{egg.id}</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink to={`${match.url}/eggs/${egg.id}`} css={tw`text-primary-400 hover:text-primary-300`}>
{egg.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>{egg.description}</td>
</TableRow>
))
}
</TableBody>
</table>
</div>
</Pagination>
</ContentWrapper>
}
</AdminTable>
);
};
export default () => {
const [ page, setPage ] = useState<number>(1);
const [ filters, setFilters ] = useState<Filters | null>(null);
const [ sort, setSortState ] = useState<string | null>(null);
const [ sortDirection, setSortDirection ] = useState<boolean>(false);
const setSort = (newSort: string | null) => {
if (sort === newSort) {
setSortDirection(!sortDirection);
} else {
setSortState(newSort);
setSortDirection(false);
}
};
return (
<EggsContext.Provider value={{ page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection }}>
<EggsTable/>
</EggsContext.Provider>
);
};

View File

@ -1,6 +1,6 @@
import CopyOnClick from '@/components/elements/CopyOnClick';
import React, { useContext, useEffect, useState } from 'react';
import getNests, { Context as NestsContext } from '@/api/admin/nests/getNests';
import getNests, { Context as NestsContext, Filters } from '@/api/admin/nests/getNests';
import NewNestButton from '@/components/admin/nests/NewNestButton';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
@ -34,7 +34,7 @@ const RowCheckbox = ({ id }: { id: number}) => {
const NestsContainer = () => {
const match = useRouteMatch();
const { page, setPage } = useContext(NestsContext);
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(NestsContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: nests, error, isValidating } = getNests();
@ -56,6 +56,17 @@ const NestsContainer = () => {
setSelectedNests(e.currentTarget.checked ? (nests?.items?.map(nest => nest.id) || []) : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve) => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ id: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedNests([]);
}, [ page ]);
@ -85,13 +96,14 @@ const NestsContainer = () => {
<ContentWrapper
checked={selectedNestsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={nests} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'}/>
<TableHeader name={'Name'}/>
<TableHeader name={'ID'} direction={sort === 'id' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('id')}/>
<TableHeader name={'Name'} direction={sort === 'name' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('name')}/>
<TableHeader name={'Description'}/>
</TableHead>
@ -132,9 +144,21 @@ const NestsContainer = () => {
export default () => {
const [ page, setPage ] = useState<number>(1);
const [ filters, setFilters ] = useState<Filters | null>(null);
const [ sort, setSortState ] = useState<string | null>(null);
const [ sortDirection, setSortDirection ] = useState<boolean>(false);
const setSort = (newSort: string | null) => {
if (sort === newSort) {
setSortDirection(!sortDirection);
} else {
setSortState(newSort);
setSortDirection(false);
}
};
return (
<NestsContext.Provider value={{ page, setPage }}>
<NestsContext.Provider value={{ page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection }}>
<NestsContainer/>
</NestsContext.Provider>
);

View File

@ -0,0 +1,90 @@
import Label from '@/components/elements/Label';
import React, { useEffect, useState } from 'react';
import AdminBox from '@/components/admin/AdminBox';
import Creatable from 'react-select/creatable';
import { ActionMeta, GroupTypeBase, InputActionMeta, ValueType } from 'react-select/src/types';
import { SelectStyle } from '@/components/elements/Select2';
import tw from 'twin.macro';
import getAllocations from '@/api/admin/nodes/getAllocations';
import { useRouteMatch } from 'react-router-dom';
interface Option {
value: string;
label: string;
}
const distinct = (value: any, index: any, self: any) => {
return self.indexOf(value) === index;
};
export default () => {
const match = useRouteMatch<{ id: string }>();
const [ ips, setIPs ] = useState<Option[]>([]);
const [ ports, setPorts ] = useState<Option[]>([]);
useEffect(() => {
getAllocations(match.params.id)
.then(allocations => {
setIPs(allocations.map(a => a.ip).filter(distinct).map(ip => {
return { value: ip, label: ip };
}));
});
}, []);
const onChange = (value: ValueType<Option, any>, action: ActionMeta<any>) => {
console.log({
event: 'onChange',
value,
action,
});
};
const onInputChange = (newValue: string, actionMeta: InputActionMeta) => {
console.log({
event: 'onInputChange',
newValue,
actionMeta,
});
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const isValidNewOption1 = (inputValue: string, selectValue: ValueType<Option, any>, selectOptions: ReadonlyArray<Option | GroupTypeBase<Option>>): boolean => {
return inputValue.match(/^([0-9a-f.:/]+)$/) !== null;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const isValidNewOption2 = (inputValue: string, selectValue: ValueType<Option, any>, selectOptions: ReadonlyArray<Option | GroupTypeBase<Option>>): boolean => {
return inputValue.match(/^([0-9-]+)$/) !== null;
};
return (
<AdminBox title={'Allocations'}>
<div css={tw`mb-6`}>
<Label>IPs and CIDRs</Label>
<Creatable
options={ips}
styles={SelectStyle}
onChange={onChange}
onInputChange={onInputChange}
isValidNewOption={isValidNewOption1}
isMulti
isSearchable
/>
</div>
<div css={tw`mb-6`}>
<Label>Ports</Label>
<Creatable
options={ports}
styles={SelectStyle}
// onChange={onChange}
// onInputChange={onInputChange}
isValidNewOption={isValidNewOption2}
isMulti
isSearchable
/>
</div>
</AdminBox>
);
};

View File

@ -13,6 +13,7 @@ import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigati
import NodeAboutContainer from '@/components/admin/nodes/NodeAboutContainer';
import NodeSettingsContainer from '@/components/admin/nodes/NodeSettingsContainer';
import NodeConfigurationContainer from '@/components/admin/nodes/NodeConfigurationContainer';
import NodeAllocationContainer from '@/components/admin/nodes/NodeAllocationContainer';
interface ctx {
node: Node | undefined;
@ -118,7 +119,7 @@ const NodeRouter = () => {
</Route>
<Route path={`${match.path}/allocations`} exact>
<p>Allocations</p>
<NodeAllocationContainer/>
</Route>
<Route path={`${match.path}/servers`} exact>

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
import React, { useContext, useEffect, useState } from 'react';
import getRoles, { Context as RolesContext, Filters } from '@/api/admin/roles/getRoles';
import { AdminContext } from '@/state/admin';
import NewRoleButton from '@/components/admin/roles/NewRoleButton';
import FlashMessageRender from '@/components/FlashMessageRender';
@ -7,9 +7,8 @@ import useFlash from '@/plugins/useFlash';
import { NavLink, useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import getRoles from '@/api/admin/roles/getRoles';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminTable, { ContentWrapper, Loading, NoItems, TableBody, TableHead, TableHeader, TableRow } from '@/components/admin/AdminTable';
import AdminTable, { TableBody, TableHead, TableHeader, TableRow, Pagination, Loading, NoItems, ContentWrapper } from '@/components/admin/AdminTable';
import CopyOnClick from '@/components/elements/CopyOnClick';
const RowCheckbox = ({ id }: { id: number }) => {
@ -32,35 +31,46 @@ const RowCheckbox = ({ id }: { id: number }) => {
);
};
export default () => {
const RolesContainer = () => {
const match = useRouteMatch();
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(RolesContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ loading, setLoading ] = useState(true);
const { data: roles, error, isValidating } = getRoles();
const roles = useDeepMemoize(AdminContext.useStoreState(state => state.roles.data));
const setRoles = AdminContext.useStoreActions(state => state.roles.setRoles);
useEffect(() => {
if (!error) {
clearFlashes('roles');
return;
}
clearAndAddHttpError({ key: 'roles', error });
}, [ error ]);
const length = roles?.items?.length || 0;
const setSelectedRoles = AdminContext.useStoreActions(actions => actions.roles.setSelectedRoles);
const selectedRolesLength = AdminContext.useStoreState(state => state.roles.selectedRoles.length);
useEffect(() => {
setLoading(!roles.length);
clearFlashes('roles');
getRoles()
.then(roles => setRoles(roles))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'roles', error });
})
.then(() => setLoading(false));
}, []);
const onSelectAllClick = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedRoles(e.currentTarget.checked ? (roles.map(role => role.id) || []) : []);
setSelectedRoles(e.currentTarget.checked ? (roles?.items?.map(role => role.id) || []) : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve) => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedRoles([]);
}, [ page ]);
return (
<AdminContentBlock title={'Roles'}>
<div css={tw`w-full flex flex-row items-center mb-8`}>
@ -77,54 +87,79 @@ export default () => {
<FlashMessageRender byKey={'roles'} css={tw`mb-4`}/>
<AdminTable>
{ loading ?
{ roles === undefined || (error && isValidating) ?
<Loading/>
:
roles.length < 1 ?
length < 1 ?
<NoItems/>
:
<ContentWrapper
checked={selectedRolesLength === (roles.length === 0 ? -1 : roles.length)}
checked={selectedRolesLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'}/>
<TableHeader name={'Name'}/>
<TableHeader name={'Description'}/>
</TableHead>
<Pagination data={roles} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'} direction={sort === 'id' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('id')}/>
<TableHeader name={'Name'} direction={sort === 'name' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('name')}/>
<TableHeader name={'Description'}/>
</TableHead>
<TableBody>
{
roles.map(role => (
<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>
<TableBody>
{
roles.items.map(role => (
<TableRow key={role.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={role.id}/>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={role.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>{role.id}</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={role.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>{role.id}</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink to={`${match.url}/${role.id}`} css={tw`text-primary-400 hover:text-primary-300`}>
{role.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink to={`${match.url}/${role.id}`} css={tw`text-primary-400 hover:text-primary-300`}>
{role.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>{role.description}</td>
</TableRow>
))
}
</TableBody>
</table>
</div>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>{role.description}</td>
</TableRow>
))
}
</TableBody>
</table>
</div>
</Pagination>
</ContentWrapper>
}
</AdminTable>
</AdminContentBlock>
);
};
export default () => {
const [ page, setPage ] = useState<number>(1);
const [ filters, setFilters ] = useState<Filters | null>(null);
const [ sort, setSortState ] = useState<string | null>(null);
const [ sortDirection, setSortDirection ] = useState<boolean>(false);
const setSort = (newSort: string | null) => {
if (sort === newSort) {
setSortDirection(!sortDirection);
} else {
setSortState(newSort);
setSortDirection(false);
}
};
return (
<RolesContext.Provider value={{ page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection }}>
<RolesContainer/>
</RolesContext.Provider>
);
};

View File

@ -1,7 +1,7 @@
import React, { useContext, useEffect, useState } from 'react';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import CopyOnClick from '@/components/elements/CopyOnClick';
import getUsers, { Context as UsersContext } from '@/api/admin/users/getUsers';
import getUsers, { Context as UsersContext, Filters } from '@/api/admin/users/getUsers';
import AdminTable, { ContentWrapper, Loading, NoItems, Pagination, TableBody, TableHead, TableHeader } from '@/components/admin/AdminTable';
import Button from '@/components/elements/Button';
import FlashMessageRender from '@/components/FlashMessageRender';
@ -34,7 +34,7 @@ const RowCheckbox = ({ id }: { id: number }) => {
const UsersContainer = () => {
const match = useRouteMatch();
const { page, setPage } = useContext(UsersContext);
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(UsersContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: users, error, isValidating } = getUsers();
@ -56,6 +56,17 @@ const UsersContainer = () => {
setSelectedUsers(e.currentTarget.checked ? (users?.items?.map(user => user.id) || []) : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve) => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ username: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedUsers([]);
}, [ page ]);
@ -89,16 +100,17 @@ const UsersContainer = () => {
<ContentWrapper
checked={selectedUserLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={users} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader name={'ID'}/>
<TableHeader name={'Name'}/>
<TableHeader name={'Username'}/>
<TableHeader name={'ID'} direction={sort === 'id' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('id')}/>
<TableHeader name={'Name'} direction={sort === 'email' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('email')}/>
<TableHeader name={'Username'} direction={sort === 'username' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('username')}/>
<TableHeader name={'Status'}/>
<TableHeader name={'Role'}/>
<TableHeader name={'Role'} direction={sort === 'admin_role_id' ? (sortDirection ? 1 : 2) : null} onClick={() => setSort('admin_role_id')}/>
</TableHead>
<TableBody>
@ -160,9 +172,21 @@ const UsersContainer = () => {
export default () => {
const [ page, setPage ] = useState<number>(1);
const [ filters, setFilters ] = useState<Filters | null>(null);
const [ sort, setSortState ] = useState<string | null>(null);
const [ sortDirection, setSortDirection ] = useState<boolean>(false);
const setSort = (newSort: string | null) => {
if (sort === newSort) {
setSortDirection(!sortDirection);
} else {
setSortState(newSort);
setSortDirection(false);
}
};
return (
<UsersContext.Provider value={{ page, setPage }}>
<UsersContext.Provider value={{ page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection }}>
<UsersContainer/>
</UsersContext.Provider>
);

View File

@ -0,0 +1,101 @@
import { CSSObject } from '@emotion/serialize';
import { ContainerProps, ControlProps, InputProps, MenuProps, MultiValueProps, OptionProps, PlaceholderProps, SingleValueProps, StylesConfig, ValueContainerProps } from 'react-select';
import { theme } from 'twin.macro';
type T = any;
export const SelectStyle: StylesConfig<T, any, any> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
container: (base: CSSObject, props: ContainerProps<T, any, any>): CSSObject => {
return {
...base,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
control: (base: CSSObject, props: ControlProps<T, any, any>): CSSObject => {
return {
...base,
height: '2.75rem',
/* paddingTop: '0.75rem',
paddingBottom: '0.75rem',
paddingLeft: '4rem',
paddingRight: '4rem', */
background: theme`colors.neutral.600`,
borderColor: theme`colors.neutral.500`,
borderWidth: '2px',
color: theme`colors.neutral.200`,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
input: (base: CSSObject, props: InputProps): CSSObject => {
return {
...base,
color: theme`colors.neutral.200`,
fontSize: '0.875rem',
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
menu: (base: CSSObject, props: MenuProps<T, any, any>): CSSObject => {
return {
...base,
background: theme`colors.neutral.900`,
color: theme`colors.neutral.200`,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multiValue: (base: CSSObject, props: MultiValueProps<T, any>): CSSObject => {
return {
...base,
background: theme`colors.neutral.900`,
color: theme`colors.neutral.200`,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multiValueLabel: (base: CSSObject, props: MultiValueProps<T, any>): CSSObject => {
return {
...base,
color: theme`colors.neutral.200`,
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
option: (base: CSSObject, props: OptionProps<T, any, any>): CSSObject => {
return {
...base,
background: theme`colors.neutral.900`,
':hover': {
background: theme`colors.neutral.700`,
cursor: 'pointer',
},
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
placeholder: (base: CSSObject, props: PlaceholderProps<T, any, any>): CSSObject => {
return {
...base,
color: theme`colors.neutral.300`,
fontSize: '0.875rem',
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
singleValue: (base: CSSObject, props: SingleValueProps<T, any>): CSSObject => {
return {
...base,
color: '#00000',
};
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
valueContainer: (base: CSSObject, props: ValueContainerProps<T, any>): CSSObject => {
return {
...base,
};
},
};