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

@ -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,
};
},
};