Skip to content

Commit ac32016

Browse files
Add functionality to delete and download updated data files (#236)
* Add helpers to download/delete/infer MIMEs * Add icons to top right corner in file tiles * Update download arrow, none of them look good * Add a test to see if close and download buttons are there * Add tests for clicking the close/download buttons * Update Playwright Snapshots * Remove some lint * Use `closeIcon` and `downloadIcon` Suggested-by: Michał Krassowski <[email protected]> * Center SVGs inside the file tile actions boxes * Fix overlay of actions icons over SVG below * Colour the SVG icons in JE slate blue * Update Playwright Snapshots --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 02dd071 commit ac32016

File tree

5 files changed

+242
-2
lines changed

5 files changed

+242
-2
lines changed

src/pages/files.tsx

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { PageTitle } from '../ui-components/PageTitle';
88
import { EverywhereIcons } from '../icons';
99
import { FilesWarningBanner } from '../ui-components/FilesWarningBanner';
1010
import React, { useState, useRef, useCallback, useEffect } from 'react';
11-
import { LabIcon } from '@jupyterlab/ui-components';
11+
import { LabIcon, closeIcon, downloadIcon } from '@jupyterlab/ui-components';
1212

1313
/**
1414
* File type icons mapping function. We currently implement four common file types:
@@ -175,6 +175,86 @@ function FilesApp(props: IFilesAppProps) {
175175
void refreshListing();
176176
}, [refreshListing]);
177177

178+
const downloadFile = React.useCallback(
179+
async (model: Contents.IModel) => {
180+
try {
181+
const fetched = await props.contentsManager.get(model.path, { content: true });
182+
if (fetched.type !== 'file') {
183+
return;
184+
}
185+
186+
const fmt = (fetched.format ?? 'text') as 'text' | 'base64';
187+
const mime = fetched.mimetype ?? inferMimeFromName(model.name);
188+
189+
let blob: Blob;
190+
if (fmt === 'base64') {
191+
const b64 = String(fetched.content ?? '');
192+
const bytes = atob(b64);
193+
const buf = new Uint8Array(bytes.length);
194+
for (let i = 0; i < bytes.length; i++) {
195+
buf[i] = bytes.charCodeAt(i);
196+
}
197+
blob = new Blob([buf], { type: mime });
198+
} else {
199+
blob = new Blob([String(fetched.content ?? '')], { type: mime || 'text/plain' });
200+
}
201+
202+
const a = document.createElement('a');
203+
a.href = URL.createObjectURL(blob);
204+
a.download = model.name;
205+
document.body.appendChild(a);
206+
a.click();
207+
requestAnimationFrame(() => {
208+
URL.revokeObjectURL(a.href);
209+
a.remove();
210+
});
211+
} catch (err) {
212+
await showErrorMessage(
213+
'Download failed',
214+
`Could not download ${model.name}: ${err instanceof Error ? err.message : String(err)}`
215+
);
216+
}
217+
},
218+
[props.contentsManager]
219+
);
220+
221+
const deleteFile = React.useCallback(
222+
async (model: Contents.IModel) => {
223+
try {
224+
await props.contentsManager.delete(model.path);
225+
await refreshListing();
226+
} catch (err) {
227+
await showErrorMessage(
228+
'Delete failed',
229+
`Could not delete ${model.name}: ${err instanceof Error ? err.message : String(err)}`
230+
);
231+
}
232+
},
233+
[props.contentsManager, refreshListing]
234+
);
235+
236+
/**
237+
* Infer the MIME type from a file name.
238+
* @param name - file name
239+
* @returns the MIME type inferred from the file extension, or an empty string if unknown.
240+
*/
241+
function inferMimeFromName(name: string): string {
242+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
243+
if (ext === 'png') {
244+
return 'image/png';
245+
}
246+
if (ext === 'jpg' || ext === 'jpeg') {
247+
return 'image/jpeg';
248+
}
249+
if (ext === 'csv') {
250+
return 'text/csv';
251+
}
252+
if (ext === 'tsv') {
253+
return 'text/tab-separated-values';
254+
}
255+
return '';
256+
}
257+
178258
return (
179259
<div className="je-FilesApp">
180260
<FileUploader
@@ -229,7 +309,34 @@ function FilesApp(props: IFilesAppProps) {
229309
const fileIcon = getFileIcon(f.name, f.mimetype ?? '');
230310
return (
231311
<div className="je-FileTile" key={f.path}>
232-
<div className="je-FileTile-box">
312+
<div className="je-FileTile-box je-FileTile-box-hasActions">
313+
<div className="je-FileTile-actions">
314+
{/* Delete (X) button */}
315+
<button
316+
className="je-FileTile-action je-FileTile-action--close"
317+
aria-label={`Delete ${f.name}`}
318+
title="Delete"
319+
onClick={e => {
320+
e.stopPropagation();
321+
void deleteFile(f);
322+
}}
323+
>
324+
<closeIcon.react tag="span" />
325+
</button>
326+
327+
{/* Download (↓) button */}
328+
<button
329+
className="je-FileTile-action je-FileTile-action--download"
330+
aria-label={`Download ${f.name}`}
331+
title="Download"
332+
onClick={e => {
333+
e.stopPropagation();
334+
void downloadFile(f);
335+
}}
336+
>
337+
<downloadIcon.react tag="span" />
338+
</button>
339+
</div>
233340
<fileIcon.react />
234341
</div>
235342
<div className="je-FileTile-label">{f.name}</div>

style/base.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
--je-scale: 0.85;
2121
--je-slate-blue: #412c88;
2222
--je-round-corners: 12px;
23+
--je-round-corners-filetiles: 6px;
2324
--je-font-family: 'Inter', sans-serif;
2425
--je-dialog-round-corners: 12px;
2526
--je-cell-height: 40px;

style/files-widget.css

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@
7171
height: 48px;
7272
}
7373

74+
.je-FileTile-actions svg {
75+
width: 16px;
76+
height: 16px;
77+
display: block; /* there is a baseline offset inside the button which I am not sure of */
78+
}
79+
7480
.je-FileTile-label {
7581
font-size: calc(var(--je-scale) * 20px);
7682
font-weight: 500;
@@ -108,3 +114,72 @@
108114
font-size: calc(var(--je-scale) * 20px);
109115
line-height: 1.5;
110116
}
117+
118+
/* Styling for the action buttons on each file tile. */
119+
120+
.je-FileTile-box-hasActions {
121+
position: relative;
122+
isolation: isolate;
123+
}
124+
125+
.je-FileTile-actions {
126+
position: absolute;
127+
top: 4px;
128+
right: 4px;
129+
display: flex;
130+
gap: 4px;
131+
opacity: 0;
132+
pointer-events: none;
133+
transition: opacity 120ms ease;
134+
z-index: 2;
135+
}
136+
137+
.je-FileTile-box-hasActions:hover .je-FileTile-actions {
138+
opacity: 1;
139+
pointer-events: auto;
140+
}
141+
142+
.je-FileTile-action {
143+
width: 20px;
144+
height: 20px;
145+
min-width: 20px;
146+
min-height: 20px;
147+
display: inline-flex;
148+
align-items: center;
149+
justify-content: center;
150+
border: 2px solid var(--je-slate-blue);
151+
border-radius: var(--je-round-corners-filetiles) !important;
152+
background: white;
153+
color: var(--je-slate-blue);
154+
font-weight: 700;
155+
cursor: pointer;
156+
padding: 0;
157+
outline: none;
158+
transition:
159+
background 120ms ease,
160+
transform 60ms ease;
161+
162+
/* center inline SVG, there is a baseline offset inside the button which I am not sure of */
163+
line-height: 0;
164+
box-sizing: border-box;
165+
}
166+
167+
.je-FileTile-action:hover {
168+
background: rgb(65 44 136 / 8%); /* a slightly faint tint */
169+
}
170+
171+
.je-FileTile-action:active {
172+
transform: translateY(1px);
173+
}
174+
175+
.je-FileTile-action > span {
176+
display: flex;
177+
align-items: center;
178+
justify-content: center;
179+
width: 100%;
180+
height: 100%;
181+
}
182+
183+
.je-FileTile-actions svg > path {
184+
--jp-inverse-layout-color3: var(--je-slate-blue) !important;
185+
}

ui-tests/tests/jupytereverywhere.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,63 @@ test.describe('Files', () => {
345345
await expect(page.locator('.je-FileTile-label', { hasText: 'a-image.jpg' })).toBeVisible();
346346
await expect(page.locator('.je-FileTile-label', { hasText: 'b-dataset.csv' })).toBeVisible();
347347
});
348+
349+
test('Hovering a file tile shows close and download actions', async ({ page }) => {
350+
await page.locator('.jp-SideBar').getByTitle('Files').click();
351+
352+
await page.locator('.je-FileTile').first().click();
353+
const jpgPath = path.resolve(__dirname, '../test-files/a-image.jpg');
354+
await page.setInputFiles('input[type="file"]', jpgPath);
355+
356+
const tile = page.locator('.je-FileTile', {
357+
has: page.locator('.je-FileTile-label', { hasText: 'a-image.jpg' })
358+
});
359+
await tile.waitFor();
360+
361+
// Hover to reveal the actions
362+
await tile.hover();
363+
await expect(tile.locator('.je-FileTile-action--close')).toBeVisible();
364+
await expect(tile.locator('.je-FileTile-action--download')).toBeVisible();
365+
366+
expect(await tile.screenshot()).toMatchSnapshot('file-tile-actions-visible.png');
367+
});
368+
369+
test('Clicking the X (close) action deletes the file tile', async ({ page }) => {
370+
await page.locator('.jp-SideBar').getByTitle('Files').click();
371+
372+
await page.locator('.je-FileTile').first().click();
373+
const jpgPath = path.resolve(__dirname, '../test-files/a-image.jpg');
374+
await page.setInputFiles('input[type="file"]', jpgPath);
375+
376+
const label = page.locator('.je-FileTile-label', { hasText: 'a-image.jpg' });
377+
await label.waitFor({ state: 'visible' });
378+
379+
const tile = page.locator('.je-FileTile', { has: label });
380+
await tile.hover();
381+
await tile.locator('.je-FileTile-action--close').click();
382+
383+
await expect(label).toHaveCount(0);
384+
});
385+
386+
test('Clicking the ⭳ (download) action downloads the file', async ({ page }) => {
387+
await page.locator('.jp-SideBar').getByTitle('Files').click();
388+
389+
await page.locator('.je-FileTile').first().click();
390+
const csvPath = path.resolve(__dirname, '../test-files/b-dataset.csv');
391+
await page.setInputFiles('input[type="file"]', csvPath);
392+
393+
const label = page.locator('.je-FileTile-label', { hasText: 'b-dataset.csv' });
394+
await label.waitFor({ state: 'visible' });
395+
396+
const tile = page.locator('.je-FileTile', { has: label });
397+
await tile.hover();
398+
const downloadPromise = page.waitForEvent('download');
399+
await tile.locator('.je-FileTile-action--download').click();
400+
const download = await downloadPromise;
401+
402+
const filePath = await download.path();
403+
expect(filePath).not.toBeNull();
404+
});
348405
});
349406

350407
test('Should remove View Only banner when the Create Copy button is clicked', async ({ page }) => {
2.6 KB
Loading

0 commit comments

Comments
 (0)