Add very simple search support to pages, togglable with "k"
This commit is contained in:
parent
807cd815ea
commit
0dbf6b51b5
|
@ -1,9 +1,9 @@
|
||||||
import { rawDataToServerObject, Server } from '@/api/server/getServer';
|
import { rawDataToServerObject, Server } from '@/api/server/getServer';
|
||||||
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
|
||||||
export default (): Promise<PaginatedResult<Server>> => {
|
export default (query?: string): Promise<PaginatedResult<Server>> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
http.get(`/api/client`, { params: { include: [ 'allocation' ] } })
|
http.get(`/api/client`, { params: { include: [ 'allocation' ], query } })
|
||||||
.then(({ data }) => resolve({
|
.then(({ data }) => resolve({
|
||||||
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
|
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
|
||||||
pagination: getPaginationSet(data.meta.pagination),
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook';
|
||||||
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
|
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
|
||||||
import { useStoreState } from 'easy-peasy';
|
import { useStoreState } from 'easy-peasy';
|
||||||
import { ApplicationStore } from '@/state';
|
import { ApplicationStore } from '@/state';
|
||||||
|
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
|
||||||
|
import SearchContainer from '@/components/dashboard/search/SearchContainer';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const user = useStoreState((state: ApplicationStore) => state.user.data!);
|
const user = useStoreState((state: ApplicationStore) => state.user.data!);
|
||||||
|
@ -22,6 +24,7 @@ export default () => {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className={'right-navigation'}>
|
<div className={'right-navigation'}>
|
||||||
|
<SearchContainer/>
|
||||||
<NavLink to={'/'} exact={true}>
|
<NavLink to={'/'} exact={true}>
|
||||||
<FontAwesomeIcon icon={faLayerGroup}/>
|
<FontAwesomeIcon icon={faLayerGroup}/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
|
||||||
|
import useEventListener from '@/plugins/useEventListener';
|
||||||
|
import SearchModal from '@/components/dashboard/search/SearchModal';
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [ visible, setVisible ] = useState(false);
|
||||||
|
|
||||||
|
useEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if ([ 'input', 'textarea' ].indexOf(((e.target as HTMLElement).tagName || 'input').toLowerCase()) < 0) {
|
||||||
|
if (!visible && e.key.toLowerCase() === 'k') {
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible &&
|
||||||
|
<SearchModal
|
||||||
|
appear={true}
|
||||||
|
visible={visible}
|
||||||
|
onDismissed={() => setVisible(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div className={'navigation-link'} onClick={() => setVisible(true)}>
|
||||||
|
<FontAwesomeIcon icon={faSearch}/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,123 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||||
|
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||||
|
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
|
import InputSpinner from '@/components/elements/InputSpinner';
|
||||||
|
import getServers from '@/api/getServers';
|
||||||
|
import { Server } from '@/api/server/getServer';
|
||||||
|
import { ApplicationStore } from '@/state';
|
||||||
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
type Props = RequiredModalProps;
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
term: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchWatcher = () => {
|
||||||
|
const { values, submitForm } = useFormikContext<Values>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (values.term.length >= 3) {
|
||||||
|
submitForm();
|
||||||
|
}
|
||||||
|
}, [ values.term ]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ({ ...props }: Props) => {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const [ loading, setLoading ] = useState(false);
|
||||||
|
const [ servers, setServers ] = useState<Server[]>([]);
|
||||||
|
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||||
|
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||||
|
|
||||||
|
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
setLoading(true);
|
||||||
|
setSubmitting(false);
|
||||||
|
clearFlashes('search');
|
||||||
|
getServers(term)
|
||||||
|
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
addError({ key: 'search', message: httpErrorToHuman(error) });
|
||||||
|
})
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.visible) {
|
||||||
|
setTimeout(() => ref.current?.focus(), 250);
|
||||||
|
}
|
||||||
|
}, [ props.visible ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={search}
|
||||||
|
validationSchema={object().shape({
|
||||||
|
term: string()
|
||||||
|
.min(3, 'Please enter at least three characters to begin searching.')
|
||||||
|
.required('A search term must be provided.'),
|
||||||
|
})}
|
||||||
|
initialValues={{ term: '' } as Values}
|
||||||
|
>
|
||||||
|
<Modal {...props}>
|
||||||
|
<Form>
|
||||||
|
<FormikFieldWrapper
|
||||||
|
name={'term'}
|
||||||
|
label={'Search term'}
|
||||||
|
description={
|
||||||
|
isAdmin
|
||||||
|
? 'Enter a server name, user email, or uuid to begin searching.'
|
||||||
|
: 'Enter a server name to begin searching.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchWatcher/>
|
||||||
|
<InputSpinner visible={loading}>
|
||||||
|
<Field
|
||||||
|
innerRef={ref}
|
||||||
|
name={'term'}
|
||||||
|
className={'input-dark'}
|
||||||
|
/>
|
||||||
|
</InputSpinner>
|
||||||
|
</FormikFieldWrapper>
|
||||||
|
</Form>
|
||||||
|
{servers.length > 0 &&
|
||||||
|
<div className={'mt-6'}>
|
||||||
|
{
|
||||||
|
servers.map(server => (
|
||||||
|
<Link
|
||||||
|
key={server.uuid}
|
||||||
|
to={`/server/${server.id}`}
|
||||||
|
className={'flex items-center block bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline hover:shadow hover:border-cyan-500 transition-colors duration-250'}
|
||||||
|
onClick={() => props.onDismissed()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className={'text-sm'}>{server.name}</p>
|
||||||
|
<p className={'mt-1 text-xs text-neutral-400'}>
|
||||||
|
{
|
||||||
|
server.allocations.filter(alloc => alloc.default).map(allocation => (
|
||||||
|
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={'flex-1 text-right'}>
|
||||||
|
<span className={'text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded'}>
|
||||||
|
{server.node}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Modal>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import { CSSTransition } from 'react-transition-group';
|
||||||
|
|
||||||
|
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
|
||||||
|
<div className={'relative'}>
|
||||||
|
<CSSTransition
|
||||||
|
timeout={250}
|
||||||
|
in={visible}
|
||||||
|
unmountOnExit={true}
|
||||||
|
appear={true}
|
||||||
|
classNames={'fade'}
|
||||||
|
>
|
||||||
|
<div className={'absolute pin-r h-full flex items-center justify-end pr-3'}>
|
||||||
|
<Spinner size={'tiny'}/>
|
||||||
|
</div>
|
||||||
|
</CSSTransition>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default InputSpinner;
|
|
@ -0,0 +1,9 @@
|
||||||
|
// noinspection ES6UnusedImports
|
||||||
|
import EasyPeasy from 'easy-peasy';
|
||||||
|
import { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
declare module 'easy-peasy' {
|
||||||
|
export function useStoreState<Result>(
|
||||||
|
mapState: (state: ApplicationStore) => Result,
|
||||||
|
): Result;
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export default (eventName: string, handler: any, element: any = window) => {
|
||||||
|
const savedHandler = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
savedHandler.current = handler;
|
||||||
|
}, [handler]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
const isSupported = element && element.addEventListener;
|
||||||
|
if (!isSupported) return;
|
||||||
|
|
||||||
|
const eventListener = (event: any) => savedHandler.current(event);
|
||||||
|
element.addEventListener(eventName, eventListener);
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener(eventName, eventListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[eventName, element],
|
||||||
|
);
|
||||||
|
};
|
|
@ -21,8 +21,8 @@
|
||||||
& .right-navigation {
|
& .right-navigation {
|
||||||
@apply .flex .h-full .items-center .justify-center;
|
@apply .flex .h-full .items-center .justify-center;
|
||||||
|
|
||||||
& > a {
|
& > a, & > .navigation-link {
|
||||||
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6;
|
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6 .cursor-pointer;
|
||||||
transition: background-color 150ms linear, color 150ms linear, box-shadow 150ms ease-in;
|
transition: background-color 150ms linear, color 150ms linear, box-shadow 150ms ease-in;
|
||||||
|
|
||||||
/*! purgecss start ignore */
|
/*! purgecss start ignore */
|
||||||
|
|
Loading…
Reference in New Issue