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 (
+
+
+
+ )
+}
+
+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)).