Skip to content

Commit

Permalink
feat: content serializer (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
HenrikFricke authored Nov 18, 2024
1 parent 4dc613c commit 5f27f6b
Show file tree
Hide file tree
Showing 15 changed files with 712 additions and 134 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
CREATE TABLE IF NOT EXISTS "documents" (
CREATE TABLE IF NOT EXISTS "docs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"content" text DEFAULT '' NOT NULL,
"content" jsonb DEFAULT '[]'::jsonb NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"deletedAt" timestamp
Expand Down
10 changes: 5 additions & 5 deletions app/db/migrations/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"id": "5c7b216a-924f-423a-bc4c-04562e3d1ba3",
"id": "a6916f44-aca9-4805-ac92-92506f6ca500",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.documents": {
"name": "documents",
"public.docs": {
"name": "docs",
"schema": "",
"columns": {
"id": {
Expand All @@ -17,10 +17,10 @@
},
"content": {
"name": "content",
"type": "text",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "''"
"default": "'[]'::jsonb"
},
"createdAt": {
"name": "createdAt",
Expand Down
4 changes: 2 additions & 2 deletions app/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1731332738397,
"tag": "0000_tense_young_avengers",
"when": 1731430480944,
"tag": "0000_amusing_gabe_jones",
"breakpoints": true
}
]
Expand Down
6 changes: 3 additions & 3 deletions app/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { pgTable, text, uuid, timestamp } from 'drizzle-orm/pg-core';
import { pgTable, jsonb, uuid, timestamp } from 'drizzle-orm/pg-core';

export const documentsTable = pgTable('documents', {
export const docsTable = pgTable('docs', {
id: uuid().primaryKey().defaultRandom(),
content: text().notNull().default(''),
content: jsonb().notNull().default([]),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
deletedAt: timestamp(),
Expand Down
64 changes: 64 additions & 0 deletions app/repos/doc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { eq } from 'drizzle-orm';
import { createDoc, getDoc, updateDoc } from './doc';
import { docsTable } from '~/db/schema';
import { db } from '~/db';
import { v4 as uuid } from 'uuid';
import { ContentBlock } from '~/types/content';

describe('createDoc', () => {
it('creates a doc', async () => {
const doc = await createDoc();
const response = db.query.docsTable.findFirst({
where: eq(docsTable.id, doc.id),
});

expect(response).toBeDefined();
});
});

describe('getDoc', () => {
it('returns doc if exists', async () => {
const expectedDoc = await db.insert(docsTable).values({}).returning();
const doc = await getDoc(expectedDoc[0].id);

expect(doc).toBeDefined();
});

it('returns null if uuid invalid', async () => {
const doc = await getDoc('abc');

expect(doc).toBe(null);
});

it('returns null if not exists', async () => {
const doc = await getDoc(uuid());

expect(doc).toBe(null);
});
});

describe('updateDoc', () => {
it('updates the doc', async () => {
const content: ContentBlock[] = [
{
type: 'paragraph',
nodes: [
{
type: 'text',
value: 'Hello, world!',
},
],
},
];

const doc = await db.insert(docsTable).values({}).returning();

await updateDoc(doc[0].id, content);

const updatedDoc = await db.query.docsTable.findFirst({
where: eq(docsTable.id, doc[0].id),
});

expect(updatedDoc?.content).toStrictEqual(content);
});
});
52 changes: 52 additions & 0 deletions app/repos/doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { eq } from 'drizzle-orm';
import { validate as isValidUuid } from 'uuid';
import { db } from '~/db';
import { docsTable } from '~/db/schema';
import { ContentBlock } from '~/types/content';

export interface Doc {
id: string;
content: ContentBlock[];
createdAt: Date;
updatedAt: Date;
}

export async function createDoc(): Promise<Doc> {
const response = await db.insert(docsTable).values({}).returning();
return formatDoc(response[0]);
}

export async function getDoc(id: string): Promise<Doc | null> {
if (!isValidUuid(id)) {
return null;
}

const doc = await db.query.docsTable.findFirst({
where: eq(docsTable.id, id),
});

return doc ? formatDoc(doc) : null;
}

export async function updateDoc(
id: string,
content: ContentBlock[]
): Promise<Doc> {
const response = await db
.update(docsTable)
.set({
content,
updatedAt: new Date(),
})
.where(eq(docsTable.id, id))
.returning();

return formatDoc(response[0]);
}

function formatDoc(doc: typeof docsTable.$inferSelect): Doc {
return {
...doc,
content: doc.content as ContentBlock[],
};
}
55 changes: 0 additions & 55 deletions app/repos/document.test.ts

This file was deleted.

44 changes: 0 additions & 44 deletions app/repos/document.ts

This file was deleted.

53 changes: 33 additions & 20 deletions app/routes/$docId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import {
useLoaderData,
useSubmit,
} from '@remix-run/react';
import { useEffect, useState } from 'react';
import invariant from 'tiny-invariant';
import { getDocument, updateDocument } from '~/repos/document';
import sanitizeHtml from 'sanitize-html';
import { getDoc, updateDoc } from '~/repos/doc';
import { toContentJson, toHtml } from '~/services/content-serializer';

export const meta: MetaFunction = () => {
return [{ title: 'Document' }];
Expand All @@ -24,13 +25,13 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => {
export async function loader({ params: { docId } }: LoaderFunctionArgs) {
invariant(docId, 'Document ID is required');

const document = await getDocument(docId);
const doc = await getDoc(docId);

if (!document) {
if (!doc) {
return redirect('/');
}

return json({ document });
return json({ doc });
}

export async function action({
Expand All @@ -39,39 +40,51 @@ export async function action({
}: LoaderFunctionArgs) {
invariant(docId, 'Document ID is required');

const formData = await request.formData();
const content = formData.get('content') as string;
const sanitizedContent = sanitizeHtml(content, {
allowedTags: ['b', 'i', 'p', 'br', 'h1', 'h2', 'h3'],
});

await updateDocument(docId, sanitizedContent);
const data = await request.json();
await updateDoc(docId, data.content);

return json({ ok: true });
}

export default function DocumentPage() {
const { document } = useLoaderData<typeof loader>();
const { doc } = useLoaderData<typeof loader>();
const submit = useSubmit();
const [html, setHtml] = useState(() =>
doc.content.length === 0 ? '<p>Start here …</p>' : ''
);

useEffect(() => {
if (doc.content.length === 0) {
return;
}

const serializedHtml = toHtml(doc.content);
setHtml(serializedHtml);
}, [doc.content]);

const handleChange = (event: React.ChangeEvent<HTMLDivElement>) => {
submit(
{ content: event.currentTarget.innerHTML },
{ method: 'post', replace: true, navigate: false }
);
const content = toContentJson(event.currentTarget.childNodes);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = { content } as any;

submit(data, {
method: 'post',
encType: 'application/json',
replace: true,
navigate: false,
});
};

return (
<div className="p-8 flex gap-3 flex-col">
<h1 className="leading text-2xl font-bold text-gray-800 dark:text-gray-100">
Document {document.id}
Document {doc.id}
</h1>
<div
onInput={handleChange}
contentEditable
dangerouslySetInnerHTML={{
__html:
document.content === '' ? '<p>Start here …</p>' : document.content,
__html: html,
}}
></div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions app/routes/new.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { redirect } from '@remix-run/node';
import { createDocument } from '~/repos/document';
import { createDoc } from '~/repos/doc';

export async function loader() {
const document = await createDocument();
return redirect(`/${document.id}`);
const doc = await createDoc();
return redirect(`/${doc.id}`);
}
Loading

0 comments on commit 5f27f6b

Please sign in to comment.