From 15fcd5a16f355e9cf23be83312ebb79b9f27e904 Mon Sep 17 00:00:00 2001 From: Maksim Chervonnyi Date: Wed, 19 Jun 2024 12:11:15 +0200 Subject: [PATCH] Drag'n'drop added/existing file entries, add empty folder (#3999) Co-authored-by: Alexei Mochalov --- catalog/app/components/Dialog/Confirm.tsx | 1 + catalog/app/components/Dialog/Prompt.tsx | 25 +- .../Bucket/PackageDialog/FilesInput.tsx | 464 +++++++++++------- .../Bucket/PackageDialog/FilesState.spec.ts | 453 +++++++++++++++++ .../Bucket/PackageDialog/FilesState.ts | 309 ++++++++++++ .../PackageDialog/PackageCreationForm.tsx | 4 + .../Bucket/PackageDialog/PackageDialog.tsx | 5 - docs/CHANGELOG.md | 1 + 8 files changed, 1077 insertions(+), 185 deletions(-) create mode 100644 catalog/app/containers/Bucket/PackageDialog/FilesState.spec.ts create mode 100644 catalog/app/containers/Bucket/PackageDialog/FilesState.ts diff --git a/catalog/app/components/Dialog/Confirm.tsx b/catalog/app/components/Dialog/Confirm.tsx index 15d1749d709..0146c0e071c 100644 --- a/catalog/app/components/Dialog/Confirm.tsx +++ b/catalog/app/components/Dialog/Confirm.tsx @@ -44,6 +44,7 @@ interface PromptProps { title: string } +// TODO: Re-use utils/Dialog export function useConfirm({ cancelTitle, title, onSubmit, submitTitle }: PromptProps) { const [key, setKey] = React.useState(0) const [opened, setOpened] = React.useState(false) diff --git a/catalog/app/components/Dialog/Prompt.tsx b/catalog/app/components/Dialog/Prompt.tsx index 999d331fa6d..dd87c916468 100644 --- a/catalog/app/components/Dialog/Prompt.tsx +++ b/catalog/app/components/Dialog/Prompt.tsx @@ -4,19 +4,23 @@ import * as M from '@material-ui/core' import * as Lab from '@material-ui/lab' interface DialogProps { + children: React.ReactNode initialValue?: string onCancel: () => void onSubmit: (value: string) => void open: boolean + placeholder?: string title: string validate: (value: string) => Error | undefined } function Dialog({ + children, initialValue, - open, onCancel, onSubmit, + open, + placeholder, title, validate, }: DialogProps) { @@ -26,6 +30,7 @@ function Dialog({ const handleChange = React.useCallback((event) => setValue(event.target.value), []) const handleSubmit = React.useCallback( (event) => { + event.stopPropagation() event.preventDefault() setSubmitted(true) if (!error) onSubmit(value) @@ -37,11 +42,13 @@ function Dialog({
{title} + {children} {!!error && !!submitted && ( @@ -69,11 +76,19 @@ function Dialog({ interface PromptProps { initialValue?: string onSubmit: (value: string) => void + placeholder?: string title: string validate: (value: string) => Error | undefined } -export function usePrompt({ initialValue, title, onSubmit, validate }: PromptProps) { +// TODO: Re-use utils/Dialog +export function usePrompt({ + onSubmit, + initialValue, + placeholder, + validate, + title, +}: PromptProps) { const [key, setKey] = React.useState(0) const [opened, setOpened] = React.useState(false) const open = React.useCallback(() => { @@ -89,20 +104,22 @@ export function usePrompt({ initialValue, title, onSubmit, validate }: PromptPro [close, onSubmit], ) const render = React.useCallback( - () => ( + (children?: React.ReactNode) => ( ), - [initialValue, key, close, handleSubmit, opened, title, validate], + [close, handleSubmit, initialValue, key, opened, placeholder, title, validate], ) return React.useMemo( () => ({ diff --git a/catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx b/catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx index 69bcc9f509b..ca781140664 100644 --- a/catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx @@ -1,27 +1,40 @@ import cx from 'classnames' import * as R from 'ramda' import * as React from 'react' -import { useDropzone, FileWithPath } from 'react-dropzone' +import { useDropzone } from 'react-dropzone' import * as RF from 'react-final-form' import * as M from '@material-ui/core' import * as Lab from '@material-ui/lab' +import * as Dialog from 'components/Dialog' import * as urls from 'constants/urls' import type * as Model from 'model' import StyledLink from 'utils/StyledLink' import assertNever from 'utils/assertNever' import computeFileChecksum from 'utils/checksums' -import dissocBy from 'utils/dissocBy' import useDragging from 'utils/dragging' import { readableBytes } from 'utils/string' import * as tagged from 'utils/taggedV2' import useMemoEq from 'utils/useMemoEq' -import * as Types from 'utils/types' import EditFileMeta from './EditFileMeta' +import { + FilesEntryState, + FilesEntry, + FilesEntryDir, + FilesEntryType, + FilesAction, + FileWithHash, + FilesState, + handleFilesAction, + EMPTY_DIR_MARKER, +} from './FilesState' import * as PD from './PackageDialog' import * as S3FilePicker from './S3FilePicker' +export { EMPTY_DIR_MARKER, FilesAction } from './FilesState' +export type { LocalFile, FilesState } from './FilesState' + const COLORS = { default: M.colors.grey[900], added: M.colors.green[900], @@ -30,17 +43,34 @@ const COLORS = { invalid: M.colors.red[400], } -interface FileWithHash extends File { - hash: { - ready: boolean - value?: Model.Checksum - error?: Error - promise: Promise +const hasHash = (f: File): f is FileWithHash => !!f && !!(f as FileWithHash).hash + +const isDragReady = (state: FilesEntryState) => { + switch (state) { + case 'added': + case 'unchanged': + return true + default: + return false } - meta?: Types.JsonRecord } -const hasHash = (f: File): f is FileWithHash => !!f && !!(f as FileWithHash).hash +const isDropReady = (state: FilesEntryState) => { + switch (state) { + case 'added': + case 'modified': + case 'unchanged': + return true + default: + return false + } +} + +const isFileDropReady = (entry: FilesEntry) => + FilesEntry.match({ + Dir: (d) => isDropReady(d.state), + File: (f) => isDropReady(f.state), + })(entry) export function computeHash(f: File) { if (hasHash(f)) return f @@ -65,156 +95,10 @@ export function computeHash(f: File) { return fh } -export const FilesAction = tagged.create( - 'app/containers/Bucket/PackageDialog/FilesInput:FilesAction' as const, - { - Add: (v: { files: FileWithHash[]; prefix?: string }) => v, - AddFromS3: (filesMap: Record) => filesMap, - Delete: (path: string) => path, - DeleteDir: (prefix: string) => prefix, - Meta: (v: { path: string; meta?: Model.EntryMeta }) => v, - Revert: (path: string) => path, - RevertDir: (prefix: string) => prefix, - Reset: () => {}, - }, -) - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export type FilesAction = tagged.InstanceOf - -export type LocalFile = FileWithPath & FileWithHash - -export interface FilesState { - added: Record - deleted: Record - existing: Record - // XXX: workaround used to re-trigger validation and dependent computations - // required due to direct mutations of File objects - counter?: number -} - -const addMetaToFile = ( - file: Model.PackageEntry | LocalFile | Model.S3File, - meta?: Model.EntryMeta, -) => { - if (file instanceof window.File) { - const fileCopy = new window.File([file as File], (file as File).name, { - type: (file as File).type, - }) - Object.defineProperty(fileCopy, 'meta', { - value: meta, - }) - Object.defineProperty(fileCopy, 'hash', { - value: (file as FileWithHash).hash, - }) - return fileCopy - } - return R.assoc('meta', meta, file) -} - -const handleFilesAction = FilesAction.match< - (state: FilesState) => FilesState, - [{ initial: FilesState }] ->({ - Add: - ({ files, prefix }) => - (state) => - files.reduce((acc, file) => { - const path = (prefix || '') + PD.getNormalizedPath(file) - return R.evolve( - { - added: R.assoc(path, file), - deleted: R.dissoc(path), - }, - acc, - ) - }, state), - AddFromS3: (filesMap) => - R.evolve({ - added: R.mergeLeft(filesMap), - deleted: R.omit(Object.keys(filesMap)), - }), - Delete: (path) => - R.evolve({ - added: R.dissoc(path), - deleted: R.assoc(path, true as const), - }), - // add all descendants from existing to deleted - DeleteDir: - (prefix) => - ({ existing, added, deleted, ...rest }) => ({ - existing, - added: dissocBy(R.startsWith(prefix))(added), - deleted: R.mergeLeft( - Object.keys(existing).reduce( - (acc, k) => (k.startsWith(prefix) ? { ...acc, [k]: true } : acc), - {}, - ), - deleted, - ), - ...rest, - }), - Meta: ({ path, meta }) => { - const mkSetMeta = - () => - (filesDict: Record) => { - const file = filesDict[path] - if (!file) return filesDict - return R.assoc(path, addMetaToFile(file, meta), filesDict) - } - return R.evolve({ - added: mkSetMeta(), - existing: mkSetMeta(), - }) - }, - Revert: (path) => R.evolve({ added: R.dissoc(path), deleted: R.dissoc(path) }), - // remove all descendants from added and deleted - RevertDir: (prefix) => - R.evolve({ - added: dissocBy(R.startsWith(prefix)), - deleted: dissocBy(R.startsWith(prefix)), - }), - Reset: - (_, { initial }) => - () => - initial, -}) - interface DispatchFilesAction { (action: FilesAction): void } -type FilesEntryState = - | 'deleted' - | 'modified' - | 'unchanged' - | 'hashing' - | 'added' - | 'invalid' - -type FilesEntryType = 's3' | 'local' - -const FilesEntryTag = 'app/containers/Bucket/PackageDialog/FilesInput:FilesEntry' as const - -const FilesEntry = tagged.create(FilesEntryTag, { - Dir: (v: { - name: string - state: FilesEntryState - childEntries: tagged.Instance[] - }) => v, - File: (v: { - name: string - state: FilesEntryState - type: FilesEntryType - size: number - meta?: Model.EntryMeta - }) => v, -}) - -// eslint-disable-next-line @typescript-eslint/no-redeclare -type FilesEntry = tagged.InstanceOf -type FilesEntryDir = ReturnType - const insertIntoDir = (path: string[], file: FilesEntry, dir: FilesEntryDir) => { const { name, childEntries } = FilesEntry.Dir.unbox(dir) const newChildren = insertIntoTree(path, file, childEntries) @@ -246,7 +130,18 @@ const insertIntoTree = (path: string[] = [], file: FilesEntry, entries: FilesEnt restEntries = R.without([existingDir], entries) baseDir = existingDir as FilesEntryDir } - inserted = insertIntoDir(rest, file, baseDir) + // If file is "hidden", + // and it is the actual file, not a parent path; + // then we skip inserting it into UI + const hiddenFileBase = + FilesEntry.match( + { + File: (f) => f.type === 'hidden', + Dir: () => false, + }, + file, + ) && !rest.length + inserted = hiddenFileBase ? baseDir : insertIntoDir(rest, file, baseDir) } const sort = R.sortWith([ R.ascend(FilesEntry.match({ Dir: () => 0, File: () => 1 })), @@ -314,7 +209,13 @@ const computeEntries = ({ meta: f.meta, }) } - const type = S3FilePicker.isS3File(f) ? ('s3' as const) : ('local' as const) + const type = + // eslint-disable-next-line no-nested-ternary + f === EMPTY_DIR_MARKER + ? ('hidden' as const) + : S3FilePicker.isS3File(f) + ? ('s3' as const) + : ('local' as const) return acc.concat({ state: 'added', type, path, size: f.size, meta: f.meta }) }, [] as IntermediateEntry[]) const entries: IntermediateEntry[] = [...existingEntries, ...addedEntries] @@ -345,6 +246,9 @@ const useEntryIconStyles = M.makeStyles((t) => ({ root: { position: 'relative', }, + draggable: { + cursor: 'move', + }, icon: { boxSizing: 'content-box', display: 'block', @@ -398,9 +302,10 @@ const useEntryIconStyles = M.makeStyles((t) => ({ type EntryIconProps = React.PropsWithChildren<{ state: FilesEntryState overlay?: React.ReactNode + setDragRef?: (el: HTMLDivElement) => void }> -function EntryIcon({ state, overlay, children }: EntryIconProps) { +function EntryIcon({ setDragRef, state, overlay, children }: EntryIconProps) { const classes = useEntryIconStyles() const stateContents = { added: '+', @@ -411,8 +316,15 @@ function EntryIcon({ state, overlay, children }: EntryIconProps) { unchanged: undefined, }[state] return ( -
- {children} +
+ {children} {!!overlay &&
{overlay}
} {!!stateContents && (
@@ -496,6 +408,7 @@ const useFileStyles = M.makeStyles((t) => ({ })) interface FileProps extends React.HTMLAttributes { + setDragRef?: (el: HTMLDivElement) => void name: string state?: FilesEntryState type?: FilesEntryType @@ -510,6 +423,7 @@ interface FileProps extends React.HTMLAttributes { } function File({ + setDragRef, name, state = 'unchanged', type = 'local', @@ -541,7 +455,11 @@ function File({ {...props} >
- + insert_drive_file
@@ -586,6 +504,10 @@ const useDirStyles = M.makeStyles((t) => ({ '$active > &, &:hover': { opacity: 1, }, + '$active > &': { + outline: `2px dashed ${t.palette.primary.light}`, + outlineOffset: '-2px', + }, '$added > &': { color: COLORS.added, }, @@ -662,6 +584,8 @@ const useDirStyles = M.makeStyles((t) => ({ })) interface DirProps extends React.HTMLAttributes { + setDragRef?: (el: HTMLDivElement) => void + setDropRef?: (el: HTMLDivElement) => void name: string state?: FilesEntryState disableStateDisplay?: boolean @@ -676,6 +600,8 @@ interface DirProps extends React.HTMLAttributes { export const Dir = React.forwardRef(function Dir( { + setDragRef, + setDropRef, name, state = 'unchanged', disableStateDisplay = false, @@ -693,6 +619,7 @@ export const Dir = React.forwardRef(function Dir( ) { const classes = useDirStyles() const stateDisplay = disableStateDisplay ? 'unchanged' : state + // on drag (and drop) head only return (
(function Dir( {...props} > {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} -
+
- + {expanded ? 'folder_open' : 'folder'}
{name}
@@ -812,7 +748,11 @@ export function Root({ ...props }: React.PropsWithChildren<{ className?: string }>) { const classes = useRootStyles() - return
+ return ( + +
+ + ) } const useHeaderStyles = M.makeStyles({ @@ -1138,9 +1078,22 @@ function FileUpload({ [dispatch, path], ) + const file = React.useMemo( + () => FilesEntry.File({ name, state, type, size, meta }), + [name, state, type, size, meta], + ) + const { onDrag } = useDnd() + const [dragRef, setDragRef] = React.useState(null) + // Note We don't have a sort order. We can move INTO dir only + React.useEffect(() => { + if (!dragRef) return + return onDrag(dragRef, [file, prefix]) + }, [onDrag, file, prefix, dragRef]) + return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events { @@ -1207,6 +1160,30 @@ function DirUpload({ [dispatch, path], ) + const dir = React.useMemo( + () => FilesEntry.Dir({ name, state, childEntries }), + [name, state, childEntries], + ) + const { draggingOver, onDrag, onDragover, onDrop: onDragMove } = useDnd() + + const [dragRef, setDragRef] = React.useState(null) + React.useEffect(() => { + if (!dragRef) return + return onDrag(dragRef, [dir, prefix]) + }, [dir, onDrag, prefix, dragRef]) + + const [dropRef, setDropRef] = React.useState(null) + React.useEffect(() => { + if (!dropRef) return + return onDragover(dropRef, dir) + }, [onDragover, dir, dropRef]) + React.useEffect(() => { + if (!dropRef) return + return onDragMove(dropRef, (source) => { + dispatch(FilesAction.Move({ source, dest: [dir, prefix] })) + }) + }, [dir, dispatch, onDragMove, prefix, dropRef]) + const { getRootProps, isDragActive } = useDropzone({ onDrop, noDragEventsBubbling: true, @@ -1265,7 +1242,7 @@ function DirUpload({ return ( } empty={!childEntries.length} + setDragRef={setDragRef} + setDropRef={setDropRef} > {!!childEntries.length && childEntries.map( @@ -1306,6 +1285,99 @@ function DirUpload({ ) } +type Unsubscribe = () => void + +type Prefix = string | undefined +interface Dnd { + dragging: [FilesEntry, Prefix] | null // what file/dir we are dragging + draggingOver: FilesEntry | null // above what file/dir we are dragging + onDrag: (el: HTMLDivElement, f: [FilesEntry, Prefix]) => Unsubscribe | void + onDragover: (el: HTMLDivElement, f: FilesEntry) => Unsubscribe | void + onDrop: ( + el: HTMLDivElement, + callback: (f: [FilesEntry, Prefix]) => void, + ) => Unsubscribe | void +} + +const noop: Unsubscribe = () => {} + +const DndContext = React.createContext({ + dragging: null, + draggingOver: null, + onDrag: () => noop, + onDragover: () => noop, + onDrop: () => noop, +}) + +interface DndProviderProps { + children: React.ReactNode +} + +const useDnd = () => React.useContext(DndContext) + +function DndProvider({ children }: DndProviderProps) { + const [dragging, setDragging] = React.useState<[FilesEntry, Prefix] | null>(null) + const onDrag = React.useCallback((el: HTMLDivElement, f: [FilesEntry, Prefix]) => { + if (!el) return + const start = () => setDragging(f) + const end = () => setDragging(null) + el.addEventListener('dragstart', start) + el.addEventListener('dragend', end) + return () => { + el.removeEventListener('dragstart', start) + el.removeEventListener('dragend', end) + } + }, []) + + const [draggingOver, setDraggingOver] = React.useState(null) + const onDragover = React.useCallback((el: HTMLDivElement, f: FilesEntry) => { + if (!el || !isFileDropReady(f)) return + let timerId: ReturnType | null = null + const enter = (e: Event) => { + e.preventDefault() + setDraggingOver(f) + // Workaround to hide draging over effect when dragleave wasn't triggered + if (timerId) clearTimeout(timerId) + timerId = setTimeout(() => setDraggingOver(null), 5000) + } + const leave = (e: Event) => { + if (e.target !== el) return + e.preventDefault() + setDraggingOver(null) + } + + el.addEventListener('dragenter', enter) + el.addEventListener('dragleave', leave) + return () => { + if (timerId) clearTimeout(timerId) + el.removeEventListener('dragenter', enter) + el.removeEventListener('dragleave', leave) + } + }, []) + + const onDrop = React.useCallback( + (el: HTMLDivElement, callback: (f: [FilesEntry, Prefix]) => void) => { + const cb = () => { + if (dragging) { + if (isFileDropReady(dragging[0])) { + callback(dragging) + } + setDragging(null) + } + } + el.addEventListener('drop', cb) + return () => el.removeEventListener('drop', cb) + }, + [dragging], + ) + + return ( + + {children} + + ) +} + const DOCS_URL_SOURCE_BUCKETS = `${urls.docsMaster}/catalog/preferences#properties` const useFilesInputStyles = M.makeStyles((t) => ({ @@ -1322,6 +1394,17 @@ const useFilesInputStyles = M.makeStyles((t) => ({ marginLeft: t.spacing(1), }, }, + iconAction: { + marginRight: t.spacing(1), + minWidth: t.spacing(6), + }, + buttons: { + display: 'flex', + marginLeft: 'auto', + }, + btnDivider: { + margin: t.spacing(0, 1), + }, warning: { marginLeft: t.spacing(1), }, @@ -1430,6 +1513,16 @@ export function FilesInput({ [dispatch], ) + const promptOpts = React.useMemo( + () => ({ + onSubmit: (name: string) => dispatch(FilesAction.AddFolder(name)), + title: 'Enter new directory path', + validate: (p: string) => (!p ? new Error("Path can't be empty") : undefined), + }), + [dispatch], + ) + const prompt = Dialog.usePrompt(promptOpts) + const resetFiles = React.useCallback(() => { dispatch(FilesAction.Reset()) }, [dispatch]) @@ -1504,6 +1597,13 @@ export function FilesInput({ onClose={closeS3FilePicker} /> )} + {prompt.render( + + You can add new directories and drag-and-drop files and folders into them. + Please note that directories that remain empty will be excluded during the + package creation process. + , + )}
)} - - {meta.dirty && ( - + {meta.dirty && ( + <> + undo} + > + {ui.reset || 'Clear files'} + + + + )} + undo} + title="Add empty folder" > - {ui.reset || 'Clear files'} - - )} + create_new_folder + +
diff --git a/catalog/app/containers/Bucket/PackageDialog/FilesState.spec.ts b/catalog/app/containers/Bucket/PackageDialog/FilesState.spec.ts new file mode 100644 index 00000000000..5adbff4a667 --- /dev/null +++ b/catalog/app/containers/Bucket/PackageDialog/FilesState.spec.ts @@ -0,0 +1,453 @@ +import type * as Model from 'model' + +import { + FileWithHash, + FilesState, + LocalFile, + FilesAction, + handleFilesAction, + renameKey, + renameKeys, + FilesEntry, +} from './FilesState' + +describe('utils/object', () => { + describe('renameKey', () => { + it('should rename key', () => { + const ref = Symbol('ref') + const obj = { 'a/b/c': ref, 'd/e/f': 2 } + const result = renameKey('a/b/c', 'x/y/z', obj) + expect(result).toEqual({ 'd/e/f': 2, 'x/y/z': ref }) + }) + it('should do nothing (except cloning) if key does not exist', () => { + const ref = Symbol('ref') + const obj = { A: ref, 'd/e/f': 2 } + const result = renameKey('B', 'C', obj) + expect(result).toEqual(obj) + expect(result).not.toBe(obj) + }) + }) + + describe('renameKeys', () => { + it('should rename keys', () => { + const refA = Symbol() + const refB = Symbol() + const refD = Symbol() + const refE = Symbol() + const obj = { + 'a/b/c/d.txt': refD, + 'a/b/c/e.txt': refE, + 'x/y/a.txt': refA, + 'x/y/b.txt': refB, + } + const result = renameKeys('x/y', 'a/b', obj) + expect(result).toEqual({ + 'a/b/c/d.txt': refD, + 'a/b/c/e.txt': refE, + 'a/b/y/a.txt': refA, + 'a/b/y/b.txt': refB, + }) + }) + }) + + describe('handleFilesAction', () => { + const emptyState = { + added: {}, + existing: {}, + deleted: {}, + } + const initial: { initial: FilesState } = { + initial: { + ...emptyState, + }, + } + const fileAA = { name: 'foo/bar.txt' } as FileWithHash + const fileEA = { physicalKey: 's3://b/a' } as Model.PackageEntry + const fileEAConvertedToAdded = { + bucket: 'b', + key: 'a', + } + const fileSA = {} as Model.S3File + const fileSB = {} as Model.S3File + const fileLA = new window.File([], 'foo.txt') as LocalFile + + describe('Add', () => { + it('adds the file', () => { + const action = FilesAction.Add({ files: [fileAA], prefix: '' }) + expect(handleFilesAction(action, initial)(emptyState)).toEqual({ + ...emptyState, + added: { 'foo/bar.txt': fileAA }, + }) + }) + it('adds the file to prefix', () => { + const action = FilesAction.Add({ files: [fileAA], prefix: 'root/nested/' }) + expect(handleFilesAction(action, initial)(emptyState)).toMatchObject({ + added: { 'root/nested/foo/bar.txt': fileAA }, + }) + }) + }) + + describe('AddFolder', () => { + it('adds folder with .quiltkeep', () => { + const action = FilesAction.AddFolder('root/nested/') + expect(handleFilesAction(action, initial)(emptyState)).toMatchObject({ + added: { + 'root/nested/[$.quiltkeep$]': { + bucket: '[$empty$]', + key: '[$empty$]', + size: 0, + }, + }, + }) + }) + }) + + it('AddFromS3', () => { + const action = FilesAction.AddFromS3({ + 'a/b/c': fileSA, + 'x/y/z': fileSB, + }) + const state = { + ...emptyState, + deleted: { + 'a/b/c': true as const, + }, + } + expect(handleFilesAction(action, initial)(state)).toEqual({ + ...state, + added: { + 'a/b/c': fileSA, + 'x/y/z': fileSB, + }, + deleted: {}, + }) + }) + + it('Delete', () => { + const action = FilesAction.Delete('a/b/c') + const state = { + added: { + 'a/b/c': fileAA, + }, + existing: { + 'a/b/c': fileEA, + }, + deleted: { + 'a/b/c': true as const, + }, + } + expect(handleFilesAction(action, initial)(state)).toEqual({ + ...state, + added: {}, + }) + }) + + it('DeleteDir', () => { + const action = FilesAction.DeleteDir('a/b') + const state = { + added: { + 'a/b/addedA': fileAA, + 'a/b/addedB': fileAA, + 'a/C/addedC': fileAA, + }, + existing: { + 'x/y/existingZ': fileEA, + 'a/b/existingD': fileEA, + 'a/b/existingE': fileEA, + }, + deleted: { + 'a/b/deletedF': true as const, + 'x/y/deletedW': true as const, + }, + } + expect(handleFilesAction(action, initial)(state)).toEqual({ + ...state, + added: { + 'a/C/addedC': fileAA, + }, + deleted: { + 'a/b/existingD': true as const, + 'a/b/existingE': true as const, + 'a/b/deletedF': true as const, + 'x/y/deletedW': true as const, + }, + }) + }) + + describe('Meta', () => { + it('adds meta to objects', () => { + const action = FilesAction.Meta({ path: 'a/b/c', meta: { foo: 'bar' } }) + const state = { + ...emptyState, + added: { + 'a/b/c': fileAA, + 'x/y/z': fileLA, + }, + existing: { + 'a/b/c': fileEA, + }, + } + const result = handleFilesAction(action, initial)(state) + expect(result.added['a/b/c'].meta).toEqual({ foo: 'bar' }) + expect(result.added['x/y/z'].meta).toBe(undefined) + expect(result.existing['a/b/c'].meta).toEqual({ foo: 'bar' }) + }) + it('adds meta to files', () => { + const action = FilesAction.Meta({ path: 'x/y/z', meta: { foo: 'bar' } }) + const state = { + ...emptyState, + added: { + 'x/y/z': fileLA, + }, + } + expect(handleFilesAction(action, initial)(state).added['x/y/z'].meta).toEqual({ + foo: 'bar', + }) + }) + }) + + describe('Move', () => { + it('Move added file', () => { + const source = FilesEntry.File({ + name: 'foo.txt', + state: 'added', + type: 'local', + size: 0, + }) + const dest = FilesEntry.Dir({ + name: 'lorem/ipsum/', + state: 'unchanged', + childEntries: [], + }) + const action = FilesAction.Move({ + source: [source, 'root/inside/'], + dest: [dest], + }) + const state = { + ...emptyState, + added: { + 'root/inside/foo.txt': fileAA, + }, + } + const result = handleFilesAction(action, initial)(state) + expect(result).toMatchObject({ + added: { + 'lorem/ipsum/foo.txt': fileAA, + }, + }) + }) + + it('Move existing file', () => { + const source = FilesEntry.File({ + name: 'foo.txt', + state: 'unchanged', + type: 'local', + size: 0, + }) + const dest = FilesEntry.Dir({ + name: 'lorem/ipsum/', + state: 'unchanged', + childEntries: [], + }) + const action = FilesAction.Move({ + source: [source, 'root/inside/'], + dest: [dest], + }) + const state = { + ...emptyState, + existing: { + 'root/inside/foo.txt': fileEA, + }, + } + const result = handleFilesAction(action, initial)(state) + expect(result).toMatchObject({ + added: { + 'lorem/ipsum/foo.txt': fileEAConvertedToAdded, + }, + }) + }) + + it('Move added dir', () => { + const source = FilesEntry.Dir({ + name: 'foo/bar/', + state: 'unchanged', + childEntries: [], + }) + const dest = FilesEntry.Dir({ + name: 'lorem/ipsum/', + state: 'unchanged', + childEntries: [], + }) + const action = FilesAction.Move({ + source: [source, 'root/inside/'], + dest: [dest], + }) + const state = { + ...emptyState, + added: { + 'root/inside/foo/bar/a.txt': fileAA, + 'root/inside/foo/bar/b.txt': fileAA, + }, + } + const result = handleFilesAction(action, initial)(state) + expect(result).toMatchObject({ + added: { + 'lorem/ipsum/bar/a.txt': fileAA, + 'lorem/ipsum/bar/b.txt': fileAA, + }, + }) + }) + + it('Move existing dir', () => { + const source = FilesEntry.Dir({ + name: 'foo/bar/', + state: 'unchanged', + childEntries: [], + }) + const dest = FilesEntry.Dir({ + name: 'lorem/ipsum/', + state: 'unchanged', + childEntries: [], + }) + const action = FilesAction.Move({ + source: [source, 'root/inside/'], + dest: [dest], + }) + const state = { + ...emptyState, + existing: { + 'root/inside/foo/bar/a.txt': fileEA, + 'root/inside/foo/bar/b.txt': fileEA, + }, + } + const result = handleFilesAction(action, initial)(state) + expect(result).toMatchObject({ + added: { + 'lorem/ipsum/bar/a.txt': fileEAConvertedToAdded, + 'lorem/ipsum/bar/b.txt': fileEAConvertedToAdded, + }, + }) + }) + + it('Moving throws error when moving non-existing file', () => { + const source = FilesEntry.File({ + name: 'foo.txt', + state: 'unchanged', + type: 'local', + size: 0, + }) + const dest = FilesEntry.Dir({ + name: 'bar', + state: 'unchanged', + childEntries: [], + }) + const action = FilesAction.Move({ + source: [source], + dest: [dest], + }) + expect(() => handleFilesAction(action, initial)(emptyState)).toThrowError( + 'Failed to move file', + ) + }) + + it('Moving throws error when moving non-existing directory', () => { + const source = FilesEntry.Dir({ + name: 'foo', + state: 'unchanged', + childEntries: [], + }) + const dest = FilesEntry.Dir({ + name: 'bar', + state: 'unchanged', + childEntries: [], + }) + const action = FilesAction.Move({ + source: [source], + dest: [dest], + }) + expect(() => handleFilesAction(action, initial)(emptyState)).toThrowError( + 'Failed to move directory', + ) + }) + }) + + it('Revert', () => { + const action = FilesAction.Revert('a/b/c') + const state = { + added: { + 'a/b/c': fileAA, + 'x/y/z': fileAA, + }, + existing: { + 'a/b/c': fileEA, + 'x/y/z': fileEA, + }, + deleted: { + 'a/b/c': true as const, + 'x/y/z': true as const, + }, + } + expect(handleFilesAction(action, initial)(state)).toEqual({ + added: { + 'x/y/z': fileAA, + }, + deleted: { + 'x/y/z': true as const, + }, + existing: state.existing, + }) + }) + + it('RevertDir', () => { + const action = FilesAction.RevertDir('a/b') + const state = { + added: { + 'a/b/a': fileAA, + 'a/b/b': fileAA, + 'x/y/z': fileAA, + }, + existing: { + 'a/b/a': fileEA, + 'a/b/b': fileEA, + 'x/y/z': fileEA, + }, + deleted: { + 'a/b/a': true as const, + 'a/b/b': true as const, + 'x/y/z': true as const, + }, + } + expect(handleFilesAction(action, initial)(state)).toEqual({ + added: { + 'x/y/z': fileAA, + }, + deleted: { + 'x/y/z': true as const, + }, + existing: state.existing, + }) + }) + + it('Reset', () => { + const action = FilesAction.Reset() + const state = { + added: { + any: fileAA, + }, + existing: { + any: fileEA, + }, + deleted: { + any: true as const, + }, + } + const result = handleFilesAction(action, initial)(state) + expect(result).toEqual({ + added: {}, + existing: {}, + deleted: {}, + }) + expect(result).toBe(initial.initial) + }) + }) +}) diff --git a/catalog/app/containers/Bucket/PackageDialog/FilesState.ts b/catalog/app/containers/Bucket/PackageDialog/FilesState.ts new file mode 100644 index 00000000000..0486a6bdbdb --- /dev/null +++ b/catalog/app/containers/Bucket/PackageDialog/FilesState.ts @@ -0,0 +1,309 @@ +import { join, basename } from 'path' + +import * as R from 'ramda' +import { FileWithPath } from 'react-dropzone' + +import type * as Model from 'model' +import dissocBy from 'utils/dissocBy' +import * as s3paths from 'utils/s3paths' +import * as tagged from 'utils/taggedV2' +import * as Types from 'utils/types' + +export const EMPTY_DIR_MARKER = { + bucket: '[$empty$]', + key: '[$empty$]', + size: 0, +} +const EMPTY_DIR_MARKER_PATH = '[$.quiltkeep$]' + +// Rename root key in object +// In other words, move value from one key to another +export function renameKey( + from: string, + to: string, + obj: Record, +) { + const { [from]: property, ...rest } = obj + return { + ...rest, + [to]: property, + } +} + +// Moves all `foo/bar/*` keys to `foo/baz/*` as `/foo/baz/bar/*` +export function renameKeys( + sourcePrefix: string, + destPath: string, + obj: Record, +) { + return Object.entries(obj).reduce((acc, [key, value]) => { + if (!key.startsWith(sourcePrefix)) return { ...acc, [key]: value } + const newKey = key.replace( + s3paths.ensureSlash(sourcePrefix), + s3paths.ensureSlash(join(destPath, basename(sourcePrefix))), + ) + return { ...acc, [newKey]: value } + }, {}) +} + +function packageEntryToS3File(entry: Model.PackageEntry): Model.S3File { + return { + ...s3paths.parseS3Url(entry.physicalKey), + meta: entry.meta, + size: entry.size, + } +} + +export function moveExistingToAdded( + sourcePath: string, + destPath: string, + state: FilesState, +) { + const converted = packageEntryToS3File(state.existing[sourcePath]) + const added = R.assoc(destPath, converted, state.added) + const deleted = R.assoc(sourcePath, true as const, state.deleted) + return { + ...state, + added, + deleted, + } +} + +export function moveExistingDirectoryToAdded( + sourcePath: string, + destPath: string, + state: FilesState, +) { + return Object.entries(state.existing).reduce((acc, [key, value]) => { + if (!key.startsWith(sourcePath)) return acc + const newKey = key.replace( + s3paths.ensureSlash(sourcePath), + s3paths.ensureSlash(join(destPath, basename(sourcePath))), + ) + const added = R.assoc(newKey, packageEntryToS3File(value), acc.added) + const deleted = R.assoc(key, true, acc.deleted) + return { ...acc, added, deleted } + }, state) +} + +export interface FileWithHash extends File { + hash: { + ready: boolean + value?: Model.Checksum + error?: Error + promise: Promise + } + meta?: Types.JsonRecord +} + +export type LocalFile = FileWithPath & FileWithHash + +export interface FilesState { + added: Record + deleted: Record + existing: Record + // XXX: workaround used to re-trigger validation and dependent computations + // required due to direct mutations of File objects + counter?: number +} + +export type FilesEntryState = + | 'deleted' + | 'modified' + | 'unchanged' + | 'hashing' + | 'added' + | 'invalid' + +export type FilesEntryType = 's3' | 'local' | 'hidden' + +const FilesEntryTag = 'app/containers/Bucket/PackageDialog/FilesInput:FilesEntry' as const + +export const FilesEntry = tagged.create(FilesEntryTag, { + Dir: (v: { + name: string + state: FilesEntryState + childEntries: tagged.Instance[] + }) => v, + File: (v: { + name: string + state: FilesEntryState + type: FilesEntryType + size: number + meta?: Model.EntryMeta + }) => v, +}) + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export type FilesEntry = tagged.InstanceOf +export type FilesEntryDir = ReturnType + +export const FilesAction = tagged.create( + 'app/containers/Bucket/PackageDialog/FilesInput:FilesAction' as const, + { + Add: (v: { files: FileWithHash[]; prefix?: string }) => v, + AddFolder: (path: string) => path, + AddFromS3: (filesMap: Record) => filesMap, + Delete: (path: string) => path, + DeleteDir: (prefix: string) => prefix, + Meta: (v: { path: string; meta?: Model.EntryMeta }) => v, + Move: (v: { source?: [FilesEntry, string?]; dest: [FilesEntry, string?] }) => v, + Revert: (path: string) => path, + RevertDir: (prefix: string) => prefix, + Reset: () => {}, + }, +) + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export type FilesAction = tagged.InstanceOf + +const addMetaToFile = ( + file: Model.PackageEntry | LocalFile | Model.S3File, + meta?: Model.EntryMeta, +) => { + if (file instanceof window.File) { + const fileCopy = new window.File([file as File], (file as File).name, { + type: (file as File).type, + }) + Object.defineProperty(fileCopy, 'meta', { + value: meta, + }) + Object.defineProperty(fileCopy, 'hash', { + value: (file as FileWithHash).hash, + }) + return fileCopy + } + return R.assoc('meta', meta, file) +} + +const getNormalizedPath = (f: { path?: string; name: string }) => { + const p = f.path || f.name + return p.startsWith('/') ? p.substring(1) : p +} + +function hasDir(dir: string, obj: FilesState['existing'] | FilesState['added']) { + for (const key in obj) { + if (key.startsWith(dir)) return true + } + return false +} + +export const handleFilesAction = FilesAction.match< + (state: FilesState) => FilesState, + [{ initial: FilesState }] +>({ + Add: + ({ files, prefix }) => + (state) => + files.reduce((acc, file) => { + const path = (prefix || '') + getNormalizedPath(file) + return R.evolve( + { + added: R.assoc(path, file), + deleted: R.dissoc(path), + }, + acc, + ) + }, state), + AddFolder: (path) => + R.evolve({ + added: R.assoc(join(path, EMPTY_DIR_MARKER_PATH), EMPTY_DIR_MARKER), + deleted: R.dissoc(path), + }), + AddFromS3: (filesMap) => + R.evolve({ + added: R.mergeLeft(filesMap), + deleted: R.omit(Object.keys(filesMap)), + }), + Delete: (path) => + R.evolve({ + added: R.dissoc(path), + deleted: R.assoc(path, true as const), + }), + // add all descendants from existing to deleted + DeleteDir: + (prefix) => + ({ existing, added, deleted, ...rest }) => ({ + existing, + added: dissocBy(R.startsWith(prefix))(added), + deleted: R.mergeLeft( + Object.keys(existing).reduce( + (acc, k) => (k.startsWith(prefix) ? { ...acc, [k]: true } : acc), + {}, + ), + deleted, + ), + ...rest, + }), + Meta: ({ path, meta }) => { + const mkSetMeta = + () => + (filesDict: Record) => { + const file = filesDict[path] + if (!file) return filesDict + return R.assoc(path, addMetaToFile(file, meta), filesDict) + } + return R.evolve({ + added: mkSetMeta(), + existing: mkSetMeta(), + }) + }, + Move: + ({ source, dest }) => + (state) => { + if (!source || !dest) return state + + const [sourceFile, sourcePrefix] = source + return FilesEntry.match( + { + Dir: (entry) => { + const sourcePath = sourcePrefix ? `${sourcePrefix}${entry.name}` : entry.name + const [destDir, destPrefix] = dest + const destPath = destPrefix + ? `${destPrefix}${destDir.value.name}` + : `${destDir.value.name}` + if (hasDir(sourcePath, state.existing)) { + return moveExistingDirectoryToAdded(sourcePath, destPath, state) + } + if (hasDir(sourcePath, state.added)) { + return { + ...state, + added: renameKeys(sourcePath, destPath, state.added), + } + } + throw new Error('Failed to move directory') + }, + File: (entry) => { + const sourcePath = sourcePrefix ? `${sourcePrefix}${entry.name}` : entry.name + const [destDir, destPrefix] = dest + const destPath = destPrefix + ? `${destPrefix}${destDir.value.name}${entry.name}` + : `${destDir.value.name}${entry.name}` + + if (state.existing[sourcePath]) { + return moveExistingToAdded(sourcePath, destPath, state) + } + if (state.added[sourcePath]) { + return { + ...state, + added: renameKey(sourcePath, destPath, state.added), + } + } + throw new Error('Failed to move file') + }, + }, + sourceFile, + ) + }, + Revert: (path) => R.evolve({ added: R.dissoc(path), deleted: R.dissoc(path) }), + // remove all descendants from added and deleted + RevertDir: (prefix) => + R.evolve({ + added: dissocBy(R.startsWith(prefix)), + deleted: dissocBy(R.startsWith(prefix)), + }), + Reset: + (_, { initial }) => + () => + initial, +}) diff --git a/catalog/app/containers/Bucket/PackageDialog/PackageCreationForm.tsx b/catalog/app/containers/Bucket/PackageDialog/PackageCreationForm.tsx index de81478b19f..684ac5373e9 100644 --- a/catalog/app/containers/Bucket/PackageDialog/PackageCreationForm.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/PackageCreationForm.tsx @@ -74,6 +74,8 @@ function filesStateToEntries(files: FI.FilesState): PD.ValidationEntry[] { R.mergeLeft(files.added, files.existing), R.omit(Object.keys(files.deleted)), Object.entries, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + R.filter(([path, file]) => file !== FI.EMPTY_DIR_MARKER), R.map(([path, file]) => ({ logical_key: path, meta: file.meta?.user_meta || {}, @@ -144,6 +146,7 @@ function FormError({ submitting, error }: FormErrorProps) { const useStyles = M.makeStyles((t) => ({ files: { height: '100%', + overflowY: 'auto', }, filesWithError: { height: `calc(90% - ${t.spacing()}px)`, @@ -295,6 +298,7 @@ function PackageCreationForm({ const addedS3Entries: S3Entry[] = [] const addedLocalEntries: LocalEntry[] = [] Object.entries(files.added).forEach(([path, file]) => { + if (file === FI.EMPTY_DIR_MARKER) return if (isS3File(file)) { addedS3Entries.push({ path, file }) } else { diff --git a/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx b/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx index c1946d2e8be..aa51b9f696d 100644 --- a/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx @@ -44,11 +44,6 @@ export const ERROR_MESSAGES = { MANIFEST: 'Error creating manifest', } -export const getNormalizedPath = (f: { path?: string; name: string }) => { - const p = f.path || f.name - return p.startsWith('/') ? p.substring(1) : p -} - function cacheDebounce( fn: (...args: I) => Promise, wait: number, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cf9c533219e..34a4e9d8968 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,7 @@ Entries inside each section should be ordered by type: ## Catalog, Lambdas * [Added] Support multiple roles per user ([#3982](https://github.com/quiltdata/quilt/pull/3982)) * [Added] Add `ui.actions = False` and `ui.actions.writeFile` for configuring visibility of buttons ([#4001](https://github.com/quiltdata/quilt/pull/4001)) +* [Added] Support creating folders and rearranging entries with drag and drop in package creation dialog ([#3999](https://github.com/quiltdata/quilt/pull/3999)) # 6.0.0a4 - 2024-06-18 ## Python API