diff --git a/catalog/app/containers/Bucket/Dir.tsx b/catalog/app/containers/Bucket/Dir.tsx index 738598ec248..d5b5ff37b69 100644 --- a/catalog/app/containers/Bucket/Dir.tsx +++ b/catalog/app/containers/Bucket/Dir.tsx @@ -88,8 +88,8 @@ interface DirContentsProps { bucket: string path: string loadMore?: () => void - selection: string[] - onSelection: (ids: string[]) => void + selection: Selection.SelectionItem[] + onSelection: (ids: Selection.SelectionItem[]) => void } function DirContents({ @@ -156,8 +156,8 @@ const useSelectionWidgetStyles = M.makeStyles({ interface SelectionWidgetProps { className: string - selection: Selection.PrefixedKeysMap - onSelection: (changed: Selection.PrefixedKeysMap) => void + selection: Selection.ListingSelection + onSelection: (changed: Selection.ListingSelection) => void } function SelectionWidget({ className, selection, onSelection }: SelectionWidgetProps) { @@ -281,7 +281,7 @@ export default function Dir() { ) }, [data.result]) - const [selection, setSelection] = React.useState>( + const [selection, setSelection] = React.useState( Selection.EMPTY_MAP, ) const handleSelection = React.useCallback( diff --git a/catalog/app/containers/Bucket/Listing.tsx b/catalog/app/containers/Bucket/Listing.tsx index e3d781f1d67..a5bcd1e46ae 100644 --- a/catalog/app/containers/Bucket/Listing.tsx +++ b/catalog/app/containers/Bucket/Listing.tsx @@ -19,6 +19,7 @@ import * as tagged from 'utils/taggedV2' import usePrevious from 'utils/usePrevious' import { RowActions } from './ListingActions' +import * as Selection from './Selection' const EMPTY = {''} @@ -474,10 +475,10 @@ function FilterToolbarButton() { // Iterate over `items`, and add slash if selection item is directory // Iterate over `items` only once, but keep the sort order as in original selection -function formatSelection(ids: DG.GridRowId[], items: Item[]): string[] { - if (!ids.length) return ids as string[] +function formatSelection(ids: DG.GridRowId[], items: Item[]): Selection.SelectionItem[] { + if (!ids.length) return [] - const names: string[] = [] + const names: Selection.SelectionItem[] = [] const sortOrder = ids.reduce( (memo, id, index) => ({ ...memo, [id]: index + 1 }), {} as Record, @@ -485,13 +486,17 @@ function formatSelection(ids: DG.GridRowId[], items: Item[]): string[] { items.some(({ name, type }) => { if (name === '..') return false if (ids.includes(name)) { - names.push(type === 'dir' ? s3paths.ensureSlash(name) : name) + names.push( + type === 'dir' + ? { logicalKey: s3paths.ensureSlash(name) } + : { logicalKey: name.toString() }, + ) } if (names.length === ids.length) return true }) names.sort((a, b) => { - const aPos = sortOrder[a] || sortOrder[s3paths.ensureNoSlash(a.toString())] - const bPos = sortOrder[b] || sortOrder[s3paths.ensureNoSlash(b.toString())] + const aPos = sortOrder[a.logicalKey] || sortOrder[s3paths.ensureNoSlash(a.logicalKey)] + const bPos = sortOrder[b.logicalKey] || sortOrder[s3paths.ensureNoSlash(b.logicalKey)] return aPos - bPos }) return names @@ -1035,8 +1040,8 @@ interface ListingProps { prefixFilter?: string toolbarContents?: React.ReactNode loadMore?: () => void - selection?: string[] - onSelectionChange?: (newSelection: string[]) => void + selection?: Selection.SelectionItem[] + onSelectionChange?: (newSelection: Selection.SelectionItem[]) => void CellComponent?: React.ComponentType RootComponent?: React.ElementType<{ className: string }> className?: string @@ -1229,10 +1234,10 @@ export function Listing({ [items, onSelectionChange], ) - const selectionModel = React.useMemo( - () => (selection?.length ? selection.map(s3paths.ensureNoSlash) : selection), - [selection], - ) + const selectionModel = React.useMemo(() => { + if (!selection) return selection + return selection.map(({ logicalKey }) => s3paths.ensureNoSlash(logicalKey)) + }, [selection]) // TODO: control page, pageSize, filtering and sorting via props return ( diff --git a/catalog/app/containers/Bucket/PackageDialog/PackageCreationForm.tsx b/catalog/app/containers/Bucket/PackageDialog/PackageCreationForm.tsx index 684ac5373e9..50b8160bc4e 100644 --- a/catalog/app/containers/Bucket/PackageDialog/PackageCreationForm.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/PackageCreationForm.tsx @@ -806,7 +806,7 @@ export function usePackageCreationDialog({ async (initial?: { successor?: workflows.Successor path?: string - selection?: Selection.PrefixedKeysMap + selection?: Selection.ListingSelection }) => { if (initial?.successor) { setSuccessor(initial?.successor) diff --git a/catalog/app/containers/Bucket/PackageDialog/S3FilePicker.tsx b/catalog/app/containers/Bucket/PackageDialog/S3FilePicker.tsx index 3a0327c1920..f92571d9b4b 100644 --- a/catalog/app/containers/Bucket/PackageDialog/S3FilePicker.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/S3FilePicker.tsx @@ -40,8 +40,8 @@ const useSelectionWidgetStyles = M.makeStyles((t) => ({ interface SelectionWidgetProps { className: string - selection: Selection.PrefixedKeysMap - onSelection: (changed: Selection.PrefixedKeysMap) => void + selection: Selection.ListingSelection + onSelection: (changed: Selection.ListingSelection) => void } function SelectionWidget({ className, selection, onSelection }: SelectionWidgetProps) { @@ -362,8 +362,8 @@ interface DirContentsProps { setPath: (path: string) => void setPrefix: (prefix: string) => void loadMore: () => void - selection: string[] - onSelectionChange: (ids: string[]) => void + selection: Selection.SelectionItem[] + onSelectionChange: (ids: Selection.SelectionItem[]) => void } function DirContents({ diff --git a/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx b/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx index 24dd7176841..4996fe76638 100644 --- a/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx +++ b/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx @@ -100,6 +100,75 @@ function TopBar({ crumbs, children }: React.PropsWithChildren) { ) } +const useSelectionWidgetStyles = M.makeStyles({ + close: { + marginLeft: 'auto', + }, + title: { + alignItems: 'center', + display: 'flex', + }, + badge: { + right: '4px', + }, +}) + +interface SelectionWidgetProps { + className: string + selection: Selection.ListingSelection + onSelection: (changed: Selection.ListingSelection) => void +} + +function SelectionWidget({ className, selection, onSelection }: SelectionWidgetProps) { + const classes = useSelectionWidgetStyles() + const location = RRDom.useLocation() + const count = Object.values(selection).reduce((memo, ids) => memo + ids.length, 0) + const [opened, setOpened] = React.useState(false) + const open = React.useCallback(() => setOpened(true), []) + const close = React.useCallback(() => setOpened(false), []) + React.useEffect(() => close(), [close, location]) + const badgeClasses = React.useMemo(() => ({ badge: classes.badge }), [classes]) + return ( + <> + + + Selected items + + + + + + + {count} items selected + + close + + + + + + + + + Close + + + + + ) +} + const useDirDisplayStyles = M.makeStyles((t) => ({ button: { flexShrink: 0, @@ -117,8 +186,8 @@ interface DirDisplayProps { path: string crumbs: BreadCrumbs.Crumb[] size?: number - selection: string[] - onSelection: (ids: string[]) => void + selection: Selection.ListingSelection + onSelection: (ids: Selection.ListingSelection) => void } function DirDisplay({ @@ -339,21 +408,11 @@ function DirDisplay({ { Ok: ({ ui: { actions } }) => ( <> - memo + ids.length, - 0, - )} - classes={{}} - className={''} - color="primary" - max={999} - showZero - > - {}} size="small"> - Selected items - - + {actions.revisePackage && ( {blocks.browser && ( + onSelection(Selection.merge(ids, bucket, path)(selection)) + } + selection={Selection.getDirectorySelection( + selection, + bucket, + path, + )} items={items} key={hash} /> @@ -843,14 +908,10 @@ function PackageTree({ tailSeparator: path.endsWith('/'), }) - const [selection, setSelection] = React.useState>( + // TODO: guard for leaving this exact package + const [selection, setSelection] = React.useState( Selection.EMPTY_MAP, ) - const handleSelection = React.useCallback( - (ids) => setSelection(Selection.merge(ids, bucket, path)), - [bucket, path], - ) - // TODO: guard for leaving this exact package return ( @@ -892,11 +953,6 @@ function PackageTree({ )} - {}} - selection={selection} - /> {' @ '} @@ -914,8 +970,8 @@ function PackageTree({ hashOrTag, crumbs, size, - selection: Selection.getDirectorySelection(selection, bucket, path), - onSelection: handleSelection, + selection, + onSelection: setSelection, }} /> ) : ( diff --git a/catalog/app/containers/Bucket/Selection/Dashboard.tsx b/catalog/app/containers/Bucket/Selection/Dashboard.tsx index 5d1bed5051c..fcf65f0b226 100644 --- a/catalog/app/containers/Bucket/Selection/Dashboard.tsx +++ b/catalog/app/containers/Bucket/Selection/Dashboard.tsx @@ -11,7 +11,7 @@ import * as NamedRoutes from 'utils/NamedRoutes' import StyledLink from 'utils/StyledLink' import * as s3paths from 'utils/s3paths' -import { EMPTY_MAP, PrefixedKeysMap, toHandlesMap } from './utils' +import { EMPTY_MAP, ListingSelection, toHandlesMap } from './utils' const useEmptyStateStyles = M.makeStyles((t) => ({ root: { @@ -126,8 +126,8 @@ const useStyles = M.makeStyles((t) => ({ interface DashboardProps { onDone: () => void - onSelection: (changed: PrefixedKeysMap) => void - selection: PrefixedKeysMap + onSelection: (changed: ListingSelection) => void + selection: ListingSelection } export default function Dashboard({ onDone, onSelection, selection }: DashboardProps) { @@ -161,7 +161,7 @@ export default function Dashboard({ onDone, onSelection, selection }: DashboardP const handleRemove = React.useCallback( (prefixUrl: string, index: number) => { - const newSelection = R.dissocPath([prefixUrl, index], selection) + const newSelection = R.dissocPath([prefixUrl, index], selection) onSelection(newSelection) if (!Object.values(newSelection).some((ids) => !!ids.length)) { onDone() diff --git a/catalog/app/containers/Bucket/Selection/utils.ts b/catalog/app/containers/Bucket/Selection/utils.ts index 41519c20176..5a9c23cd19b 100644 --- a/catalog/app/containers/Bucket/Selection/utils.ts +++ b/catalog/app/containers/Bucket/Selection/utils.ts @@ -5,11 +5,16 @@ import * as R from 'ramda' import type * as Model from 'model' import * as s3paths from 'utils/s3paths' -export interface PrefixedKeysMap { - [prefixUrl: string]: string[] +export interface SelectionItem { + logicalKey: string + physicalKey?: string } -export const EMPTY_MAP: PrefixedKeysMap = {} +export interface ListingSelection { + [prefixUrl: string]: SelectionItem[] +} + +export const EMPTY_MAP: ListingSelection = {} interface SelectionHandles { [prefixUrl: string]: Model.S3.S3ObjectLocation[] @@ -23,47 +28,53 @@ const convertIdToHandle = ( key: join(parentHandle.key, id.toString()), }) -export const toHandlesMap = (selection: PrefixedKeysMap): SelectionHandles => +export const toHandlesMap = (selection: ListingSelection): SelectionHandles => Object.entries(selection).reduce( - (memo, [prefixUrl, keys]) => ({ + (memo, [prefixUrl, items]) => ({ ...memo, - [prefixUrl]: keys.map((id) => convertIdToHandle(id, s3paths.parseS3Url(prefixUrl))), + [prefixUrl]: items.map((item) => + convertIdToHandle(item.logicalKey, s3paths.parseS3Url(prefixUrl)), + ), }), {} as SelectionHandles, ) -export const toHandlesList = (selection: PrefixedKeysMap): Model.S3.S3ObjectLocation[] => +export const toHandlesList = (selection: ListingSelection): Model.S3.S3ObjectLocation[] => Object.entries(selection).reduce( - (memo, [prefixUrl, keys]) => [ + (memo, [prefixUrl, items]) => [ ...memo, - ...keys.map((key) => convertIdToHandle(key, s3paths.parseS3Url(prefixUrl))), + ...items.map((item) => + convertIdToHandle(item.logicalKey, s3paths.parseS3Url(prefixUrl)), + ), ], [] as Model.S3.S3ObjectLocation[], ) const mergeWithFiltered = - (prefix: string, filteredIds: string[]) => (allIds: string[]) => { - if (!allIds || !allIds.length) return filteredIds - const selectionOutsideFilter = allIds.filter((id) => !id.startsWith(prefix)) - const newIds = [...selectionOutsideFilter, ...filteredIds] - return R.equals(newIds, allIds) ? allIds : newIds // avoids cyclic update + (prefix: string, filteredItems: SelectionItem[]) => (allItems: SelectionItem[]) => { + if (!allItems || !allItems.length) return filteredItems + const selectionOutsideFilter = allItems.filter( + (item) => !item.logicalKey.startsWith(prefix), + ) + const newIds = [...selectionOutsideFilter, ...filteredItems] + return R.equals(newIds, allItems) ? allItems : newIds // avoids cyclic update } export function merge( - ids: string[], + items: SelectionItem[], bucket: string, path: string, filter?: string, -): React.SetStateAction { +): (state: ListingSelection) => ListingSelection { const prefixUrl = `s3://${bucket}/${path}` - const lens = R.lensProp>(prefixUrl) - return filter ? R.over(lens, mergeWithFiltered(filter, ids)) : R.set(lens, ids) + const lens = R.lensProp>(prefixUrl) + return filter ? R.over(lens, mergeWithFiltered(filter, items)) : R.set(lens, items) } const EmptyKeys: string[] = [] export const getDirectorySelection = ( - selection: PrefixedKeysMap, + selection: ListingSelection, bucket: string, path: string, ) => selection[`s3://${bucket}/${path}`] || EmptyKeys