From 0dbf6b51b5d9b9ee94a605e1dc5fabae696b29de Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 3 Apr 2020 22:39:53 -0700 Subject: [PATCH] Add very simple search support to pages, togglable with "k" --- resources/scripts/api/getServers.ts | 4 +- .../scripts/components/NavigationBar.tsx | 3 + .../dashboard/search/SearchContainer.tsx | 32 +++++ .../dashboard/search/SearchModal.tsx | 123 ++++++++++++++++++ .../components/elements/InputSpinner.tsx | 22 ++++ resources/scripts/easy-peasy.d.ts | 9 ++ resources/scripts/plugins/useEventListener.ts | 23 ++++ resources/styles/components/navigation.css | 4 +- 8 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 resources/scripts/components/dashboard/search/SearchContainer.tsx create mode 100644 resources/scripts/components/dashboard/search/SearchModal.tsx create mode 100644 resources/scripts/components/elements/InputSpinner.tsx create mode 100644 resources/scripts/easy-peasy.d.ts create mode 100644 resources/scripts/plugins/useEventListener.ts diff --git a/resources/scripts/api/getServers.ts b/resources/scripts/api/getServers.ts index b77440da2..c67322a78 100644 --- a/resources/scripts/api/getServers.ts +++ b/resources/scripts/api/getServers.ts @@ -1,9 +1,9 @@ import { rawDataToServerObject, Server } from '@/api/server/getServer'; import http, { getPaginationSet, PaginatedResult } from '@/api/http'; -export default (): Promise> => { +export default (query?: string): Promise> => { return new Promise((resolve, reject) => { - http.get(`/api/client`, { params: { include: [ 'allocation' ] } }) + http.get(`/api/client`, { params: { include: [ 'allocation' ], query } }) .then(({ data }) => resolve({ items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)), pagination: getPaginationSet(data.meta.pagination), diff --git a/resources/scripts/components/NavigationBar.tsx b/resources/scripts/components/NavigationBar.tsx index 0482069e5..7ff60bef0 100644 --- a/resources/scripts/components/NavigationBar.tsx +++ b/resources/scripts/components/NavigationBar.tsx @@ -8,6 +8,8 @@ import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook'; import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs'; import { useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; +import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch'; +import SearchContainer from '@/components/dashboard/search/SearchContainer'; export default () => { const user = useStoreState((state: ApplicationStore) => state.user.data!); @@ -22,6 +24,7 @@ export default () => {
+ diff --git a/resources/scripts/components/dashboard/search/SearchContainer.tsx b/resources/scripts/components/dashboard/search/SearchContainer.tsx new file mode 100644 index 000000000..475d65510 --- /dev/null +++ b/resources/scripts/components/dashboard/search/SearchContainer.tsx @@ -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 && + setVisible(false)} + /> + } +
setVisible(true)}> + +
+ + ); +}; diff --git a/resources/scripts/components/dashboard/search/SearchModal.tsx b/resources/scripts/components/dashboard/search/SearchModal.tsx new file mode 100644 index 000000000..af6f6fcca --- /dev/null +++ b/resources/scripts/components/dashboard/search/SearchModal.tsx @@ -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(); + + useEffect(() => { + if (values.term.length >= 3) { + submitForm(); + } + }, [ values.term ]); + + return null; +}; + +export default ({ ...props }: Props) => { + const ref = useRef(null); + const [ loading, setLoading ] = useState(false); + const [ servers, setServers ] = useState([]); + const isAdmin = useStoreState(state => state.user.data!.rootAdmin); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + + const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers) => { + 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 ( + + +
+ + + + + + +
+ {servers.length > 0 && +
+ { + servers.map(server => ( + props.onDismissed()} + > +
+

{server.name}

+

+ { + server.allocations.filter(alloc => alloc.default).map(allocation => ( + {allocation.alias || allocation.ip}:{allocation.port} + )) + } +

+
+
+ + {server.node} + +
+ + )) + } +
+ } +
+
+ ); +}; diff --git a/resources/scripts/components/elements/InputSpinner.tsx b/resources/scripts/components/elements/InputSpinner.tsx new file mode 100644 index 000000000..1dbc3c84a --- /dev/null +++ b/resources/scripts/components/elements/InputSpinner.tsx @@ -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 }) => ( +
+ +
+ +
+
+ {children} +
+); + +export default InputSpinner; diff --git a/resources/scripts/easy-peasy.d.ts b/resources/scripts/easy-peasy.d.ts new file mode 100644 index 000000000..939ad54cf --- /dev/null +++ b/resources/scripts/easy-peasy.d.ts @@ -0,0 +1,9 @@ +// noinspection ES6UnusedImports +import EasyPeasy from 'easy-peasy'; +import { ApplicationStore } from '@/state'; + +declare module 'easy-peasy' { + export function useStoreState( + mapState: (state: ApplicationStore) => Result, + ): Result; +} diff --git a/resources/scripts/plugins/useEventListener.ts b/resources/scripts/plugins/useEventListener.ts new file mode 100644 index 000000000..7cb14690a --- /dev/null +++ b/resources/scripts/plugins/useEventListener.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; + +export default (eventName: string, handler: any, element: any = window) => { + const savedHandler = useRef(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], + ); +}; diff --git a/resources/styles/components/navigation.css b/resources/styles/components/navigation.css index 16f64e41d..31951ebfb 100644 --- a/resources/styles/components/navigation.css +++ b/resources/styles/components/navigation.css @@ -21,8 +21,8 @@ & .right-navigation { @apply .flex .h-full .items-center .justify-center; - & > a { - @apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6; + & > a, & > .navigation-link { + @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; /*! purgecss start ignore */