Created
November 21, 2023 14:36
-
-
Save samselikoff/1b7d18b0aad30145e2f2a8c899fdf5bc to your computer and use it in GitHub Desktop.
Diff from "Optimistic UI in Remix": https://www.youtube.com/watch?v=d0p95C3Kcsg
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
diff --git a/app/components/entry-form.tsx b/app/components/entry-form.tsx | |
index 50e5aeb..84c64fc 100644 | |
--- a/app/components/entry-form.tsx | |
+++ b/app/components/entry-form.tsx | |
@@ -1,6 +1,6 @@ | |
-import { useFetcher } from "@remix-run/react"; | |
+import { Form, useSubmit } from "@remix-run/react"; | |
import { format } from "date-fns"; | |
-import { useEffect, useRef } from "react"; | |
+import { useRef } from "react"; | |
export default function EntryForm({ | |
entry, | |
@@ -11,24 +11,30 @@ export default function EntryForm({ | |
type: string; | |
}; | |
}) { | |
- let fetcher = useFetcher(); | |
let textareaRef = useRef<HTMLTextAreaElement>(null); | |
+ let submit = useSubmit(); | |
- let hasSubmitted = fetcher.data !== undefined && fetcher.state === "idle"; | |
+ return ( | |
+ <Form | |
+ onSubmit={(e) => { | |
+ e.preventDefault(); | |
+ let formData = new FormData(e.currentTarget); | |
+ let data = validate(Object.fromEntries(formData)); | |
- useEffect(() => { | |
- if (textareaRef.current && hasSubmitted) { | |
- textareaRef.current.value = ""; | |
- textareaRef.current.focus(); | |
- } | |
- }, [hasSubmitted]); | |
+ submit( | |
+ { ...data, id: window.crypto.randomUUID() }, | |
+ { navigate: false, method: "post" } | |
+ ); | |
- return ( | |
- <fetcher.Form method="post" className="mt-4"> | |
- <fieldset | |
- className="disabled:opacity-70" | |
- disabled={fetcher.state !== "idle"} | |
- > | |
+ if (textareaRef.current) { | |
+ textareaRef.current.value = ""; | |
+ textareaRef.current.focus(); | |
+ } | |
+ }} | |
+ method="post" | |
+ className="mt-4" | |
+ > | |
+ <fieldset> | |
<div className="lg:flex lg:items-center lg:justify-between"> | |
<div className="lg:order-2"> | |
<input | |
@@ -71,6 +77,14 @@ export default function EntryForm({ | |
required | |
rows={3} | |
defaultValue={entry?.text} | |
+ onKeyDown={(e) => { | |
+ if (e.key === "Enter") { | |
+ e.preventDefault(); | |
+ e.currentTarget.form?.dispatchEvent( | |
+ new Event("submit", { bubbles: true, cancelable: true }) | |
+ ); | |
+ } | |
+ }} | |
/> | |
</div> | |
@@ -79,10 +93,24 @@ export default function EntryForm({ | |
type="submit" | |
className="w-full rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-600 focus:ring-offset-2 focus:ring-offset-gray-900 lg:w-auto lg:py-1.5" | |
> | |
- {fetcher.state !== "idle" ? "Saving..." : "Save"} | |
+ Save | |
</button> | |
</div> | |
</fieldset> | |
- </fetcher.Form> | |
+ </Form> | |
); | |
} | |
+ | |
+function validate(data: Record<string, any>) { | |
+ let { date, type, text } = data; | |
+ | |
+ if ( | |
+ typeof date !== "string" || | |
+ typeof type !== "string" || | |
+ typeof text !== "string" | |
+ ) { | |
+ throw new Error("Bad data"); | |
+ } | |
+ | |
+ return { date, type, text }; | |
+} | |
diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx | |
index 2e15361..4371097 100644 | |
--- a/app/routes/_index.tsx | |
+++ b/app/routes/_index.tsx | |
@@ -2,13 +2,14 @@ import { | |
type ActionFunctionArgs, | |
type LoaderFunctionArgs, | |
} from "@remix-run/node"; | |
-import { Link, useLoaderData } from "@remix-run/react"; | |
+import { Link, useFetchers, useLoaderData } from "@remix-run/react"; | |
import { format, parseISO, startOfWeek } from "date-fns"; | |
import EntryForm from "~/components/entry-form"; | |
import prisma from "~/prisma.server"; | |
import { getSession } from "~/session"; | |
+import { CloudIcon } from "@heroicons/react/20/solid"; | |
-const DELAY = 500; | |
+const DELAY = 5000; | |
export async function action({ request }: ActionFunctionArgs) { | |
await new Promise((resolve) => setTimeout(resolve, DELAY)); | |
@@ -22,10 +23,11 @@ export async function action({ request }: ActionFunctionArgs) { | |
} | |
let formData = await request.formData(); | |
- let { date, type, text } = validate(Object.fromEntries(formData)); | |
+ let { date, type, text, id } = validate(Object.fromEntries(formData)); | |
return prisma.entry.create({ | |
data: { | |
+ id, | |
date: new Date(date), | |
type, | |
text, | |
@@ -51,6 +53,21 @@ export async function loader({ request }: LoaderFunctionArgs) { | |
export default function Index() { | |
let { session, entries } = useLoaderData<typeof loader>(); | |
+ let fetchers = useFetchers(); | |
+ let optimisticEntries = fetchers.reduce<Entry[]>((memo, f) => { | |
+ if (f.formData) { | |
+ let data = validate(Object.fromEntries(f.formData)); | |
+ | |
+ if (!entries.map((e) => e.id).includes(data.id)) { | |
+ memo.push(data); | |
+ } | |
+ } | |
+ | |
+ return memo; | |
+ }, []); | |
+ | |
+ entries = [...entries, ...optimisticEntries]; | |
+ | |
let entriesByWeek = entries | |
.sort((a, b) => b.date.localeCompare(a.date)) | |
.reduce<Record<string, typeof entries>>((memo, entry) => { | |
@@ -78,9 +95,15 @@ export default function Index() { | |
<div> | |
{session.isAdmin && ( | |
<div className="mb-8 rounded-lg border border-gray-700/30 bg-gray-800/50 p-4 lg:mb-20 lg:p-6"> | |
- <p className="text-sm font-medium text-gray-500 lg:text-base"> | |
- New entry | |
- </p> | |
+ <div className="inline-center flex justify-between"> | |
+ <p className="text-sm font-medium text-gray-500 lg:text-base"> | |
+ New entry | |
+ </p> | |
+ | |
+ {optimisticEntries.length > 0 && ( | |
+ <CloudIcon className="h-4 w-4 text-gray-500" /> | |
+ )} | |
+ </div> | |
<EntryForm /> | |
</div> | |
@@ -148,9 +171,10 @@ function EntryListItem({ entry }: { entry: Entry }) { | |
} | |
function validate(data: Record<string, any>) { | |
- let { date, type, text } = data; | |
+ let { date, type, text, id } = data; | |
if ( | |
+ typeof id !== "string" || | |
typeof date !== "string" || | |
typeof type !== "string" || | |
typeof text !== "string" | |
@@ -158,5 +182,5 @@ function validate(data: Record<string, any>) { | |
throw new Error("Bad data"); | |
} | |
- return { date, type, text }; | |
+ return { date, type, text, id }; | |
} | |
diff --git a/prisma/migrations/20231117234024_change_id_to_string/migration.sql b/prisma/migrations/20231117234024_change_id_to_string/migration.sql | |
new file mode 100644 | |
index 0000000..84607cb | |
--- /dev/null | |
+++ b/prisma/migrations/20231117234024_change_id_to_string/migration.sql | |
@@ -0,0 +1,19 @@ | |
+/* | |
+ Warnings: | |
+ | |
+ - The primary key for the `Entry` table will be changed. If it partially fails, the table could be left without primary key constraint. | |
+ | |
+*/ | |
+-- RedefineTables | |
+PRAGMA foreign_keys=OFF; | |
+CREATE TABLE "new_Entry" ( | |
+ "id" TEXT NOT NULL PRIMARY KEY, | |
+ "date" DATETIME NOT NULL, | |
+ "type" TEXT NOT NULL, | |
+ "text" TEXT NOT NULL | |
+); | |
+INSERT INTO "new_Entry" ("date", "id", "text", "type") SELECT "date", "id", "text", "type" FROM "Entry"; | |
+DROP TABLE "Entry"; | |
+ALTER TABLE "new_Entry" RENAME TO "Entry"; | |
+PRAGMA foreign_key_check; | |
+PRAGMA foreign_keys=ON; | |
diff --git a/prisma/schema.prisma b/prisma/schema.prisma | |
index b10d1d2..3176959 100644 | |
--- a/prisma/schema.prisma | |
+++ b/prisma/schema.prisma | |
@@ -11,7 +11,7 @@ datasource db { | |
} | |
model Entry { | |
- id Int @id @default(autoincrement()) | |
+ id String @id @default(uuid()) | |
date DateTime | |
type String | |
text String |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment