From 42c6d981ab1d60c9b4663c2415d95e5637797cb8 Mon Sep 17 00:00:00 2001 From: Max Mykal Date: Tue, 14 Feb 2023 08:46:06 -0800 Subject: [PATCH 1/5] add configrmation for delete action in folder tree --- .../src/library/components/modal/Modal.tsx | 42 +++++++++++++++++++ .../components/modal/ModalConfirmation.tsx | 23 ++++++++++ 2 files changed, 65 insertions(+) create mode 100644 web/client/src/library/components/modal/Modal.tsx create mode 100644 web/client/src/library/components/modal/ModalConfirmation.tsx 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..7c12708f2d --- /dev/null +++ b/web/client/src/library/components/modal/Modal.tsx @@ -0,0 +1,42 @@ +import { Transition, Dialog } from '@headlessui/react' +import { Fragment } from 'react'; + +export default function Modal({ show, children }: any) { + + return ( + + undefined}> + +
+ + +
+
+ + {children} + +
+
+
+
+ ); +} \ No newline at end of file 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..72016b9dca --- /dev/null +++ b/web/client/src/library/components/modal/ModalConfirmation.tsx @@ -0,0 +1,23 @@ +import { Dialog } from '@headlessui/react' +import Modal from './Modal'; + +export default function ModalConfirmation({ show, headline, tagline, description, children }: any) { + return ( + + +
+

{headline}

+

{tagline}

+

{description}

+
+
+ {children} +
+
+
+ ); +} \ No newline at end of file From 30cc326ea8d3326ac266a0abfb09773f4b71cc70 Mon Sep 17 00:00:00 2001 From: Max Mykal Date: Tue, 14 Feb 2023 12:55:39 -0800 Subject: [PATCH 2/5] add option to use alias when importing files --- web/client/tsconfig.json | 1 + web/client/vite.config.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/web/client/tsconfig.json b/web/client/tsconfig.json index 62aa0ed8b2..9a30fcf333 100644 --- a/web/client/tsconfig.json +++ b/web/client/tsconfig.json @@ -18,6 +18,7 @@ "composite": true, "strictNullChecks": true, "noUncheckedIndexedAccess": true, + "baseUrl": ".", "paths": { "~/*": ["./src/*"] }, 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', }, From fd0d86dce1bc4a9104606371b51e902e4c18bc53 Mon Sep 17 00:00:00 2001 From: Max Mykal Date: Tue, 14 Feb 2023 13:15:48 -0800 Subject: [PATCH 3/5] show confirmation modal when deleting --- web/client/src/api/instance.ts | 12 +- .../components/folderTree/FolderTree.tsx | 456 +++++++++++------- .../src/library/components/folderTree/help.ts | 17 +- .../src/library/components/modal/Modal.tsx | 22 +- .../components/modal/ModalConfirmation.tsx | 48 +- web/client/src/models/directory.ts | 12 +- 6 files changed, 370 insertions(+), 197 deletions(-) diff --git a/web/client/src/api/instance.ts b/web/client/src/api/instance.ts index 8d75ee15cd..0084da5290 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..8cfce830e2 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, 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 [showCofirmation, setShowConfirmation] = useState(false) + + useEffect(() => { + setShowConfirmation(confirmation != null) + }, [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([ + `Diroctory: ${directory.path}`, + (created as any).detail, + ]) + + return + } + + directory.addDirectory(created) + + 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(created) - 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([ + `Diroctory: ${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 index 7c12708f2d..e550b81c0b 100644 --- a/web/client/src/library/components/modal/Modal.tsx +++ b/web/client/src/library/components/modal/Modal.tsx @@ -1,14 +1,26 @@ import { Transition, Dialog } from '@headlessui/react' -import { Fragment } from 'react'; +import { Fragment } from 'react' -export default function Modal({ show, children }: any) { +interface PropsModal extends React.HTMLAttributes { + show: boolean + onClose: () => void +} +export default function Modal({ + show, + children, + onClose = () => undefined, +}: PropsModal): JSX.Element { return ( - undefined}> + - ); -} \ No newline at end of file + ) +} diff --git a/web/client/src/library/components/modal/ModalConfirmation.tsx b/web/client/src/library/components/modal/ModalConfirmation.tsx index 72016b9dca..a2b85484ba 100644 --- a/web/client/src/library/components/modal/ModalConfirmation.tsx +++ b/web/client/src/library/components/modal/ModalConfirmation.tsx @@ -1,23 +1,51 @@ import { Dialog } from '@headlessui/react' -import Modal from './Modal'; +import Modal from './Modal' -export default function ModalConfirmation({ show, headline, tagline, description, children }: any) { +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 | undefined) => void +} + +export default function ModalConfirmation({ + show, + headline, + tagline, + description, + children, + onClose, +}: PropsModalConfirmation): JSX.Element { return ( - -
-

{headline}

+ +
+

{headline}

{tagline}

{description}

-
+
{children}
- ); -} \ No newline at end of file + ) +} diff --git a/web/client/src/models/directory.ts b/web/client/src/models/directory.ts index f7e12da5c5..fe6f803413 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)) } } @@ -58,12 +58,12 @@ export class ModelDirectory extends ModelArtifact { return this.withFiles || this.withDirectories } - addFile(file: ModelFile): void { - this.files.push(file) + addFile(file: File): void { + this.files.push(new ModelFile(file, this)) } - addDirectory(directory: ModelDirectory): void { - this.directories.push(directory) + addDirectory(directory: Directory): void { + this.directories.push(new ModelDirectory(directory, this)) } removeFile(file: ModelFile): void { From be18d50c740cd63e9519023ced7b5797ed448991 Mon Sep 17 00:00:00 2001 From: Max Mykal Date: Tue, 14 Feb 2023 13:23:15 -0800 Subject: [PATCH 4/5] clean up --- .../src/library/components/folderTree/FolderTree.tsx | 4 ++-- web/client/src/models/directory.ts | 8 ++++---- web/client/tsconfig.json | 1 - 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/web/client/src/library/components/folderTree/FolderTree.tsx b/web/client/src/library/components/folderTree/FolderTree.tsx index 8cfce830e2..cf262e2f05 100644 --- a/web/client/src/library/components/folderTree/FolderTree.tsx +++ b/web/client/src/library/components/folderTree/FolderTree.tsx @@ -142,7 +142,7 @@ function Directory({ return } - directory.addDirectory(created) + directory.addDirectory(new ModelDirectory(created, directory)) setOpen(true) }) @@ -173,7 +173,7 @@ function Directory({ return } - directory.addFile(created) + directory.addFile(new ModelFile(created, directory)) setOpen(true) }) diff --git a/web/client/src/models/directory.ts b/web/client/src/models/directory.ts index fe6f803413..754bebc689 100644 --- a/web/client/src/models/directory.ts +++ b/web/client/src/models/directory.ts @@ -58,12 +58,12 @@ export class ModelDirectory extends ModelArtifact { return this.withFiles || this.withDirectories } - addFile(file: File): void { - this.files.push(new ModelFile(file, this)) + addFile(file: ModelFile): void { + this.files.push(file) } - addDirectory(directory: Directory): void { - this.directories.push(new ModelDirectory(directory, this)) + addDirectory(directory: ModelDirectory): void { + this.directories.push(directory) } removeFile(file: ModelFile): void { diff --git a/web/client/tsconfig.json b/web/client/tsconfig.json index 9a30fcf333..62aa0ed8b2 100644 --- a/web/client/tsconfig.json +++ b/web/client/tsconfig.json @@ -18,7 +18,6 @@ "composite": true, "strictNullChecks": true, "noUncheckedIndexedAccess": true, - "baseUrl": ".", "paths": { "~/*": ["./src/*"] }, From 607ae29589c0f92e472c29a02a3fd9e65569ca69 Mon Sep 17 00:00:00 2001 From: Max Mykal Date: Wed, 15 Feb 2023 14:04:23 -0800 Subject: [PATCH 5/5] PR comments --- web/client/src/api/instance.ts | 2 +- .../src/library/components/folderTree/FolderTree.tsx | 12 ++++++------ .../library/components/modal/ModalConfirmation.tsx | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/web/client/src/api/instance.ts b/web/client/src/api/instance.ts index 0084da5290..6d2491aef4 100644 --- a/web/client/src/api/instance.ts +++ b/web/client/src/api/instance.ts @@ -55,7 +55,7 @@ export async function fetchAPI({ return { ok: false, detail: 'Empty response' } if (response.status === 204) return { ok: true, ...(await response.json()) } - if (response.status > 400) + if (response.status >= 400) return { ok: false, ...(await response.json()) } let json = null diff --git a/web/client/src/library/components/folderTree/FolderTree.tsx b/web/client/src/library/components/folderTree/FolderTree.tsx index cf262e2f05..221247e0f4 100644 --- a/web/client/src/library/components/folderTree/FolderTree.tsx +++ b/web/client/src/library/components/folderTree/FolderTree.tsx @@ -29,7 +29,7 @@ import { getAllFilesInDirectory, toUniqueName } from './help' import ModalConfirmation from '../modal/ModalConfirmation' import type { Confirmation, WithConfirmation } from '../modal/ModalConfirmation' import { Button } from '../button/Button' -import { isFalse, isTrue } from '~/utils' +import { isFalse, isNotNil, isTrue } from '~/utils' /* TODO: - add ability to create file or directory on top level @@ -58,16 +58,16 @@ export function FolderTree({ }): JSX.Element { const directory = useMemo(() => new ModelDirectory(project), [project]) const [confirmation, setConfirmation] = useState() - const [showCofirmation, setShowConfirmation] = useState(false) + const [showConfirmation, setShowConfirmation] = useState(false) useEffect(() => { - setShowConfirmation(confirmation != null) + setShowConfirmation(isNotNil(confirmation)) }, [confirmation]) return (
{ @@ -135,7 +135,7 @@ function Directory({ .then(created => { if (isFalse((created as any).ok)) { console.warn([ - `Diroctory: ${directory.path}`, + `Directory: ${directory.path}`, (created as any).detail, ]) @@ -195,7 +195,7 @@ function Directory({ .then(response => { if (isFalse((response as any).ok)) { console.warn([ - `Diroctory: ${directory.path}`, + `Directory: ${directory.path}`, (response as any).detail, ]) diff --git a/web/client/src/library/components/modal/ModalConfirmation.tsx b/web/client/src/library/components/modal/ModalConfirmation.tsx index a2b85484ba..2794bfe705 100644 --- a/web/client/src/library/components/modal/ModalConfirmation.tsx +++ b/web/client/src/library/components/modal/ModalConfirmation.tsx @@ -20,7 +20,7 @@ export interface Confirmation { } export interface WithConfirmation { - setConfirmation: (confirmation: Confirmation | undefined) => void + setConfirmation: (confirmation?: Confirmation) => void } export default function ModalConfirmation({