Skip to content

Commit

Permalink
Select files and directories in a package to download (#4173)
Browse files Browse the repository at this point in the history
Co-authored-by: Alexei Mochalov <nl_0@quiltdata.io>
  • Loading branch information
fiskus and nl0 authored Oct 17, 2024
1 parent 4481e22 commit fd98908
Show file tree
Hide file tree
Showing 17 changed files with 557 additions and 249 deletions.
1 change: 1 addition & 0 deletions .github/workflows/deploy-catalog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- master
- selective-package-download
paths:
- '.github/workflows/deploy-catalog.yaml'
- 'catalog/**'
Expand Down
1 change: 1 addition & 0 deletions catalog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ where verb is one of

## Changes

- [Added] Selective package downloading ([#4173](https://github.com/quiltdata/quilt/pull/4173))
- [Added] Qurator Omni: initial public release ([#4032](https://github.com/quiltdata/quilt/pull/4032), [#4181](https://github.com/quiltdata/quilt/pull/4181))
- [Added] Admin: UI for configuring longitudinal queries (Tabulator) ([#4135](https://github.com/quiltdata/quilt/pull/4135), [#4164](https://github.com/quiltdata/quilt/pull/4164), [#4165](https://github.com/quiltdata/quilt/pull/4165))
- [Changed] Admin: Move bucket settings to a separate page ([#4122](https://github.com/quiltdata/quilt/pull/4122))
Expand Down
5 changes: 4 additions & 1 deletion catalog/app/containers/Bucket/Bucket.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as RT from 'utils/reactTools'

import BucketNav from './BucketNav'
import CatchNotFound from './CatchNotFound'
import * as Selection from './Selection'
import { displayError } from './errors'

const SuspensePlaceholder = () => <Placeholder color="text.secondary" />
Expand Down Expand Up @@ -103,7 +104,9 @@ export default function Bucket() {
<File />
</Route>
<Route path={paths.bucketDir} exact>
<Dir />
<Selection.Provider>
<Dir />
</Selection.Provider>
</Route>
<Route path={paths.bucketOverview} exact>
<Overview />
Expand Down
104 changes: 16 additions & 88 deletions catalog/app/containers/Bucket/Dir.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,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({
Expand Down Expand Up @@ -142,75 +142,6 @@ function DirContents({
)
}

const useSelectionWidgetStyles = M.makeStyles({
close: {
marginLeft: 'auto',
},
title: {
alignItems: 'center',
display: 'flex',
},
badge: {
right: '4px',
},
})

interface SelectionWidgetProps {
className: string
selection: Selection.PrefixedKeysMap
onSelection: (changed: Selection.PrefixedKeysMap) => 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 (
<>
<M.Badge
badgeContent={count}
classes={badgeClasses}
className={className}
color="primary"
max={999}
showZero
>
<M.Button onClick={open} size="small">
Selected items
</M.Button>
</M.Badge>

<M.Dialog open={opened} onClose={close} fullWidth maxWidth="md">
<M.DialogTitle disableTypography>
<M.Typography className={classes.title} variant="h6">
{count} items selected
<M.IconButton size="small" className={classes.close} onClick={close}>
<M.Icon>close</M.Icon>
</M.IconButton>
</M.Typography>
</M.DialogTitle>
<M.DialogContent>
<Selection.Dashboard
onSelection={onSelection}
onDone={close}
selection={selection}
/>
</M.DialogContent>
<M.DialogActions>
<M.Button onClick={close} variant="contained" color="primary" size="small">
Close
</M.Button>
</M.DialogActions>
</M.Dialog>
</>
)
}

const useStyles = M.makeStyles((t) => ({
crumbs: {
...t.typography.body1,
Expand Down Expand Up @@ -282,12 +213,10 @@ export default function Dir() {
)
}, [data.result])

const [selection, setSelection] = React.useState<Record<string, string[]>>(
Selection.EMPTY_MAP,
)
const slt = Selection.use()
const handleSelection = React.useCallback(
(ids) => setSelection(Selection.merge(ids, bucket, path, prefix)),
[bucket, path, prefix],
(ids) => slt.merge(ids, bucket, path, prefix),
[bucket, path, prefix, slt],
)

const packageDirectoryDialog = PD.usePackageCreationDialog({
Expand All @@ -302,10 +231,10 @@ export default function Dir() {
packageDirectoryDialog.open({
path,
successor,
selection,
selection: slt.selection,
})
},
[packageDirectoryDialog, path, selection],
[packageDirectoryDialog, path, slt.selection],
)

const { paths, urls } = NamedRoutes.use<RouteMap>()
Expand All @@ -315,7 +244,6 @@ export default function Dir() {
)
const crumbs = BreadCrumbs.use(path, getSegmentRoute, bucket)

const hasSelection = Object.values(selection).some((ids) => !!ids.length)
const guardNavigation = React.useCallback(
(location) => {
if (
Expand All @@ -337,7 +265,7 @@ export default function Dir() {

<AssistantContext.ListingContext data={data} />

<RRDom.Prompt when={hasSelection} message={guardNavigation} />
<RRDom.Prompt when={!slt.isEmpty} message={guardNavigation} />

{packageDirectoryDialog.render({
successTitle: 'Package created',
Expand All @@ -352,11 +280,7 @@ export default function Dir() {
{BreadCrumbs.render(crumbs)}
</div>
<div className={classes.actions}>
<SelectionWidget
className={cx(classes.button)}
selection={selection}
onSelection={setSelection}
/>
<Selection.Control className={cx(classes.button)} />
{BucketPreferences.Result.match(
{
Ok: ({ ui: { actions } }) => (
Expand All @@ -366,8 +290,8 @@ export default function Dir() {
bucket={bucket}
className={classes.button}
onChange={openPackageCreationDialog}
variant={hasSelection ? 'contained' : 'outlined'}
color={hasSelection ? 'primary' : 'default'}
variant={slt.isEmpty ? 'outlined' : 'contained'}
color={slt.isEmpty ? 'default' : 'primary'}
>
Create package
</Successors.Button>
Expand Down Expand Up @@ -420,7 +344,11 @@ export default function Dir() {
bucket={bucket}
path={path}
loadMore={loadMore}
selection={Selection.getDirectorySelection(selection, res.bucket, res.path)}
selection={Selection.getDirectorySelection(
slt.selection,
res.bucket,
res.path,
)}
onSelection={handleSelection}
/>
) : (
Expand Down
15 changes: 13 additions & 2 deletions catalog/app/containers/Bucket/Download.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,23 @@ import mkStorage from 'utils/storage'
import * as Buttons from 'components/Buttons'

import * as FileView from './FileView'
import * as Selection from './Selection'

interface DownloadButtonProps {
className: string
label: string
onClick: () => void
path?: string
selection: Selection.ListingSelection
}

export function DownloadButton({ className, label, onClick, path }: DownloadButtonProps) {
export function DownloadButton({
className,
selection,
label,
onClick,
path,
}: DownloadButtonProps) {
if (cfg.noDownload) return null

if (cfg.desktop) {
Expand All @@ -30,7 +38,10 @@ export function DownloadButton({ className, label, onClick, path }: DownloadButt
}

return (
<FileView.ZipDownloadForm suffix={path}>
<FileView.ZipDownloadForm
suffix={path}
files={Selection.toHandlesList(selection).map(({ key }) => key)}
>
<Buttons.Iconized
className={className}
label={label}
Expand Down
11 changes: 10 additions & 1 deletion catalog/app/containers/Bucket/FileView.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ export function ViewModeSelector({ className, ...props }) {
}

/** Child button must have `type="submit"` */
export function ZipDownloadForm({ className = '', suffix, children, newTab = false }) {
export function ZipDownloadForm({
className = '',
suffix,
children,
newTab = false,
files = [],
}) {
const { token } = redux.useSelector(tokensSelector) || {}
if (!token || cfg.noDownload) return null
const action = `${cfg.s3Proxy}/zip/${suffix}`
Expand All @@ -62,6 +68,9 @@ export function ZipDownloadForm({ className = '', suffix, children, newTab = fal
style={{ flexShrink: 0 }}
>
<input type="hidden" name="token" value={token} />
{files.map((file) => (
<input type="hidden" name="file" value={file} key={file} />
))}
{children}
</form>
)
Expand Down
24 changes: 13 additions & 11 deletions catalog/app/containers/Bucket/Listing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <i>{'<EMPTY>'}</i>

Expand Down Expand Up @@ -474,24 +475,26 @@ 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<DG.GridRowId, number>,
)
items.some(({ name, type }) => {
if (name === '..') return false
if (ids.includes(name)) {
names.push(type === 'dir' ? s3paths.ensureSlash(name) : name)
names.push({
logicalKey: type === 'dir' ? s3paths.ensureSlash(name) : 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
Expand Down Expand Up @@ -1035,8 +1038,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<CellProps>
RootComponent?: React.ElementType<{ className: string }>
className?: string
Expand Down Expand Up @@ -1230,7 +1233,7 @@ export function Listing({
)

const selectionModel = React.useMemo(
() => (selection?.length ? selection.map(s3paths.ensureNoSlash) : selection),
() => selection.map(({ logicalKey }) => s3paths.ensureNoSlash(logicalKey)),
[selection],
)

Expand Down Expand Up @@ -1264,8 +1267,7 @@ export function Listing({
disableMultipleSelection
disableMultipleColumnsSorting
localeText={{ noRowsLabel, ...localeText }}
// selection-related props
checkboxSelection={!!onSelectionChange}
checkboxSelection
selectionModel={selectionModel}
onSelectionModelChange={handleSelectionModelChange}
{...dataGridProps}
Expand Down
3 changes: 2 additions & 1 deletion catalog/app/containers/Bucket/Meta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ interface PackageMetaSectionProps {

export function PackageMetaSection({ meta, preferences }: PackageMetaSectionProps) {
const classes = usePackageMetaStyles()
if (!meta || R.isEmpty(meta)) return null
if (!meta) return null
const { message, user_meta: userMeta, workflow } = meta
if (!message && !userMeta && !workflow) return null
return (
<Section icon="list" heading="Metadata" defaultExpanded>
<M.Table className={classes.table} size="small" data-testid="package-meta">
Expand Down
18 changes: 11 additions & 7 deletions catalog/app/containers/Bucket/PackageDialog/FilesInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { readableBytes } from 'utils/string'
import * as tagged from 'utils/taggedV2'
import useMemoEq from 'utils/useMemoEq'

import * as Selection from '../Selection'

import EditFileMeta from './EditFileMeta'
import {
FilesEntryState,
Expand Down Expand Up @@ -1589,13 +1591,15 @@ export function FilesInput({
return (
<Root className={className}>
{isS3FilePickerEnabled && (
<S3FilePicker.Dialog
bucket={bucket}
buckets={buckets}
selectBucket={selectBucket}
open={s3FilePickerOpen}
onClose={closeS3FilePicker}
/>
<Selection.Provider>
<S3FilePicker.Dialog
bucket={bucket}
buckets={buckets}
selectBucket={selectBucket}
open={s3FilePickerOpen}
onClose={closeS3FilePicker}
/>
</Selection.Provider>
)}
{prompt.render(
<M.Typography variant="body2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit fd98908

Please sign in to comment.