Skip to content
Draft
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions packages/core/components/StatusMessage/StatusMessage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,27 @@
height: 3px;
background-color: var(--info-status-text-color);
}

.file-list {
margin: 4px 0 2px 0;
padding-left: 18px;
font-size: var(--s-paragraph-size);
list-style-type: disc;
}

.file-list li {
line-height: 1.4;
word-break: break-word;
overflow-wrap: break-word;
}

.view-more-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: var(--s-paragraph-size);
color: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
}
47 changes: 46 additions & 1 deletion packages/core/components/StatusMessage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,48 @@ const verticalStackProps = {
},
};

const MAX_FILES_SHOWN = 3;

interface FileListProps {
fileNames: string[];
}

/**
* Renders a truncated list of file names with a "Show more / Show less" toggle.
*/
function FileList({ fileNames }: FileListProps) {
const [expanded, setExpanded] = React.useState(false);

if (fileNames.length <= MAX_FILES_SHOWN) {
return (
<ul className={styles.fileList}>
{fileNames.map((name, index) => (
<li key={index}>{name}</li>
))}
</ul>
);
}

const visibleNames = expanded ? fileNames : fileNames.slice(0, MAX_FILES_SHOWN);
const hiddenCount = fileNames.length - MAX_FILES_SHOWN;

return (
<>
<ul className={styles.fileList}>
{visibleNames.map((name, index) => (
<li key={index}>{name}</li>
))}
</ul>
<button
className={styles.viewMoreButton}
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? "Show less" : `Show ${hiddenCount} more`}
</button>
</>
);
}

/**
* Pop-up banners that display status messages of processes, such as the download of a CSV manifest. They
* stack vertically at the top of the window.
Expand All @@ -45,7 +87,7 @@ export default function StatusMessage() {
useSelector(interaction.selectors.getProcessStatuses),
(statusUpdate: StatusUpdate) => {
const {
data: { msg, status = ProcessStatus.NOT_SET, progress },
data: { msg, status = ProcessStatus.NOT_SET, progress, fileNames },
onCancel,
} = statusUpdate;
let onDismiss; // If has cancel option, don't show dismiss button
Expand Down Expand Up @@ -88,6 +130,9 @@ export default function StatusMessage() {
style={{ userSelect: "text" }}
></div>
</div>
{fileNames && fileNames.length > 0 && (
<FileList fileNames={fileNames} />
)}
{progress !== undefined && (
<ProgressIndicator
className={styles.progressIndicator}
Expand Down
9 changes: 7 additions & 2 deletions packages/core/state/interaction/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ export enum ProcessStatus {
export interface StatusUpdate {
data: {
fileId?: string[]; // if relevant/applicable, fileid(s) related to this status update
fileNames?: string[]; // if relevant/applicable, file names for grouped download notifications
msg: string;
Comment thread
SeanDuHare marked this conversation as resolved.
status?: ProcessStatus;
progress?: number; // num in range [0, 1] indicating progress
Expand Down Expand Up @@ -466,13 +467,15 @@ export function processStart(
processId: string,
msg: string,
onCancel?: () => void,
fileId?: string[]
fileId?: string[],
fileNames?: string[]
): ProcessStartAction {
return {
type: SET_STATUS,
payload: {
data: {
fileId,
fileNames,
msg,
status: ProcessStatus.STARTED,
},
Expand All @@ -497,13 +500,15 @@ export function processProgress(
progress: number,
msg: string,
onCancel: () => void,
fileId?: string[]
fileId?: string[],
fileNames?: string[]
): ProcessProgressAction {
return {
type: SET_STATUS,
payload: {
data: {
fileId,
fileNames,
msg,
status: ProcessStatus.PROGRESS,
progress,
Expand Down
167 changes: 102 additions & 65 deletions packages/core/state/interaction/logics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,81 +322,118 @@ const downloadFilesLogic = createLogic({
const totalBytesToDownload = sumBy(filesToDownload, "size") || 0;
const totalBytesDisplay = getBytesDisplay(totalBytesToDownload, someFilesHaveUnknownSize);

// TODO: Download these into a zip using new streamsaver zipped code
await Promise.allSettled(
filesToDownload.map(async (file) => {
let isCancelled = false;
const downloadRequestId = uniqueId();
const allFileIds = filesToDownload.map((f) => f.id);
const allFileNames = filesToDownload.map((f) => f.name);
const fileWord = filesToDownload.length === 1 ? "file" : "files";

// Use a single process ID for the entire batch of downloads
const groupProcessId = uniqueId();
// Map each file to its own download request ID (used by the download service)
const downloadRequestIds = filesToDownload.map(() => uniqueId());

let groupCancelled = false;
const onGroupCancel = () => {
groupCancelled = true;
downloadRequestIds.forEach((id) => dispatch(cancelFileDownload(id)));
};

const onCancel = () => {
isCancelled = true;
dispatch(cancelFileDownload(downloadRequestId));
};
// Track overall bytes downloaded across all files
let groupTotalBytesDownloaded = 0;
const throttledGroupProgressDispatcher = throttle(() => {
if (groupCancelled) return;
const updatedBytesDisplay = numberFormatter.displayValue(
groupTotalBytesDownloaded,
"bytes"
);
const progressMsg = `Downloading ${filesToDownload.length} ${fileWord}.<br/>${updatedBytesDisplay} out of ${totalBytesDisplay} set to download`;
dispatch(
processProgress(
groupProcessId,
totalBytesToDownload ? groupTotalBytesDownloaded / totalBytesToDownload : 0,
progressMsg,
onGroupCancel,
allFileIds,
allFileNames
)
);
}, 1000);

// A function that dispatches progress events, throttled
// to only be invokable at most once/second
let totalBytesDownloaded = 0;
const throttledProgressDispatcher = throttle((progressMsg: string) => {
if (isCancelled) return;
dispatch(
processProgress(
downloadRequestId,
totalBytesToDownload ? totalBytesDownloaded / totalBytesToDownload : 0,
progressMsg,
onCancel,
[file.id]
)
);
}, 1000);
// TODO: Download these into a zip using new streamsaver zipped code
if (!someFilesHaveUnknownSize) {
const msg = `Downloading ${filesToDownload.length} ${fileWord}.<br/>${totalBytesDisplay} set to download`;
dispatch(processStart(groupProcessId, msg, onGroupCancel, allFileIds, allFileNames));
}

const onProgress = (transferredBytes: number) => {
totalBytesDownloaded += transferredBytes;
const settledResults = await Promise.allSettled(
filesToDownload.map(async (file, index) => {
const downloadRequestId = downloadRequestIds[index];

// Generate new message
const updatedBytesDisplay = numberFormatter.displayValue(
totalBytesDownloaded,
"bytes"
);
const progressMsg = `Downloading ${file.name}. <br/> ${updatedBytesDisplay} out of ${totalBytesDisplay} set to download`;
throttledProgressDispatcher(progressMsg);
const onProgress = (transferredBytes: number) => {
groupTotalBytesDownloaded += transferredBytes;
throttledGroupProgressDispatcher();
};

try {
// Start the download and handle progress reporting
if (!someFilesHaveUnknownSize) {
const fileByteDisplay = getBytesDisplay(file.size || 0);
const msg = `Downloading ${file.name}. <br/> ${fileByteDisplay} out of ${totalBytesDisplay} set to download`;
dispatch(processStart(downloadRequestId, msg, onCancel, [file.id]));
}

const result = await fileDownloadService.download(
file,
downloadRequestId,
onProgress
);
const result = await fileDownloadService.download(
file,
downloadRequestId,
onProgress
);

if (!someFilesHaveUnknownSize) {
if (result.resolution === DownloadResolution.CANCELLED) {
onCancel();
} else {
// This gets sent before some large files are complete
dispatch(
processSuccess(
downloadRequestId,
result.msg || "Download started successfully."
)
);
}
}
} catch (err) {
const errorMsg = `File download failed for file ${file.name}. Details:<br/>${
err instanceof Error ? err.message : err
}`;
dispatch(processError(downloadRequestId, errorMsg));
}
return { file, result };
})
);

if (!someFilesHaveUnknownSize) {
if (groupCancelled) {
dispatch(removeStatus(groupProcessId));
} else {
const failed = settledResults.filter(
(r) =>
r.status === "rejected" ||
(r.status === "fulfilled" &&
r.value.result.resolution !== DownloadResolution.SUCCESS &&
r.value.result.resolution !== DownloadResolution.CANCELLED)
);
const cancelled = settledResults.filter(
(r) =>
r.status === "fulfilled" &&
r.value.result.resolution === DownloadResolution.CANCELLED
);

if (failed.length > 0) {
const failedNames = failed.map((r) =>
r.status === "fulfilled" ? r.value.file.name : "unknown"
);
const errorMsg =
filesToDownload.length === 1
? `File download failed for file ${allFileNames[0]}. Details:<br/>${
failed[0].status === "rejected"
? failed[0].reason instanceof Error
? failed[0].reason.message
: failed[0].reason
: "Unknown error"
}`
: `Download failed for ${failed.length} of ${filesToDownload.length} ${fileWord}:<br/>${failedNames.join(", ")}`;
dispatch(processError(groupProcessId, errorMsg));
} else if (cancelled.length === filesToDownload.length) {
// All cancelled
dispatch(removeStatus(groupProcessId));
} else {
const succeededCount = filesToDownload.length - cancelled.length;
const successWord = succeededCount === 1 ? "file" : "files";
const firstResult =
settledResults[0].status === "fulfilled"
? settledResults[0].value.result
: undefined;
const msg =
filesToDownload.length === 1
? firstResult?.msg || "Download started successfully."
: `Successfully downloaded ${succeededCount} ${successWord}.`;
dispatch(processSuccess(groupProcessId, msg));
}
}
}

done();
},
});
Expand Down
25 changes: 11 additions & 14 deletions packages/core/state/interaction/test/logics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ describe("Interaction logics", () => {
).to.equal(true);
});

it("downloads multiple files", async () => {
it("downloads multiple files grouped into one status notification", async () => {
// Arrange
const state = mergeState(initialState, {
interaction: {
Expand Down Expand Up @@ -361,25 +361,22 @@ describe("Interaction logics", () => {
store.dispatch(downloadFiles([file1, file2]));
await logicMiddleware.whenComplete();

// Assert
expect(
actions.includesMatch({
type: SET_STATUS,
payload: {
data: {
status: ProcessStatus.STARTED,
fileId: [file1.id],
},
},
})
).to.be.true;
// Assert: only one STARTED status for the entire batch (not one per file)
const startedActions = actions.list.filter(
(a: { type: string; payload?: { data?: { status?: ProcessStatus } } }) =>
a.type === SET_STATUS && a.payload?.data?.status === ProcessStatus.STARTED
);
expect(startedActions).to.have.length(1);

// The single status update should reference both files
expect(
actions.includesMatch({
type: SET_STATUS,
payload: {
data: {
status: ProcessStatus.STARTED,
fileId: [file2.id],
fileId: [file1.id, file2.id],
fileNames: [file1.name, file2.name],
},
},
})
Expand Down