diff --git a/web/client/src/api/instance.ts b/web/client/src/api/instance.ts index 8d75ee15cd..6d2491aef4 100644 --- a/web/client/src/api/instance.ts +++ b/web/client/src/api/instance.ts @@ -49,12 +49,16 @@ export async function fetchAPI({ body: toRequestBody(data), }) .then(async response => { - if (response.status === 204) return {} - - let json = null const headerContentType = response.headers.get('Content-Type') - if (headerContentType == null) return { ok: false } + if (headerContentType == null) + return { ok: false, detail: 'Empty response' } + if (response.status === 204) + return { ok: true, ...(await response.json()) } + if (response.status >= 400) + return { ok: false, ...(await response.json()) } + + let json = null const isEventStream = headerContentType.includes('text/event-stream') const isApplicationJson = headerContentType.includes('application/json') diff --git a/web/client/src/library/components/folderTree/FolderTree.tsx b/web/client/src/library/components/folderTree/FolderTree.tsx index a9148f7ac9..221247e0f4 100644 --- a/web/client/src/library/components/folderTree/FolderTree.tsx +++ b/web/client/src/library/components/folderTree/FolderTree.tsx @@ -14,19 +14,26 @@ import { ChevronDownIcon, CheckCircleIcon, } from '@heroicons/react/20/solid' -import { FormEvent, MouseEvent, useMemo, useState } from 'react' +import { FormEvent, MouseEvent, useEffect, useMemo, useState } from 'react' import clsx from 'clsx' -import { singular } from 'pluralize' import { ModelFile, ModelDirectory } from '../../../models' -import type { Directory as DirectoryFromAPI } from '../../../api/client' +import { + createDirectoryApiDirectoriesPathPost, + deleteDirectoryApiDirectoriesPathDelete, + deleteFileApiFilesPathDelete, + Directory as DirectoryApi, + writeFileApiFilesPathPost, +} from '../../../api/client' import { useStoreFileTree } from '../../../context/fileTree' -import { getAllFilesInDirectory, counter } from './help' +import { getAllFilesInDirectory, toUniqueName } from './help' +import ModalConfirmation from '../modal/ModalConfirmation' +import type { Confirmation, WithConfirmation } from '../modal/ModalConfirmation' +import { Button } from '../button/Button' +import { isFalse, isNotNil, isTrue } from '~/utils' /* TODO: - - connect to API - add ability to create file or directory on top level - add context menu - - add confirmation before delete - add rename - add drag and drop - add copy and paste @@ -34,11 +41,11 @@ import { getAllFilesInDirectory, counter } from './help' - add search */ -interface PropsDirectory { +interface PropsDirectory extends WithConfirmation { directory: ModelDirectory } -interface PropsFile { +interface PropsFile extends WithConfirmation { file: ModelFile } @@ -47,21 +54,65 @@ const CSS_ICON_SIZE = 'w-4 h-4' export function FolderTree({ project, }: { - project?: DirectoryFromAPI + project?: DirectoryApi }): JSX.Element { const directory = useMemo(() => new ModelDirectory(project), [project]) + const [confirmation, setConfirmation] = useState() + const [showConfirmation, setShowConfirmation] = useState(false) + + useEffect(() => { + setShowConfirmation(isNotNil(confirmation)) + }, [confirmation]) return ( -
- +
+ { + confirmation?.cancel?.() + }} + > + + + +
) } -function Directory({ directory }: PropsDirectory): JSX.Element { +function Directory({ + directory, + setConfirmation, +}: PropsDirectory): JSX.Element { const openedFiles = useStoreFileTree(s => s.openedFiles) const setOpenedFiles = useStoreFileTree(s => s.setOpenedFiles) - const isRoot = !directory.withParent const [isLoading, setIsLoading] = useState(false) const [isOpen, setOpen] = useState(false) @@ -78,23 +129,30 @@ function Directory({ directory }: PropsDirectory): JSX.Element { setIsLoading(true) - setTimeout(() => { - const count = counter.countByKey(directory) - const name = `new_directory_${count}`.toLowerCase() - - directory.addDirectory( - new ModelDirectory( - { - name, - path: `${directory.path}/${name}`, - }, - directory, - ), - ) - - setOpen(true) - setIsLoading(false) - }) + const name = toUniqueName('new_directory') + + createDirectoryApiDirectoriesPathPost(`${directory.path}/${name}`) + .then(created => { + if (isFalse((created as any).ok)) { + console.warn([ + `Directory: ${directory.path}`, + (created as any).detail, + ]) + + return + } + + directory.addDirectory(new ModelDirectory(created, directory)) + + setOpen(true) + }) + .catch(error => { + // TODO: Show error notification + console.log(error) + }) + .finally(() => { + setIsLoading(false) + }) } function createFile(e: MouseEvent): void { @@ -104,54 +162,77 @@ function Directory({ directory }: PropsDirectory): JSX.Element { setIsLoading(true) - setTimeout(() => { - const extension = '.py' - const count = counter.countByKey(directory) - - const name = directory.name.startsWith('new_') - ? `new_file_${count}${extension}` - : `new_${String( - singular(directory.name), - )}_${count}${extension}`.toLowerCase() - - directory.addFile( - new ModelFile( - { - name, - extension, - path: `${directory.path}/${name}`, - content: '', - is_supported: true, - }, - directory, - ), - ) + const extension = '.py' + const name = toUniqueName('new_file', extension) - setOpen(true) - setIsLoading(false) - }) - } + writeFileApiFilesPathPost(`${directory.path}/${name}`, { content: '' }) + .then(created => { + if (isFalse((created as any).ok)) { + console.warn([`File: ${directory.path}`, (created as any).detail]) - function remove(e: MouseEvent): void { - e.stopPropagation() + return + } - if (isLoading) return + directory.addFile(new ModelFile(created, directory)) - setIsLoading(true) + setOpen(true) + }) + .catch(error => { + // TODO: Show error notification + console.log(error) + }) + .finally(() => { + setIsLoading(false) + }) + } - setTimeout(() => { - if (directory.isNotEmpty) { - const files = getAllFilesInDirectory(directory) + function remove(): void { + if (isLoading) return - files.forEach(file => { - openedFiles.delete(file.id) - }) - } + setIsLoading(true) - directory.parent?.removeDirectory(directory) + deleteDirectoryApiDirectoriesPathDelete(directory.path) + .then(response => { + if (isFalse((response as any).ok)) { + console.warn([ + `Directory: ${directory.path}`, + (response as any).detail, + ]) + + return + } + + if (directory.isNotEmpty) { + const files = getAllFilesInDirectory(directory) + + files.forEach(file => { + openedFiles.delete(file.id) + }) + } + + directory.parent?.removeDirectory(directory) + + setOpenedFiles(openedFiles) + }) + .catch(error => { + // TODO: Show error notification + console.log({ error }) + }) + .finally(() => { + setIsLoading(false) + }) + } - setOpenedFiles(openedFiles) - setIsLoading(false) + function removeWithConfirmation(): void { + setConfirmation({ + headline: 'Removing Directory', + description: `Are you sure you want to remove the directory "${directory.name}"?`, + yesText: 'Yes, Remove', + noText: 'No, Cancel', + action: remove, + cancel: () => { + setConfirmation(undefined) + }, }) } @@ -170,113 +251,140 @@ function Directory({ directory }: PropsDirectory): JSX.Element { }) } + function renameWithConfirmation(): void { + setConfirmation({ + headline: 'Renaming Directory', + description: `Are you sure you want to rename the directory "${directory.name}"?`, + yesText: 'Yes, Rename', + noText: 'No, Cancel', + action: rename, + cancel: () => { + setConfirmation(undefined) + }, + }) + } + return ( <> - {!isRoot && ( - - -
{ - e.stopPropagation() - - setOpen(!isOpen) - }} - > + {directory.withParent && ( + +
{ + e.stopPropagation() + + setOpen(!isOpen) + }} + > + {(directory.withDirectories || directory.withFiles) && ( - -
- - {renamingDirectory?.id === directory.id ? ( -
- ) => { + )} + +
+ + {renamingDirectory?.id === directory.id ? ( +
+ ) => { + e.stopPropagation() + + const elInput = e.target as HTMLInputElement + + setNewName(elInput.value) + }} + /> +
+ { e.stopPropagation() - const elInput = e.target as HTMLInputElement - - setNewName(elInput.value) + renameWithConfirmation() }} /> -
- { - e.stopPropagation() - - rename() - }} - /> -
- ) : ( - - + ) : ( + + { + e.stopPropagation() + + setOpen(!isOpen) + }} + onDoubleClick={(e: MouseEvent) => { + e.stopPropagation() + + setNewName(directory.name) + setRenamingDirectory(directory) + }} + > + {directory.name} + + + + + { e.stopPropagation() - setOpen(!isOpen) - }} - onDoubleClick={(e: MouseEvent) => { - e.stopPropagation() - - setRenamingDirectory(directory) + removeWithConfirmation() }} - className="w-full text-sm overflow-hidden overflow-ellipsis group-hover:text-secondary-500" - > - {directory.name} - - - - - - + className={`cursor-pointer inline-block ${CSS_ICON_SIZE} ml-2 text-danger-300 hover:text-danger-500`} + /> - )} - + + )} )} - {(isOpen || isRoot) && directory.withDirectories && ( -
    + {(isOpen || !directory.withParent) && directory.withDirectories && ( +
      {directory.directories.map(dir => (
    • - +
    • ))}
    )} - {(isOpen || isRoot) && directory.withFiles && ( + {(isOpen || !directory.withParent) && directory.withFiles && (
      {directory.files.map(file => ( @@ -284,11 +392,13 @@ function Directory({ directory }: PropsDirectory): JSX.Element { key={file.id} title={file.name} className={clsx( - 'pl-1', - file.parent?.withParent != null && 'border-l', + isTrue(file.parent?.withParent) && 'border-l px-0', )} > - + ))}
    @@ -297,7 +407,7 @@ function Directory({ directory }: PropsDirectory): JSX.Element { ) } -function File({ file }: PropsFile): JSX.Element { +function File({ file, setConfirmation }: PropsFile): JSX.Element { const activeFileId = useStoreFileTree(s => s.activeFileId) const openedFiles = useStoreFileTree(s => s.openedFiles) const setOpenedFiles = useStoreFileTree(s => s.setOpenedFiles) @@ -307,18 +417,42 @@ function File({ file }: PropsFile): JSX.Element { const [renamingFile, setRenamingFile] = useState() const [newName, setNewName] = useState('') - function remove(file: ModelFile): void { + function remove(): void { if (isLoading) return setIsLoading(true) - setTimeout(() => { - openedFiles.delete(file.id) + deleteFileApiFilesPathDelete(file.path) + .then(response => { + if ((response as unknown as { ok: boolean }).ok) { + openedFiles.delete(file.id) - file.parent?.removeFile(file) + file.parent?.removeFile(file) + + setOpenedFiles(openedFiles) + } else { + // TODO: Show error notification + } + }) + .catch(error => { + // TODO: Show error notification + console.log(error) + }) + .finally(() => { + setIsLoading(false) + }) + } - setOpenedFiles(openedFiles) - setIsLoading(false) + function removeWithConfirmation(): void { + setConfirmation({ + headline: 'Deleting File', + description: `Are you sure you want to remove the file "${file.name}"?`, + yesText: 'Yes, Delete', + noText: 'No, Cancel', + action: remove, + cancel: () => { + setConfirmation(undefined) + }, }) } @@ -353,7 +487,7 @@ function File({ file }: PropsFile): JSX.Element { return ( {openedFiles?.has(file.id) ? ( ) : ( )}
@@ -423,7 +557,7 @@ function File({ file }: PropsFile): JSX.Element { onClick={(e: MouseEvent) => { e.stopPropagation() - remove(file) + removeWithConfirmation() }} > () +export function toUniqueName(prefix?: string, suffix?: string): string { + // Should be enough for now + const hex = (Date.now() % 100000).toString(16) - countByKey(key: ModelDirectory): number { - const count = (this.store.get(key) ?? 0) + 1 - - this.store.set(key, count) - - return count - } + return `${prefix == null ? '' : `${prefix}_`}${hex}${ + suffix ?? '' + }`.toLowerCase() } - -export const counter = new Counter() diff --git a/web/client/src/library/components/modal/Modal.tsx b/web/client/src/library/components/modal/Modal.tsx new file mode 100644 index 0000000000..e550b81c0b --- /dev/null +++ b/web/client/src/library/components/modal/Modal.tsx @@ -0,0 +1,54 @@ +import { Transition, Dialog } from '@headlessui/react' +import { Fragment } from 'react' + +interface PropsModal extends React.HTMLAttributes { + show: boolean + onClose: () => void +} + +export default function Modal({ + show, + children, + onClose = () => undefined, +}: PropsModal): JSX.Element { + return ( + + + +
+ + +
+
+ + {children} + +
+
+
+
+ ) +} diff --git a/web/client/src/library/components/modal/ModalConfirmation.tsx b/web/client/src/library/components/modal/ModalConfirmation.tsx new file mode 100644 index 0000000000..2794bfe705 --- /dev/null +++ b/web/client/src/library/components/modal/ModalConfirmation.tsx @@ -0,0 +1,51 @@ +import { Dialog } from '@headlessui/react' +import Modal from './Modal' + +interface PropsModalConfirmation extends React.HTMLAttributes { + show: boolean + headline?: string + tagline?: string + description?: string + onClose: () => void +} + +export interface Confirmation { + action?: () => void + cancel?: () => void + headline?: string + description?: string + tagline?: string + yesText: string + noText: string +} + +export interface WithConfirmation { + setConfirmation: (confirmation?: Confirmation) => void +} + +export default function ModalConfirmation({ + show, + headline, + tagline, + description, + children, + onClose, +}: PropsModalConfirmation): JSX.Element { + return ( + + +
+

{headline}

+

{tagline}

+

{description}

+
+
+ {children} +
+
+
+ ) +} diff --git a/web/client/src/models/directory.ts b/web/client/src/models/directory.ts index f7e12da5c5..754bebc689 100644 --- a/web/client/src/models/directory.ts +++ b/web/client/src/models/directory.ts @@ -27,10 +27,10 @@ export class ModelDirectory extends ModelArtifact { this.directories = (initial as ModelDirectory).directories this.files = (initial as ModelDirectory).files } else { - this.directories = this.initial.directories.map( + this.directories = this.initial.directories?.map( d => new ModelDirectory(d, this), ) - this.files = this.initial.files.map(f => new ModelFile(f, this)) + this.files = this.initial.files?.map(f => new ModelFile(f, this)) } } diff --git a/web/client/vite.config.ts b/web/client/vite.config.ts index 053d31f097..dd1eda2779 100644 --- a/web/client/vite.config.ts +++ b/web/client/vite.config.ts @@ -1,8 +1,12 @@ +import path from 'path' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react-swc' // https://vitejs.dev/config/ export default defineConfig({ + resolve: { + alias: [{ find: '~', replacement: path.resolve(__dirname, './src') }], + }, build: { outDir: 'prod', },