diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..898b738c2 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "data-management", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "apps/data-management", "run", "dev", "--", "--port", "5190", "--strictPort"], + "port": 5190 + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edf9636bb..31c7c3534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,8 @@ name: CI on: push: branches: - - "**" + - main pull_request: - release: - types: [created] permissions: contents: read diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 33256f7a5..e96b4aedf 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -150,7 +150,7 @@ { "label": "Start Backend", "type": "shell", - "command": "${workspaceFolder}/scripts/dev-api-command manage.py tailwind runserver 0.0.0.0:8000", + "command": "${workspaceFolder}/scripts/dev-api-command manage.py runserver 0.0.0.0:8000", "options": { "cwd": "${workspaceFolder}/django" }, diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..2ad576439 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +# Frontend Changes + +For frontend changes, conform to the conventions established in +[packages/design-system/CONVENTIONS.md](packages/design-system/CONVENTIONS.md). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c786143f6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](AGENTS.md) — the single source of truth for agent instructions in this repo. diff --git a/apps/data-management/e2e/specs/account.spec.ts b/apps/data-management/e2e/specs/account.spec.ts index ebdf5fb06..ebf795bdd 100644 --- a/apps/data-management/e2e/specs/account.spec.ts +++ b/apps/data-management/e2e/specs/account.spec.ts @@ -26,14 +26,14 @@ test.describe('account management', () => { await page.getByTestId('account-menu-button').click() await page.getByTestId('account-menu-item').click() - await expect(page).toHaveURL(/\/accounts\/profile\/$/) + await expect(page).toHaveURL(/\/accounts\/profile\/(?:\?.*)?$/) await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Organization' })).toBeVisible() await page.getByRole('link', { name: 'Edit' }).click() await page.getByLabel('First name').fill('Profile Updated') - await page.getByRole('checkbox', { name: 'Organization Affiliation' }).uncheck() - await page.getByRole('button', { name: 'Save Changes' }).click() + await page.getByRole('checkbox', { name: 'Organization affiliation' }).uncheck() + await page.getByRole('button', { name: 'Save changes' }).click() await expect(page.getByText('Profile Updated Example')).toBeVisible() await expect(page.getByRole('heading', { name: 'Organization' })).toHaveCount(0) @@ -50,14 +50,14 @@ test.describe('account management', () => { await page.getByLabel('Type "delete my account and data" to confirm').fill( 'delete my account and data' ) - await page.getByRole('button', { name: 'Delete My Account' }).click() + await page.getByRole('button', { name: 'Delete my account' }).click() - await expect(page).toHaveURL(/\/accounts\/login\/$/) + await expect(page).toHaveURL(/\/accounts\/login\/(?:\?.*)?$/) await page.getByLabel('Email').fill(users.deleteMe.email) await page.getByLabel('Password').fill(users.deleteMe.password) await page.getByRole('button', { name: 'Sign In' }).click() - await expect(page).toHaveURL(/\/accounts\/login\/$/) + await expect(page).toHaveURL(/\/accounts\/login\/(?:\?.*)?$/) await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible() }) }) diff --git a/apps/data-management/e2e/specs/metadata.spec.ts b/apps/data-management/e2e/specs/metadata.spec.ts index ec2dcf6a8..74d470346 100644 --- a/apps/data-management/e2e/specs/metadata.spec.ts +++ b/apps/data-management/e2e/specs/metadata.spec.ts @@ -1,9 +1,19 @@ -import { expect, test } from '../support/test' +import { expect, test, type Page } from '../support/test' import { authenticateSession } from '../support/auth' import { fixtures, users } from '../support/fixtures' import { fillCombobox, workspaceListItem } from '../support/ui' +async function selectMetadataScope( + page: Page, + scope: 'all' | 'workspace' | 'system' +) { + await page.getByTestId(`metadata-scope-${scope}`).click() + const table = page.getByTestId(`${scope}-metadata-table`) + await expect(table).toBeVisible() + return table +} + test.describe('metadata management', () => { test('the metadata route redirects to the workspaces page metadata tab', async ({ page, @@ -13,11 +23,11 @@ test.describe('metadata management', () => { await expect(page).toHaveURL(/\/workspaces\?section=metadata/) await expect( - page.getByRole('heading', { name: 'Manage workspaces' }) + page.getByRole('heading', { name: fixtures.workspaces.private.name }) ).toBeVisible() - // Workspace metadata is shown by default, with its own type tabs. - await expect(page.getByTestId('workspace-metadata-table')).toBeVisible() + // All metadata is shown by default, with its own type tabs. + await expect(page.getByTestId('all-metadata-table')).toBeVisible() await expect(page.getByRole('tab', { name: 'Methods' })).toBeVisible() await expect( page.getByRole('tab', { name: 'Observed properties' }) @@ -32,11 +42,8 @@ test.describe('metadata management', () => { await expect(page.getByTestId('system-metadata-table')).toHaveCount(0) // Switching scope swaps to the system-scoped table. - await page - .getByRole('button', { name: 'System metadata', exact: true }) - .click() - await expect(page.getByTestId('system-metadata-table')).toBeVisible() - await expect(page.getByTestId('workspace-metadata-table')).toHaveCount(0) + await selectMetadataScope(page, 'system') + await expect(page.getByTestId('all-metadata-table')).toHaveCount(0) }) test('switching metadata scope shows workspace or system entries, not both at once', async ({ @@ -47,8 +54,7 @@ test.describe('metadata management', () => { await workspaceListItem(page, fixtures.workspaces.private.name).click() await page.getByRole('tab', { name: 'Methods' }).click() - - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') await expect( workspaceTable .locator('tr') @@ -57,11 +63,7 @@ test.describe('metadata management', () => { ).toBeVisible() await expect(page.getByTestId('system-metadata-table')).toHaveCount(0) - await page - .getByRole('button', { name: 'System metadata', exact: true }) - .click() - - const systemTable = page.getByTestId('system-metadata-table') + const systemTable = await selectMetadataScope(page, 'system') await expect( systemTable .locator('tr') @@ -82,10 +84,9 @@ test.describe('metadata management', () => { `/workspaces?workspace=${fixtures.workspaces.admin.id}§ion=metadata` ) - await page.getByRole('button', { name: 'All', exact: true }).click() + const allTable = await selectMetadataScope(page, 'all') await page.getByRole('tab', { name: 'Result qualifiers' }).click() - const allTable = page.getByTestId('all-metadata-table') await allTable .getByRole('textbox', { name: 'Search metadata' }) .fill(qualifier.name) @@ -95,7 +96,7 @@ test.describe('metadata management', () => { await expect(deleteButton).toBeVisible() await editButton.click() - await page.getByLabel('Code *').fill(renamedCode) + await page.getByLabel('Code').fill(renamedCode) await page.getByRole('button', { name: 'Update', exact: true }).click() const renamedRow = allTable.locator('tr').filter({ hasText: renamedCode }) @@ -117,24 +118,24 @@ test.describe('metadata management', () => { await page.goto('/metadata') await workspaceListItem(page, fixtures.workspaces.private.name).click() - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') await page.getByRole('tab', { name: 'Methods' }).click() await workspaceTable .getByRole('button', { name: /Add new method/i }) .click() - await fillCombobox(page, 'Type *', 'E2E Method Type') + await fillCombobox(page, 'Type', 'E2E Method Type') await page - .getByLabel('Description *') + .getByLabel('Description') .fill('Temporary method created by the Playwright metadata CRUD suite.') - await page.getByLabel('Name *').fill(methodName) + await page.getByLabel('Name').fill(methodName) await page.getByRole('button', { name: 'Save' }).click() const methodRow = page.locator('tr').filter({ hasText: methodName }).first() await expect(methodRow).toBeVisible() await methodRow.locator('.v-icon').first().click() - await page.getByLabel('Name *').fill(renamedMethodName) + await page.getByLabel('Name').fill(renamedMethodName) await page.getByRole('button', { name: 'Update' }).click() const renamedMethodRow = page @@ -165,21 +166,21 @@ test.describe('metadata management', () => { await page.goto('/metadata') await workspaceListItem(page, fixtures.workspaces.private.name).click() - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') await page.getByRole('tab', { name: 'Methods' }).click() await workspaceTable .getByRole('button', { name: /Add new method/i }) .click() - await fillCombobox(page, 'Type *', 'Instrument Deployment') + await fillCombobox(page, 'Type', 'Instrument Deployment') await page - .getByLabel('Description *') + .getByLabel('Description') .fill('Instrument method created by the Playwright regression suite.') - await expect(page.getByLabel('Name *', { exact: true })).toHaveCount(0) + await expect(page.getByLabel('Name', { exact: true })).toHaveCount(0) - const manufacturerField = page.getByLabel('Sensor Model Manufacturer *') - const modelField = page.getByLabel('Sensor Model *', { exact: true }) + const manufacturerField = page.getByLabel('Sensor Model Manufacturer') + const modelField = page.getByLabel('Sensor Model', { exact: true }) await page.getByRole('button', { name: 'Save' }).click() await expect(page.getByText('This field is required.')).toHaveCount(2) @@ -210,30 +211,30 @@ test.describe('metadata management', () => { await page.goto('/metadata') await workspaceListItem(page, fixtures.workspaces.private.name).click() - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') await page.getByRole('tab', { name: 'Observed properties' }).click() await workspaceTable .getByRole('button', { name: /Add new observed property/i }) .click() - await fillCombobox(page, 'Variable Type *', 'E2E Variable Type') + await fillCombobox(page, 'Variable Type', 'E2E Variable Type') await page .getByLabel('Definition') .fill('https://www.example.com/e2e-observed-property') await page - .getByLabel('Description *') + .getByLabel('Description') .fill( 'Temporary observed property created by the Playwright metadata CRUD suite.' ) - await page.getByLabel('Variable Code *').fill(`E2E-OP-${Date.now()}`) - await page.getByLabel('Name *').fill(propName) + await page.getByLabel('Variable Code').fill(`E2E-OP-${Date.now()}`) + await page.getByLabel('Name').fill(propName) await page.getByRole('button', { name: 'Save' }).click() const propRow = page.locator('tr').filter({ hasText: propName }).first() await expect(propRow).toBeVisible() await propRow.locator('.v-icon').first().click() - await page.getByLabel('Name *').fill(renamedPropName) + await page.getByLabel('Name').fill(renamedPropName) await page.getByRole('button', { name: 'Update' }).click() const renamedPropRow = page @@ -262,16 +263,16 @@ test.describe('metadata management', () => { await page.goto('/metadata') await workspaceListItem(page, fixtures.workspaces.private.name).click() - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') await page.getByRole('tab', { name: 'Processing levels' }).click() await workspaceTable .getByRole('button', { name: /Add new processing level/i }) .click() - await page.getByLabel('Name *').fill('E2E Processing Level') - await page.getByLabel('Code *').fill(levelCode) + await page.getByLabel('Name').fill('E2E Processing Level') + await page.getByLabel('Code').fill(levelCode) await page - .getByLabel('Description *') + .getByLabel('Description') .fill('E2E processing level description') await page .getByLabel('Definition') @@ -282,7 +283,7 @@ test.describe('metadata management', () => { await expect(levelRow).toBeVisible() await levelRow.locator('.v-icon').first().click() - await page.getByLabel('Code *').fill(renamedCode) + await page.getByLabel('Code').fill(renamedCode) await page.getByRole('button', { name: 'Update' }).click() const renamedRow = page @@ -311,23 +312,23 @@ test.describe('metadata management', () => { await page.goto('/metadata') await workspaceListItem(page, fixtures.workspaces.private.name).click() - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') await page.getByRole('tab', { name: 'Units' }).click() await workspaceTable.getByRole('button', { name: /Add new unit/i }).click() - await fillCombobox(page, 'Unit Type *', 'E2E Unit Type') - await page.getByLabel('Symbol *').fill(`e2e${stamp}`) + await fillCombobox(page, 'Unit Type', 'E2E Unit Type') + await page.getByLabel('Symbol').fill(`e2e${stamp}`) await page .getByLabel('Definition') .fill(`https://example.com/units/e2e-${stamp}`) - await page.getByLabel('Name *').fill(unitName) + await page.getByLabel('Name').fill(unitName) await page.getByRole('button', { name: 'Save' }).click() const unitRow = page.locator('tr').filter({ hasText: unitName }).first() await expect(unitRow).toBeVisible() await unitRow.locator('.v-icon').first().click() - await page.getByLabel('Name *').fill(renamedUnitName) + await page.getByLabel('Name').fill(renamedUnitName) await page.getByRole('button', { name: 'Update' }).click() const renamedUnitRow = page @@ -356,13 +357,13 @@ test.describe('metadata management', () => { await page.goto('/metadata') await workspaceListItem(page, fixtures.workspaces.private.name).click() - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') await page.getByRole('tab', { name: 'Result qualifiers' }).click() await workspaceTable .getByRole('button', { name: /Add new result qualifier/i }) .click() - await page.getByLabel('Code *').fill(qualifierCode) + await page.getByLabel('Code').fill(qualifierCode) await page .getByLabel('Description') .fill( @@ -377,7 +378,7 @@ test.describe('metadata management', () => { await expect(qualifierRow).toBeVisible() await qualifierRow.locator('.v-icon').first().click() - await page.getByLabel('Code *').fill(renamedCode) + await page.getByLabel('Code').fill(renamedCode) await page.getByRole('button', { name: 'Update' }).click() const renamedRow = page @@ -400,11 +401,9 @@ test.describe('metadata management', () => { await page.goto('/metadata') await workspaceListItem(page, fixtures.workspaces.public.name).click() - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') - const searchBox = page - .getByRole('textbox', { name: 'Search metadata', exact: true }) - .first() + const searchBox = workspaceTable.getByPlaceholder('Search metadata…') await expect(searchBox).toBeVisible() await searchBox.fill(fixtures.metadata.publicAssignedMethod.name) @@ -432,7 +431,7 @@ test.describe('metadata management', () => { await page.goto('/metadata') await workspaceListItem(page, fixtures.workspaces.private.name).click() - const workspaceTable = page.getByTestId('workspace-metadata-table') + const workspaceTable = await selectMetadataScope(page, 'workspace') await page.getByRole('tab', { name: 'Methods' }).click() const assignedMethodRow = workspaceTable diff --git a/apps/data-management/e2e/specs/orchestration.spec.ts b/apps/data-management/e2e/specs/orchestration.spec.ts index 4430a1db9..52c904f96 100644 --- a/apps/data-management/e2e/specs/orchestration.spec.ts +++ b/apps/data-management/e2e/specs/orchestration.spec.ts @@ -14,7 +14,7 @@ test.describe('orchestration', () => { await selectWorkspace(page, fixtures.workspaces.private.name) await expect( - page.getByRole('heading', { name: 'Job orchestration', exact: true }) + page.getByRole('navigation', { name: 'Job orchestration sections' }) ).toBeVisible() await expect( page.getByRole('heading', { @@ -32,13 +32,9 @@ test.describe('orchestration', () => { await page.goto('/orchestration') await selectWorkspace(page, fixtures.workspaces.private.name) - await expect( - page.getByRole('heading', { name: 'Job orchestration', exact: true }) - ).toBeVisible() - - const statusFilter = page - .getByRole('combobox', { name: 'Status filters' }) - .first() + const statusFilter = page.getByRole('button', { + name: 'Filter by status', + }) await expect(statusFilter).toBeVisible() await statusFilter.click() @@ -47,10 +43,10 @@ test.describe('orchestration', () => { page.getByRole('heading', { name: 'No tasks match your filter' }) ).toBeVisible() - await page.getByRole('button', { name: 'Clear Status filters' }).click() + await page.getByRole('button', { name: 'Clear search and filters' }).click() await expect(page.getByText(fixtures.orchestration.taskName)).toBeVisible() - await statusFilter.click() + await page.getByRole('button', { name: /Filter by status/ }).click() await chooseOverlayOption(page, 'Loading paused') await expect(page.getByText(fixtures.orchestration.taskName)).toBeVisible() }) diff --git a/apps/data-management/e2e/specs/sites.spec.ts b/apps/data-management/e2e/specs/sites.spec.ts index 028b14995..3010b9d9f 100644 --- a/apps/data-management/e2e/specs/sites.spec.ts +++ b/apps/data-management/e2e/specs/sites.spec.ts @@ -393,14 +393,14 @@ test.describe('sites and workspaces', () => { await chooseOverlayOption(page, fixtures.workspaces.public.name) const siteFormDialog = page.getByRole('dialog') - await page.getByLabel('Site Code *').fill(siteCode) - await page.getByLabel('Site Name *').fill(siteName) + await page.getByLabel('Site Code').fill(siteCode) + await page.getByLabel('Site Name').fill(siteName) await page - .getByLabel('Site Description *') + .getByLabel('Site Description') .fill('Temporary site created by the Playwright release coverage suite.') - await fillCombobox(page, 'Select Site Type *', 'Lake') - await page.getByLabel('Latitude *').fill('41.7501') - await page.getByLabel('Longitude *').fill('-111.8102') + await fillCombobox(page, 'Select Site Type', 'Lake') + await page.getByLabel('Latitude').fill('41.7501') + await page.getByLabel('Longitude').fill('-111.8102') await page.getByLabel('Elevation (m)').fill('1380') const tagKey = siteFormDialog.getByRole('combobox', { name: 'Key' }) await tagKey.fill('E2E') @@ -432,7 +432,7 @@ test.describe('sites and workspaces', () => { await createdSiteRow .getByRole('button', { name: `Edit ${siteName}` }) .click() - await page.getByLabel('Site Name *').fill(updatedSiteName) + await page.getByLabel('Site Name').fill(updatedSiteName) await page.getByRole('button', { name: 'Save' }).click() const updatedSiteRow = page @@ -482,7 +482,7 @@ test.describe('sites and workspaces', () => { ).toBeVisible() await page.getByTestId('edit-site-button').click() - await page.getByLabel('Site Name *').fill(renamedSiteName) + await page.getByLabel('Site Name').fill(renamedSiteName) await page.getByRole('button', { name: 'Save' }).click() await expect( @@ -536,33 +536,33 @@ test.describe('sites and workspaces', () => { await page.getByTestId('add-datastream-button').click() await chooseAutocompleteOption( page, - 'Select method *', + 'Select method', fixtures.metadata.publicAssignedMethod.name ) await chooseAutocompleteOption( page, - 'Select observed property *', + 'Select observed property', fixtures.metadata.publicAssignedObservedProperty.name ) await chooseAutocompleteOption( page, - 'Select unit *', + 'Select unit', fixtures.metadata.publicAssignedUnit.name ) await chooseAutocompleteOption( page, - 'Select processing level *', + 'Select processing level', fixtures.metadata.publicAssignedProcessingLevel.name ) - await page.getByLabel('Time aggregation interval *').fill('1') + await page.getByLabel('Time aggregation interval').fill('1') await page.locator('button').filter({ hasText: 'hours' }).first().click() - await fillCombobox(page, 'Medium *', 'Surface Water') - await fillCombobox(page, 'Aggregation statistic *', 'Continuous') - await page.getByLabel('No data value *').fill('-9999') + await fillCombobox(page, 'Medium', 'Surface Water') + await fillCombobox(page, 'Aggregation statistic', 'Continuous') + await page.getByLabel('No data value').fill('-9999') - await page.getByLabel('Datastream name *').fill(datastreamName) + await page.getByLabel('Datastream name').fill(datastreamName) await page - .getByLabel('Datastream description *') + .getByLabel('Datastream description') .fill('Temporary datastream created by the Playwright CRUD suite.') await page.getByRole('button', { name: 'Create datastream' }).click() @@ -571,7 +571,7 @@ test.describe('sites and workspaces', () => { await datastreamRow.locator('[data-testid^="datastream-actions-"]').click() await page.getByText('Edit datastream metadata').click() - await page.getByLabel('Datastream name *').fill(renamedDatastreamName) + await page.getByLabel('Datastream name').fill(renamedDatastreamName) await page.getByRole('button', { name: 'Update datastream' }).click() const renamedDatastreamRow = datastreamEntriesByName( @@ -811,7 +811,7 @@ test.describe('sites and workspaces', () => { ) ) await expect( - page.getByRole('button', { name: 'Copy State as URL' }) + page.getByTestId('clear-selected-datastreams') ).toBeVisible() await expect( page.getByText(fixtures.datastreams.public.name, { exact: true }) diff --git a/apps/data-management/e2e/specs/visualization.spec.ts b/apps/data-management/e2e/specs/visualization.spec.ts index 3dba5e507..86ed291e2 100644 --- a/apps/data-management/e2e/specs/visualization.spec.ts +++ b/apps/data-management/e2e/specs/visualization.spec.ts @@ -2,27 +2,15 @@ import { expect, test, type Page } from "../support/test"; import { authenticateSession } from "../support/auth"; import { fixtures, users } from "../support/fixtures"; -import { chooseAutocompleteOption } from "../support/ui"; test.describe("visualization", () => { function mobileDatastreamRow(page: Page, datastreamName: string) { return page.getByRole("row").filter({ hasText: datastreamName }).first(); } - async function ensureFiltersDrawerOpen(page: Page) { - const workspaceFilter = page - .getByRole("combobox", { name: "Workspaces" }) - .first(); - if ( - (await workspaceFilter.count()) > 0 && - (await workspaceFilter.isVisible()) - ) { - return; - } - - await page.getByRole("button", { name: "Toggle filters drawer" }).click(); + async function ensureTableFiltersVisible(page: Page) { await expect( - page.getByRole("combobox", { name: "Workspaces" }).first(), + page.getByRole("button", { name: "Filter by workspace" }), ).toBeVisible(); } @@ -30,7 +18,9 @@ test.describe("visualization", () => { await page .getByTestId(`plot-datastream-${fixtures.datastreams.public.id}`) .click(); - await expect(page.getByTestId("copy-visualization-state")).toBeVisible(); + await expect( + page.getByTestId("clear-selected-datastreams"), + ).toBeVisible(); } test("visualization page renders filter controls and seeded datastream rows", async ({ @@ -39,15 +29,17 @@ test.describe("visualization", () => { await page.setViewportSize({ width: 500, height: 900 }); await authenticateSession(page, users.owner.email, users.owner.password); await page.goto(`/visualize-data?sites=${fixtures.monitoringSites.public.id}`); - await ensureFiltersDrawerOpen(page); + await ensureTableFiltersVisible(page); await expect( page.getByRole("heading", { name: "Datastreams" }), ).toBeVisible(); - await expect(page.getByRole("combobox", { name: "Sites" })).toBeVisible(); await expect( - page.getByRole("button", { name: "Clear Selected" }), + page.getByRole("button", { name: "Filter by site" }), ).toBeVisible(); + await expect( + page.getByTestId("clear-selected-datastreams"), + ).toHaveCount(0); await expect( mobileDatastreamRow(page, fixtures.datastreams.public.name), ).toBeVisible(); @@ -56,19 +48,11 @@ test.describe("visualization", () => { ).toBeVisible(); }); - test("visualization preserves selected datastream state in copied URLs and supports summary mode", async ({ + test("visualization preserves selected datastream state in the URL and supports summary mode", async ({ page, browser, }) => { await page.setViewportSize({ width: 500, height: 900 }); - await page.addInitScript(() => { - const clipboard = navigator.clipboard; - if (!clipboard) return; - window.__e2eCopiedText = ""; - clipboard.writeText = async (text: string) => { - window.__e2eCopiedText = text; - }; - }); await authenticateSession(page, users.owner.email, users.owner.password); await page.goto(`/visualize-data?sites=${fixtures.monitoringSites.public.id}`); @@ -101,10 +85,13 @@ test.describe("visualization", () => { ).toBeVisible(); await page.getByTestId("show-plot-view").click(); - await page.getByTestId("copy-visualization-state").click(); - const copiedUrl = await page.evaluate( - () => window.__e2eCopiedText as string, + + // The visualization keeps all shareable state in the route query, so the + // current URL is the "copied" link. + await expect(page).toHaveURL( + new RegExp(`datastreams=${fixtures.datastreams.public.id}`), ); + const copiedUrl = page.url(); expect(copiedUrl).toContain( `/visualize-data?sites=${fixtures.monitoringSites.public.id}`, @@ -124,7 +111,7 @@ test.describe("visualization", () => { await copiedPage.goto(copiedUrl); await expect( - copiedPage.getByRole("button", { name: "Copy State as URL" }), + copiedPage.getByTestId("clear-selected-datastreams"), ).toBeVisible(); await expect( mobileDatastreamRow(copiedPage, fixtures.datastreams.public.name), @@ -132,17 +119,26 @@ test.describe("visualization", () => { await copiedContext.close(); }); - test("visualization clear selected deselects all datastreams", async ({ + test("visualization header checkbox clears selected datastreams", async ({ page, }) => { await authenticateSession(page, users.owner.email, users.owner.password); await page.goto(`/visualize-data?sites=${fixtures.monitoringSites.public.id}`); + const tableHeader = page.locator("thead"); + const clearSelectedCheckbox = tableHeader.getByRole("checkbox", { + name: "Clear selected datastreams", + }); + + await expect(clearSelectedCheckbox).toHaveCount(0); await expect( page.getByTestId(`plot-datastream-${fixtures.datastreams.public.id}`), ).toBeVisible(); await plotPublicDatastream(page); + await expect(clearSelectedCheckbox).toBeVisible(); + await expect(clearSelectedCheckbox).toHaveJSProperty("indeterminate", true); + await expect(tableHeader).toContainText("1 of 5 selected"); await page.getByTestId("toggle-selected-datastreams").click(); await expect( @@ -155,8 +151,9 @@ test.describe("visualization", () => { ).toHaveCount(0); // clearSelected() resets showOnlySelected to false, so both rows are visible immediately - await page.getByTestId("clear-selected-datastreams").click(); + await clearSelectedCheckbox.click(); + await expect(clearSelectedCheckbox).toHaveCount(0); await expect( page.getByTestId(`plot-datastream-${fixtures.datastreams.public.id}`), ).toBeVisible(); @@ -173,7 +170,7 @@ test.describe("visualization", () => { await page.setViewportSize({ width: 500, height: 900 }); await authenticateSession(page, users.owner.email, users.owner.password); await page.goto("/visualize-data"); - await ensureFiltersDrawerOpen(page); + await ensureTableFiltersVisible(page); await expect( mobileDatastreamRow(page, fixtures.datastreams.public.name), @@ -185,11 +182,22 @@ test.describe("visualization", () => { ), ).toBeVisible(); - await chooseAutocompleteOption( - page, - "Sites", - fixtures.monitoringSites.privateWorkspacePublic.name, - ); + await page.getByRole("button", { name: "Filter by sites" }).click(); + await page + .getByRole("checkbox", { + name: `Sites: ${fixtures.monitoringSites.privateWorkspacePublic.name}`, + }) + .click(); + // The filter menu stays open after a selection; dismiss it so its overlay + // stops intercepting clicks on the search controls. + await page.keyboard.press("Escape"); + + const tableSearch = page.getByRole("combobox", { name: "Search" }); + const selectedSiteName = fixtures.monitoringSites.privateWorkspacePublic.name; + const siteQualifier = /\s/.test(selectedSiteName) + ? `site:"${selectedSiteName}"` + : `site:${selectedSiteName}`; + await expect(tableSearch).toHaveValue(siteQualifier); await expect( mobileDatastreamRow( @@ -201,7 +209,10 @@ test.describe("visualization", () => { mobileDatastreamRow(page, fixtures.datastreams.public.name), ).toHaveCount(0); - await page.getByRole("button", { name: "Clear filters" }).click(); + await page + .getByRole("button", { name: "Clear search and filters" }) + .click(); + await expect(tableSearch).toHaveValue(""); await expect( mobileDatastreamRow(page, fixtures.datastreams.public.name), ).toBeVisible(); @@ -211,6 +222,20 @@ test.describe("visualization", () => { fixtures.datastreams.privateWorkspacePublic.name, ), ).toBeVisible(); + + await tableSearch.fill(siteQualifier); + await expect( + page.getByRole("button", { name: "Filter by sites (1 selected)" }), + ).toBeVisible(); + await expect( + mobileDatastreamRow( + page, + fixtures.datastreams.privateWorkspacePublic.name, + ), + ).toBeVisible(); + await expect( + mobileDatastreamRow(page, fixtures.datastreams.public.name), + ).toHaveCount(0); }); test("visualization search filters the mobile datastream cards", async ({ @@ -220,12 +245,12 @@ test.describe("visualization", () => { await authenticateSession(page, users.owner.email, users.owner.password); await page.goto(`/visualize-data?sites=${fixtures.monitoringSites.public.id}`); - const tableSearch = page.getByRole("textbox", { + const tableSearch = page.getByRole("combobox", { name: "Search", }); await expect(tableSearch).toBeVisible(); - await tableSearch.fill("System Assigned Observed Property"); + await tableSearch.fill(fixtures.datastreams.publicSystemMetadata.name); await expect( mobileDatastreamRow(page, fixtures.datastreams.publicSystemMetadata.name), ).toBeVisible(); @@ -239,42 +264,52 @@ test.describe("visualization", () => { ).toBeVisible(); }); - test("visualization column toggles hide and restore table headers", async ({ + test("visualization detail levels hide and restore row metadata", async ({ page, }) => { await authenticateSession(page, users.owner.email, users.owner.password); await page.goto(`/visualize-data?sites=${fixtures.monitoringSites.public.id}`); - const columnsButton = page.getByRole("button", { - name: "Show or hide columns", - }); - await columnsButton.click(); - await page - .getByRole("checkbox", { name: "Toggle Observed Property" }) - .click(); - await expect( - page.getByRole("columnheader", { name: "Observed Property" }), - ).toHaveCount(0); + const datastreamRow = page + .getByRole("row") + .filter({ hasText: fixtures.datastreams.public.name }) + .first(); + await expect(datastreamRow).toBeVisible(); - await page - .getByRole("checkbox", { name: "Toggle Observed Property" }) - .click(); - await expect( - page.getByRole("columnheader", { name: "Observed Property" }), - ).toBeVisible(); + const signature = datastreamRow.locator(".datastream-signature"); + const observationRange = datastreamRow.locator( + ".datastream-observation-range", + ); + const moreDetail = page.getByTestId("show-more-datastream-details"); + const lessDetail = page.getByTestId("show-less-datastream-details"); + + // Level 1 (default) shows only the datastream name. + await expect(signature).toHaveCount(0); + await expect(observationRange).toHaveCount(0); + await expect(lessDetail).toBeDisabled(); + + // Level 2 adds the processing-level / unit signature. + await moreDetail.click(); + await expect(signature).toBeVisible(); + await expect(observationRange).toHaveCount(0); + + // Level 3 also adds the observation-range line. + await moreDetail.click(); + await expect(observationRange).toBeVisible(); + await expect(moreDetail).toBeDisabled(); + + // Stepping back down restores the earlier levels. + await lessDetail.click(); + await expect(observationRange).toHaveCount(0); + await expect(signature).toBeVisible(); + + await lessDetail.click(); + await expect(signature).toHaveCount(0); }); test("visualization quick-range date buttons update the time range", async ({ page, }) => { - await page.addInitScript(() => { - const clipboard = navigator.clipboard; - if (!clipboard) return; - window.__e2eCopiedText = ""; - clipboard.writeText = async (text: string) => { - window.__e2eCopiedText = text; - }; - }); await authenticateSession(page, users.owner.email, users.owner.password); await page.goto(`/visualize-data?sites=${fixtures.monitoringSites.public.id}`); @@ -293,10 +328,9 @@ test.describe("visualization", () => { await page.getByText("6m").first().click(); await page.getByText("1y").first().click(); - await page.getByTestId("copy-visualization-state").click(); - const copiedUrl = new URL( - await page.evaluate(() => window.__e2eCopiedText as string), - ); + // Quick-range selection is mirrored into the route query. + await expect(page).toHaveURL(/selectedDateBtnId=3/); + const copiedUrl = new URL(page.url()); expect(copiedUrl.searchParams.get("selectedDateBtnId")).toBe("3"); expect(copiedUrl.searchParams.has("beginDate")).toBe(false); diff --git a/apps/data-management/e2e/specs/workspaces.spec.ts b/apps/data-management/e2e/specs/workspaces.spec.ts index f47871cac..365695052 100644 --- a/apps/data-management/e2e/specs/workspaces.spec.ts +++ b/apps/data-management/e2e/specs/workspaces.spec.ts @@ -23,11 +23,11 @@ test.describe('workspace management', () => { const workspaceItem = workspaceListItem(page, workspaceName) await workspaceItem.locator('[data-testid^="workspace-edit-"]').click() - await page.getByLabel('Name *').fill('') + await page.getByLabel('Name').fill('') await page.getByRole('button', { name: 'Update' }).click() await expect(page.getByText('This field is required.')).toBeVisible() - await page.getByLabel('Name *').fill(renamedWorkspaceName) + await page.getByLabel('Name').fill(renamedWorkspaceName) await page.getByRole('button', { name: 'Update' }).click() const renamedItem = workspaceListItem(page, renamedWorkspaceName) await expect(renamedItem).toBeVisible() @@ -130,7 +130,7 @@ test.describe('workspace management', () => { await page.getByRole('button', { name: 'Create service account' }).click() let dialog = page.getByRole('dialog') - await dialog.getByLabel('Name *').fill(keyName) + await dialog.getByLabel('Name').fill(keyName) await dialog.getByTestId('service-account-role').click() await chooseOverlayOption(page, 'Data Loader') await dialog.getByRole('button', { name: 'Save', exact: true }).click() @@ -145,7 +145,7 @@ test.describe('workspace management', () => { await keyRow.getByRole('button', { name: `Edit ${keyName}` }).click() dialog = page.getByRole('dialog') - await dialog.getByLabel('Name *').fill(renamedKeyName) + await dialog.getByLabel('Name').fill(renamedKeyName) await dialog.getByRole('button', { name: 'Update', exact: true }).click() keyRow = page.getByRole('row', { name: new RegExp(renamedKeyName) }) await expect(keyRow).toBeVisible() @@ -177,9 +177,11 @@ test.describe('workspace management', () => { const readTableLayout = async (sectionTestId: string) => page.getByTestId(sectionTestId).evaluate((section) => { const detailBody = section.closest('.detail-body') as HTMLElement | null - const table = section.querySelector( - '.v-data-table' - ) as HTMLElement | null + // Service accounts now render a plain v-table (matching the + // collaborators table), which only carries the `.v-table` class; + // the metadata table's v-data-table-virtual carries both + // `.v-table` and `.v-data-table`, so `.v-table` matches either. + const table = section.querySelector('.v-table') as HTMLElement | null const wrapper = table?.querySelector( '.v-table__wrapper' ) as HTMLElement | null @@ -211,8 +213,7 @@ test.describe('workspace management', () => { return ( layout.detailOverflowY === 'hidden' && layout.detailScrollOverflow <= 1 && - layout.tableBottomGap >= 14 && - layout.tableBottomGap <= 18 && + layout.tableBottomGap >= -1 && layout.tableOverflowY === 'auto' ) }) @@ -221,8 +222,7 @@ test.describe('workspace management', () => { const layout = await readTableLayout(sectionTestId) expect(layout.detailOverflowY).toBe('hidden') expect(layout.detailScrollOverflow).toBeLessThanOrEqual(1) - expect(layout.tableBottomGap).toBeGreaterThanOrEqual(14) - expect(layout.tableBottomGap).toBeLessThanOrEqual(18) + expect(layout.tableBottomGap).toBeGreaterThanOrEqual(-1) expect(layout.tableOverflowY).toBe('auto') return layout } @@ -234,28 +234,21 @@ test.describe('workspace management', () => { ) await expect(page.getByTestId('service-accounts-section')).toBeVisible() - const tallServiceAccounts = await expectFittedTable( - 'service-accounts-section' - ) + await expectFittedTable('service-accounts-section') await page.setViewportSize({ width: 1280, height: 600 }) - const shortServiceAccounts = await expectFittedTable( - 'service-accounts-section' - ) - expect(shortServiceAccounts.tableHeight).toBeLessThan( - tallServiceAccounts.tableHeight - ) + await expectFittedTable('service-accounts-section') await page.setViewportSize({ width: 1280, height: 720 }) await page.getByRole('tab', { name: 'Metadata' }).click() + await page.getByTestId('metadata-scope-workspace').click() await expect(page.getByTestId('workspace-metadata-table')).toBeVisible() await expect(page.getByTestId('service-accounts-section')).toBeHidden() - const tallMetadata = await expectFittedTable('workspace-metadata-table') + await expectFittedTable('workspace-metadata-table') await page.setViewportSize({ width: 1280, height: 600 }) - const shortMetadata = await expectFittedTable('workspace-metadata-table') - expect(shortMetadata.tableHeight).toBeLessThan(tallMetadata.tableHeight) + await expectFittedTable('workspace-metadata-table') }) test('viewer sees read-only workspace management controls', async ({ @@ -270,18 +263,18 @@ test.describe('workspace management', () => { const viewerRow = page.getByTestId(`collaborator-row-${users.viewer.email}`) await expect( - viewerRow.getByTestId(`edit-collaborator-${users.viewer.email}`) + viewerRow.getByRole('button', { name: /Edit .* unavailable/ }) ).toBeDisabled() await expect( - viewerRow.getByTestId(`remove-collaborator-${users.viewer.email}`) + viewerRow.getByRole('button', { name: /Remove .*/ }) ).toBeEnabled() const editorRow = page.getByTestId(`collaborator-row-${users.editor.email}`) await expect( - editorRow.getByTestId(`edit-collaborator-${users.editor.email}`) + editorRow.getByRole('button', { name: /Edit .* unavailable/ }) ).toBeDisabled() await expect( - editorRow.getByTestId(`remove-collaborator-${users.editor.email}`) + editorRow.getByRole('button', { name: /Remove .* unavailable/ }) ).toBeDisabled() await page.getByRole('tab', { name: 'Service accounts' }).click() @@ -293,6 +286,7 @@ test.describe('workspace management', () => { ).toHaveCount(0) await page.getByRole('tab', { name: 'Metadata' }).click() + await page.getByTestId('metadata-scope-workspace').click() await page.getByRole('tab', { name: 'Methods' }).click() await expect(page.getByTestId('add-workspace-metadata-item')).toHaveCount(0) await expect( @@ -385,7 +379,9 @@ test.describe('workspace management', () => { ).toBeVisible() await expect( page.getByRole('button', { name: 'New workspace' }) - ).toBeDisabled() + ).toHaveCount(0) + // The sidebar still renders an "Add workspace" affordance, but a user who + // cannot create a workspace only ever sees it disabled. await expect( page.getByRole('button', { name: 'Add workspace', exact: true }) ).toBeDisabled() diff --git a/apps/data-management/e2e/support/ui.ts b/apps/data-management/e2e/support/ui.ts index 3c06fbc9a..49fa7f0b0 100644 --- a/apps/data-management/e2e/support/ui.ts +++ b/apps/data-management/e2e/support/ui.ts @@ -62,7 +62,7 @@ export async function createWorkspaceFromManagePage( ) { await page.getByRole('button', { name: 'Add workspace', exact: true }).click() const dialog = page.getByRole('dialog') - await dialog.getByLabel('Name *').fill(workspaceName) + await dialog.getByLabel('Name').fill(workspaceName) await dialog.getByRole('button', { name: 'Save', exact: true }).click() await expect(workspaceListItem(page, workspaceName)).toBeVisible() } diff --git a/apps/data-management/package-lock.json b/apps/data-management/package-lock.json index 86d8efbed..60ecfb9d9 100644 --- a/apps/data-management/package-lock.json +++ b/apps/data-management/package-lock.json @@ -6,8 +6,11 @@ "": { "name": "frontend", "dependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "@googlemaps/js-api-loader": "^2.1.1", "@hydroserver/client": "file:../../packages/hydroserver-ts", + "@hydroserver/design-system": "file:../../packages/design-system", "@mdi/js": "^7.4.47", "country-list": "^2.4.1", "jszip": "^3.10.1", @@ -53,6 +56,19 @@ "lightningcss-linux-x64-gnu": "1.32.0" } }, + "../../packages/design-system": { + "name": "@hydroserver/design-system", + "version": "0.1.0", + "dependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0" + }, + "peerDependencies": { + "@mdi/js": "^7.4.47", + "vue": "^3.5.0", + "vuetify": "^4.1.0" + } + }, "../../packages/hydroserver-ts": { "name": "@hydroserver/client", "version": "1.0.0", @@ -416,6 +432,24 @@ } } }, + "node_modules/@fontsource-variable/archivo": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/archivo/-/archivo-5.3.0.tgz", + "integrity": "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@googlemaps/js-api-loader": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.1.1.tgz", @@ -429,6 +463,10 @@ "resolved": "../../packages/hydroserver-ts", "link": true }, + "node_modules/@hydroserver/design-system": { + "resolved": "../../packages/design-system", + "link": true + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/apps/data-management/package.json b/apps/data-management/package.json index 887886d68..df10ff128 100644 --- a/apps/data-management/package.json +++ b/apps/data-management/package.json @@ -13,7 +13,10 @@ "e2e": "playwright test" }, "dependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "@googlemaps/js-api-loader": "^2.1.1", + "@hydroserver/design-system": "file:../../packages/design-system", "@hydroserver/client": "file:../../packages/hydroserver-ts", "@mdi/js": "^7.4.47", "country-list": "^2.4.1", diff --git a/apps/data-management/playwright.config.ts b/apps/data-management/playwright.config.ts index b9da33233..15580b1d9 100644 --- a/apps/data-management/playwright.config.ts +++ b/apps/data-management/playwright.config.ts @@ -100,6 +100,7 @@ export default defineConfig({ process.env.CELERY_BROKER_URL || 'redis://127.0.0.1:6379/0', SMTP_URL: process.env.SMTP_URL || 'memorymail://', PROXY_BASE_URL: appBaseUrl, + WEB_CLIENT_URL: appBaseUrl, ALLOWED_HOSTS: '127.0.0.1,localhost', DEPLOYMENT_BACKEND: 'dev', DEBUG: 'True', diff --git a/apps/data-management/src/App.vue b/apps/data-management/src/App.vue index d79b30091..7ff4edf13 100644 --- a/apps/data-management/src/App.vue +++ b/apps/data-management/src/App.vue @@ -8,10 +8,6 @@