diff --git a/catalog/app/components/BreadCrumbs/BreadCrumbs.js b/catalog/app/components/BreadCrumbs/BreadCrumbs.js index 275430aa900..d464b7b5c3e 100644 --- a/catalog/app/components/BreadCrumbs/BreadCrumbs.js +++ b/catalog/app/components/BreadCrumbs/BreadCrumbs.js @@ -1,3 +1,4 @@ +import * as R from 'ramda' import * as React from 'react' import Link from 'utils/StyledLink' @@ -10,13 +11,15 @@ export const Crumb = tagged([ 'Sep', // value ]) -export const Segment = ({ label, to }) => - to ? {label || EMPTY} : label || EMPTY +export const Segment = ({ label, to, getLinkProps = R.identity }) => + to != null ? {label || EMPTY} : label || EMPTY -export const render = (items) => +export const render = (items, { getLinkProps = undefined } = {}) => items.map( Crumb.case({ - Segment: (s, i) => , + Segment: (s, i) => ( + + ), Sep: (s, i) => {s}, }), ) diff --git a/catalog/app/containers/Bucket/Dir.tsx b/catalog/app/containers/Bucket/Dir.tsx index ba0cd6d1d21..efe7b923946 100644 --- a/catalog/app/containers/Bucket/Dir.tsx +++ b/catalog/app/containers/Bucket/Dir.tsx @@ -33,24 +33,6 @@ interface RouteMap { type Urls = NamedRoutes.Urls -interface ListingFile { - bucket: string - key: string - modified: Date - size: number - etag: string - archived: boolean -} - -interface ListingResponse { - dirs: string[] - files: ListingFile[] - truncated: boolean - bucket: string - path: string - prefix: string -} - const getCrumbs = R.compose( R.intersperse(Crumb.Sep(<> / )), ({ bucket, path, urls }: { bucket: string; path: string; urls: Urls }) => @@ -63,39 +45,42 @@ const getCrumbs = R.compose( ), ) -const formatListing = ({ urls }: { urls: Urls }, r: ListingResponse) => { - const dirs = r.dirs.map((name) => ({ - type: 'dir' as const, - name: ensureNoSlash(withoutPrefix(r.path, name)), - to: urls.bucketDir(r.bucket, name), - })) - const files = r.files.map(({ key, size, modified, archived }) => ({ - type: 'file' as const, - name: withoutPrefix(r.path, key), - to: urls.bucketFile(r.bucket, key), - size, - modified, - archived, - })) - const items = [ - ...(r.path !== '' && !r.prefix - ? [ - { - type: 'dir' as const, - name: '..', - to: urls.bucketDir(r.bucket, up(r.path)), - }, - ] - : []), - ...dirs, - ...files, - ] - // filter-out files with same name as one of dirs - return R.uniqBy(R.prop('name'), items) +function useFormattedListing(r: requests.BucketListingResult) { + const { urls } = NamedRoutes.use() + return React.useMemo(() => { + const dirs = r.dirs.map((name) => ({ + type: 'dir' as const, + name: ensureNoSlash(withoutPrefix(r.path, name)), + to: urls.bucketDir(r.bucket, name), + })) + const files = r.files.map(({ key, size, modified, archived }) => ({ + type: 'file' as const, + name: withoutPrefix(r.path, key), + to: urls.bucketFile(r.bucket, key), + size, + modified, + archived, + })) + const items = [ + ...(r.path !== '' && !r.prefix + ? [ + { + type: 'dir' as const, + name: '..', + to: urls.bucketDir(r.bucket, up(r.path)), + }, + ] + : []), + ...dirs, + ...files, + ] + // filter-out files with same name as one of dirs + return R.uniqBy(R.prop('name'), items) + }, [r, urls]) } interface DirContentsProps { - response: ListingResponse + response: requests.BucketListingResult locked: boolean bucket: string path: string @@ -127,7 +112,7 @@ function DirContents({ [history, urls, bucket, path], ) - const items = React.useMemo(() => formatListing({ urls }, response), [urls, response]) + const items = useFormattedListing(response) // TODO: should prefix filtering affect summary? return ( @@ -277,7 +262,7 @@ export default function Dir({ Err: displayError(), Init: () => null, _: (x: $TSFixMe) => { - const res: ListingResponse | null = AsyncResult.getPrevResult(x) + const res: requests.BucketListingResult | null = AsyncResult.getPrevResult(x) return res ? ( {''} @@ -17,6 +18,9 @@ const TIP_DELAY = 1000 const TOOLBAR_INNER_HEIGHT = 28 +// monkey-patch MUI built-in colDef to better align checkboxes +DG.gridCheckboxSelectionColDef.width = 32 + export interface Item { type: 'dir' | 'file' name: string @@ -265,7 +269,7 @@ function Pagination({ truncated, loadMore }: PaginationProps) { ) - if (!pages) return null + if (!pages) return return (
@@ -465,6 +469,9 @@ function Toolbar({ const usePanelStyles = M.makeStyles((t) => ({ root: { zIndex: 1, + '& select, & input': { + boxSizing: 'content-box', + }, }, paper: { backgroundColor: t.palette.background.paper, @@ -504,6 +511,7 @@ function Panel({ children, open }: DG.GridPanelProps) { anchorEl={anchorEl} modifiers={getPopperModifiers()} className={classes.root} + disablePortal > @@ -717,6 +725,16 @@ function FilteredOverlay() { ) } +export type CellProps = React.PropsWithChildren<{ + item: Item + title?: string + className?: string +}> + +function Cell({ item, ...props }: CellProps) { + return +} + function compareBy(a: T, b: T, getValue: (arg: T) => V) { const va = getValue(a) const vb = getValue(b) @@ -760,7 +778,7 @@ const COL_MODIFIED_W = 176 const useStyles = M.makeStyles((t) => ({ '@global': { '.MuiDataGridMenu-root': { - zIndex: 1, + zIndex: t.zIndex.modal + 1, // show it over modals }, }, root: { @@ -774,8 +792,15 @@ const useStyles = M.makeStyles((t) => ({ background: fade(t.palette.background.paper, 0.5), zIndex: 1, }, + '& .MuiDataGrid-checkboxInput': { + padding: 7, + '& svg': { + fontSize: 18, + }, + }, '& .MuiDataGrid-cell': { border: 'none', + outline: 'none !important', padding: 0, }, '& .MuiDataGrid-colCell': { @@ -801,11 +826,11 @@ const useStyles = M.makeStyles((t) => ({ pointerEvents: 'none', }, // "Size" column - '&:nth-child(2)': { + '&:nth-last-child(2)': { justifyContent: 'flex-end', }, // "Last modified" column - '&:nth-child(3)': { + '&:last-child': { justifyContent: 'flex-end', '& .MuiDataGrid-colCellTitleContainer': { order: 1, @@ -842,6 +867,10 @@ const useStyles = M.makeStyles((t) => ({ alignItems: 'center', display: 'flex', }, + ellipsis: { + overflow: 'hidden', + textOverflow: 'ellipsis', + }, icon: { fontSize: t.typography.body1.fontSize, marginRight: t.spacing(0.5), @@ -858,6 +887,12 @@ interface ListingProps { prefixFilter?: string toolbarContents?: React.ReactNode loadMore?: () => void + selection?: DG.GridRowId[] + onSelectionChange?: (newSelection: DG.GridRowId[]) => void + CellComponent?: React.ComponentType + RootComponent?: React.ElementType<{ className: string }> + className?: string + dataGridProps?: Partial } export function Listing({ @@ -867,6 +902,12 @@ export function Listing({ toolbarContents, prefixFilter, loadMore, + selection, + onSelectionChange, + CellComponent = Cell, + RootComponent = M.Paper, + className, + dataGridProps, }: ListingProps) { const classes = useStyles() @@ -879,6 +920,32 @@ export function Listing({ [setFilteredToZero], ) + const [page, setPage] = React.useState(0) + const [pageSize, setPageSize] = React.useState(25) + + const handlePageChange = React.useCallback( + ({ page: newPage }: DG.GridPageChangeParams) => { + setPage(newPage) + }, + [], + ) + + const handlePageSizeChange = React.useCallback( + ({ pageSize: newPageSize }: DG.GridPageChangeParams) => { + setPageSize(newPageSize) + }, + [], + ) + + usePrevious(items, (prevItems?: Item[]) => { + if (!prevItems) return + const itemsOnPrevPages = page * pageSize + // reset page if items on previous pages change + if (!R.equals(R.take(itemsOnPrevPages, items), R.take(itemsOnPrevPages, prevItems))) { + setPage(0) + } + }) + const columns: DG.GridColumns = React.useMemo( () => [ { @@ -904,20 +971,20 @@ export function Listing({ renderCell: (params: DG.GridCellParams) => { const i = (params.row as unknown) as Item return ( - {i.type === 'file' ? 'insert_drive_file' : 'folder_open'} - {i.name || EMPTY} - + {i.name || EMPTY} + ) }, }, @@ -938,13 +1005,13 @@ export function Listing({ renderCell: (params: DG.GridCellParams) => { const i = (params.row as unknown) as Item return ( - {i.size == null ? <>  : readableBytes(i.size)} - + ) }, }, @@ -957,18 +1024,18 @@ export function Listing({ renderCell: (params: DG.GridCellParams) => { const i = (params.row as unknown) as Item return ( - {i.modified == null ? <>  : i.modified.toLocaleString()} - + ) }, }, ], - [classes], + [classes, CellComponent], ) const noRowsLabel = `No files / directories${ @@ -978,9 +1045,16 @@ export function Listing({ // abuse loading overlay to show warning when all the items are filtered-out const LoadingOverlay = !locked && filteredToZero ? FilteredOverlay : undefined + const handleSelectionModelChange = React.useCallback( + (newSelection: DG.GridSelectionModelChangeParams) => { + if (onSelectionChange) onSelectionChange(newSelection.selectionModel) + }, + [onSelectionChange], + ) + // TODO: control page, pageSize, filtering and sorting via props return ( - + row.name} pagination - pageSize={25} - // page={1} - // onPageChange={({ page }) => set page} - // onPageSizeChange={({ pageSize }) => set page size} + pageSize={pageSize} + onPageSizeChange={handlePageSizeChange} + page={page} + onPageChange={handlePageChange} loading={locked || filteredToZero} headerHeight={36} rowHeight={36} @@ -1008,8 +1082,13 @@ export function Listing({ disableMultipleSelection disableMultipleColumnsSorting localeText={{ noRowsLabel, ...localeText }} + // selection-related props + checkboxSelection={!!onSelectionChange} + selectionModel={selection} + onSelectionModelChange={handleSelectionModelChange} + {...dataGridProps} /> - + ) } diff --git a/catalog/app/containers/Bucket/PackageCreateDialog.js b/catalog/app/containers/Bucket/PackageCreateDialog.js index a7a062e39df..084d256ce87 100644 --- a/catalog/app/containers/Bucket/PackageCreateDialog.js +++ b/catalog/app/containers/Bucket/PackageCreateDialog.js @@ -184,13 +184,15 @@ function FilesInput({ value, ]) - const warn = totalSize > PD.MAX_SIZE + const warn = totalSize > PD.MAX_UPLOAD_SIZE // eslint-disable-next-line no-nested-ternary const label = error ? ( errors[error] || error ) : warn ? ( - <>Total file size exceeds recommended maximum of {readableBytes(PD.MAX_SIZE)} + <> + Total file size exceeds recommended maximum of {readableBytes(PD.MAX_UPLOAD_SIZE)} + ) : ( 'Drop files here or click to browse' ) diff --git a/catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx b/catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx index a1df5404f26..f270cf63ed7 100644 --- a/catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx @@ -6,11 +6,13 @@ import * as M from '@material-ui/core' import { fade } from '@material-ui/core/styles' import useDragging from 'utils/dragging' +import { withoutPrefix } from 'utils/s3paths' import { readableBytes } from 'utils/string' import * as tagged from 'utils/taggedV2' import useMemoEq from 'utils/useMemoEq' import * as PD from './PackageDialog' +import * as S3FilePicker from './S3FilePicker' const COLORS = { default: M.colors.grey[900], @@ -50,6 +52,10 @@ function computeHash(f: F) { return fh } +function ensureExhaustive(x: never): never { + throw new Error(`Non-exhaustive match: '${x}'`) +} + export const FilesAction = tagged.create( 'app/containers/Bucket/PackageDialog/FilesInput:FilesAction' as const, { @@ -57,6 +63,11 @@ export const FilesAction = tagged.create( ...v, files: v.files.map(computeHash), }), + AddFromS3: (v: { + files: S3FilePicker.S3File[] + basePrefix: string + prefix?: string + }) => v, Delete: (path: string) => path, DeleteDir: (prefix: string) => prefix, Revert: (path: string) => path, @@ -76,8 +87,10 @@ export interface ExistingFile { size: number } +export type LocalFile = FileWithPath & FileWithHash + export interface FilesState { - added: Record + added: Record deleted: Record existing: Record // XXX: workaround used to re-trigger validation and dependent computations @@ -112,6 +125,17 @@ const handleFilesAction = FilesAction.match< acc, ) as FilesState }, state), + AddFromS3: ({ files, basePrefix, prefix }) => (state) => + files.reduce((acc, file) => { + const path = (prefix || '') + withoutPrefix(basePrefix, file.key) + return R.evolve( + { + added: R.assoc(path, file), + deleted: R.dissoc(path), + }, + acc, + ) as FilesState + }, state), Delete: (path) => R.evolve({ added: R.dissoc(path), @@ -147,6 +171,8 @@ interface DispatchFilesAction { type FilesEntryState = 'deleted' | 'modified' | 'unchanged' | 'hashing' | 'added' +type FilesEntryType = 's3' | 'local' + const FilesEntryTag = 'app/containers/Bucket/PackageDialog/FilesInput:FilesEntry' as const const FilesEntry = tagged.create(FilesEntryTag, { @@ -155,7 +181,12 @@ const FilesEntry = tagged.create(FilesEntryTag, { state: FilesEntryState childEntries: tagged.Instance[] }) => v, - File: (v: { name: string; state: FilesEntryState; size: number }) => v, + File: (v: { + name: string + state: FilesEntryState + type: FilesEntryType + size: number + }) => v, }) // eslint-disable-next-line @typescript-eslint/no-redeclare @@ -203,30 +234,42 @@ const insertIntoTree = (path: string[] = [], file: FilesEntry, entries: FilesEnt interface IntermediateEntry { state: FilesEntryState + type: FilesEntryType path: string size: number } const computeEntries = ({ added, deleted, existing }: FilesState) => { - const existingEntries = Object.entries(existing).map(([path, { size, hash }]) => { - if (path in deleted) { - return { state: 'deleted' as const, path, size } - } - if (path in added) { - const a = added[path] - // eslint-disable-next-line no-nested-ternary - const state = !a.hash.ready - ? ('hashing' as const) - : a.hash.value === hash - ? ('unchanged' as const) - : ('modified' as const) - return { state, path, size: a.size } - } - return { state: 'unchanged' as const, path, size } - }) - const addedEntries = Object.entries(added).reduce((acc, [path, { size }]) => { + const existingEntries: IntermediateEntry[] = Object.entries(existing).map( + ([path, { size, hash }]) => { + if (path in deleted) { + return { state: 'deleted' as const, type: 'local' as const, path, size } + } + if (path in added) { + const a = added[path] + let state: FilesEntryState + let type: FilesEntryType + if (S3FilePicker.isS3File(a)) { + type = 's3' as const + state = 'modified' as const + } else { + type = 'local' as const + // eslint-disable-next-line no-nested-ternary + state = !a.hash.ready + ? ('hashing' as const) + : a.hash.value === hash + ? ('unchanged' as const) + : ('modified' as const) + } + return { state, type, path, size: a.size } + } + return { state: 'unchanged' as const, type: 'local' as const, path, size } + }, + ) + const addedEntries = Object.entries(added).reduce((acc, [path, f]) => { if (path in existing) return acc - return acc.concat({ state: 'added', path, size }) + const type = S3FilePicker.isS3File(f) ? ('s3' as const) : ('local' as const) + return acc.concat({ state: 'added', type, path, size: f.size }) }, [] as IntermediateEntry[]) const entries: IntermediateEntry[] = [...existingEntries, ...addedEntries] return entries.reduce((children, { path, ...rest }) => { @@ -242,7 +285,9 @@ export const HASHING = 'hashing' export const HASHING_ERROR = 'hashingError' export const validateHashingComplete = (state: FilesState) => { - const files = Object.values(state.added) + const files = Object.values(state.added).filter( + (f) => !S3FilePicker.isS3File(f), + ) as FileWithHash[] if (files.some((f) => f.hash.ready && !f.hash.value)) return HASHING_ERROR if (files.some((f) => !f.hash.ready)) return HASHING return undefined @@ -265,6 +310,19 @@ const useEntryIconStyles = M.makeStyles((t) => ({ fontSize: 18, padding: 3, }, + overlay: { + alignItems: 'center', + bottom: 0, + color: t.palette.background.paper, + display: 'flex', + fontFamily: t.typography.fontFamily, + fontSize: 8, + justifyContent: 'center', + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, stateContainer: { alignItems: 'center', background: 'currentColor', @@ -289,9 +347,12 @@ const useEntryIconStyles = M.makeStyles((t) => ({ }, })) -type EntryIconProps = React.PropsWithChildren<{ state: FilesEntryState }> +type EntryIconProps = React.PropsWithChildren<{ + state: FilesEntryState + overlay?: React.ReactNode +}> -function EntryIcon({ state, children }: EntryIconProps) { +function EntryIcon({ state, overlay, children }: EntryIconProps) { const classes = useEntryIconStyles() const stateContents = state && @@ -305,6 +366,7 @@ function EntryIcon({ state, children }: EntryIconProps) { return (
{children} + {!!overlay &&
{overlay}
} {!!stateContents && (
{stateContents === 'hashing' ? ( @@ -378,6 +440,7 @@ const useFileStyles = M.makeStyles((t) => ({ interface FileProps extends React.HTMLAttributes { name: string state?: FilesEntryState + type?: FilesEntryType size?: number action?: React.ReactNode interactive?: boolean @@ -387,6 +450,7 @@ interface FileProps extends React.HTMLAttributes { function File({ name, state = 'unchanged', + type = 'local', size, action, interactive = false, @@ -407,7 +471,9 @@ function File({ {...props} >
- insert_drive_file + + insert_drive_file +
{name}
@@ -563,7 +629,7 @@ const Dir = React.forwardRef(function Dir( )}
{(!!children || empty) && ( - +
{children ||
}
@@ -595,7 +661,7 @@ const useDropzoneMessageStyles = M.makeStyles((t) => ({ interface DropzoneMessageProps { error: React.ReactNode - warn: boolean + warn: { upload: boolean; s3: boolean } } function DropzoneMessage({ error, warn }: DropzoneMessageProps) { @@ -603,21 +669,32 @@ function DropzoneMessage({ error, warn }: DropzoneMessageProps) { const label = React.useMemo(() => { if (error) return error - if (warn) + if (warn.upload || warn.s3) return ( <> - Total size of new files exceeds recommended maximum of{' '} - {readableBytes(PD.MAX_SIZE)} + {warn.upload && ( + <> + Total size of local files exceeds recommended maximum of{' '} + {readableBytes(PD.MAX_UPLOAD_SIZE)}. + + )} + {warn.upload && warn.s3 &&
} + {warn.s3 && ( + <> + Total size of files from S3 exceeds recommended maximum of{' '} + {readableBytes(PD.MAX_S3_SIZE)}. + + )} ) return 'Drop files here or click to browse' - }, [error, warn]) + }, [error, warn.upload, warn.s3]) return (
{label} @@ -837,6 +914,7 @@ const useContentsStyles = M.makeStyles((t) => ({ minHeight: 80, outline: 'none', overflow: 'hidden', + position: 'relative', }, interactive: { cursor: 'pointer', @@ -885,41 +963,51 @@ type FileUploadProps = tagged.ValueOf & { dispatch: DispatchFilesAction } -function FileUpload({ name, state, size, prefix, dispatch }: FileUploadProps) { +function FileUpload({ name, state, type, size, prefix, dispatch }: FileUploadProps) { const path = (prefix || '') + name - const handle = React.useCallback( - (cons: tagged.ConstructorOf) => (e: React.MouseEvent) => { + // eslint-disable-next-line consistent-return + const action = React.useMemo(() => { + const handle = (a: FilesAction) => (e: React.MouseEvent) => { // stop click from propagating to the root element and triggering its handler e.stopPropagation() - dispatch(cons(path)) - }, - [dispatch, path], - ) - - const action = React.useMemo( - () => - ({ - added: { hint: 'Remove', icon: 'clear', handler: handle(FilesAction.Revert) }, - modified: { + dispatch(a) + } + switch (state) { + case 'added': + return { + hint: 'Remove', + icon: 'clear', + handler: handle(FilesAction.Revert(path)), + } + case 'modified': + return { hint: 'Revert', icon: 'undo', - handler: handle(FilesAction.Revert), - }, - hashing: { + handler: handle(FilesAction.Revert(path)), + } + case 'hashing': + return { hint: 'Revert', icon: 'undo', - handler: handle(FilesAction.Revert), - }, - deleted: { + handler: handle(FilesAction.Revert(path)), + } + case 'deleted': + return { hint: 'Restore', icon: 'undo', - handler: handle(FilesAction.Revert), - }, - unchanged: { hint: 'Delete', icon: 'clear', handler: handle(FilesAction.Delete) }, - }[state]), - [state, handle], - ) + handler: handle(FilesAction.Revert(path)), + } + case 'unchanged': + return { + hint: 'Delete', + icon: 'clear', + handler: handle(FilesAction.Delete(path)), + } + default: + ensureExhaustive(state) + } + }, [state, dispatch, path]) const onClick = React.useCallback((e: React.MouseEvent) => { // stop click from propagating to parent elements and triggering their handlers @@ -933,6 +1021,7 @@ function FileUpload({ name, state, size, prefix, dispatch }: FileUploadProps) { role="button" tabIndex={0} state={state} + type={type} name={name} size={size} action={ @@ -950,7 +1039,7 @@ type DirUploadProps = tagged.ValueOf & { } function DirUpload({ name, state, childEntries, prefix, dispatch }: DirUploadProps) { - const [expanded, setExpanded] = React.useState(true) + const [expanded, setExpanded] = React.useState(false) const toggleExpanded = React.useCallback( (e) => { @@ -981,42 +1070,48 @@ function DirUpload({ name, state, childEntries, prefix, dispatch }: DirUploadPro noClick: true, }) - const handle = React.useCallback( - (cons: tagged.ConstructorOf) => (e: React.MouseEvent) => { + // eslint-disable-next-line consistent-return + const action = React.useMemo(() => { + const handle = (a: FilesAction) => (e: React.MouseEvent) => { // stop click from propagating to the root element and triggering its handler e.stopPropagation() - dispatch(cons(path)) - }, - [dispatch, path], - ) - - const action = React.useMemo( - () => - ({ - added: { hint: 'Remove', icon: 'clear', handler: handle(FilesAction.RevertDir) }, - modified: { + dispatch(a) + } + switch (state) { + case 'added': + return { + hint: 'Remove', + icon: 'clear', + handler: handle(FilesAction.RevertDir(path)), + } + case 'modified': + return { hint: 'Revert', icon: 'undo', - handler: handle(FilesAction.RevertDir), - }, - hashing: { + handler: handle(FilesAction.RevertDir(path)), + } + case 'hashing': + return { hint: 'Revert', icon: 'undo', - handler: handle(FilesAction.RevertDir), - }, - deleted: { + handler: handle(FilesAction.RevertDir(path)), + } + case 'deleted': + return { hint: 'Restore', icon: 'undo', - handler: handle(FilesAction.RevertDir), - }, - unchanged: { + handler: handle(FilesAction.RevertDir(path)), + } + case 'unchanged': + return { hint: 'Delete', icon: 'clear', - handler: handle(FilesAction.DeleteDir), - }, - }[state]), - [state, handle], - ) + handler: handle(FilesAction.DeleteDir(path)), + } + default: + ensureExhaustive(state) + } + }, [state, dispatch, path]) return ( ({ - added: { - color: t.palette.success.main, + hashing: { marginLeft: t.spacing(1), }, - deleted: { - color: t.palette.error.main, - marginLeft: t.spacing(1), + actions: { + display: 'flex', + marginTop: t.spacing(1), }, - hashing: { - marginLeft: t.spacing(1), + action: { + flexGrow: 1, + '& + &': { + marginLeft: t.spacing(1), + }, }, })) @@ -1089,6 +1186,7 @@ interface FilesInputProps { loaded: number percent: number } + bucket: string } export function FilesInput({ @@ -1100,6 +1198,7 @@ export function FilesInput({ onFilesAction, title, totalProgress, + bucket, }: FilesInputProps) { const classes = useFilesInputStyles() @@ -1138,7 +1237,10 @@ export function FilesInput({ // XXX: maybe observe value and trigger this when it changes, // regardless of the source of change (e.g. new value supplied directly via the prop) const waitFor = Object.values(newValue.added).reduce( - (acc, f) => (f.hash.ready ? acc : acc.concat(f.hash.promise.catch(() => {}))), + (acc, f) => + S3FilePicker.isS3File(f) || f.hash.ready + ? acc + : acc.concat(f.hash.promise.catch(() => {})), [] as Promise[], ) cur.scheduleUpdate(waitFor) @@ -1159,30 +1261,64 @@ export function FilesInput({ }, [dispatch]) const isDragging = useDragging() - const { getRootProps, getInputProps, isDragActive } = useDropzone({ disabled, onDrop }) + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + disabled, + onDrop, + }) const computedEntries = useMemoEq(value, computeEntries) - const stats = useMemoEq(value, ({ added, deleted, existing }) => ({ - added: Object.entries(added).reduce( + const stats = useMemoEq(value, ({ added, existing }) => ({ + upload: Object.entries(added).reduce( (acc, [path, f]) => { + if (S3FilePicker.isS3File(f)) return acc // dont count s3 files const e = existing[path] if (e && (!f.hash.ready || f.hash.value === e.hash)) return acc return R.evolve({ count: R.inc, size: R.add(f.size) }, acc) }, { count: 0, size: 0 }, ), - deleted: Object.keys(deleted).reduce( - (acc, path) => R.evolve({ count: R.inc, size: R.add(existing[path].size) }, acc), + s3: Object.entries(added).reduce( + (acc, [, f]) => + S3FilePicker.isS3File(f) + ? R.evolve({ count: R.inc, size: R.add(f.size) }, acc) + : acc, { count: 0, size: 0 }, ), - hashing: Object.values(added).reduce((acc, f) => acc || !f.hash.ready, false), + hashing: Object.values(added).reduce( + (acc, f) => acc || (!S3FilePicker.isS3File(f) && !f.hash.ready), + false, + ), })) - const warn = stats.added.size > PD.MAX_SIZE + const warn = { + upload: stats.upload.size > PD.MAX_UPLOAD_SIZE, + s3: stats.s3.size > PD.MAX_S3_SIZE, + } + + const [s3FilePickerOpen, setS3FilePickerOpen] = React.useState(false) + + const closeS3FilePicker = React.useCallback( + (reason: S3FilePicker.CloseReason) => { + if (!!reason && typeof reason === 'object') { + dispatch(FilesAction.AddFromS3({ files: reason.files, basePrefix: reason.path })) + } + setS3FilePickerOpen(false) + }, + [dispatch, setS3FilePickerOpen], + ) + + const handleS3Btn = React.useCallback(() => { + setS3FilePickerOpen(true) + }, []) return ( +
{title} - {!!stats.added.count && ( - - {' +'} - {stats.added.count} ({readableBytes(stats.added.size)}) - - )} - {!!stats.deleted.count && ( - - {' -'} - {stats.deleted.count} ({readableBytes(stats.deleted.size)}) - + {(!!stats.upload.count || !!stats.s3.count) && ( + + ( + {!!stats.upload.count && ( + + {readableBytes(stats.upload.size)} to upload + + )} + {!!stats.upload.count && !!stats.s3.count && ( + + {', '} + + )} + {!!stats.s3.count && ( + + {readableBytes(stats.s3.size)} from S3 + + )} + ) + )} - {warn && ( + {(warn.upload || warn.s3) && ( error_outline @@ -1240,12 +1398,12 @@ export function FilesInput({ interactive active={isDragActive && !ref.current.disabled} error={!!error} - warn={warn} + warn={warn.upload || warn.s3} > {!!computedEntries.length && ( - + {computedEntries.map( FilesEntry.match({ Dir: (ps) => ( @@ -1263,6 +1421,26 @@ export function FilesInput({ {submitting && } +
+ + Add local files + + + Add files from S3 + +
) } diff --git a/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx b/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx index 0a37103cb99..659df240f59 100644 --- a/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx @@ -27,7 +27,8 @@ import * as requests from '../requests' import MetaInputErrorHelper from './MetaInputErrorHelper' import SelectWorkflow from './SelectWorkflow' -export const MAX_SIZE = 1000 * 1000 * 1000 // 1GB +export const MAX_UPLOAD_SIZE = 1000 * 1000 * 1000 // 1GB +export const MAX_S3_SIZE = 10 * 1000 * 1000 * 1000 // 10GB export const ES_LAG = 3 * 1000 export const MAX_META_FILE_SIZE = 10 * 1000 * 1000 // 10MB diff --git a/catalog/app/containers/Bucket/PackageDialog/S3FilePicker.tsx b/catalog/app/containers/Bucket/PackageDialog/S3FilePicker.tsx new file mode 100644 index 00000000000..2149d074d51 --- /dev/null +++ b/catalog/app/containers/Bucket/PackageDialog/S3FilePicker.tsx @@ -0,0 +1,345 @@ +import cx from 'classnames' +import pLimit from 'p-limit' +import * as R from 'ramda' +import * as React from 'react' +import * as M from '@material-ui/core' +import * as DG from '@material-ui/data-grid' + +import { Crumb, render as renderCrumbs } from 'components/BreadCrumbs' +import AsyncResult from 'utils/AsyncResult' +import { useData } from 'utils/Data' +import { getBreadCrumbs, ensureNoSlash, withoutPrefix } from 'utils/s3paths' + +import * as Listing from '../Listing' +import { displayError } from '../errors' +import * as requests from '../requests' + +import SubmitSpinner from './SubmitSpinner' + +const limit = pLimit(5) + +export interface S3File { + bucket: string + key: string + version?: string + size: number +} + +export const isS3File = (f: any): f is S3File => + !!f && + typeof f === 'object' && + typeof f.bucket === 'string' && + typeof f.key === 'string' && + (typeof f.version === 'string' || typeof f.version === 'undefined') && + typeof f.size === 'number' + +const getCrumbs = R.compose( + R.intersperse(Crumb.Sep(<> / )), + ({ bucket, path }: { bucket: string; path: string }) => + [ + { label: bucket, path: '' }, + ...getBreadCrumbs(path), + ].map(({ label, path: segPath }) => + Crumb.Segment({ label, to: segPath === path ? undefined : segPath }), + ), +) + +type MuiCloseReason = 'backdropClick' | 'escapeKeyDown' +export type CloseReason = MuiCloseReason | 'cancel' | { path: string; files: S3File[] } + +const useStyles = M.makeStyles((t) => ({ + paper: { + height: '100vh', + }, + crumbs: { + ...t.typography.body1, + marginTop: -t.spacing(1), + maxWidth: '100%', + overflowWrap: 'break-word', + paddingLeft: t.spacing(3), + paddingRight: t.spacing(3), + }, + lock: { + background: 'rgba(255,255,255,0.5)', + bottom: 52, + left: 0, + position: 'absolute', + right: 0, + top: 56, + zIndex: 1, + }, +})) + +interface DialogProps { + bucket: string + open: boolean + onClose: (reason: CloseReason) => void +} + +export function Dialog({ bucket, open, onClose }: DialogProps) { + const classes = useStyles() + + const bucketListing = requests.useBucketListing() + + const [path, setPath] = React.useState('') + const [prefix, setPrefix] = React.useState('') + const [prev, setPrev] = React.useState(null) + const [selection, setSelection] = React.useState([]) + + const [locked, setLocked] = React.useState(false) + + const cancel = React.useCallback(() => onClose('cancel'), [onClose]) + + const handleClose = React.useCallback( + (_e: {}, reason: MuiCloseReason) => { + if (!locked) onClose(reason) + }, + [locked, onClose], + ) + + const crumbs = React.useMemo(() => getCrumbs({ bucket, path }), [bucket, path]) + + const getCrumbLinkProps = ({ to }: { to: string }) => ({ + onClick: () => { + setPath(to) + }, + }) + + React.useLayoutEffect(() => { + // reset accumulated results when bucket, path and / or prefix change + setPrev(null) + }, [bucket, path, prefix]) + + const data = useData(bucketListing, { bucket, path, prefix, prev, drain: true }) + + const loadMore = React.useCallback(() => { + AsyncResult.case( + { + Ok: (res: requests.BucketListingResult) => { + // this triggers a re-render and fetching of next page of results + if (res.continuationToken) setPrev(res) + }, + _: () => {}, + }, + data.result, + ) + }, [data.result]) + + const add = React.useCallback(() => { + data.case({ + Ok: async (r: requests.BucketListingResult) => { + try { + setLocked(true) + const dirsByBasename = R.fromPairs( + r.dirs.map((name) => [ensureNoSlash(withoutPrefix(r.path, name)), name]), + ) + const filesByBasename = R.fromPairs( + r.files.map((f) => [withoutPrefix(r.path, f.key), f]), + ) + const { dirs, files } = selection.reduce( + (acc, id) => { + const dir = dirsByBasename[id] + if (dir) return { ...acc, dirs: [...acc.dirs, dir] } + const file = filesByBasename[id] + if (file) return { ...acc, files: [...acc.files, file] } + return acc // shouldnt happen + }, + { files: [] as requests.BucketListingFile[], dirs: [] as string[] }, + ) + + const dirsPromises = dirs.map((dir) => + limit(bucketListing, { bucket, path: dir, delimiter: false, drain: true }), + ) + + const dirsChildren = await Promise.all(dirsPromises) + const allChildren = dirsChildren.reduce( + (acc, res) => acc.concat(res.files), + [] as requests.BucketListingFile[], + ) + + onClose({ files: files.concat(allChildren), path }) + } finally { + setLocked(false) + } + }, + _: () => {}, + }) + }, [onClose, selection, data, bucket, path, bucketListing]) + + const handleExited = React.useCallback(() => { + setPath('') + setPrefix('') + setPrev(null) + setSelection([]) + }, []) + + return ( + + Add files from S3 +
+ {renderCrumbs(crumbs, { getLinkProps: getCrumbLinkProps })} +
+ {data.case({ + // TODO: customized error display? + Err: displayError(), + Init: () => null, + _: (x: $TSFixMe) => { + const res: requests.BucketListingResult | null = AsyncResult.getPrevResult(x) + return res ? ( + + ) : ( + // TODO: skeleton + + + + ) + }, + })} + {locked &&
} + + {locked ? ( + Adding files + ) : ( + + + {selection.length} item{selection.length === 1 ? '' : 's'} selected + + + )} + Cancel + + Add files + + + + ) +} + +function useFormattedListing(r: requests.BucketListingResult) { + return React.useMemo(() => { + const dirs = r.dirs.map((name) => ({ + type: 'dir' as const, + name: ensureNoSlash(withoutPrefix(r.path, name)), + to: name, + })) + const files = r.files.map(({ key, size, modified, archived }) => ({ + type: 'file' as const, + name: withoutPrefix(r.path, key), + to: key, + size, + modified, + archived, + })) + const items = [...dirs, ...files] + // filter-out files with same name as one of dirs + return R.uniqBy(R.prop('name'), items) + }, [r]) +} + +const useDirContentsStyles = M.makeStyles((t) => ({ + root: { + borderBottom: `1px solid ${t.palette.divider}`, + borderTop: `1px solid ${t.palette.divider}`, + flexGrow: 1, + marginLeft: t.spacing(2), + marginRight: t.spacing(2), + marginTop: t.spacing(1), + }, + interactive: { + cursor: 'pointer', + }, +})) + +interface DirContentsProps { + response: requests.BucketListingResult + locked: boolean + setPath: (path: string) => void + setPrefix: (prefix: string) => void + loadMore: () => void + selection: DG.GridRowId[] + onSelectionChange: (newSelection: DG.GridRowId[]) => void +} + +function DirContents({ + response, + locked, + setPath, + setPrefix, + loadMore, + selection, + onSelectionChange, +}: DirContentsProps) { + const classes = useDirContentsStyles() + const items = useFormattedListing(response) + const { bucket, path, prefix, truncated } = response + + React.useLayoutEffect(() => { + // reset selection when bucket, path and / or prefix change + onSelectionChange([]) + }, [onSelectionChange, bucket, path, prefix]) + + const CellComponent = React.useMemo( + () => + function Cell({ item, className, ...props }: Listing.CellProps) { + const onClick = React.useCallback(() => { + if (item.type === 'dir') { + setPath(item.to) + setPrefix('') + } + }, [item]) + return ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events +
+ ) + }, + [classes.interactive, setPath, setPrefix], + ) + + return ( + + } + /> + ) +} diff --git a/catalog/app/containers/Bucket/PackageUpdateDialog.tsx b/catalog/app/containers/Bucket/PackageUpdateDialog.tsx index ceefbd4cce7..91e75d4c3ed 100644 --- a/catalog/app/containers/Bucket/PackageUpdateDialog.tsx +++ b/catalog/app/containers/Bucket/PackageUpdateDialog.tsx @@ -20,6 +20,7 @@ import * as validators from 'utils/validators' import type * as workflows from 'utils/workflows' import * as PD from './PackageDialog' +import { isS3File, S3File } from './PackageDialog/S3FilePicker' import * as requests from './requests' interface Manifest { @@ -59,6 +60,16 @@ interface Uploads { } } +interface LocalEntry { + path: string + file: PD.LocalFile +} + +interface S3Entry { + path: string + file: S3File +} + const getTotalProgress = R.pipe( R.values, R.reduce( @@ -147,7 +158,7 @@ function DialogForm({ [setUploads], ) - const updatePackage = requests.useUpdatePackage() + const createPackage = requests.useCreatePackage() const onSubmit = async ({ name, @@ -163,11 +174,17 @@ function DialogForm({ workflow: workflows.Workflow // eslint-disable-next-line consistent-return }) => { - const addedEntries = Object.entries(files.added).map(([path, file]) => ({ - path, - file, - })) - const toUpload = addedEntries.filter(({ path, file }) => { + const addedS3Entries = [] as S3Entry[] + const addedLocalEntries = [] as LocalEntry[] + Object.entries(files.added).forEach(([path, file]) => { + if (isS3File(file)) { + addedS3Entries.push({ path, file }) + } else { + addedLocalEntries.push({ path, file }) + } + }) + + const toUpload = addedLocalEntries.filter(({ path, file }) => { const e = files.existing[path] return !e || e.hash !== file.hash.value }) @@ -227,8 +244,9 @@ function DialogForm({ string, { physicalKey: string; size: number; hash: string; meta: unknown }, ] - const newEntries = pipeThru(toUpload, uploaded)( - R.zipWith((f, r) => { + + const uploadedEntries = pipeThru(toUpload, uploaded)( + R.zipWith((f, r) => { invariant(f.file.hash.value, 'File must have a hash') return [ f.path, @@ -250,9 +268,18 @@ function DialogForm({ { physicalKey: string; size: number; hash: string; meta: unknown } > + const s3Entries = pipeThru(addedS3Entries)( + R.map(({ path, file }: S3Entry) => [ + path, + { physicalKey: s3paths.handleToS3Url(file) }, + ]), + R.fromPairs, + ) as Record + const contents = pipeThru(files.existing)( R.omit(Object.keys(files.deleted)), - R.mergeLeft(newEntries), + R.mergeLeft(uploadedEntries), + R.mergeLeft(s3Entries), R.toPairs, R.map(([path, data]) => ({ logical_key: path, @@ -265,7 +292,7 @@ function DialogForm({ ) try { - const res = await updatePackage( + const res = await createPackage( { contents, message: msg, @@ -455,6 +482,7 @@ function DialogForm({ onFilesAction={onFilesAction} isEqual={R.equals} initialValue={initialFiles} + bucket={bucket} /> diff --git a/catalog/app/containers/Bucket/requests/bucketListing.ts b/catalog/app/containers/Bucket/requests/bucketListing.ts index 35f618dd943..2f46e87a4f2 100644 --- a/catalog/app/containers/Bucket/requests/bucketListing.ts +++ b/catalog/app/containers/Bucket/requests/bucketListing.ts @@ -1,11 +1,56 @@ import type { S3 } from 'aws-sdk' +import * as React from 'react' import * as R from 'ramda' +import * as AWS from 'utils/AWS' + import * as errors from '../errors' import { decodeS3Key } from './utils' -interface File { +const DEFAULT_DRAIN_REQUESTS = 10 + +interface DrainObjectListParams { + s3: S3 + bucket: string + prefix: string + delimiter?: string + continuationToken?: string + maxRequests: true | number +} + +const drainObjectList = async ({ + s3, + bucket, + prefix, + delimiter, + continuationToken, + maxRequests, +}: DrainObjectListParams) => { + let reqNo = 0 + let Contents: S3.Object[] = [] + let CommonPrefixes: S3.CommonPrefixList = [] + let ContinuationToken: string | undefined + while (true) { + // eslint-disable-next-line no-await-in-loop + const r = await s3 + .listObjectsV2({ + Bucket: bucket, + Delimiter: delimiter, + Prefix: prefix, + ContinuationToken: ContinuationToken || continuationToken, + EncodingType: 'url', + }) + .promise() + Contents = Contents.concat(r.Contents || []) + CommonPrefixes = CommonPrefixes.concat(r.CommonPrefixes || []) + reqNo += 1 + if (!r.IsTruncated || reqNo >= maxRequests) return { ...r, Contents, CommonPrefixes } + ContinuationToken = r.NextContinuationToken + } +} + +export interface BucketListingFile { bucket: string key: string modified: Date @@ -16,7 +61,7 @@ interface File { export interface BucketListingResult { dirs: string[] - files: File[] + files: BucketListingFile[] truncated: boolean continuationToken?: string bucket: string @@ -24,30 +69,36 @@ export interface BucketListingResult { prefix?: string } -interface BucketListingParams { +interface BucketListingDependencies { s3: S3 +} + +interface BucketListingParams { bucket: string path?: string prefix?: string prev?: BucketListingResult + delimiter?: string | false + drain?: true | number } -export const bucketListing = ({ +export const bucketListing = async ({ s3, bucket, path = '', prefix, prev, -}: BucketListingParams): Promise => - s3 - .listObjectsV2({ - Bucket: bucket, - Delimiter: '/', - Prefix: path + (prefix || ''), - EncodingType: 'url', - ContinuationToken: prev ? prev.continuationToken : undefined, - }) - .promise() + delimiter = '/', + drain = 0, +}: BucketListingParams & BucketListingDependencies): Promise => + drainObjectList({ + s3, + bucket, + prefix: path + (prefix || ''), + delimiter: delimiter === false ? undefined : delimiter, + continuationToken: prev ? prev.continuationToken : undefined, + maxRequests: drain === true ? DEFAULT_DRAIN_REQUESTS : drain, + }) .then((res) => { let dirs = (res.CommonPrefixes || []) .map((p) => decodeS3Key(p.Prefix!)) @@ -60,7 +111,6 @@ export const bucketListing = ({ // filter-out "directory-files" (files that match prefixes) .filter(({ Key }: S3.Object) => Key !== path && !Key!.endsWith('/')) .map((i: S3.Object) => ({ - // TODO: expose VersionId? bucket, key: i.Key!, modified: i.LastModified!, @@ -81,3 +131,11 @@ export const bucketListing = ({ } }) .catch(errors.catchErrors()) + +export function useBucketListing() { + const s3: S3 = AWS.S3.use() + return React.useCallback( + (params: BucketListingParams) => bucketListing({ s3, ...params }), + [s3], + ) +} diff --git a/catalog/app/containers/Bucket/requests/package.ts b/catalog/app/containers/Bucket/requests/package.ts index 106a5bab374..de50fb04a06 100644 --- a/catalog/app/containers/Bucket/requests/package.ts +++ b/catalog/app/containers/Bucket/requests/package.ts @@ -1,9 +1,11 @@ +import type { S3 } from 'aws-sdk' import * as R from 'ramda' import * as React from 'react' import { JsonValue } from 'components/JsonEditor/constants' import * as APIConnector from 'utils/APIConnector' import * as AWS from 'utils/AWS' +import * as Config from 'utils/Config' import { makeSchemaDefaultsSetter, JsonSchema } from 'utils/json-schema' import mkSearch from 'utils/mkSearch' import pipeThru from 'utils/pipeThru' @@ -17,7 +19,6 @@ interface AWSCredentials { } // "CREATE package" - creates package from scratch to new target -// "UPDATE package" - creates package from scratch (using existing manifest) to defined target // "COPY package" - creates package from source (existing manifest) to new target // "WRAP package" - creates package (wraps directory) from source (bucket + directory) to new target @@ -28,14 +29,11 @@ interface FileEntry { } interface FileUpload { - hash: string logical_key: string - physical_key: { - bucket: string - key: string - version: string - } - size: number + physical_key: string + hash?: string + size?: number + meta?: {} } interface RequestBodyBase { @@ -45,10 +43,7 @@ interface RequestBodyBase { workflow?: string | null } -interface RequestBodyCreate extends RequestBodyBase { - contents: FileUpload[] - name: string -} +type RequestBodyCreate = string interface RequestBodyCopy extends RequestBodyBase { name: string @@ -67,21 +62,13 @@ interface RequestBodyWrap extends RequestBodyBase { entries: FileEntry[] } -type RequestBody = RequestBodyCreate | RequestBodyWrap | RequestBodyCopy - const ENDPOINT_CREATE = '/packages' -const ENDPOINT_UPDATE = '/packages' - const ENDPOINT_COPY = '/packages/promote' const ENDPOINT_WRAP = '/packages/from-folder' -type Endpoint = - | typeof ENDPOINT_CREATE - | typeof ENDPOINT_UPDATE - | typeof ENDPOINT_COPY - | typeof ENDPOINT_WRAP +const CREATE_PACKAGE_PAYLOAD_KEY = 'user-requests/create-package' // TODO: reuse it from some other place, don't remember where I saw it interface ManifestHandleTarget { @@ -107,14 +94,6 @@ interface CreatePackageParams extends BasePackageParams { } } -interface UpdatePackageParams extends BasePackageParams { - contents: FileUpload[] - target: { - bucket: string - name: string - } -} - interface CopyPackageParams extends BasePackageParams { source: ManifestHandleSource target: ManifestHandleTarget @@ -132,20 +111,53 @@ interface Response { // FIXME: this is copypasted from PackageDialog -- next time we need to TSify utils/APIConnector properly interface ApiRequest { - (opts: { + (opts: { endpoint: string method?: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'HEAD' - body?: Body + body?: {} }): Promise } -const uploadManifest = ( +interface CredentialsQuery { + access_key: string + secret_key: string + session_token: string +} + +const getCredentialsQuery = (credentials: AWSCredentials): CredentialsQuery => ({ + access_key: credentials.accessKeyId, + secret_key: credentials.secretAccessKey, + session_token: credentials.sessionToken, +}) + +interface UploadManifest { + ( + req: ApiRequest, + endpoint: typeof ENDPOINT_CREATE, + body: RequestBodyCreate, + query: CredentialsQuery, + ): Promise + ( + req: ApiRequest, + endpoint: typeof ENDPOINT_COPY, + body: RequestBodyCopy, + query: CredentialsQuery, + ): Promise + ( + req: ApiRequest, + endpoint: typeof ENDPOINT_WRAP, + body: RequestBodyWrap, + query: CredentialsQuery, + ): Promise +} + +const uploadManifest: UploadManifest = ( req: ApiRequest, - endpoint: Endpoint, - body: RequestBody, - query?: Record, + endpoint: string, + body: {}, + query?: {}, ): Promise => - req({ + req({ endpoint: `${endpoint}${query ? mkSearch(query) : ''}`, method: 'POST', body, @@ -170,50 +182,56 @@ const getWorkflowApiParam = R.cond([ slug: typeof workflows.notAvailable | typeof workflows.notSelected | string, ) => string | null | undefined -const createPackage = ( - req: ApiRequest, +interface CreatePackageDependencies { + s3: S3 + credentials: AWSCredentials + req: ApiRequest + serviceBucket: string +} + +const mkCreatePackage = ({ + s3, + credentials, + req, + serviceBucket, +}: CreatePackageDependencies) => async ( { contents, message, meta, target, workflow }: CreatePackageParams, schema: JsonSchema, // TODO: should be already inside workflow -) => - uploadManifest(req, ENDPOINT_CREATE, { +) => { + await credentials.getPromise() + const header = { name: target.name, registry: `s3://${target.bucket}`, message, - contents, meta: getMetaValue(meta, schema), workflow: getWorkflowApiParam(workflow.slug), + } + const payload = [header, ...contents].map((x) => JSON.stringify(x)).join('\n') + const upload = s3.upload({ + Bucket: serviceBucket, + Key: CREATE_PACKAGE_PAYLOAD_KEY, + Body: payload, }) - -export function useCreatePackage() { - const req: ApiRequest = APIConnector.use() - return React.useCallback( - (params: CreatePackageParams, schema: JsonSchema) => - createPackage(req, params, schema), - [req], + const res = await upload.promise() + return uploadManifest( + req, + ENDPOINT_CREATE, + JSON.stringify((res as any).VersionId as string), + getCredentialsQuery(credentials), ) } -const updatePackage = ( - req: ApiRequest, - { contents, message, meta, target, workflow }: UpdatePackageParams, - schema: JsonSchema, // TODO: should be already inside workflow -) => - uploadManifest(req, ENDPOINT_UPDATE, { - name: target.name, - registry: `s3://${target.bucket}`, - message, - contents, - meta: getMetaValue(meta, schema), - workflow: getWorkflowApiParam(workflow.slug), - }) - -export function useUpdatePackage() { +export function useCreatePackage() { const req: ApiRequest = APIConnector.use() - return React.useCallback( - (params: UpdatePackageParams, schema: JsonSchema) => - updatePackage(req, params, schema), - [req], - ) + const { serviceBucket } = Config.use() + const credentials = AWS.Credentials.use() + const s3 = AWS.S3.use() + return React.useMemo(() => mkCreatePackage({ s3, credentials, req, serviceBucket }), [ + s3, + credentials, + req, + serviceBucket, + ]) } const copyPackage = async ( @@ -240,11 +258,7 @@ const copyPackage = async ( registry: `s3://${target.bucket}`, workflow: getWorkflowApiParam(workflow.slug), }, - { - access_key: credentials.accessKeyId, - secret_key: credentials.secretAccessKey, - session_token: credentials.sessionToken, - }, + getCredentialsQuery(credentials), ) } @@ -281,11 +295,7 @@ const wrapPackage = async ( registry: `s3://${source}`, workflow: getWorkflowApiParam(workflow.slug), }, - { - access_key: credentials.accessKeyId, - secret_key: credentials.secretAccessKey, - session_token: credentials.sessionToken, - }, + getCredentialsQuery(credentials), ) } diff --git a/catalog/app/utils/AWS/S3.js b/catalog/app/utils/AWS/S3.js index 68008207183..db7a8840d0c 100644 --- a/catalog/app/utils/AWS/S3.js +++ b/catalog/app/utils/AWS/S3.js @@ -52,6 +52,7 @@ function useSmartS3() { // (not sure if there are any such operations that can be used from the browser) !bucket || (cfg.analyticsBucket && cfg.analyticsBucket === bucket) || + (cfg.serviceBucket && cfg.serviceBucket === bucket) || (cfg.mode !== 'OPEN' && isInStack(bucket)) ) { return 'signed' @@ -157,9 +158,3 @@ export function useS3() { } export const use = useS3 - -export function InjectS3({ children }) { - return children(useS3()) -} - -export const Inject = InjectS3 diff --git a/catalog/app/utils/s3paths.js b/catalog/app/utils/s3paths.js index fb7990bb78b..bd1ef7c31ca 100644 --- a/catalog/app/utils/s3paths.js +++ b/catalog/app/utils/s3paths.js @@ -179,8 +179,8 @@ export const handleFromUrl = (url, referrer) => { export const handleToHttpsUri = ({ bucket, key, version }) => `https://${bucket}.s3.amazonaws.com/${encode(key)}${mkSearch({ versionId: version })}` -export const handleToS3Url = ({ bucket, key, version }) => - `s3://${bucket}/${key}${mkSearch({ versionId: version })}` +export const handleToS3Url = ({ bucket, key, version = undefined }) => + `s3://${bucket}/${encode(key)}${mkSearch({ versionId: version })}` /** * Get breadcrumbs for a path. diff --git a/catalog/config-schema.json b/catalog/config-schema.json index 2782a64dcac..a7f6724516d 100644 --- a/catalog/config-schema.json +++ b/catalog/config-schema.json @@ -86,6 +86,10 @@ "type": "string", "description": "Token used for logging to Sentry." }, + "serviceBucket": { + "type": "string", + "description": "Utility / service bucket used by Quilt internals." + }, "ssoAuth": { "$ref": "#/definitions/AuthMethodConfig", "description": "Whether Single Sign-On authentication is enabled for sign in and sign up." @@ -104,6 +108,7 @@ "passwordAuth", "registryUrl", "s3Proxy", + "serviceBucket", "ssoAuth", "ssoProviders" ], diff --git a/catalog/config.json.tmpl b/catalog/config.json.tmpl index 05052dff096..4356d05f959 100644 --- a/catalog/config.json.tmpl +++ b/catalog/config.json.tmpl @@ -19,5 +19,6 @@ "sentryDSN": "${SENTRY_DSN}", "mixpanelToken": "${MIXPANEL_TOKEN}", "analyticsBucket": "${ANALYTICS_BUCKET}", + "serviceBucket": "${SERVICE_BUCKET}", "mode": "${CATALOG_MODE}" } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a4f0e118f1f..a094d30b080 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,7 @@ * [Added] Prepopulate today date for metadata ([#2121](https://github.com/quiltdata/quilt/pull/2121)) * [Added] Limit and offset parameters in pkgselect lambda ([#2124](https://github.com/quiltdata/quilt/pull/2124)) * [Added] File listing: "load more" button to fetch more entries from S3 ([#2150](https://github.com/quiltdata/quilt/pull/2150)) +* [Added] Ability to add files from S3 while revising a package ([#2171](https://github.com/quiltdata/quilt/pull/2171)) * [Added] Lambdas for pushing an existing package/creation of package ([#2147](https://github.com/quiltdata/quilt/pull/2147), [#2180](https://github.com/quiltdata/quilt/pull/2180)) * [Changed] New DataGrid-based file listing UI with arbitrary sorting and filtering ([#2097](https://github.com/quiltdata/quilt/pull/2097)) * [Changed] Item selection in folder-to-package dialog ([#2122](https://github.com/quiltdata/quilt/pull/2122))