Skip to content

Commit

Permalink
Fix client side maintaining of song list
Browse files Browse the repository at this point in the history
- utilize the URLSearchParams without triggering loader
- debounce the textual input so we don't have a history entry for each keypress
- use navigate(-1) over links to preserve history
  • Loading branch information
dvnrsn committed Nov 27, 2023
1 parent 8f5be8d commit da1c44a
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 47 deletions.
28 changes: 12 additions & 16 deletions app/components/filter-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,30 @@
import * as Ariakit from "@ariakit/react";
import { useSearchParams } from "@remix-run/react";
import { useRef, useState } from "react";
import { useRef } from "react";
import { FiFilter } from "react-icons/fi/index.js";

export default function FilterMenu() {
const [searchParams, setSearchParams] = useSearchParams();
export default function FilterMenu({
incomplete,
setIncomplete,
handleSubmit,
}: {
incomplete: boolean;
setIncomplete: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmit: ({ incompleteArg }: { incompleteArg?: boolean }) => void;
}) {
const filterRef = useRef<HTMLInputElement>(null);
const [incomplete, setIncomplete] = useState(
searchParams.get("filter") === "incomplete",
);

const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
setSearchParams((prev) => {
if (!incomplete) {
prev.set("filter", "incomplete");
} else {
prev.delete("filter");
}
return prev;
});
setIncomplete((prev) => !prev);
handleSubmit({ incompleteArg: !incomplete });
};

return (
<Ariakit.MenuProvider>
<Ariakit.MenuButton className="items-center justify-center w-11 ml-auto md:h-[revert] md:w-[revert] flex dark:hover:bg-slate-700 hover:bg-slate-200 rounded-lg p-2 relative">
<FiFilter size={24} />
{incomplete ? (
<div className="absolute top-0 rounded-lg right-0 bg-red-700 h-4 w-4 translate-x-2 -translate-y-2 text-xs">
<div className="absolute top-0 rounded-lg right-0 bg-red-300 dark:bg-red-700 h-4 w-4 translate-x-2 -translate-y-2 text-xs">
1
</div>
) : null}
Expand Down
11 changes: 5 additions & 6 deletions app/components/mobile-song-list.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import type { Song } from "@prisma/client";
import { Link } from "@remix-run/react";
import { useNavigate } from "@remix-run/react";

export default function MobileSongList({
songListItems,
q,
}: {
songListItems: Pick<
Song,
"id" | "title" | "artist" | "danceChoreographer" | "danceName"
>[];
q?: string;
}) {
const navigate = useNavigate();
return (
<div className="flex flex-col md:hidden mt-4">
{songListItems.map((song, index) => (
<Link
to={`${song.id}${q ? `?q=${q}` : ""}`}
<button
onClick={() => navigate(-1)}
key={song.id}
className={`flex flex-col border-gray-200 ${
index !== songListItems.length - 1
Expand All @@ -27,7 +26,7 @@ export default function MobileSongList({
<div className="text-lg font-bold truncate">{song.title}</div>
<div className="text-sm">{song.artist}</div>
</div>
</Link>
</button>
))}
</div>
);
Expand Down
13 changes: 6 additions & 7 deletions app/routes/songs.$songId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isRouteErrorResponse,
useActionData,
useLoaderData,
useNavigate,
useRouteError,
useSearchParams,

Check failure on line 11 in app/routes/songs.$songId.tsx

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

'useSearchParams' is defined but never used
} from "@remix-run/react";
Expand Down Expand Up @@ -55,7 +56,7 @@ export const action = async ({ params, request }: ActionFunctionArgs) => {
export default function SongDetailsPage() {
const { song } = useLoaderData<typeof loader>();
const user = useOptionalUser();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const result = useActionData<typeof action>();

useEffect(() => {
Expand All @@ -72,16 +73,14 @@ export default function SongDetailsPage() {
<div className="w-full flex justify-center md:mt-6">
<div className="relative w-full md:w-[550px] flex-col justify-center">
<div className="flex justify-between">
<Link
to={`..${
searchParams.get("q") ? `?q=${searchParams.get("q")}` : ""
}`}
<button
className="block p-2 md:absolute md:-translate-x-14 dark:hover:bg-slate-700 hover:bg-slate-200 rounded-lg"
aria-label="Songs"
onClick={() => navigate(-1)}
>
<BsArrowLeft size={24} />
<span className="sr-only">Songs</span>
</Link>
<span className="sr-only">Back to Songs</span>
</button>
{user?.isAdmin ? (
<Link
to="edit"
Expand Down
99 changes: 81 additions & 18 deletions app/routes/songs._index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, Link, useLoaderData, useSearchParams } from "@remix-run/react";
import {
Form,
Link,
useLoaderData,
useSearchParams,
useSubmit,
} from "@remix-run/react";
import Fuse from "fuse.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { MdOutlineClear } from "react-icons/md/index.js";
Expand All @@ -11,21 +16,27 @@ import MobileSongList from "~/components/mobile-song-list";
import { getSongListItems } from "~/models/song.server";
import { useOptionalUser } from "~/utils";

export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const q = url.searchParams.get("q") ?? "";

export const loader = async () => {
const songListItems = await getSongListItems();
return json({ songListItems, q });
return json({ songListItems });
};

// don't revalidate because we don't want to refetch the song list
// all filtering/searching/sorting will be client side
export const shouldRevalidate = () => false;

export default function SongsPage() {
const { q, songListItems } = useLoaderData<typeof loader>();
const { songListItems } = useLoaderData<typeof loader>();
const user = useOptionalUser();
const inputRef = useRef<HTMLInputElement>(null);
const theadRef = useRef<HTMLTableSectionElement>(null);
const [search, setSearch] = useState(q);
const [searchParams] = useSearchParams();
const [search, setSearch] = useState(searchParams.get("q") ?? "");
const [incomplete, setIncomplete] = useState(
searchParams.get("filter") === "incomplete",
);
const submit = useSubmit();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

useEffect(() => {
const thead = theadRef.current as HTMLTableSectionElement;
Expand All @@ -43,13 +54,63 @@ export default function SongsPage() {
};
}, [theadRef]);

useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [timeoutRef]);

const q = searchParams.get("q");
const incompleteFilter = searchParams.get("filter");

// update the UI to match the URL SearchParams (e.g., seen on back button)
// this does fire superflously when user types in the search box
// but it's a necessary tradeoff afaict to keep state in sync with URL
useEffect(() => {
setSearch(q || "");
}, [q]);

useEffect(() => {
setIncomplete(incompleteFilter === "incomplete");
}, [incompleteFilter]);

const handleSubmit = ({
incompleteArg,
searchArg,
debounce = false,
}: {
incompleteArg?: boolean;
searchArg?: string;
debounce?: boolean;
} = {}) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);

const q = typeof searchArg === "string" ? searchArg : searchParams.get("q");
const filter =
typeof incompleteArg === "boolean"
? incompleteArg
: searchParams.get("filter");

const formData = new FormData();
if (q) {
formData.set("q", q);
}
if (filter) {
formData.set("filter", "incomplete");
}
if (debounce) {
timeoutRef.current = setTimeout(() => submit(formData), 300);
} else {
submit(formData);
}
};

const handleClear = () => {
setSearch("");
inputRef.current?.focus();
handleSubmit({ searchArg: "" });
};

const incompleteFilter = searchParams.get("filter");

const filteredSongItems = useMemo(() => {
let list = songListItems;
if (incompleteFilter) {
Expand All @@ -71,7 +132,10 @@ export default function SongsPage() {
<input
ref={inputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => {
setSearch(e.target.value);
handleSubmit({ searchArg: e.target.value, debounce: true });
}}
aria-label="Search songs"
type="search"
placeholder="Search"
Expand Down Expand Up @@ -99,10 +163,11 @@ export default function SongsPage() {
</Link>
) : null}
<div className="md:hidden flex ml-auto">
<FilterMenu {...{ incomplete, setIncomplete, handleSubmit }} />
<LoginMenu />
</div>
<div className="flex ml-2">
<FilterMenu />
<div className="md:flex ml-2 hidden">
<FilterMenu {...{ incomplete, setIncomplete, handleSubmit }} />
</div>
</div>
{filteredSongItems.length === 0 ? (
Expand All @@ -116,7 +181,7 @@ export default function SongsPage() {
</div>
) : (
<>
<MobileSongList songListItems={filteredSongItems} q={q} />
<MobileSongList songListItems={filteredSongItems} />
<table className="w-full text-left hidden md:table table-fixed mt-4">
<thead
className="sticky top-[-1px] bg-white dark:bg-gray-900 border-gray-500 border-solid border-b-2"
Expand All @@ -141,9 +206,7 @@ export default function SongsPage() {
(song.title?.length || 0) > 30 ? song.title : ""
}`}
>
<Link to={`${song.id}${q ? `?q=${q}` : ""}`}>
{song.title}
</Link>
<Link to={`${song.id}`}>{song.title}</Link>
</td>
<td
className="py-3 px-2 border-gray-100 dark:border-gray-600 border-solid border-b-2 truncate"
Expand Down

0 comments on commit da1c44a

Please sign in to comment.