more features in search bar
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
Reference in New Issue
Block a user