more features in search bar

This commit is contained in:
Dylan Knutson
2023-04-04 20:08:20 +09:00
parent 57083dc74c
commit f1e40a405f
3 changed files with 221 additions and 86 deletions

View File

@@ -14,7 +14,14 @@ class Domain::Fa::ApiController < ApplicationController
map do |id, name, url_name|
{ id: id, name: name, url_name: url_name }
end
render json: { users: users }
if params[:force_sleep]
sleep 2
end
if params[:force_error]
render status: 500, json: { error: "failed!" }
else
render json: { users: users }
end
end
def object_statuses

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useState } from "react";
import PropTypes from "prop-types";
import { debounce, isEmpty } from "lodash";
const HOST = "http://scraper.local:3001";
// staging
// const HOST = "http://scraper.local:3001";
const HOST = "";
const COMMON_LIST_ELEM_CLASSES = `
w-full p-2 pl-8
text-xl font-light
@@ -23,46 +25,115 @@ const INPUT_ELEM_CLASSES = `
placeholder:font-extralight
`;
function UserSearchBar(props) {
function UserSearchBar({}) {
const [userName, setUserName] = useState("");
const [items, setUserList] = useState([]);
const [selectedIdx, setSelectedIdx] = useState(1);
const [selectedIdx, setSelectedIdx] = useState(null);
const [showSpinner, setShowSpinner] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);
const [pendingRequest, setPendingRequest] = useState(null);
const inputRef = useRef(null);
const inputHasFocus = document.activeElement == inputRef.current;
const searchForUser = useMemo(
const clearResults = useCallback(() => {
setUserList([]);
setErrorMessage(null);
setSelectedIdx(null);
}, []);
const cancelPendingRequest = useCallback(async () => {
if (pendingRequest) {
clearResults();
setPendingRequest(null);
await pendingRequest.abort();
}
}, [pendingRequest, userName]);
const searchForUserDebounced = useMemo(
() =>
debounce(async (userName) => {
if (isEmpty(userName)) {
return;
}
setShowSpinner(true);
const response = await fetch(
`${HOST}/api/fa/search_users?name=${encodeURIComponent(userName)}`
);
const { users } = await response.json();
setShowSpinner(false);
setUserList(users);
setUserName(userName);
}, 250),
[]
);
const searchForUser = useMemo(
() => (userName) => {
cancelPendingRequest();
if (isEmpty(userName)) {
setUserName(userName);
clearResults();
} else {
searchForUserDebounced(userName);
}
},
[pendingRequest]
);
useEffect(() => {
searchForUser(userName);
return () => searchForUser.cancel();
if (isEmpty(userName)) {
return;
}
const controller = new AbortController();
setPendingRequest(controller);
setShowSpinner(true);
fetch(`${HOST}/api/fa/search_users?name=${encodeURIComponent(userName)}`, {
signal: controller.signal,
})
.then(handleServerResponse)
.catch((err) => {
if (err.code != err.ABORT_ERR) {
clearResults();
setErrorMessage(err.toString());
}
})
.finally(() => setShowSpinner(false));
return () => {
cancelPendingRequest();
};
}, [userName]);
const handleServerResponse = useCallback(async (response) => {
clearResults();
const contentType = response.headers.get("Content-Type");
if (response.status == 200) {
const { users } = await response.json();
setUserList(users);
} else if (
!isEmpty(contentType) &&
contentType.startsWith("application/json")
) {
const { error } = await response.json();
setErrorMessage(`${response.status}: ${error}`);
} else {
const text = await response.text();
setErrorMessage(`${response.status}: ${text}`);
}
}, []);
function onSearchInputKeyDown(event) {
const { code } = event;
const { code, shiftKey } = event;
switch (code) {
case "Tab":
if (shiftKey) {
selectPrevListElem();
} else {
selectNextListElem();
}
break;
case "ArrowDown":
selectNextListElem();
event.preventDefault();
break;
case "ArrowUp":
selectPrevListElem();
event.preventDefault();
break;
default:
return;
}
event.preventDefault();
}
function selectNextListElem() {
@@ -86,6 +157,37 @@ function UserSearchBar(props) {
setSelectedIdx(newIdx);
}
const anyItemsShown =
inputHasFocus && (items.length > 0 || !isEmpty(errorMessage));
function UserSearchBarItems() {
if (!anyItemsShown) {
return null;
}
return (
<div>
{errorMessage ? (
<ListItem
key="error"
isLast={items.length == 0}
selected={false}
style="error"
value={errorMessage}
/>
) : null}
{items.map(({ name }, idx) => (
<ListItem
key={name}
isLast={idx == items.length - 1}
selected={idx == selectedIdx}
style="item"
value={name}
/>
))}
</div>
);
}
return (
<div
className="group mx-auto w-full p-2
@@ -96,14 +198,11 @@ function UserSearchBar(props) {
>
<label className={`relative block ${INPUT_ELEM_CLASSES}`}>
<Icon
type="magnifying_glass"
type="magnifying-glass"
className={`ml-2 ${FOCUSABLE_SVG_ELEM_CLASSES}`}
/>
{showSpinner ? (
<Icon
type="spinner"
className={`ml-2 right-2 ${SVG_ELEM_CLASSES}`}
/>
<Icon type="spinner" className={`right-2 ${SVG_ELEM_CLASSES}`} />
) : null}
<input
autoFocus
@@ -112,38 +211,47 @@ function UserSearchBar(props) {
INPUT_ELEM_CLASSES,
"outline-none rounded-lg",
"placeholder:italic bg-slate-100",
items.length > 0 ? "rounded-b-none" : null,
anyItemsShown ? "rounded-b-none" : null,
].join(" ")}
placeholder={"Enter Username"}
defaultValue={userName}
onChange={(v) => setUserName(v.target.value)}
onChange={(v) => searchForUser(v.target.value)}
onKeyDown={(v) => onSearchInputKeyDown(v)}
ref={inputRef}
/>
</label>
<div>
{items.map(({ name }, idx) => (
<ListItem
key={name}
isLast={idx == items.length - 1}
selected={idx == selectedIdx}
value={name}
/>
))}
</div>
<UserSearchBarItems />
</div>
);
}
function ListItem({ value, isLast, selected }) {
function ListItem({ value, isLast, selected, style }) {
const iconClassName = ["ml-2"];
const textClassName = [
COMMON_LIST_ELEM_CLASSES,
"border-t-0",
isLast ? "rounded-b-lg" : "",
];
switch (style) {
case "item":
textClassName.push(selected ? "bg-slate-700 text-slate-100" : "");
break;
case "info":
textClassName.push("text-slate-500 italic");
break;
case "error":
textClassName.push("text-red-500");
iconClassName.push("text-red-500");
break;
}
return (
<div
className={`
${COMMON_LIST_ELEM_CLASSES} border-t-0
${isLast ? "rounded-b-lg" : ""}
${selected ? "bg-slate-700 text-slate-100" : ""}`}
>
{value}
<div className="relative block">
{style == "error" ? (
<Icon type="exclamation-circle" className={iconClassName.join(" ")} />
) : null}
<div className={textClassName.join(" ")}>{value}</div>
</div>
);
}
@@ -151,6 +259,7 @@ ListItem.propTypes = {
name: PropTypes.string,
isLast: PropTypes.bool,
selected: PropTypes.bool,
style: PropTypes.oneOf(["item", "info", "error"]),
};
// from https://heroicons.com/
@@ -159,50 +268,69 @@ function Icon(props) {
const className = `w-6 h-6 pointer-events-none absolute
transform top-1/2 -translate-y-1/2 ${props.className}`;
if (type == "magnifying_glass") {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={"2"}
className={className}
>
<path
switch (type) {
case "magnifying-glass":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
viewBox="0 0 24 24"
strokeWidth={"2"}
className={className}
>
<path
fill="none"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
case "spinner":
return (
<svg
version="1.1"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="6 6 38 38"
className={className}
>
<path d="M25.251,6.461c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615V6.461z">
<animateTransform
attributeType="xml"
attributeName="transform"
type="rotate"
from="0 25 25"
to="360 25 25"
dur="0.6s"
repeatCount="indefinite"
/>
</path>
</svg>
);
case "exclamation-circle":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className={`w-6 h-6 ${className}`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
);
}
if (type == "spinner") {
return (
<svg
version="1.1"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="6 6 38 38"
className={className}
>
<path d="M25.251,6.461c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615V6.461z">
<animateTransform
attributeType="xml"
attributeName="transform"
type="rotate"
from="0 25 25"
to="360 25 25"
dur="0.6s"
repeatCount="indefinite"
/>
</path>
</svg>
);
}
throw `unhandled icon type ${type}`;
}
Icon.propTypes = {
type: PropTypes.oneOf(["magnifying_glass", "spinner"]),
type: PropTypes.oneOf(["magnifying-glass", "exclamation-circle", "spinner"]),
className: PropTypes.string,
};

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>ReFurrer</title>
<title><%= @site_title %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>