Skip to content

Instantly share code, notes, and snippets.

@samselikoff
Created November 21, 2023 14:36
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save samselikoff/1b7d18b0aad30145e2f2a8c899fdf5bc to your computer and use it in GitHub Desktop.
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
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