Skip to content

Commit 0dbf6b5

Browse files
committed
Add very simple search support to pages, togglable with "k"
1 parent 807cd81 commit 0dbf6b5

File tree

8 files changed

+216
-4
lines changed

8 files changed

+216
-4
lines changed

resources/scripts/api/getServers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { rawDataToServerObject, Server } from '@/api/server/getServer';
22
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
33

4-
export default (): Promise<PaginatedResult<Server>> => {
4+
export default (query?: string): Promise<PaginatedResult<Server>> => {
55
return new Promise((resolve, reject) => {
6-
http.get(`/api/client`, { params: { include: [ 'allocation' ] } })
6+
http.get(`/api/client`, { params: { include: [ 'allocation' ], query } })
77
.then(({ data }) => resolve({
88
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
99
pagination: getPaginationSet(data.meta.pagination),

resources/scripts/components/NavigationBar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook';
88
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
99
import { useStoreState } from 'easy-peasy';
1010
import { ApplicationStore } from '@/state';
11+
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
12+
import SearchContainer from '@/components/dashboard/search/SearchContainer';
1113

1214
export default () => {
1315
const user = useStoreState((state: ApplicationStore) => state.user.data!);
@@ -22,6 +24,7 @@ export default () => {
2224
</Link>
2325
</div>
2426
<div className={'right-navigation'}>
27+
<SearchContainer/>
2528
<NavLink to={'/'} exact={true}>
2629
<FontAwesomeIcon icon={faLayerGroup}/>
2730
</NavLink>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React, { useState } from 'react';
2+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3+
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
4+
import useEventListener from '@/plugins/useEventListener';
5+
import SearchModal from '@/components/dashboard/search/SearchModal';
6+
7+
export default () => {
8+
const [ visible, setVisible ] = useState(false);
9+
10+
useEventListener('keydown', (e: KeyboardEvent) => {
11+
if ([ 'input', 'textarea' ].indexOf(((e.target as HTMLElement).tagName || 'input').toLowerCase()) < 0) {
12+
if (!visible && e.key.toLowerCase() === 'k') {
13+
setVisible(true);
14+
}
15+
}
16+
});
17+
18+
return (
19+
<>
20+
{visible &&
21+
<SearchModal
22+
appear={true}
23+
visible={visible}
24+
onDismissed={() => setVisible(false)}
25+
/>
26+
}
27+
<div className={'navigation-link'} onClick={() => setVisible(true)}>
28+
<FontAwesomeIcon icon={faSearch}/>
29+
</div>
30+
</>
31+
);
32+
};
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
3+
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
4+
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
5+
import { object, string } from 'yup';
6+
import { debounce } from 'lodash-es';
7+
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
8+
import InputSpinner from '@/components/elements/InputSpinner';
9+
import getServers from '@/api/getServers';
10+
import { Server } from '@/api/server/getServer';
11+
import { ApplicationStore } from '@/state';
12+
import { httpErrorToHuman } from '@/api/http';
13+
import { Link } from 'react-router-dom';
14+
15+
type Props = RequiredModalProps;
16+
17+
interface Values {
18+
term: string;
19+
}
20+
21+
const SearchWatcher = () => {
22+
const { values, submitForm } = useFormikContext<Values>();
23+
24+
useEffect(() => {
25+
if (values.term.length >= 3) {
26+
submitForm();
27+
}
28+
}, [ values.term ]);
29+
30+
return null;
31+
};
32+
33+
export default ({ ...props }: Props) => {
34+
const ref = useRef<HTMLInputElement>(null);
35+
const [ loading, setLoading ] = useState(false);
36+
const [ servers, setServers ] = useState<Server[]>([]);
37+
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
38+
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
39+
40+
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
41+
setLoading(true);
42+
setSubmitting(false);
43+
clearFlashes('search');
44+
getServers(term)
45+
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
46+
.catch(error => {
47+
console.error(error);
48+
addError({ key: 'search', message: httpErrorToHuman(error) });
49+
})
50+
.then(() => setLoading(false));
51+
}, 500);
52+
53+
useEffect(() => {
54+
if (props.visible) {
55+
setTimeout(() => ref.current?.focus(), 250);
56+
}
57+
}, [ props.visible ]);
58+
59+
return (
60+
<Formik
61+
onSubmit={search}
62+
validationSchema={object().shape({
63+
term: string()
64+
.min(3, 'Please enter at least three characters to begin searching.')
65+
.required('A search term must be provided.'),
66+
})}
67+
initialValues={{ term: '' } as Values}
68+
>
69+
<Modal {...props}>
70+
<Form>
71+
<FormikFieldWrapper
72+
name={'term'}
73+
label={'Search term'}
74+
description={
75+
isAdmin
76+
? 'Enter a server name, user email, or uuid to begin searching.'
77+
: 'Enter a server name to begin searching.'
78+
}
79+
>
80+
<SearchWatcher/>
81+
<InputSpinner visible={loading}>
82+
<Field
83+
innerRef={ref}
84+
name={'term'}
85+
className={'input-dark'}
86+
/>
87+
</InputSpinner>
88+
</FormikFieldWrapper>
89+
</Form>
90+
{servers.length > 0 &&
91+
<div className={'mt-6'}>
92+
{
93+
servers.map(server => (
94+
<Link
95+
key={server.uuid}
96+
to={`/server/${server.id}`}
97+
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'}
98+
onClick={() => props.onDismissed()}
99+
>
100+
<div>
101+
<p className={'text-sm'}>{server.name}</p>
102+
<p className={'mt-1 text-xs text-neutral-400'}>
103+
{
104+
server.allocations.filter(alloc => alloc.default).map(allocation => (
105+
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
106+
))
107+
}
108+
</p>
109+
</div>
110+
<div className={'flex-1 text-right'}>
111+
<span className={'text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded'}>
112+
{server.node}
113+
</span>
114+
</div>
115+
</Link>
116+
))
117+
}
118+
</div>
119+
}
120+
</Modal>
121+
</Formik>
122+
);
123+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from 'react';
2+
import Spinner from '@/components/elements/Spinner';
3+
import { CSSTransition } from 'react-transition-group';
4+
5+
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
6+
<div className={'relative'}>
7+
<CSSTransition
8+
timeout={250}
9+
in={visible}
10+
unmountOnExit={true}
11+
appear={true}
12+
classNames={'fade'}
13+
>
14+
<div className={'absolute pin-r h-full flex items-center justify-end pr-3'}>
15+
<Spinner size={'tiny'}/>
16+
</div>
17+
</CSSTransition>
18+
{children}
19+
</div>
20+
);
21+
22+
export default InputSpinner;

resources/scripts/easy-peasy.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// noinspection ES6UnusedImports
2+
import EasyPeasy from 'easy-peasy';
3+
import { ApplicationStore } from '@/state';
4+
5+
declare module 'easy-peasy' {
6+
export function useStoreState<Result>(
7+
mapState: (state: ApplicationStore) => Result,
8+
): Result;
9+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
export default (eventName: string, handler: any, element: any = window) => {
4+
const savedHandler = useRef<any>(null);
5+
6+
useEffect(() => {
7+
savedHandler.current = handler;
8+
}, [handler]);
9+
10+
useEffect(
11+
() => {
12+
const isSupported = element && element.addEventListener;
13+
if (!isSupported) return;
14+
15+
const eventListener = (event: any) => savedHandler.current(event);
16+
element.addEventListener(eventName, eventListener);
17+
return () => {
18+
element.removeEventListener(eventName, eventListener);
19+
};
20+
},
21+
[eventName, element],
22+
);
23+
};

resources/styles/components/navigation.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
& .right-navigation {
2222
@apply .flex .h-full .items-center .justify-center;
2323

24-
& > a {
25-
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6;
24+
& > a, & > .navigation-link {
25+
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6 .cursor-pointer;
2626
transition: background-color 150ms linear, color 150ms linear, box-shadow 150ms ease-in;
2727

2828
/*! purgecss start ignore */

0 commit comments

Comments
 (0)