diff --git a/catalog/app/components/Dialog/Prompt.tsx b/catalog/app/components/Dialog/Prompt.tsx new file mode 100644 index 00000000000..999d331fa6d --- /dev/null +++ b/catalog/app/components/Dialog/Prompt.tsx @@ -0,0 +1,115 @@ +import * as R from 'ramda' +import * as React from 'react' +import * as M from '@material-ui/core' +import * as Lab from '@material-ui/lab' + +interface DialogProps { + initialValue?: string + onCancel: () => void + onSubmit: (value: string) => void + open: boolean + title: string + validate: (value: string) => Error | undefined +} + +function Dialog({ + initialValue, + open, + onCancel, + onSubmit, + title, + validate, +}: DialogProps) { + const [value, setValue] = React.useState(initialValue || '') + const [submitted, setSubmitted] = React.useState(false) + const error = React.useMemo(() => validate(value), [validate, value]) + const handleChange = React.useCallback((event) => setValue(event.target.value), []) + const handleSubmit = React.useCallback( + (event) => { + event.preventDefault() + setSubmitted(true) + if (!error) onSubmit(value) + }, + [error, onSubmit, value], + ) + return ( + +
+ {title} + + + {!!error && !!submitted && ( + {error.message} + )} + + + + Cancel + + + Submit + + +
+
+ ) +} + +interface PromptProps { + initialValue?: string + onSubmit: (value: string) => void + title: string + validate: (value: string) => Error | undefined +} + +export function usePrompt({ initialValue, title, onSubmit, validate }: PromptProps) { + const [key, setKey] = React.useState(0) + const [opened, setOpened] = React.useState(false) + const open = React.useCallback(() => { + setKey(R.inc) + setOpened(true) + }, []) + const close = React.useCallback(() => setOpened(false), []) + const handleSubmit = React.useCallback( + (value: string) => { + onSubmit(value) + close() + }, + [close, onSubmit], + ) + const render = React.useCallback( + () => ( + + ), + [initialValue, key, close, handleSubmit, opened, title, validate], + ) + return React.useMemo( + () => ({ + close, + open, + render, + }), + [close, open, render], + ) +} diff --git a/catalog/app/components/Dialog/index.ts b/catalog/app/components/Dialog/index.ts new file mode 100644 index 00000000000..ff8f16cb3fe --- /dev/null +++ b/catalog/app/components/Dialog/index.ts @@ -0,0 +1 @@ +export * from './Prompt' diff --git a/catalog/app/containers/Bucket/Dir.tsx b/catalog/app/containers/Bucket/Dir.tsx index 69173d0a573..04f8154f03e 100644 --- a/catalog/app/containers/Bucket/Dir.tsx +++ b/catalog/app/containers/Bucket/Dir.tsx @@ -1,4 +1,4 @@ -import { basename, join } from 'path' +import { basename, join, extname } from 'path' import dedent from 'dedent' import * as R from 'ramda' @@ -8,6 +8,8 @@ import * as M from '@material-ui/core' import { Crumb, copyWithoutSpaces, render as renderCrumbs } from 'components/BreadCrumbs' import type * as DG from 'components/DataGrid' +import * as Dialog from 'components/Dialog' +import { detect } from 'components/FileEditor/loader' import * as Bookmarks from 'containers/Bookmarks' import AsyncResult from 'utils/AsyncResult' import * as AWS from 'utils/AWS' @@ -23,12 +25,63 @@ import type * as workflows from 'utils/workflows' import Code from './Code' import * as FileView from './FileView' import { Item, Listing, PrefixFilter } from './Listing' +import Menu from './Menu' import * as PD from './PackageDialog' import * as Successors from './Successors' import Summary from './Summary' import { displayError } from './errors' import * as requests from './requests' +interface DirectoryMenuProps { + bucket: string + className?: string + path: string +} + +function DirectoryMenu({ bucket, path, className }: DirectoryMenuProps) { + const { urls } = NamedRoutes.use() + const history = RRDom.useHistory() + + const createFile = React.useCallback( + (name: string) => { + if (!name) return + history.push(urls.bucketFile(bucket, join(path, name), { edit: true })) + }, + [bucket, history, path, urls], + ) + const validateFileName = React.useCallback((value: string) => { + if (!value) { + return new Error('File name is required') + } + if (!detect(value).brace || extname(value) === '.' || !extname(value)) { + // TODO: get list of supported extensions from FileEditor + return new Error('Supported file formats are JSON, Markdown, YAML and text') + } + }, []) + const prompt = Dialog.usePrompt({ + onSubmit: createFile, + initialValue: 'README.md', + title: 'Enter file name', + validate: validateFileName, + }) + const menuItems = React.useMemo( + () => [ + { + onClick: prompt.open, + title: 'Create file', + }, + ], + [prompt.open], + ) + + return ( + <> + {prompt.render()} + + + ) +} + const useAddToBookmarksStyles = M.makeStyles((t) => ({ root: { alignItems: 'baseline', @@ -105,7 +158,17 @@ function AddToBookmarks({ interface RouteMap { bucketDir: [bucket: string, path?: string, prefix?: string] - bucketFile: [bucket: string, path: string, version?: string] + bucketFile: [ + bucket: string, + path: string, + options?: { + add?: boolean + edit?: boolean + mode?: string + next?: string + version?: string + }, + ] } type Urls = NamedRoutes.Urls @@ -363,6 +426,7 @@ export default function Dir({ label="Download directory" /> )} + {preferences?.ui?.blocks?.code && {code}} diff --git a/catalog/app/containers/Bucket/FileView.js b/catalog/app/containers/Bucket/FileView.js index a98c1aae4d5..0488a46444f 100644 --- a/catalog/app/containers/Bucket/FileView.js +++ b/catalog/app/containers/Bucket/FileView.js @@ -13,7 +13,7 @@ export * from './Meta' // TODO: move here everything that's reused btw Bucket/File, Bucket/PackageTree and Embed/File -const useDownloadButtonStyles = M.makeStyles((t) => ({ +const useAdaptiveButtonStyles = M.makeStyles((t) => ({ root: { flexShrink: 0, marginBottom: -3, @@ -25,7 +25,7 @@ const useDownloadButtonStyles = M.makeStyles((t) => ({ })) export function AdaptiveButtonLayout({ className, label, icon, ...props }) { - const classes = useDownloadButtonStyles() + const classes = useAdaptiveButtonStyles() const t = M.useTheme() const sm = M.useMediaQuery(t.breakpoints.down('sm')) @@ -64,7 +64,7 @@ export function DownloadButton({ className, handle }) { } export function ViewModeSelector({ className, ...props }) { - const classes = useDownloadButtonStyles() + const classes = useAdaptiveButtonStyles() const t = M.useTheme() const sm = M.useMediaQuery(t.breakpoints.down('sm')) return ( diff --git a/catalog/app/containers/Bucket/Menu.tsx b/catalog/app/containers/Bucket/Menu.tsx new file mode 100644 index 00000000000..41500c34472 --- /dev/null +++ b/catalog/app/containers/Bucket/Menu.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' + +import * as M from '@material-ui/core' + +interface MenuProps { + className?: string + items: { + onClick: () => void + title: string + }[] +} + +export default function Menu({ className, items }: MenuProps) { + const [anchorEl, setAnchorEl] = React.useState(null) + + const handleOpen = React.useCallback( + (event) => setAnchorEl(event.target), + [setAnchorEl], + ) + + const handleClose = React.useCallback(() => setAnchorEl(null), [setAnchorEl]) + + const mkClickHandler = React.useCallback( + (onClick: () => void) => () => { + onClick() + setAnchorEl(null) + }, + [], + ) + + return ( + <> + + more_vert + + + + {items.map(({ onClick, title }) => ( + + {title} + + ))} + + + ) +} diff --git a/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx b/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx index 24f8fa4e18c..0d817fdf119 100644 --- a/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx +++ b/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx @@ -395,9 +395,6 @@ function DirDisplay({ const downloadPath = path ? `package/${bucket}/${name}/${hash}/${path}` : `package/${bucket}/${name}/${hash}` - const hasRevisionMenu = - preferences?.ui?.actions?.deleteRevision || - preferences?.ui?.actions?.openInDesktop // TODO: disable if nothing to revise on desktop const hasReviseButton = preferences?.ui?.actions?.revisePackage @@ -431,13 +428,11 @@ function DirDisplay({ onClick={openInDesktopState.confirm} path={downloadPath} /> - {hasRevisionMenu && ( - - )} + {preferences?.ui?.blocks?.code && ( diff --git a/catalog/app/containers/Bucket/PackageTree/RevisionMenu.tsx b/catalog/app/containers/Bucket/PackageTree/RevisionMenu.tsx index 5308a8da608..bf98fb0ced1 100644 --- a/catalog/app/containers/Bucket/PackageTree/RevisionMenu.tsx +++ b/catalog/app/containers/Bucket/PackageTree/RevisionMenu.tsx @@ -1,9 +1,10 @@ import * as React from 'react' -import * as M from '@material-ui/core' import * as BucketPreferences from 'utils/BucketPreferences' import * as Config from 'utils/Config' +import Menu from '../Menu' + interface RevisionMenuProps { className: string onDelete: () => void @@ -17,42 +18,25 @@ export default function RevisionMenu({ }: RevisionMenuProps) { const preferences = BucketPreferences.use() const { desktop }: { desktop: boolean } = Config.use() - const [anchorEl, setAnchorEl] = React.useState(null) - - const handleOpen = React.useCallback( - (event) => { - setAnchorEl(event.target) - }, - [setAnchorEl], - ) - - const handleClose = React.useCallback(() => { - setAnchorEl(null) - }, [setAnchorEl]) - - const handleDeleteClick = React.useCallback(() => { - onDelete() - setAnchorEl(null) - }, [onDelete, setAnchorEl]) - const handleDesktopClick = React.useCallback(() => { - onDesktop() - setAnchorEl(null) - }, [onDesktop, setAnchorEl]) - - return ( - <> - - more_vert - - - {preferences?.ui?.actions?.deleteRevision && ( - Delete revision - )} - {preferences?.ui?.actions?.openInDesktop && !desktop && ( - Open in Teleport - )} - - - ) + const items = React.useMemo(() => { + const menu = [] + if (preferences?.ui?.actions?.deleteRevision) { + menu.push({ + onClick: onDelete, + title: 'Delete revision', + }) + } + if (preferences?.ui?.actions?.openInDesktop && !desktop) { + menu.push({ + onClick: onDesktop, + title: 'Open in Teleport', + }) + } + return menu + }, [desktop, onDelete, onDesktop, preferences]) + + if (!items.length) return null + + return } diff --git a/catalog/app/containers/Bucket/Successors.tsx b/catalog/app/containers/Bucket/Successors.tsx index fe90fa9bc91..32b514f5a88 100644 --- a/catalog/app/containers/Bucket/Successors.tsx +++ b/catalog/app/containers/Bucket/Successors.tsx @@ -223,6 +223,7 @@ interface ButtonInnerProps { onClick: React.MouseEventHandler } +// TODO: Replace by FileView.AdaptiveButtonLayout function ButtonInner({ children, className, onClick }: ButtonInnerProps) { const t = M.useTheme() const sm = M.useMediaQuery(t.breakpoints.down('sm')) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cfd046d8801..b6074f1ffac 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,6 +26,7 @@ * [Added] Add missing README to package ([#2960](https://github.com/quiltdata/quilt/pull/2960), [#2979](https://github.com/quiltdata/quilt/pull/2979)) * [Added] View and copy full Athena query by expanding table row ([2993](https://github.com/quiltdata/quilt/pull/2993)) * [Added] Create packages from Athena query results ([#3004](https://github.com/quiltdata/quilt/pull/3004)) +* [Added] Add "Create text file" menu ([#3017](https://github.com/quiltdata/quilt/pull/3017)) * [Fixed] Fix package creation in S3 buckets with SSE-KMS enabled ([#2754](https://github.com/quiltdata/quilt/pull/2754)) * [Fixed] Fix creation of packages with large (4+ GiB) files ([#2933](https://github.com/quiltdata/quilt/pull/2933)) * [Changed] Clean up home page ([#2780](https://github.com/quiltdata/quilt/pull/2780)).