From 61ba4dce2f017dd9392016b2041bac4607616d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Sans=C3=B3n?= Date: Wed, 24 Jul 2024 11:09:30 +0200 Subject: [PATCH] Document Editor page --- .../src/actions/documents/getContentByPath.ts | 23 +++++ .../src/actions/documents/updateContent.ts | 25 ++++++ .../src/app/(private)/_data-access/index.ts | 15 ++++ .../[...documentPath]/route.test.ts | 12 ++- .../_components/DocumentEditor/index.tsx | 80 ++++++++++++++++++ .../documents/[documentUuid]/layout.tsx | 2 +- .../documents/[documentUuid]/page.tsx | 16 +++- packages/web-ui/package.json | 1 + .../molecules/DocumentTextEditor/editor.tsx | 83 ++++++++++++------- .../ds/molecules/DocumentTextEditor/index.tsx | 13 ++- packages/web-ui/src/index.ts | 7 +- .../src/sections/Document/Editor/index.tsx | 46 ++++++++-- packages/web-ui/src/sections/index.ts | 7 ++ pnpm-lock.yaml | 12 +++ 14 files changed, 292 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/actions/documents/getContentByPath.ts create mode 100644 apps/web/src/actions/documents/updateContent.ts create mode 100644 apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx create mode 100644 packages/web-ui/src/sections/index.ts diff --git a/apps/web/src/actions/documents/getContentByPath.ts b/apps/web/src/actions/documents/getContentByPath.ts new file mode 100644 index 000000000..49d1be1b3 --- /dev/null +++ b/apps/web/src/actions/documents/getContentByPath.ts @@ -0,0 +1,23 @@ +'use server' + +import { getDocumentByPath } from '$/app/(private)/_data-access' +import { z } from 'zod' + +import { withProject } from '../procedures' + +export const getDocumentContentByPathAction = withProject + .createServerAction() + .input( + z.object({ + commitId: z.number(), + path: z.string(), + }), + { type: 'json' }, + ) + .handler(async ({ input }) => { + const document = await getDocumentByPath({ + commitId: input.commitId, + path: input.path, + }) + return document.content + }) diff --git a/apps/web/src/actions/documents/updateContent.ts b/apps/web/src/actions/documents/updateContent.ts new file mode 100644 index 000000000..eaf97f8fc --- /dev/null +++ b/apps/web/src/actions/documents/updateContent.ts @@ -0,0 +1,25 @@ +'use server' + +import { updateDocument } from '@latitude-data/core' +import { z } from 'zod' + +import { withProject } from '../procedures' + +export const updateDocumentContentAction = withProject + .createServerAction() + .input( + z.object({ + documentUuid: z.string(), + commitId: z.number(), + content: z.string(), + }), + { type: 'json' }, + ) + .handler(async ({ input }) => { + const result = await updateDocument({ + commitId: input.commitId, + documentUuid: input.documentUuid, + content: input.content, + }) + return result.unwrap() + }) diff --git a/apps/web/src/app/(private)/_data-access/index.ts b/apps/web/src/app/(private)/_data-access/index.ts index b50902378..8523bb865 100644 --- a/apps/web/src/app/(private)/_data-access/index.ts +++ b/apps/web/src/app/(private)/_data-access/index.ts @@ -2,8 +2,10 @@ import { cache } from 'react' import { getDocumentAtCommit, + NotFoundError, findCommitByUuid as originalfindCommit, findProject as originalFindProject, + getDocumentsAtCommit as originalGetDocumentsAtCommit, getFirstProject as originalGetFirstProject, type FindCommitByUuidProps, type FindProjectProps, @@ -45,3 +47,16 @@ export const getDocumentByUuid = cache( return document }, ) + +export const getDocumentByPath = cache( + async ({ commitId, path }: { commitId: number; path: string }) => { + const documents = ( + await originalGetDocumentsAtCommit({ commitId }) + ).unwrap() + const document = documents.find((d) => d.path === path) + if (!document) { + throw new NotFoundError('Document not found') + } + return document + }, +) diff --git a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts index 229e7da5d..f09e866cf 100644 --- a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts +++ b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts @@ -31,7 +31,9 @@ describe('GET documentVersion', () => { ) expect(response.status).toBe(200) - expect((await response.json()).id).toEqual(doc.id) + const responseDoc = await response.json() + expect(responseDoc.documentUuid).toEqual(doc.documentUuid) + expect(responseDoc.commitId).toEqual(doc.commitId) }) test('returns the document in main branch if commitUuid is HEAD', async (ctx) => { @@ -57,7 +59,9 @@ describe('GET documentVersion', () => { ) expect(response.status).toBe(200) - expect((await response.json()).id).toEqual(doc.id) + const responseDoc = await response.json() + expect(responseDoc.documentUuid).toEqual(doc.documentUuid) + expect(responseDoc.commitId).toEqual(doc.commitId) }) test('returns 404 if document is not found', async (ctx) => { @@ -103,6 +107,8 @@ describe('GET documentVersion', () => { ) expect(response.status).toBe(200) - expect((await response.json()).id).toEqual(doc.id) + const responseDoc = await response.json() + expect(responseDoc.documentUuid).toEqual(doc.documentUuid) + expect(responseDoc.commitId).toEqual(doc.commitId) }) }) diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx new file mode 100644 index 000000000..bfb82eb4c --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx @@ -0,0 +1,80 @@ +'use client' + +import { Suspense, useCallback, useRef } from 'react' + +import { Commit, DocumentVersion } from '@latitude-data/core' +import { DocumentEditor, useToast } from '@latitude-data/web-ui' +import { getDocumentContentByPathAction } from '$/actions/documents/getContentByPath' +import { updateDocumentContentAction } from '$/actions/documents/updateContent' +import { useServerAction } from 'zsa-react' + +export default function ClientDocumentEditor({ + commit, + document, +}: { + commit: Commit + document: DocumentVersion +}) { + const updateDocumentAction = useServerAction(updateDocumentContentAction) + const readDocumentContentAction = useServerAction( + getDocumentContentByPathAction, + ) + const { toast } = useToast() + + const documentsByPathRef = useRef<{ [path: string]: string | undefined }>({}) + + const readDocument = useCallback( + async (path: string) => { + const documentsByPath = documentsByPathRef.current + if (!(path in documentsByPath)) { + const [content, error] = await readDocumentContentAction.execute({ + projectId: commit.projectId, + commitId: commit.id, + path, + }) + documentsByPathRef.current = { + ...documentsByPath, + [path]: error ? undefined : content, + } + } + + const documentContent = documentsByPath[path] + if (documentContent === undefined) { + throw new Error('Document not found') + } + + return documentContent + }, + [readDocumentContentAction.status, commit.id], + ) + + const saveDocumentContent = useCallback( + async (content: string) => { + const [_, error] = await updateDocumentAction.execute({ + projectId: commit.projectId, + documentUuid: document.documentUuid, + commitId: commit.id, + content, + }) + + if (error) { + toast({ + title: 'Could not save document', + description: error.message, + variant: 'destructive', + }) + } + }, + [commit, document, updateDocumentAction, toast], + ) + + return ( + Loading...}> + + + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx index 33b91b6ed..0bfbc02c3 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx @@ -27,7 +27,7 @@ export default async function DocumentLayout({ documentUuid={params.documentUuid} documentPath={document.path} /> -
{children}
+ {children} ) } diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx index 8409009ec..8203cfadf 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx @@ -1,7 +1,19 @@ -export default function DocumentPage({ +import { findCommit, getDocumentByUuid } from '$/app/(private)/_data-access' + +import ClientDocumentEditor from './_components/DocumentEditor' + +export default async function DocumentPage({ params, }: { params: { projectId: string; commitUuid: string; documentUuid: string } }) { - return
Documents {params.documentUuid}
+ const commit = await findCommit({ + projectId: Number(params.projectId), + uuid: params.commitUuid, + }) + const document = await getDocumentByUuid({ + documentUuid: params.documentUuid, + commitId: commit.id, + }) + return } diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 17292ad17..7a61c1f40 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -45,6 +45,7 @@ "react-dom": "18.3.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", + "use-debounce": "^10.0.1", "zod": "^3.23.8", "zustand": "^4.5.4" }, diff --git a/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx b/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx index 592c06b90..da339a34e 100644 --- a/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx +++ b/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx @@ -21,12 +21,14 @@ export function DocumentTextEditor({ value, metadata, onChange, + readOnlyMessage, }: DocumentTextEditorProps) { + const [defaultValue, _] = useState(value) const editorRef = useRef(null) const monacoRef = useRef(null) const [isEditorMounted, setIsEditorMounted] = useState(false) // to avoid race conditions - function handleEditorWillMount(monaco: Monaco) { + const handleEditorWillMount = useCallback((monaco: Monaco) => { const style = getComputedStyle(document.body) monaco.languages.register({ id: 'document' }) @@ -39,16 +41,16 @@ export function DocumentTextEditor({ 'editor.background': style.getPropertyValue('--secondary'), }, }) - } + }, []) - function handleEditorDidMount( - editor: editor.IStandaloneCodeEditor, - monaco: Monaco, - ) { - editorRef.current = editor - monacoRef.current = monaco - setIsEditorMounted(true) - } + const handleEditorDidMount = useCallback( + (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { + editorRef.current = editor + monacoRef.current = monaco + setIsEditorMounted(true) + }, + [], + ) const updateMarkers = useCallback(() => { if (!metadata) return @@ -77,28 +79,49 @@ export function DocumentTextEditor({ updateMarkers() }, [metadata, isEditorMounted]) - function handleValueChange(value: string | undefined) { - if (value) onChange?.(value) - } + const handleValueChange = useCallback( + (value: string | undefined) => { + onChange?.(value ?? '') + }, + [onChange], + ) return ( -
- +
+
+ +
) } diff --git a/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx b/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx index 144165c18..c314233fe 100644 --- a/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx +++ b/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { lazy } from 'react' +import React, { lazy, useEffect, useState } from 'react' import type { ConversationMetadata } from '@latitude-data/compiler' @@ -10,6 +10,7 @@ export type DocumentTextEditorProps = { value: string metadata?: ConversationMetadata onChange?: (value: string) => void + readOnlyMessage?: string } const DocumentTextEditor = lazy(() => @@ -25,7 +26,15 @@ function EditorWrapper(props: DocumentTextEditorProps) { // When imported, Monaco automatically tries to use the window object. // Since this is not available when rendering on the server, we only // render the fallback component for SSR. - if (typeof window === 'undefined') return + const [isBrowser, setIsBrowser] = useState(false) + + useEffect(() => { + setIsBrowser(typeof window !== 'undefined') + }, []) + + if (!isBrowser) { + return + } return } diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index dcaf82620..42ca5d80d 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -2,9 +2,4 @@ export * from './ds/tokens' export * from './ds/atoms' export * from './ds/molecules' export * from './providers' - -export { default as DocumentSidebar } from './sections/Document/Sidebar' -export type { SidebarDocument } from './sections/Document/Sidebar/Files/useTree' - -export { default as DocumentDetailWrapper } from './sections/Document/DetailWrapper' -export * from './sections/Document/Sidebar/Files' +export * from './sections' diff --git a/packages/web-ui/src/sections/Document/Editor/index.tsx b/packages/web-ui/src/sections/Document/Editor/index.tsx index 0ba88a97a..a7611e299 100644 --- a/packages/web-ui/src/sections/Document/Editor/index.tsx +++ b/packages/web-ui/src/sections/Document/Editor/index.tsx @@ -1,6 +1,13 @@ 'use client' -import { ReactNode, Suspense, useEffect, useMemo, useState } from 'react' +import { + ReactNode, + Suspense, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' import { ConversationMetadata, readMetadata } from '@latitude-data/compiler' import { Badge, Input, Text } from '$ui/ds/atoms' @@ -8,6 +15,8 @@ import { DocumentTextEditor, DocumentTextEditorFallback, } from '$ui/ds/molecules' +import { useCurrentCommit } from '$ui/providers' +import { useDebouncedCallback } from 'use-debounce' function Header({ title, children }: { title: string; children?: ReactNode }) { return ( @@ -18,26 +27,51 @@ function Header({ title, children }: { title: string; children?: ReactNode }) { ) } -export function DocumentEditor({ document }: { document: string }) { +export default function DocumentEditor({ + document, + saveDocumentContent, + readDocument, +}: { + document: string + saveDocumentContent: (content: string) => void + readDocument?: (uuid: string) => Promise +}) { const [value, setValue] = useState(document) const [metadata, setMetadata] = useState() + + const { commit } = useCurrentCommit() + + const debouncedSave = useDebouncedCallback(saveDocumentContent, 2_000) + + const onChange = useCallback((value: string) => { + setValue(value) + debouncedSave(value) + }, []) + useEffect(() => { - readMetadata({ prompt: value }).then(setMetadata) - }, [value]) + readMetadata({ + prompt: value, + referenceFn: readDocument, + }).then(setMetadata) + }, [value, readDocument]) + const inputs = useMemo(() => { if (!metadata) return [] return Array.from(metadata.parameters) }, [metadata]) return ( -
+
}>
diff --git a/packages/web-ui/src/sections/index.ts b/packages/web-ui/src/sections/index.ts new file mode 100644 index 000000000..1626a1814 --- /dev/null +++ b/packages/web-ui/src/sections/index.ts @@ -0,0 +1,7 @@ +export { default as DocumentSidebar } from './Document/Sidebar' +export type { SidebarDocument } from './Document/Sidebar/Files/useTree' + +export { default as DocumentDetailWrapper } from './Document/DetailWrapper' +export * from './Document/Sidebar/Files' + +export { default as DocumentEditor } from './Document/Editor' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86333ce49..139fa716b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,6 +416,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.4) + use-debounce: + specifier: ^10.0.1 + version: 10.0.1(react@18.3.0) zod: specifier: ^3.23.8 version: 3.23.8 @@ -8775,6 +8778,15 @@ packages: tslib: 2.6.3 dev: false + /use-debounce@10.0.1(react@18.3.0): + resolution: {integrity: sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + react: '>=16.8.0 || 19.x' + dependencies: + react: 18.3.0 + dev: false + /use-sidecar@1.1.2(@types/react@18.3.0)(react@18.3.0): resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'}