Skip to content

Commit eeed425

Browse files
authored
Merge pull request #562 from fractal-analytics-platform/view-plate-btn
Added "View plate" button
2 parents 27e758d + 9809b10 commit eeed425

File tree

7 files changed

+255
-13
lines changed

7 files changed

+255
-13
lines changed

.github/workflows/end_to_end_tests.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jobs:
1818
OAUTH_DEXIDP_REDIRECT_URL: "http://localhost:5173/auth/login/oauth2/"
1919
OAUTH_DEXIDP_OIDC_CONFIGURATION_ENDPOINT: "http://127.0.0.1:5556/dex/.well-known/openid-configuration"
2020
PUBLIC_OAUTH_CLIENT_NAME: dexidp
21+
PUBLIC_FRACTAL_VIZARR_VIEWER_URL: http://localhost:3000/vizarr
2122

2223
strategy:
2324
matrix:

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
# Unreleased
44

5+
* Fixed duplicated entries in owner dropdown (\#562);
6+
* Added "View plate" button on dataset page (\#562);
57
* Improved stability of end to end tests (\#560);
68

79
# 1.6.0

src/routes/v2/admin/tasks/+page.server.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ export async function load({ fetch }) {
88

99
const usersList = await listUsers(fetch);
1010

11-
const users = /** @type {string[]} */ (
12-
usersList.map((u) => (u.username ? u.username : u.slurm_user)).filter((u) => !!u)
13-
);
11+
const users = /** @type {string[]} */ ([
12+
...new Set(usersList.map((u) => (u.username ? u.username : u.slurm_user)).filter((u) => !!u))
13+
]);
1414

1515
users.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
1616

src/routes/v2/projects/[projectId]/datasets/[datasetId]/+page.svelte

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,76 @@
4848
/** @type {CreateUpdateImageModal|undefined} */
4949
let imageModal = undefined;
5050
51+
$: plates = Array.isArray(imagePage.attributes['plate'])
52+
? imagePage.attributes['plate'].sort()
53+
: [];
54+
let selectedPlate = '';
55+
let platePath = '';
56+
let platePathLoading = false;
57+
let platePathError = '';
58+
59+
$: if (plates.length > 0 && selectedPlate !== '') {
60+
computePlatePath();
61+
} else {
62+
platePath = '';
63+
platePathError = '';
64+
}
65+
66+
async function computePlatePath() {
67+
platePathError = '';
68+
if (!plates.includes(selectedPlate)) {
69+
selectedPlate = '';
70+
return;
71+
}
72+
let imageWithPlate = imagePage.images.find((i) => i.attributes['plate'] === selectedPlate);
73+
if (!imageWithPlate) {
74+
platePathLoading = true;
75+
imageWithPlate = await loadImageForSelectedPlate();
76+
platePathLoading = false;
77+
}
78+
if (imageWithPlate) {
79+
// Removes the last 3 elements from the path
80+
platePath = imageWithPlate.zarr_url.split('/').slice(0, -3).join('/');
81+
if (!platePath) {
82+
platePathError = `Unable to load plate URL from zarr URL ${imageWithPlate.zarr_url}`;
83+
}
84+
} else {
85+
platePath = '';
86+
platePathError = 'Unable to load plate URL. No image found for the selected plate.';
87+
}
88+
}
89+
90+
/**
91+
* @returns {Promise<import('$lib/types-v2').Image|undefined>}
92+
*/
93+
async function loadImageForSelectedPlate() {
94+
const params = { filters: { attributes: { plate: selectedPlate } } };
95+
const headers = new Headers();
96+
headers.set('Content-Type', 'application/json');
97+
const response = await fetch(
98+
`/api/v2/project/${projectId}/dataset/${dataset.id}/images/query?page=1&page_size=1`,
99+
{
100+
method: 'POST',
101+
headers,
102+
credentials: 'include',
103+
body: JSON.stringify(params)
104+
}
105+
);
106+
if (!response.ok) {
107+
console.error(`Unable to load image for plate ${selectedPlate}`);
108+
return undefined;
109+
}
110+
/** @type {import('$lib/types-v2').ImagePage}*/
111+
const result = await response.json();
112+
if (result.images.length === 0) {
113+
console.error(
114+
`Unable to load image for plate ${selectedPlate}. Server replied with empty list`
115+
);
116+
return undefined;
117+
}
118+
return result.images[0];
119+
}
120+
51121
onMount(() => {
52122
loadAttributesSelectors();
53123
loadTypesSelector();
@@ -429,6 +499,45 @@
429499
</div>
430500
</div>
431501
502+
{#if vizarrViewerUrl && plates.length > 0}
503+
<div class="border border-info rounded bg-light p-3 mt-2">
504+
<div class="row mb-2">
505+
<div class="col">
506+
This dataset contains {plates.length}
507+
{plates.length === 1 ? 'plate' : 'plates'}. Select which plate you want to view
508+
</div>
509+
</div>
510+
<div class="row row-cols-md-auto g-3 align-items-center">
511+
<div class="col-12">
512+
<select class="form-select" aria-label="Select plate" bind:value={selectedPlate}>
513+
<option value="">Select...</option>
514+
{#each plates as plate}
515+
<option>{plate}</option>
516+
{/each}
517+
</select>
518+
</div>
519+
<div class="col-12">
520+
{#if platePath}
521+
<a
522+
href="{vizarrViewerUrl}?source={vizarrViewerUrl}data{platePath}"
523+
class="btn btn-info me-2"
524+
target="_blank"
525+
class:disabled={platePathLoading}
526+
>
527+
<i class="bi bi-eye" />
528+
View plate
529+
</a>
530+
{:else if platePathError}
531+
<span class="text-danger">{platePathError}</span>
532+
{/if}
533+
{#if platePathLoading}
534+
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" />
535+
{/if}
536+
</div>
537+
</div>
538+
</div>
539+
{/if}
540+
432541
{#if !showTable}
433542
<p class="fw-bold ms-4 mt-5">No entries in the image list yet</p>
434543
<button class="btn btn-outline-secondary ms-4" on:click={() => imageModal?.openForCreate()}>

tests/v2/admin_groups.spec.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,22 @@ test('Admin groups management', async ({ page }) => {
3737
}
3838
});
3939

40-
let group2;
40+
let group2, group3;
4141
await test.step('Create other 2 test groups', async () => {
4242
group2 = await createTestGroup(page);
43-
await createTestGroup(page);
43+
group3 = await createTestGroup(page);
4444
});
4545

4646
const groupBadges = page.locator('.row', { hasText: 'Groups' }).locator('.badge');
47+
let initialGroupBadgesCount;
4748

4849
await test.step('Open user editing page', async () => {
4950
await page.goto('/v2/admin/users');
5051
await waitPageLoading(page);
5152
await page.getByRole('row', { name: user1 }).getByRole('link', { name: 'Edit' }).click();
5253
await waitPageLoading(page);
5354
const currentGroups = await groupBadges.allInnerTexts();
54-
expect(currentGroups.length).toEqual(2);
55+
initialGroupBadgesCount = await groupBadges.count();
5556
expect(currentGroups.includes('All')).toBeTruthy();
5657
expect(currentGroups.includes(group1)).toBeTruthy();
5758
});
@@ -65,7 +66,7 @@ test('Admin groups management', async ({ page }) => {
6566
await selectSlimSelect(page, page.getByLabel('Select groups'), group2, true);
6667
await modal.getByRole('button', { name: 'Add' }).click();
6768
await waitModalClosed(page);
68-
await expect(groupBadges).toHaveCount(3);
69+
await expect(groupBadges).toHaveCount(initialGroupBadgesCount + 1);
6970
});
7071

7172
await test.step('Reopen modal and check options', async () => {
@@ -80,24 +81,23 @@ test('Admin groups management', async ({ page }) => {
8081

8182
await test.step('Remove group2 from groups to add', async () => {
8283
await page.getByLabel(`Remove group ${group2}`).click();
83-
await expect(groupBadges).toHaveCount(2);
84+
await expect(groupBadges).toHaveCount(initialGroupBadgesCount);
8485
});
8586

8687
let finalCount;
87-
await test.step('Reopen modal and add all the groups to the user', async () => {
88+
await test.step('Reopen modal and group2 again and group3', async () => {
8889
await page.getByRole('button', { name: 'Add group' }).click();
8990
const modal = page.locator('.modal.show');
9091
await modal.waitFor();
9192
const selectableGroups = await page.getByRole('option').allInnerTexts();
9293
expect(selectableGroups.length).toEqual(selectableGroups1);
93-
for (const group of selectableGroups) {
94+
for (let group of [group2, group3].sort()) {
9495
await selectSlimSelect(page, page.getByLabel('Select groups'), group, true);
9596
}
9697
await modal.getByRole('button', { name: 'Add' }).click();
9798
await waitModalClosed(page);
98-
finalCount = selectableGroups1 + 2;
99+
finalCount = initialGroupBadgesCount + 2;
99100
await expect(groupBadges).toHaveCount(finalCount);
100-
await expect(page.getByRole('button', { name: 'Add group' })).not.toBeVisible();
101101
});
102102

103103
await test.step('Save and check', async () => {

tests/v2/images.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ test('Dataset images [v2]', async ({ page, project }) => {
2424
await test.step('Open test dataset', async () => {
2525
await page.getByRole('link', { name: 'test-dataset' }).click();
2626
await page.waitForURL(/\/v2\/projects\/\d+\/datasets\/\d+/);
27-
expect(await page.getByText('No entries in the image list yet').isVisible()).toEqual(true);
27+
await expect(page.getByText('No entries in the image list yet')).toBeVisible();
2828
});
2929

3030
await test.step('Create an image without filters', async () => {

tests/v2/view_plate.spec.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { waitModalClosed, waitPageLoading } from '../utils.js';
2+
import { expect, test } from './project_fixture.js';
3+
4+
test('View plate', async ({ page, project }) => {
5+
await page.waitForURL(project.url);
6+
await waitPageLoading(page);
7+
8+
const randomPath = `/tmp/${Math.random().toString(36).substring(7)}`;
9+
10+
await test.step('Create test dataset', async () => {
11+
const createDatasetButton = page.getByRole('button', { name: 'Create new dataset' });
12+
await createDatasetButton.click();
13+
const modal = page.locator('.modal.show');
14+
await modal.waitFor();
15+
await modal.getByRole('textbox', { name: 'Dataset Name' }).fill('test-dataset');
16+
await modal.getByRole('textbox', { name: 'Zarr dir' }).fill(randomPath);
17+
await modal.getByRole('button', { name: 'Save' }).click();
18+
await waitModalClosed(page);
19+
});
20+
21+
await test.step('Open test dataset', async () => {
22+
await page.getByRole('link', { name: 'test-dataset' }).click();
23+
await page.waitForURL(/\/v2\/projects\/\d+\/datasets\/\d+/);
24+
await expect(page.getByText('No entries in the image list yet')).toBeVisible();
25+
});
26+
27+
await test.step('Create test images', async () => {
28+
for (let i = 0; i < 5; i++) {
29+
await createImageWithPlate(page, `${randomPath}/plate1.zarr/B/03/${i}`, 'plate1');
30+
}
31+
for (let i = 5; i < 11; i++) {
32+
await createImageWithPlate(page, `${randomPath}/plate2.zarr/B/03/${i}`, 'plate2');
33+
}
34+
await createImageWithPlate(page, `${randomPath}/plate3.zarr/B/03/11`, 'plate3');
35+
});
36+
37+
await test.step('Check plate selector', async () => {
38+
await checkPlateSelector(page, 'plate1', 'plate2', 'plate3');
39+
});
40+
41+
await test.step('Select plates', async () => {
42+
for (let i = 1; i <= 3; i++) {
43+
await page.getByRole('combobox', { name: 'Select plate' }).selectOption(`plate${i}`);
44+
await expect(page.getByRole('link', { name: 'View plate' })).toHaveAttribute(
45+
'href',
46+
new RegExp(`\\/plate${i}\\.zarr$`)
47+
);
48+
await expect(page.getByRole('combobox', { name: 'Select plate' })).toHaveValue(`plate${i}`);
49+
}
50+
});
51+
52+
await test.step('Delete image with plate3 and check selection', async () => {
53+
// go to page 2
54+
await page.getByRole('button', { name: '2', exact: true }).click();
55+
await page
56+
.getByRole('row', { name: 'plate3.zarr' })
57+
.getByRole('button', { name: 'Delete' })
58+
.click();
59+
const modal = page.locator('.modal.show');
60+
await modal.waitFor();
61+
await modal.getByRole('button', { name: 'Confirm' }).click();
62+
await waitModalClosed(page);
63+
await expect(page.getByRole('combobox', { name: 'Select plate' })).toHaveValue('');
64+
});
65+
66+
await test.step('Check plate selector', async () => {
67+
await checkPlateSelector(page, 'plate1', 'plate2');
68+
});
69+
70+
await test.step('Create another test dataset with invalid zarr dir', async () => {
71+
await page.getByRole('link', { name: project.projectName }).click();
72+
await waitPageLoading(page);
73+
const createDatasetButton = page.getByRole('button', { name: 'Create new dataset' });
74+
await createDatasetButton.click();
75+
const modal = page.locator('.modal.show');
76+
await modal.waitFor();
77+
await modal.getByRole('textbox', { name: 'Dataset Name' }).fill('test-dataset-2');
78+
await modal.getByRole('textbox', { name: 'Zarr dir' }).fill('/tmp');
79+
await modal.getByRole('button', { name: 'Save' }).click();
80+
await waitModalClosed(page);
81+
});
82+
83+
await test.step('Open test dataset 2 and create new image', async () => {
84+
await page.getByRole('link', { name: 'test-dataset-2' }).click();
85+
await page.waitForURL(/\/v2\/projects\/\d+\/datasets\/\d+/);
86+
await expect(page.getByText('No entries in the image list yet')).toBeVisible();
87+
await createImageWithPlate(page, '/tmp/invalid', 'plate1');
88+
await checkPlateSelector(page, 'plate1');
89+
await page.getByRole('combobox', { name: 'Select plate' }).selectOption('plate1');
90+
await expect(
91+
page.getByText('Unable to load plate URL from zarr URL /tmp/invalid')
92+
).toBeVisible();
93+
});
94+
});
95+
96+
/**
97+
* @param {import('@playwright/test').Page} page
98+
* @param {string} zarrUrl
99+
* @param {string} plate
100+
*/
101+
async function createImageWithPlate(page, zarrUrl, plate) {
102+
const newImageBtn = page.getByRole('button', { name: 'Add an image list entry' });
103+
await newImageBtn.waitFor();
104+
await newImageBtn.click();
105+
const modal = page.locator('.modal.show');
106+
await modal.waitFor();
107+
await modal.getByRole('textbox', { name: 'Zarr URL' }).fill(zarrUrl);
108+
await modal.getByRole('button', { name: 'Add attribute' }).click();
109+
await modal.getByPlaceholder('Key').fill('plate');
110+
await modal.getByPlaceholder('Value').fill(plate);
111+
await modal.getByRole('button', { name: 'Save' }).click();
112+
await waitModalClosed(page);
113+
}
114+
115+
/**
116+
* @param {import('@playwright/test').Page} page
117+
* @param {...string} expectedOptions
118+
*/
119+
async function checkPlateSelector(page, ...expectedOptions) {
120+
await expect(page.getByText(`This dataset contains ${expectedOptions.length} plate`)).toBeVisible();
121+
const options = await page
122+
.getByRole('combobox', { name: 'Select plate' })
123+
.getByRole('option')
124+
.all();
125+
expect(options).toHaveLength(expectedOptions.length + 1);
126+
expect(options[0]).toHaveText('Select...');
127+
for (let i = 0; i < expectedOptions.length; i++) {
128+
expect(options[i + 1]).toHaveText(expectedOptions[i]);
129+
}
130+
}

0 commit comments

Comments
 (0)